diff --git a/README.md b/README.md
index 58bfe2c..eda985c 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,32 @@ To learn an unfamiliar codebase first:
/onboard
```
+## Onboarding a new project
+
+Before the plugin is published to a marketplace (or if you want to try it against a local repo
+first), `scripts/onboard.mjs` does the whole bootstrap in one shot: copies the behaviour layer
+(`.github/agents`, `skills`, `instructions`, `prompts`, `mcp.json`, `copilot-instructions.md`,
+`AGENTS.md`) into a target repo, `npm link`s the `lh` CLI so it's on `PATH` there, runs
+`lh init` and `lh doctor`, and prints next steps.
+
+```bash
+# from inside this repo
+node scripts/onboard.mjs /path/to/target-repo --dry-run # preview, writes nothing
+node scripts/onboard.mjs /path/to/target-repo --yes # do it
+```
+
+| Flag | Effect |
+| --- | --- |
+| `--yes` | Non-interactive: auto `git init` if needed, run `lh init` without asking |
+| `--force` | Overwrite target files that already exist and differ (default: skip + warn) |
+| `--dry-run` | Print the plan, write and run nothing |
+| `--no-npm-link` | Skip `npm link`; print the absolute `node .../src/cli.mjs` invocation instead |
+
+Safe to re-run: files that are already identical are left alone, and re-running `lh init` on an
+already-initialized repo warns instead of failing (rerun with `lh init --force` to reset).
+`npm install -g @redsen/lean-harness` (once published) replaces steps 3–5 of the script with a
+normal global install.
+
## Pipeline
```
@@ -163,6 +189,43 @@ exploration is deliberately pushed onto the cheap tier; only summaries return to
| `lh report [runId] [--journal]` | Markdown telemetry report / run journal |
| `lh doctor` | Environment checks |
+### Full flag reference
+
+
+Every flag each subcommand reads (click to expand)
+
+- `lh init [--yes] [--force] [--json]` — `--yes` skips prompts with defaults; `--force` re-initializes
+ an existing `.agents/harness.config.json`.
+- `lh index [--budget N] [--focus ] [--fetch] [--force] [--stats] [--json]` — `--fetch` lazily
+ downloads/caches missing tree-sitter grammars; `--force` rebuilds the cache instead of reusing it;
+ `--stats` prints token counts instead of the map.
+- `lh graph [--severity ] [--fix-manifest] [--json]` — terse brief is the default output;
+ `--fix-manifest` rewrites `codegraph.manifest.json`-style baselines.
+- `lh lane create --id [--title t] [--kind read\|write] [--scope glob...] [--depends-on id]
+ [--base ref] [--json]`
+- `lh lane list [--status s] [--run-id id] [--json]`
+- `lh lane status --id [--json]`
+- `lh lane merge --id [--abort] [--strategy s] [--json]`
+- `lh lane drop --id [--force] [--json]`
+- `lh run start --objective "..." [--spec slug] [--host h] [--strategy s] [--attrs '{"k":"v"}']
+ [--json]`
+- `lh run event --run --type [--name n] [--status ok\|fail] [--agent a] [--lane id]
+ [--model m] [--operation op] [--input-tokens N] [--output-tokens N] [--duration-ms N]
+ [--exit-code N] [--attrs '{"k":"v"}'] [--json]`
+- `lh run end --run [--status ok\|fail] [--summary "..."] [--json]`
+- `lh run list [--json]` / `lh run show [runId] [--json]`
+- `lh memory list [--shard s] [--json]`
+- `lh memory get --shard s [--query q] [--limit N] [--json]`
+- `lh memory put --shard s --title t [--body "..." | piped via stdin] [--tags a,b] [--allow-secrets]
+ [--json]` — writes are refused if a secret pattern matches unless `--allow-secrets` is set.
+- `lh memory compact [--force] [--json]`
+- `lh memory scan` — secret scan only, no write
+- `lh host [--strategy s] [--json]`
+- `lh report [runId] [--run-id id] [--json] [--board] [--journal] [--spec slug] [--write]`
+- `lh doctor [--json]`
+
+
+
## Repository footprint
The harness writes into one configurable directory:
@@ -222,11 +285,13 @@ npm install
npm run validate # manifests + every markdown frontmatter block
npm test
node src/cli.mjs --help
+node scripts/onboard.mjs --dry-run # preview bootstrapping another repo
```
`npm run validate` is the distribution gate: it checks `plugin.json`, `marketplace.json`,
agent/skill/instruction frontmatter, delegation targets, and scans `.github/mcp.json` for
-committed secrets.
+committed secrets. `npm test` covers config, memory, lane scope safety, glob matching, secret
+scanning, telemetry, and the onboarding script end to end (`tests/onboard.test.mjs`).
## Prior art
diff --git a/package.json b/package.json
index 1b724c7..db7968f 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,8 @@
"scripts": {
"validate": "node scripts/validate.mjs",
"test": "node --test tests/",
- "lh": "node src/cli.mjs"
+ "lh": "node src/cli.mjs",
+ "onboard": "node scripts/onboard.mjs"
},
"dependencies": {
"web-tree-sitter": "^0.25.10"
diff --git a/scripts/onboard.mjs b/scripts/onboard.mjs
new file mode 100644
index 0000000..16add53
--- /dev/null
+++ b/scripts/onboard.mjs
@@ -0,0 +1,262 @@
+#!/usr/bin/env node
+/**
+ * scripts/onboard.mjs — bootstrap a target repo onto redsen-lean-harness.
+ *
+ * For local/dev use before the plugin is published to a marketplace: copies
+ * the behaviour layer (.github/agents, skills, instructions, prompts,
+ * mcp.json, copilot-instructions.md, AGENTS.md) into a target repo, links the
+ * `lh` CLI, runs `lh init` + `lh doctor`, and prints next steps. Never
+ * overwrites a file that already differs unless --force is passed.
+ *
+ * Usage:
+ * node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link]
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const HARNESS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const CLI = path.join(HARNESS_ROOT, 'src', 'cli.mjs');
+const PKG_NAME = JSON.parse(fs.readFileSync(path.join(HARNESS_ROOT, 'package.json'), 'utf8')).name;
+
+const NO_COLOR = process.env.NO_COLOR !== undefined || !process.stdout.isTTY;
+const wrap = (code, s) => (NO_COLOR ? s : `\u001b[${code}m${s}\u001b[0m`);
+const dim = (s) => wrap('2', s);
+const bold = (s) => wrap('1', s);
+const red = (s) => wrap('31', s);
+const green = (s) => wrap('32', s);
+const yellow = (s) => wrap('33', s);
+const out = (l = '') => process.stdout.write(`${l}\n`);
+const err = (l) => process.stderr.write(`${l}\n`);
+const ok = (m) => out(`${green('ok')} ${m}`);
+const warn = (m) => out(`${yellow('warn')} ${m}`);
+const step = (m) => out(`\n${bold(m)}`);
+
+function parseArgs(argv) {
+ const flags = {};
+ const positional = [];
+ for (const arg of argv) {
+ if (arg.startsWith('--')) flags[arg.slice(2)] = true;
+ else positional.push(arg);
+ }
+ return { flags, positional };
+}
+
+function usage() {
+ out(`usage: node scripts/onboard.mjs [--yes] [--force] [--dry-run] [--no-npm-link]
+
+ repo to onboard onto the harness (must already exist)
+ --yes non-interactive: run \`git init\` (if needed) and \`lh init\` without asking
+ --force overwrite target files that already exist and differ
+ --dry-run print what would happen, write and run nothing
+ --no-npm-link skip \`npm link\`; invoke lh via its absolute path instead`);
+}
+
+// Behaviour-layer paths copied verbatim into the target repo. Everything else
+// (src/, templates/, tests/, workflows) stays inside this package — the `lh`
+// CLI resolves templates relative to its own install location, not the
+// target repo, so nothing else needs to travel.
+const HARNESS_FILES = [
+ '.github/agents',
+ '.github/skills',
+ '.github/instructions',
+ '.github/prompts',
+ '.github/mcp.json',
+ '.github/copilot-instructions.md',
+ 'AGENTS.md',
+];
+
+function listFilesRecursive(entry) {
+ if (!fs.existsSync(entry)) return [];
+ if (!fs.statSync(entry).isDirectory()) return [entry];
+ const found = [];
+ for (const child of fs.readdirSync(entry, { withFileTypes: true })) {
+ const full = path.join(entry, child.name);
+ if (child.isDirectory()) found.push(...listFilesRecursive(full));
+ else found.push(full);
+ }
+ return found;
+}
+
+function planCopy(target) {
+ const plan = [];
+ for (const rel of HARNESS_FILES) {
+ for (const src of listFilesRecursive(path.join(HARNESS_ROOT, rel))) {
+ const dest = path.join(target, path.relative(HARNESS_ROOT, src));
+ const exists = fs.existsSync(dest);
+ const identical = exists && fs.readFileSync(src).equals(fs.readFileSync(dest));
+ plan.push({ src, dest, exists, identical });
+ }
+ }
+ return plan;
+}
+
+function applyCopy(plan, { force, dryRun }) {
+ const written = [];
+ const conflicts = [];
+ for (const item of plan) {
+ if (item.exists && item.identical) continue;
+ if (item.exists && !item.identical && !force) {
+ conflicts.push(item.dest);
+ continue;
+ }
+ if (!dryRun) {
+ fs.mkdirSync(path.dirname(item.dest), { recursive: true });
+ fs.copyFileSync(item.src, item.dest);
+ }
+ written.push(item.dest);
+ }
+ return { written, conflicts };
+}
+
+function run(cmd, args, opts = {}) {
+ try {
+ const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts });
+ return { code: 0, stdout, stderr: '' };
+ } catch (e) {
+ return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? String(e.message ?? e) };
+ }
+}
+
+const nodeMajor = () => Number(process.versions.node.split('.')[0]);
+
+function main() {
+ const { flags, positional } = parseArgs(process.argv.slice(2));
+ if (!positional[0] || flags.help || flags.h) {
+ usage();
+ process.exit(positional[0] ? 0 : 2);
+ }
+
+ const target = path.resolve(process.cwd(), positional[0]);
+ const dryRun = Boolean(flags['dry-run']);
+ const forceWrite = Boolean(flags.force);
+ const yes = Boolean(flags.yes) || dryRun; // dry-run never mutates, so treat as answered
+ const noLink = Boolean(flags['no-npm-link']);
+ let failed = false;
+
+ out(bold('redsen-lean-harness onboarding'));
+ out(dim(`harness: ${HARNESS_ROOT}`));
+ out(dim(`target: ${target}`));
+ if (dryRun) out(dim('(dry run — no files written, no commands executed)'));
+
+ // ---- 1. prerequisites --------------------------------------------------
+ step('1. prerequisites');
+
+ if (nodeMajor() < 20) {
+ err(`${red('fail')} node ${process.version} — need >= 20`);
+ failed = true;
+ } else ok(`node ${process.version}`);
+
+ const gitVersion = run('git', ['--version']);
+ if (gitVersion.code !== 0) {
+ err(`${red('fail')} git not found on PATH`);
+ failed = true;
+ } else ok(gitVersion.stdout.trim());
+
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
+ err(`${red('fail')} target directory does not exist: ${target}`);
+ failed = true;
+ } else ok('target directory exists');
+
+ if (failed) {
+ err('\naborting — fix the above and re-run.');
+ process.exit(1);
+ }
+
+ const gitCheck = run('git', ['rev-parse', '--show-toplevel'], { cwd: target });
+ const isGitRepo = gitCheck.code === 0;
+ if (isGitRepo) {
+ ok(`git repo (root: ${gitCheck.stdout.trim()})`);
+ } else if (yes) {
+ warn('not a git repo — running `git init`');
+ if (!dryRun) run('git', ['init', '-q'], { cwd: target });
+ } else {
+ warn('not a git repo — pass --yes to auto-`git init`, or run it yourself first');
+ }
+
+ // ---- 2. copy behaviour layer -------------------------------------------
+ step('2. copy behaviour layer (.github + AGENTS.md)');
+ const plan = planCopy(target);
+ const { written, conflicts } = applyCopy(plan, { force: forceWrite, dryRun });
+ if (written.length) {
+ for (const f of written) out(` ${dryRun ? 'would write' : 'wrote'} ${path.relative(target, f)}`);
+ ok(`${written.length} file(s) ${dryRun ? 'would be written' : 'written'}`);
+ } else {
+ ok('nothing to write — already up to date');
+ }
+ if (conflicts.length) {
+ warn(`${conflicts.length} file(s) already exist and differ — skipped (rerun with --force to overwrite):`);
+ for (const f of conflicts) out(` ${path.relative(target, f)}`);
+ }
+
+ // ---- 3. link the lh CLI -------------------------------------------------
+ step('3. link the `lh` CLI');
+ let lhCommand = ['node', CLI];
+ if (dryRun) {
+ out(dim('(dry run — skipping npm link)'));
+ } else if (noLink) {
+ out(dim('--no-npm-link — using absolute path invocation'));
+ } else {
+ const linkSelf = run('npm', ['link'], { cwd: HARNESS_ROOT });
+ const linkTarget = linkSelf.code === 0 ? run('npm', ['link', PKG_NAME], { cwd: target }) : linkSelf;
+ if (linkSelf.code === 0 && linkTarget.code === 0) {
+ ok('linked — `lh` is now on PATH inside the target repo (via node_modules/.bin)');
+ lhCommand = ['lh'];
+ } else {
+ warn('`npm link` failed — falling back to absolute path invocation');
+ out(dim((linkTarget.stderr || linkSelf.stderr).trim().split('\n')[0] ?? ''));
+ }
+ }
+
+ const lh = (args) => run(lhCommand[0], [...lhCommand.slice(1), ...args], { cwd: target });
+
+ // ---- 4. lh init ---------------------------------------------------------
+ step('4. `lh init`');
+ if (dryRun) {
+ out(dim('(dry run — skipping `lh init`)'));
+ } else if (!yes) {
+ warn('skipping `lh init` — pass --yes to run it non-interactively');
+ } else {
+ const init = lh(['init', '--yes']);
+ if (init.code === 0) {
+ ok(init.stdout.trim() || 'lh init ok');
+ } else if (/config exists/i.test(init.stderr)) {
+ warn('already initialized — rerun with `lh init --force` to reset .agents/harness.config.json');
+ } else {
+ err(`${red('fail')} lh init exited ${init.code}`);
+ out(init.stderr.trim());
+ failed = true;
+ }
+ }
+
+ // ---- 5. lh doctor --------------------------------------------------------
+ step('5. `lh doctor`');
+ if (dryRun) {
+ out(dim('(dry run — skipping `lh doctor`)'));
+ } else {
+ const doctor = lh(['doctor']);
+ out(doctor.stdout.trim());
+ if (doctor.code !== 0) warn('doctor reported missing requirements — see above');
+ else ok('doctor: all required checks passed');
+ }
+
+ // ---- summary ------------------------------------------------------------
+ step('done');
+ const lhHint = lhCommand[0] === 'lh' ? 'lh' : `node ${path.relative(target, CLI)}`;
+ out(`
+ cd ${target}
+ ${lhHint} doctor
+ ${lhHint} report # after your first run
+
+ In Copilot CLI or VS Code Copilot:
+ @conductor
+ /fast-track
+ /onboard # summarize repo state for a new contributor
+
+ Full pipeline docs: ${path.join(HARNESS_ROOT, 'README.md')}`);
+
+ process.exit(failed ? 1 : 0);
+}
+
+main();
diff --git a/tests/onboard.test.mjs b/tests/onboard.test.mjs
new file mode 100644
index 0000000..5fc6982
--- /dev/null
+++ b/tests/onboard.test.mjs
@@ -0,0 +1,79 @@
+import { test, describe, after } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+import { tempRepo, cleanup } from './helpers.mjs';
+
+after(cleanup);
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const ONBOARD = join(ROOT, 'scripts', 'onboard.mjs');
+
+/** Run the onboarding script directly. Never throws — returns {code, stdout, stderr}. */
+function onboard(args) {
+ try {
+ const stdout = execFileSync(process.execPath, [ONBOARD, ...args], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return { code: 0, stdout, stderr: '' };
+ } catch (e) {
+ return { code: e.status ?? 1, stdout: e.stdout ?? '', stderr: e.stderr ?? '' };
+ }
+}
+
+describe('onboard script', () => {
+ test('--dry-run writes nothing', () => {
+ const dir = tempRepo();
+ const r = onboard([dir, '--dry-run']);
+ assert.equal(r.code, 0);
+ assert.ok(!existsSync(join(dir, '.github', 'agents')), 'dry run must not create files');
+ assert.ok(!existsSync(join(dir, '.agents')), 'dry run must not run lh init');
+ assert.match(r.stdout, /would write/);
+ });
+
+ test('--yes --no-npm-link copies the behaviour layer and runs lh init + doctor', () => {
+ const dir = tempRepo({
+ 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2),
+ });
+ const r = onboard([dir, '--yes', '--no-npm-link']);
+ assert.equal(r.code, 0, r.stdout + r.stderr);
+ for (const rel of ['.github/agents/conductor.agent.md', '.github/skills/design/SKILL.md',
+ '.github/mcp.json', 'AGENTS.md', '.agents/harness.config.json']) {
+ assert.ok(existsSync(join(dir, rel)), `expected ${rel} to exist`);
+ }
+ assert.match(r.stdout, /doctor: all required checks passed/);
+ });
+
+ test('rerunning with --yes is idempotent (no forced overwrite, no init failure)', () => {
+ const dir = tempRepo({
+ 'package.json': JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'true' } }, null, 2),
+ });
+ assert.equal(onboard([dir, '--yes', '--no-npm-link']).code, 0);
+ const second = onboard([dir, '--yes', '--no-npm-link']);
+ assert.equal(second.code, 0, second.stdout + second.stderr);
+ assert.match(second.stdout, /already up to date/);
+ assert.match(second.stdout, /already initialized/);
+ });
+
+ test('refuses a target directory that does not exist', () => {
+ const r = onboard(['/nonexistent/path/for/sure', '--yes']);
+ assert.notEqual(r.code, 0);
+ });
+
+ test('a conflicting existing file is skipped unless --force', () => {
+ const dir = tempRepo({ 'AGENTS.md': 'custom project notes, do not clobber\n' });
+ const r = onboard([dir, '--yes', '--no-npm-link']);
+ assert.equal(r.code, 0);
+ assert.match(r.stdout, /skipped/);
+ const kept = existsSync(join(dir, 'AGENTS.md'));
+ assert.ok(kept, 'AGENTS.md must still exist');
+
+ const forced = onboard([dir, '--yes', '--no-npm-link', '--force']);
+ assert.equal(forced.code, 0);
+ assert.match(forced.stdout, /written/);
+ });
+});