The board line's next-cost field had a reader (board.sh) and no writer, so
its value was retyped by hand every session and drifted into several
competing spellings. Cleaning the data could not fix that: the cause was the
missing write path.
route.sh is that writer. Four scored traits of the next task -- path,
verification, reversibility, scope -- plus a required rationale, run through
the operator's model rubric moved here as the single copy. The row table is a
closed set of six values, so a seventh spelling cannot enter circulation, and
route-selftest.sh section 6 runs the round trip (route emits -> board parses)
inside one repo rather than across two.
Two spellings of one decision come out of one table: the rubric name for the
board line, the CLI alias for the command the operator pastes. Effort levels
are the set pinned in this marketplace; model aliases are gated against the
installed claude rather than hardcoded.
Three things worth naming:
- Escalation is asymmetric. Any single trait escalates; the cheapest row needs
all four at the cheap end. Underkill costs one session, overkill costs quota
every session -- but a wrong architecture call costs more than either.
- The Fable rows fire only from an explicit judgement flag, never inferred
from the last-session record. "The session did not finish" also covers
context exhaustion and operator interrupts, which say nothing about the
model, and Fable runs without an advisor.
- The trait block is a single-line HTML comment because board.sh's NESTE
extractor skips only lines that START with '<!--'. Measured first: a YAML
block or a multi-line comment silently replaces the repo's next step on the
board with "next_task:". Pinned by section 7.
board.sh is untouched as a program; its header now points at route.sh for the
value set so this does not reopen the two-specs defect 305f168 closed.
Selftests: coord 136, board 30, route 47 (new), node 7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017peNgsxVt1BR4BTuMwiPoX
123 lines
6.7 KiB
JavaScript
123 lines
6.7 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, 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');
|
|
|
|
test('coord bash selftest passes', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'coord-selftest.sh')], { encoding: 'utf8' });
|
|
});
|
|
|
|
// 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', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'board-selftest.sh')], { encoding: 'utf8' });
|
|
});
|
|
|
|
// 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', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'route-selftest.sh')], { encoding: 'utf8' });
|
|
});
|
|
|
|
// 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');
|
|
});
|