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

@ -23,15 +23,25 @@ try {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..');
// No --repo, and no identity resolution here. This used to resolve the repo
// itself and fall back to process.cwd(), which made it a fourth independent
// copy of the identity rule - and the only one that runs in production, so
// the engine's guards were bypassed exactly where they mattered. The engine
// runs in this same cwd and derives the identity from git alone; passing
// --repo would also mark the read as an explicit override and suppress the
// mailbox-collision check. Boundary rule: no mailbox logic lives in the hook.
// No identity resolution here. This used to resolve the repo itself and fall
// back to process.cwd(), which made it a fourth independent copy of the
// identity rule - and the only one that runs in production, so the engine's
// guards were bypassed exactly where they mattered. The engine runs in this
// same cwd and derives the identity from git alone.
//
// CLAUDE_COORD_REPO is the one exception, and it is a DECLARATION rather than
// a derivation: a working surface that is not a git repo (~/repos, $HOME) has
// nothing to derive from, so the read path declines silently and the surface
// loses its injection with no error - loss wearing the shape of normal. The
// operator sets this in that directory's settings to say which mailbox the
// surface owns. It is not the pwd fallback returning: the fallback guessed,
// this is written down, readable back, and deletable. Forwarded verbatim as
// --repo, so it inherits the engine's rules - including that an explicit
// override never claims .origin. Boundary rule holds: no mailbox logic here.
const declared = process.env.CLAUDE_COORD_REPO;
const script = join(pluginRoot, 'scripts', 'coord-inbox.sh');
const inbox = execFileSync('bash',
[join(pluginRoot, 'scripts', 'coord-inbox.sh')],
declared ? [script, '--repo', declared] : [script],
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
emit(inbox.trim() ? '== Repo coordination (unread messages) ==\n' + inbox : '');