feat: onboarding script, full lh flag reference, expanded README

- scripts/onboard.mjs: bootstraps a target repo onto the harness — copies
  the behaviour layer (.github/agents, skills, instructions, prompts,
  mcp.json, copilot-instructions.md, AGENTS.md), npm links the `lh` CLI,
  runs `lh init` + `lh doctor`, prints next steps. Idempotent: identical
  files are skipped, differing files require --force, re-running `lh init`
  on an initialized repo warns instead of failing. Supports --dry-run and
  --no-npm-link for CI/sandboxed use.
- tests/onboard.test.mjs: 5 new e2e tests (dry-run, full run, idempotent
  rerun, missing target dir, conflict + --force).
- README: new "Onboarding a new project" section, full lh flag reference
  for every subcommand (previously only one-line summaries), Development
  section mentions the onboarding script.

Live-tested against ~/Sources/ralph-runtime (a real, not-yet-git-tracked
project): git init, .github/ copied, `npm link` succeeded, `lh init`
ran, `lh doctor` correctly flagged its one real gap (no verify command
configured) rather than a false pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-09-09 22:58:46 +02:00
co-authored by Copilot
parent cd36cc0efc
commit da20a6eaff
4 changed files with 409 additions and 2 deletions
+66 -1
View File
@@ -103,6 +103,32 @@ To learn an unfamiliar codebase first:
/onboard /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 ## 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 report [runId] [--journal]` | Markdown telemetry report / run journal |
| `lh doctor` | Environment checks | | `lh doctor` | Environment checks |
### Full flag reference
<details>
<summary>Every flag each subcommand reads (click to expand)</summary>
- `lh init [--yes] [--force] [--json]` — `--yes` skips prompts with defaults; `--force` re-initializes
an existing `.agents/harness.config.json`.
- `lh index [--budget N] [--focus <glob>] [--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 <level>] [--fix-manifest] [--json]` — terse brief is the default output;
`--fix-manifest` rewrites `codegraph.manifest.json`-style baselines.
- `lh lane create --id <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 <id> [--json]`
- `lh lane merge --id <id> [--abort] [--strategy s] [--json]`
- `lh lane drop --id <id> [--force] [--json]`
- `lh run start --objective "..." [--spec slug] [--host h] [--strategy s] [--attrs '{"k":"v"}']
[--json]`
- `lh run event --run <id> --type <t> [--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 <id> [--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]`
</details>
## Repository footprint ## Repository footprint
The harness writes into one configurable directory: The harness writes into one configurable directory:
@@ -222,11 +285,13 @@ npm install
npm run validate # manifests + every markdown frontmatter block npm run validate # manifests + every markdown frontmatter block
npm test npm test
node src/cli.mjs --help node src/cli.mjs --help
node scripts/onboard.mjs <target-dir> --dry-run # preview bootstrapping another repo
``` ```
`npm run validate` is the distribution gate: it checks `plugin.json`, `marketplace.json`, `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 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 ## Prior art
+2 -1
View File
@@ -27,7 +27,8 @@
"scripts": { "scripts": {
"validate": "node scripts/validate.mjs", "validate": "node scripts/validate.mjs",
"test": "node --test tests/", "test": "node --test tests/",
"lh": "node src/cli.mjs" "lh": "node src/cli.mjs",
"onboard": "node scripts/onboard.mjs"
}, },
"dependencies": { "dependencies": {
"web-tree-sitter": "^0.25.10" "web-tree-sitter": "^0.25.10"
+262
View File
@@ -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 <target-dir> [--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 <target-dir> [--yes] [--force] [--dry-run] [--no-npm-link]
<target-dir> 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 <describe what you want built>
/fast-track <small brownfield fix>
/onboard # summarize repo state for a new contributor
Full pipeline docs: ${path.join(HARNESS_ROOT, 'README.md')}`);
process.exit(failed ? 1 : 0);
}
main();
+79
View File
@@ -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/);
});
});