repo-mailbox/hooks/scripts/session-start.mjs
Kjell Tore Guttormsen 28e7cb42cd 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
2026-07-27 08:56:31 +02:00

50 lines
2.3 KiB
JavaScript

#!/usr/bin/env node
// coord - SessionStart hook: inject this repo's pending coordination inbox
// (directed messages + unseen broadcasts) as additionalContext.
//
// Thin Node wrapper (marketplace convention: hooks are .mjs) around the bash
// engine scripts/coord-inbox.sh, which owns the mailbox semantics and is
// covered by scripts/coord-selftest.sh. Zero dependencies. Always exits 0 -
// a broken mailbox must never block a session.
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
function emit(context) {
const out = { continue: true };
if (context) {
out.hookSpecificOutput = { hookEventName: 'SessionStart', additionalContext: context };
}
process.stdout.write(JSON.stringify(out) + '\n');
}
try {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT
|| join(dirname(fileURLToPath(import.meta.url)), '..', '..');
// 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',
declared ? [script, '--repo', declared] : [script],
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' });
emit(inbox.trim() ? '== Repo coordination (unread messages) ==\n' + inbox : '');
} catch {
emit('');
}