feat(hook): let a non-git surface declare its identity via CLAUDE_COORD_REPO

0.6.0 removed the working-directory fallback, which was right, but it left
every non-git working surface with nothing to derive from. The read path
declines silently by design - the hook must never fail a session - so such a
surface simply stops seeing its inbox: no error, no exit code, nothing. That is
the same loss-looks-like-normal shape 0.6.0 set out to remove, reintroduced one
layer up.

The hook now forwards CLAUDE_COORD_REPO verbatim as --repo. This is not the
fallback returning, and the distinction is the whole point: the fallback GUESSED
a name from wherever the session happened to stand, while a declaration is
written down in that directory's settings, readable back, and deletable.
Identity stays a choice someone made.

Forwarding as --repo rather than reimplementing anything means it inherits every
engine rule, including that an explicit override never claims <repo>/.origin -
otherwise a surface borrowing a name would steal the claim from the checkout
that owns it. Both halves are pinned by tests, and the shared test helper now
deletes CLAUDE_COORD_REPO unless a test asks for it, so a value set globally
later cannot silently satisfy the tests that prove the hook resolves nothing on
its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
This commit is contained in:
Kjell Tore Guttormsen 2026-07-27 08:56:31 +02:00
commit 28e7cb42cd
7 changed files with 91 additions and 18 deletions

View file

@ -22,12 +22,14 @@ test('coord bash selftest passes', () => {
// --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) {
const out = execFileSync('node', [hook], {
cwd,
env: { ...process.env, CLAUDE_COORD_DIR: mailbox },
encoding: 'utf8',
});
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);
}
@ -67,3 +69,37 @@ test('hook lets the engine derive identity, so the mailbox claim is recorded', (
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');
});