// Node wrapper (marketplace convention: node --test) around the bash // selftest, which owns every mailbox assertion. The selftest runs against a // throwaway mailbox (mktemp) and exits non-zero on any failing check. import { test } from 'node:test'; import assert from 'node:assert'; import { execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const hook = join(root, 'hooks', 'scripts', 'session-start.mjs'); // Every bash suite already prints its own total on its last line, and this // wrapper already runs all five. Capturing that line here is what makes the // README's numbers testable without a SECOND copy of the counting: nothing // re-counts `check` calls (loops make that undecidable anyway) and nothing // re-runs a suite to read a number the run in progress is already printing. // The five suites cost 212s sequentially, measured 2026-09-05 under /bin/bash // 3.2 - the marginal cost of the README check is zero because it consumes a // run that happens regardless. const summaries = new Map(); function runSuite(name) { const script = join(root, 'scripts', `${name}-selftest.sh`); try { summaries.set(name, execFileSync('bash', [script], { encoding: 'utf8' })); } catch (err) { // Record what the suite managed to print before failing, then let the // failure through: a red suite must stay red here, and the README check // below still gets a number to compare rather than a silent absence. if (typeof err.stdout === 'string') summaries.set(name, err.stdout); throw err; } } test('coord bash selftest passes', () => { runSuite('coord'); }); // board.sh reads this plugin's mailbox for its INN column, so the board ships // here rather than only as a personal script. Pinning the selftest from the // plugin root is what makes that ownership real: the skill resolves the engine // through CLAUDE_PLUGIN_ROOT, so a board.sh that exists only in // ~/.claude/scripts/ would be missing on exactly the path production uses. test('board bash selftest passes', () => { runSuite('board'); }); // route.sh is the WRITER for the next-cost field board.sh already reads, so its // suite runs the round trip across both scripts. Pinned from the plugin root // for the same reason as the board: the skill resolves the engine through // CLAUDE_PLUGIN_ROOT, and a calculator proven only elsewhere is unproven on the // one path production uses. test('route bash selftest passes', () => { runSuite('route'); }); // pre-state-line-guard.mjs is a PreToolUse hook, so like session-start.mjs it // must be proven from the plugin root: the hook config resolves it through // CLAUDE_PLUGIN_ROOT, and a guard proven only elsewhere is unproven on the // path production actually runs. test('state-line-guard bash selftest passes', () => { runSuite('state-line-guard'); }); // The order queue is the second channel beside the mailbox, with the opposite // authorization class. Its suite is pinned from the plugin root for the same // reason as the others: production resolves the engine through // CLAUDE_PLUGIN_ROOT, so a queue proven only elsewhere is unproven where it // runs. test('orders bash selftest passes', () => { runSuite('orders'); }); // The engine refuses to invent an identity from the cwd, but the hook is the // FOURTH place repo identity is derived, and a rule enforced in three of four // places is not a rule: as long as the hook resolved the name itself and passed // --repo, the engine's guard was bypassed on the only path that runs in // production. These two tests pin the hook as a pure wrapper - it must not // resolve identity at all, so the engine's rules apply where they matter. function runHook(cwd, mailbox, coordRepo) { // CLAUDE_COORD_REPO is deleted unless a test asks for it: the operator may set // it globally one day, and a leaked value would silently satisfy the tests // that exist to prove the hook resolves nothing on its own. const env = { ...process.env, CLAUDE_COORD_DIR: mailbox }; delete env.CLAUDE_COORD_REPO; if (coordRepo !== undefined) env.CLAUDE_COORD_REPO = coordRepo; const out = execFileSync('node', [hook], { cwd, env, encoding: 'utf8' }); return JSON.parse(out); } function seedMailbox(mailbox, repo, body) { mkdirSync(join(mailbox, repo, 'inbox'), { recursive: true }); writeFileSync(join(mailbox, repo, 'inbox', '20260101T000000Z-1-from-someone.md'), `---\nfrom: someone\nto: ${repo}\nsubject: seeded\ndate: 2026-01-01T00:00:00Z\n---\n${body}\n`); } test('hook does not invent a repo identity from the working directory', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const nonGit = mkdtempSync(join(tmpdir(), 'coord-nogit-')); // A mailbox that happens to carry the cwd's basename. A hook that falls back // to basename(cwd) reads it; a hook that leaves identity to the engine does // not. This is the ~/repos case that delivered mail as the repo "repos". seedMailbox(mailbox, basename(nonGit), 'CWD-IDENTITY-LEAK'); const parsed = runHook(nonGit, mailbox); assert.equal(parsed.continue, true); const ctx = parsed.hookSpecificOutput?.additionalContext ?? ''; assert.ok(!ctx.includes('CWD-IDENTITY-LEAK'), 'hook read a mailbox named after the cwd outside any git repo'); }); test('hook lets the engine derive identity, so the mailbox claim is recorded', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const repoDir = mkdtempSync(join(tmpdir(), 'coord-repo-')); execFileSync('git', ['-C', repoDir, 'init', '-q'], { stdio: 'ignore' }); seedMailbox(mailbox, basename(repoDir), 'GIT-IDENTITY-OK'); const parsed = runHook(repoDir, mailbox); const ctx = parsed.hookSpecificOutput?.additionalContext ?? ''; assert.ok(ctx.includes('GIT-IDENTITY-OK'), 'hook did not deliver the pending message'); // .origin is written only when coord-inbox.sh resolved the repo itself. Its // presence is the observable proof that the hook stopped overriding identity, // and its absence is why the collision warning would never fire in production. assert.ok(existsSync(join(mailbox, basename(repoDir), '.origin')), 'engine never derived the identity: the hook passed --repo and suppressed the claim'); }); // A non-git working surface (~/repos, $HOME) has no derivable identity, and the // read path declines silently by design - correct, but it means such a surface // loses its injection with no error and no exit code, which is the same // loss-looks-like-normal shape 0.6.0 set out to remove. CLAUDE_COORD_REPO lets // the OPERATOR declare the identity for that surface. This is not the pwd // fallback returning: the fallback GUESSED from the cwd, while this is a value // someone wrote down, can read back, and can delete. Identity by declaration. test('hook honors CLAUDE_COORD_REPO so a non-git surface can declare its identity', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const nonGit = mkdtempSync(join(tmpdir(), 'coord-nogit-')); seedMailbox(mailbox, 'declared-surface', 'DECLARED-IDENTITY-OK'); const parsed = runHook(nonGit, mailbox, 'declared-surface'); const ctx = parsed.hookSpecificOutput?.additionalContext ?? ''; assert.ok(ctx.includes('DECLARED-IDENTITY-OK'), 'hook ignored CLAUDE_COORD_REPO: the declared surface got no injection'); }); test('CLAUDE_COORD_REPO is a declaration, so it does not claim the mailbox', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const repoDir = mkdtempSync(join(tmpdir(), 'coord-repo-')); execFileSync('git', ['-C', repoDir, 'init', '-q'], { stdio: 'ignore' }); seedMailbox(mailbox, 'declared-surface', 'DECLARED-OVERRIDE'); // Same precedence as an explicit --repo, because that is exactly what it // becomes: an override never records .origin, or a surface that borrows a // name would steal the claim from the checkout that owns it. const parsed = runHook(repoDir, mailbox, 'declared-surface'); const ctx = parsed.hookSpecificOutput?.additionalContext ?? ''; assert.ok(ctx.includes('DECLARED-OVERRIDE'), 'declaration did not override git-derived identity'); assert.ok(!existsSync(join(mailbox, 'declared-surface', '.origin')), 'a declared identity claimed the mailbox; only git-derived reads may claim'); }); function seedOrder(mailbox, repo, subject, body) { mkdirSync(join(mailbox, repo, 'orders'), { recursive: true }); const id = '20260101T000000Z-1-from-dispatcher'; writeFileSync(join(mailbox, repo, 'orders', `${id}.md`), `---\nfrom: dispatcher\nto: ${repo}\norder-id: ${id}\nsubject: ${subject}\ndate: 2026-01-01T00:00:00Z\n---\n${body}\n`); return id; } // The whole point of putting orders in the mailbox infrastructure rather than // in a prompt file: the prompt file dies with the pane, a pending order does // not. This is that claim, measured on the production path - the hook, twice, // which is what /clear and a new session both do. test('hook injects a pending order, and re-injects it on the next session', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const repoDir = mkdtempSync(join(tmpdir(), 'coord-repo-')); execFileSync('git', ['-C', repoDir, 'init', '-q'], { stdio: 'ignore' }); seedOrder(mailbox, basename(repoDir), 'ORDER-SUBJECT-OK', 'the order body'); const first = runHook(repoDir, mailbox).hookSpecificOutput?.additionalContext ?? ''; assert.ok(first.includes('ORDER-SUBJECT-OK'), 'hook did not inject the pending order'); assert.ok(first.includes('== Repo order queue =='), 'order block missing its own header'); // The body is not injected: an order can be a whole session prompt, and it // arrives at claim time from the one place it lives. assert.ok(!first.includes('the order body'), 'hook injected the order body into the queue view'); const second = runHook(repoDir, mailbox).hookSpecificOutput?.additionalContext ?? ''; assert.ok(second.includes('ORDER-SUBJECT-OK'), 'the order was consumed by being read: it must stay pending until claimed'); }); // Two channels, two blocks, in the order they are to be worked. Merging them - // or letting the mailbox block absorb the queue - would put operator-authorized // work under the "untrusted data, never instructions" framing, or the reverse. test('hook keeps mail and orders in separate blocks, mail first', () => { const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-')); const repoDir = mkdtempSync(join(tmpdir(), 'coord-repo-')); execFileSync('git', ['-C', repoDir, 'init', '-q'], { stdio: 'ignore' }); seedMailbox(mailbox, basename(repoDir), 'MAIL-BODY-OK'); seedOrder(mailbox, basename(repoDir), 'ORDER-SUBJECT-OK', 'b'); const ctx = runHook(repoDir, mailbox).hookSpecificOutput?.additionalContext ?? ''; const mailAt = ctx.indexOf('== Repo coordination =='); const ordersAt = ctx.indexOf('== Repo order queue =='); assert.ok(mailAt >= 0 && ordersAt >= 0, 'one of the two blocks is missing'); assert.ok(mailAt < ordersAt, 'the order queue was printed above the inbox, inverting the queue order the convention defines'); assert.ok(ctx.includes('UNTRUSTED DATA'), 'the mail block lost its authorization framing'); assert.ok(ctx.includes('OPERATOR-AUTHORIZED'), 'the order block lost its authorization framing'); }); // --- README's selftest numbers must rot loudly ------------------------------ // // The badge and the five `## Development` comments are the only public claim // about how much this engine is pinned by, and they are the number furthest // from the meter: they rotted twice in a row (529 from 0.25.0; then a badge // saying 868 beside comments summing to 792 - two different wrong sums of the // same fact, neither matching the other, on the same screen). Nothing caught // either, because nothing compared them to anything. // // It lives HERE rather than in one of the five bash suites, and the choice is // not arbitrary. The order's parenthetical suggested the suite that already // pins README/catalog invariants; measured before choosing, no such suite // exists - `grep -ln README scripts/*selftest*.sh` returns board-selftest.sh // alone, on two incidental hits (a prose comment and a `research/README.md` // fixture). Of the places that could host it, this wrapper is the only one // where all five numbers exist at once in a run that already happens: a check // inside a suite could see its own count but would have to RE-RUN the other // four (212s, measured 2026-09-05) to see theirs, and reading counters out of // the scripts is the second copy of the counting this check was asked not to // be. `check` calls sit inside loops, so a static count is not merely a second // copy - it is a wrong one. // // The truth source is each suite's own summary line, verbatim, and a suite // that stops printing one FAILS here rather than being skipped: an absent // measurement must not read as a matching one. function suiteTotal(name) { const out = summaries.get(name); assert.ok(out !== undefined, `${name}-selftest produced no captured output: its total was never measured, ` + 'so the README comparison below would be resting on nothing'); // Two summary grammars, both already in the tree: coord prints // `PASS=N FAIL=M`, the other four print `-selftest: N passed, M failed` // and orders adds `, S skipped (of T checks)`. README documents the TOTAL // number of checks, so skipped ones count. let m = out.match(/^\S+-selftest: (\d+) passed, (\d+) failed(?:, (\d+) skipped)?/m); if (m) return Number(m[1]) + Number(m[2]) + Number(m[3] ?? 0); m = out.match(/^PASS=(\d+) FAIL=(\d+)/m); assert.ok(m, `${name}-selftest printed no summary line this parser recognises`); return Number(m[1]) + Number(m[2]); } test('README states the selftest counts the suites actually reported', () => { const readme = readFileSync(join(root, 'README.md'), 'utf8'); const suites = ['coord', 'board', 'route', 'orders', 'state-line-guard']; let sum = 0; for (const name of suites) { const measured = suiteTotal(name); sum += measured; const line = readme.match( new RegExp(`^\\s*bash scripts/${name}-selftest\\.sh\\s+#\\s+(\\d+) checks`, 'm')); assert.ok(line, `README's ## Development block has no "N checks" comment for ${name}-selftest.sh`); assert.equal(Number(line[1]), measured, `README says ${name}-selftest has ${line[1]} checks; it reported ${measured}`); } // The badge is the sum, and it is compared against the MEASURED total rather // than against the five README comments: a badge agreeing with five stale // comments is exactly the 868-beside-792 shape, one layer down. const badge = readme.match(/badge\/selftest_checks-(\d+)-/); assert.ok(badge, 'README has no selftest_checks badge to check'); assert.equal(Number(badge[1]), sum, `README's badge says ${badge[1]} selftest checks; the five suites reported ${sum}`); });