Recovered from crashed session (Node OOM). Repo contains full P0-P6 scaffold: plugin.json/marketplace.json, AGENTS.md, ADRs 0001-0006, lh CLI (init/index/graph/lane/run/memory/host/report/doctor), 10 .github/agents, 12 CLI skills, instructions, context7 mcp.json, and unit/e2e test suite. Fixed: run.mjs read --in-tokens/--out-tokens but tests and CLI docs use --input-tokens/--output-tokens, so telemetry totals were always 0. Now accepts both forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
import { test, describe, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
import { tempRepo, write, commitAll, lh, cleanup } from './helpers.mjs';
|
|
|
|
after(cleanup);
|
|
|
|
const PKG = JSON.stringify({
|
|
name: 'fixture',
|
|
version: '1.0.0',
|
|
scripts: { test: "node -e \"console.log('ok')\"" },
|
|
}, null, 2);
|
|
|
|
/** A brownfield repo carrying two planted structural defects. */
|
|
function brownfield() {
|
|
return tempRepo({
|
|
'package.json': PKG,
|
|
'src/a.js': 'export function helper(x) { return x + 1; }\nexport function shared() { return 1; }\n',
|
|
'src/b.js': "import { helper } from './a.js';\nexport function shared() { return 2; }\nexport function main() { return helper(missingFn()); }\n",
|
|
'README.md': '# fixture\n',
|
|
});
|
|
}
|
|
|
|
describe('cli contract', () => {
|
|
test('--help exits 0 and lists the command surface', () => {
|
|
const r = lh(process.cwd(), ['--help']);
|
|
assert.equal(r.code, 0);
|
|
for (const cmd of ['init', 'index', 'graph', 'lane', 'run', 'memory', 'host', 'report', 'doctor']) {
|
|
assert.ok(r.stdout.includes(cmd), `help should mention ${cmd}`);
|
|
}
|
|
});
|
|
|
|
test('an unknown command fails rather than silently succeeding', () => {
|
|
assert.notEqual(lh(process.cwd(), ['no-such-command']).code, 0);
|
|
});
|
|
|
|
test('host detection always emits a usable strategy', () => {
|
|
const r = lh(brownfield(), ['host', '--json']);
|
|
assert.equal(r.code, 0);
|
|
const host = JSON.parse(r.stdout);
|
|
assert.ok(host.host, 'a host must always be named');
|
|
assert.ok(host.maxWriteLanes >= 1, 'must allow at least one write lane');
|
|
assert.ok(host.readOnlyFanOut >= 1, 'must allow at least one scout');
|
|
});
|
|
});
|
|
|
|
describe('init (greenfield + brownfield)', () => {
|
|
test('greenfield init creates config and memory shards', () => {
|
|
const dir = tempRepo({ 'README.md': '# empty\n' });
|
|
assert.equal(lh(dir, ['init', '--yes']).code, 0);
|
|
assert.ok(existsSync(join(dir, '.agents/harness.config.json')));
|
|
assert.ok(existsSync(join(dir, '.agents/memory/INDEX.md')));
|
|
for (const shard of ['failures', 'corrections', 'insights', 'conventions', 'quirks']) {
|
|
assert.ok(existsSync(join(dir, `.agents/memory/${shard}.md`)), `missing shard ${shard}`);
|
|
}
|
|
});
|
|
|
|
test('brownfield init autodetects the existing verify command', () => {
|
|
const dir = brownfield();
|
|
const r = lh(dir, ['init', '--yes', '--json']);
|
|
assert.equal(r.code, 0);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.ok(out.verify.some((v) => v.command.includes('test')), 'should detect npm test');
|
|
});
|
|
|
|
test('the Context7 key is never written to disk', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
const raw = readFileSync(join(dir, '.agents/harness.config.json'), 'utf8');
|
|
assert.ok(raw.includes('CONTEXT7_API_KEY'), 'config should reference the env var by name');
|
|
if (process.env.CONTEXT7_API_KEY) {
|
|
assert.ok(!raw.includes(process.env.CONTEXT7_API_KEY), 'the key value must never be persisted');
|
|
}
|
|
});
|
|
|
|
test('re-running init refuses to clobber an existing config', () => {
|
|
const dir = brownfield();
|
|
assert.equal(lh(dir, ['init', '--yes']).code, 0);
|
|
assert.notEqual(lh(dir, ['init', '--yes']).code, 0, 'second init must refuse');
|
|
});
|
|
|
|
test('doctor passes once the repo is initialised', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
lh(dir, ['index']);
|
|
assert.equal(lh(dir, ['doctor']).code, 0);
|
|
});
|
|
|
|
test('doctor fails on an uninitialised repo', () => {
|
|
assert.notEqual(lh(tempRepo({ 'README.md': '#\n' }), ['doctor']).code, 0);
|
|
});
|
|
});
|
|
|
|
describe('index and structural gate', () => {
|
|
test('index works offline and reports symbols', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
const r = lh(dir, ['index', '--stats']);
|
|
assert.equal(r.code, 0);
|
|
assert.match(r.stdout, /symbols\s+[1-9]/, 'should discover symbols without network access');
|
|
assert.ok(existsSync(join(dir, '.agents/.cache/symbols.json')));
|
|
});
|
|
|
|
test('a second index run hits the cache', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
lh(dir, ['index']);
|
|
assert.match(lh(dir, ['index', '--stats']).stdout, /cacheHits\s+[1-9]/);
|
|
});
|
|
|
|
test('the gate exits 1 and names both planted defects', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
lh(dir, ['index']);
|
|
const r = lh(dir, ['graph', '--brief']);
|
|
assert.equal(r.code, 1, 'structural violations must exit 1 — the Ralph loop gates on this');
|
|
assert.match(r.stdout, /duplicate-definition/);
|
|
assert.match(r.stdout, /shared/);
|
|
assert.match(r.stdout, /unresolved-call/);
|
|
assert.match(r.stdout, /missingFn/);
|
|
});
|
|
|
|
test('the gate exits 0 on a clean repo', () => {
|
|
const dir = tempRepo({
|
|
'package.json': PKG,
|
|
'src/a.js': 'export function helper(x) { return x + 1; }\n',
|
|
'src/b.js': "import { helper } from './a.js';\nexport function main() { return helper(1); }\n",
|
|
});
|
|
lh(dir, ['init', '--yes']);
|
|
lh(dir, ['index']);
|
|
const r = lh(dir, ['graph', '--brief']);
|
|
assert.equal(r.code, 0, `clean repo must pass, got: ${r.stdout}${r.stderr}`);
|
|
});
|
|
});
|
|
|
|
describe('parallel write lanes', () => {
|
|
function laneRepo() {
|
|
const dir = tempRepo({
|
|
'package.json': PKG,
|
|
'src/a/deep/f.txt': 'one\n',
|
|
'src/b/g.txt': 'two\n',
|
|
'root.txt': 'base\n',
|
|
});
|
|
lh(dir, ['init', '--yes']);
|
|
return dir;
|
|
}
|
|
|
|
test('disjoint lanes both get a worktree', () => {
|
|
const dir = laneRepo();
|
|
assert.equal(lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']).code, 0);
|
|
assert.equal(lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']).code, 0);
|
|
assert.match(lh(dir, ['lane', 'list']).stdout, /\ba\b[\s\S]*\bb\b/);
|
|
});
|
|
|
|
test('an overlapping write lane is rejected', () => {
|
|
const dir = laneRepo();
|
|
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
|
|
// src/a/deep/** is a strict subset of src/a/** — must still be refused.
|
|
const r = lh(dir, ['lane', 'create', '--id', 'c', '--title', 'C', '--kind', 'write', '--scope', 'src/a/deep/**']);
|
|
assert.notEqual(r.code, 0, 'overlapping write scopes must never both be leased');
|
|
assert.match(r.stderr + r.stdout, /overlap|intersect/i);
|
|
});
|
|
|
|
test('out-of-scope writes are detected and exit 1', () => {
|
|
const dir = laneRepo();
|
|
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
|
|
const wt = join(dir, '.agents/.cache/worktrees/a');
|
|
write(wt, 'src/a/deep/f.txt', 'legit edit\n');
|
|
write(wt, 'root.txt', 'OUT OF SCOPE\n');
|
|
commitAll(wt, 'one legit, one violation');
|
|
|
|
const r = lh(dir, ['lane', 'status', 'a']);
|
|
assert.equal(r.code, 1, 'scope violation is Ralph exit criterion 5 — must be machine-detectable');
|
|
assert.match(r.stdout, /root\.txt/);
|
|
assert.match(r.stdout, /OUT/);
|
|
});
|
|
|
|
test('a clean lane merges, then a conflicting lane is blocked not auto-resolved', () => {
|
|
const dir = laneRepo();
|
|
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
|
|
lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']);
|
|
|
|
const wtA = join(dir, '.agents/.cache/worktrees/a');
|
|
write(wtA, 'root.txt', 'LANE A\n');
|
|
commitAll(wtA, 'lane a');
|
|
|
|
const wtB = join(dir, '.agents/.cache/worktrees/b');
|
|
write(wtB, 'root.txt', 'LANE B\n');
|
|
commitAll(wtB, 'lane b');
|
|
|
|
assert.equal(lh(dir, ['lane', 'merge', 'a']).code, 0, 'first merge should be clean');
|
|
|
|
const conflict = lh(dir, ['lane', 'merge', 'b']);
|
|
assert.notEqual(conflict.code, 0, 'a conflict must fail loudly');
|
|
assert.match(conflict.stdout + conflict.stderr, /root\.txt/);
|
|
assert.match(lh(dir, ['lane', 'list']).stdout, /blocked/);
|
|
});
|
|
|
|
test('merge --abort restores a clean tree', () => {
|
|
const dir = laneRepo();
|
|
lh(dir, ['lane', 'create', '--id', 'a', '--title', 'A', '--kind', 'write', '--scope', 'src/a/**']);
|
|
lh(dir, ['lane', 'create', '--id', 'b', '--title', 'B', '--kind', 'write', '--scope', 'src/b/**']);
|
|
for (const id of ['a', 'b']) {
|
|
const wt = join(dir, `.agents/.cache/worktrees/${id}`);
|
|
write(wt, 'root.txt', `LANE ${id}\n`);
|
|
commitAll(wt, `lane ${id}`);
|
|
}
|
|
lh(dir, ['lane', 'merge', 'a']);
|
|
lh(dir, ['lane', 'merge', 'b']);
|
|
assert.equal(lh(dir, ['lane', 'merge', 'b', '--abort']).code, 0);
|
|
assert.equal(lh(dir, ['lane', 'drop', 'b', '--force']).code, 0);
|
|
});
|
|
});
|
|
|
|
describe('memory', () => {
|
|
function memRepo() {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
return dir;
|
|
}
|
|
|
|
test('put then retrieve by query', () => {
|
|
const dir = memRepo();
|
|
assert.equal(lh(dir, ['memory', 'put', '--shard', 'failures', '--title', 'flaky suite', '--body', 'Retry once before reporting.']).code, 0);
|
|
const r = lh(dir, ['memory', 'get', '--query', 'flaky']);
|
|
assert.equal(r.code, 0);
|
|
assert.match(r.stdout, /Retry once/);
|
|
});
|
|
|
|
test('writing a secret is refused', () => {
|
|
const dir = memRepo();
|
|
const r = lh(dir, ['memory', 'put', '--shard', 'insights', '--title', 'leak',
|
|
'--body', 'key ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789']);
|
|
assert.notEqual(r.code, 0, 'secrets must never reach the memory shards');
|
|
const shard = readFileSync(join(dir, '.agents/memory/insights.md'), 'utf8');
|
|
assert.ok(!shard.includes('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 'shard must stay clean');
|
|
});
|
|
|
|
test('INDEX.md stays small — it is the only always-loaded file', () => {
|
|
const dir = memRepo();
|
|
for (let i = 0; i < 20; i++) {
|
|
lh(dir, ['memory', 'put', '--shard', 'insights', '--title', `note ${i}`, '--body', 'x'.repeat(200)]);
|
|
}
|
|
const index = readFileSync(join(dir, '.agents/memory/INDEX.md'), 'utf8');
|
|
assert.ok(index.length < 2000, `INDEX.md must stay lean, was ${index.length} bytes`);
|
|
});
|
|
});
|
|
|
|
describe('telemetry', () => {
|
|
test('a full run produces events, a board and a report', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
|
|
const start = lh(dir, ['run', 'start', '--objective', 'test run', '--json']);
|
|
assert.equal(start.code, 0);
|
|
const runId = JSON.parse(start.stdout).runId;
|
|
assert.ok(runId, 'run start must return a runId');
|
|
|
|
assert.equal(lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end',
|
|
'--agent', 'scout', '--input-tokens', '100', '--output-tokens', '50']).code, 0);
|
|
assert.equal(lh(dir, ['run', 'end', '--run', runId]).code, 0);
|
|
|
|
assert.ok(existsSync(join(dir, `.agents/runs/${runId}/events.ndjson`)));
|
|
assert.ok(existsSync(join(dir, `.agents/runs/${runId}/board.md`)));
|
|
|
|
const report = lh(dir, ['report', runId, '--json']);
|
|
assert.equal(report.code, 0);
|
|
assert.equal(JSON.parse(report.stdout).totals.totalTokens, 150);
|
|
});
|
|
|
|
test('a malformed NDJSON line does not break reporting', () => {
|
|
const dir = brownfield();
|
|
lh(dir, ['init', '--yes']);
|
|
const runId = JSON.parse(lh(dir, ['run', 'start', '--objective', 'test run', '--json']).stdout).runId;
|
|
lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end', '--input-tokens', '10']);
|
|
|
|
const events = join(dir, `.agents/runs/${runId}/events.ndjson`);
|
|
appendFileSync(events, '{ this is not json\n');
|
|
lh(dir, ['run', 'event', '--run', runId, '--type', 'agent.end', '--input-tokens', '5']);
|
|
|
|
const r = lh(dir, ['report', runId, '--json']);
|
|
assert.equal(r.code, 0, 'the parser must tolerate corrupt lines');
|
|
assert.equal(JSON.parse(r.stdout).totals.inputTokens, 15, 'valid events must still be counted');
|
|
});
|
|
});
|