ORDRE 59. A dispatched order used to live only in a scratch prompt file
passed through argv, so it died with the pane it was typed into. Measured
2026-08-17: one order was dispatched three times over 90 minutes before it
was worked, because the first two tabs ran something else and the order
left no trace in the receiving repo at all.
New channel `~/.claude/coord/<repo>/orders/`, beside `inbox/` and never
merged with it. The axis is authorization: inbox content is untrusted
cross-repo data that may never instruct a session (Rule 6), a dispatch
order is operator-authorized work by construction. One channel carrying
both classes would mean either mail that can instruct or orders that
cannot, so the infrastructure is reused and the channel is not.
Four one-verb engines: coord-order-send.sh (write), coord-order-inbox.sh
(read, writes nothing at all), coord-order-claim.sh (atomic claim),
coord-order-done.sh (executed with a commit pointer / --no-commit with a
reason / --return with a reason).
The claim is a rename with no check-then-act step, so of N racing sessions
exactly one finds the source and the rest get ENOENT. The test that proves
it spawns 20 claimers BARRIERED on a start flag - unbarriered children do
not race at all - and runs the identical harness against a deliberately
racy `[ -e src ] && cp && rm` as a known-negative control, which must
produce many winners. Without that control, "exactly one winner" is
indistinguishable from "the race never happened".
Channel separation is pinned structurally, not only behaviourally: no mail
script may contain the string `orders`, with a known-positive control
proving the grep can find. coord-done cannot archive an order and
coord-order-claim cannot claim a message.
board gains an ORDRE column beside INN, counted with the identical idiom
and never summed with it: INN is "others are waiting on YOU", ORDRE is
"work is waiting on this REPO". Claimed orders are excluded - the column
answers what a session can pick up. board.sh --dispatch --order-id emits a
thin starter carrying only the id and the four steps, so the order text has
exactly one home; the id is validated shell-clean and must be pending in
the target's queue.
SessionStart injects the queue as its own block below the mailbox block.
Two channels, two blocks, mail first: it carries Rule 7, and the queue
order is mail -> orders -> STATE's NESTE.
Also folds in dde392d (board prefix-match fix), which landed after the
0.26.0 bump and before any tag. v0.26.0 was never tagged, so 0.27.0 is the
release that carries all of it.
Suites: coord 220, board 237, route 69, orders 97, guard 40; npm test 11/11.
Antakelse 4 (atomic claim) and antakelse 6 (morning --plan-file --dry-run
reports 1 of 1 for the thin starter) both measured, not assumed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134iB7ipXGgEpv9imYoVmr2
190 lines
10 KiB
JavaScript
190 lines
10 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' });
|
|
});
|
|
|
|
// 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', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'state-line-guard-selftest.sh')], { encoding: 'utf8' });
|
|
});
|
|
|
|
// 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', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'orders-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');
|
|
});
|
|
|
|
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');
|
|
});
|