Order 20260905T053602Z-6743615726-from-.claude (.claude, 2026-09-05), asking for the check this repo recommended when it re-measured the README a run earlier. The badge and the five `## Development` comments rotted twice in a row - 529 carried from 0.25.0, then a badge saying 868 beside comments summing to 792, two different wrong sums of the same fact on the same screen - because nothing compared them to anything. It lives in tests/selftest.test.mjs, not in a bash suite, and the choice was measured rather than assumed. The order's parenthetical pointed at whichever suite already pins README/catalog invariants; 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). This wrapper is the only place where all five numbers exist at once in a run that already happens. runSuite() captures each suite's own summary line, so the truth source is the line the suite prints. A check inside one suite could see its own total but would have to RE-RUN the other four - 212s sequentially, measured 2026-09-05 under /bin/bash 3.2 (coord 16, board 169, route 12, orders 5, guard 10) - and grepping `check` calls out of the scripts is both the second copy of the counting the order warned against and a wrong one, since those calls sit inside loops. Three properties are deliberate. The badge is compared against the MEASURED sum, never against the five README comments: a badge agreeing with five stale comments is the 868-beside-792 shape one layer down. A suite that stops printing a recognisable summary FAILS the check rather than being skipped - an absent measurement must not read as a matching one. And the check adds no bash check anywhere, so the five counts and the 893 badge are unchanged by its arrival, exactly as the order expects; a counted self-check would have had to compare against PASS+FAIL+1 and would break for whoever adds a check after it. Ground truth on this HEAD, run before anything was written: coord 257, board 393, route 73, orders 116, guard 54 = 893, 0 failed in all five - the README was already correct, so the red step is the mutation. Mutation-verified in both directions: 73 -> 74 on the route comment gives "README says route-selftest has 74 checks; it reported 73"; 893 -> 894 on the badge gives "README's badge says 894 selftest checks; the five suites reported 893"; restored, npm test is 12/12 green. Bounded gap, stated rather than closed: CLAUDE.md's own copies of the five counts are a second surface carrying the same numbers and are NOT checked. Measured and left alone - widening the check to it was not ordered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
15 KiB
JavaScript
279 lines
15 KiB
JavaScript
// 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 `<name>-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}`);
|
|
});
|