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

@ -1,6 +1,6 @@
{
"name": "repo-mailbox",
"version": "0.6.0",
"version": "0.7.0",
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
"author": {
"name": "Kjell Tore Guttormsen"

View file

@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.0] - 2026-07-27
### Added
- **`CLAUDE_COORD_REPO` — declaring an identity for a non-git surface.** 0.6.0
removed the working-directory fallback, which was right, but it left every
non-git working surface (`~/repos`, `$HOME`) with nothing to derive from: the
read path declines silently, so such a surface simply stops seeing its inbox,
with no error and no exit code. That is the same loss-looks-like-normal shape
0.6.0 set out to remove. The hook now reads `CLAUDE_COORD_REPO` and forwards
it verbatim as `--repo`, so a directory can *declare* which mailbox it owns:
{ "env": { "CLAUDE_COORD_REPO": "repos" } }
This is not the fallback returning. The fallback guessed a name from wherever
the session happened to stand; a declaration is written down, readable back,
and deletable. Because it forwards as `--repo` it inherits every engine rule,
including that an explicit override never claims `<repo>/.origin` — otherwise
a surface borrowing a name could steal the claim from the checkout that owns
it. Both halves are pinned by tests.
## [0.6.0] - 2026-07-26
### Fixed

View file

@ -8,7 +8,7 @@
*AI-generated: all code produced by Claude Code through dialog-driven development.*
![Version](https://img.shields.io/badge/version-0.6.0-blue)
![Version](https://img.shields.io/badge/version-0.7.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple)
![Hooks](https://img.shields.io/badge/hooks-1-green)
![Skills](https://img.shields.io/badge/skills-1-orange)
@ -36,6 +36,12 @@ Mailbox layout (default `~/.claude/coord/`, override with `CLAUDE_COORD_DIR`):
Repo identity is the basename of the git toplevel, or an explicit `--from`/`--repo`. There is no third source: a directory that is not a git repo has no identity, so the write paths refuse and the read path stays silent. (The old fallback to the working-directory name was removed in 0.6.0: on a global surface like `~/repos` it silently invented the identity `repos` for a directory that is no repo at all, and signed real mail with it.) There is no registration — a repo joins the moment something is sent to it, or when it first reads a broadcast.
**Declaring an identity for a non-git surface.** A working directory that is not a git repository (`~/repos`, `$HOME`) has nothing to derive from, so the read path declines silently and that surface never sees its inbox — loss wearing the shape of normal. Set `CLAUDE_COORD_REPO` in that directory's `.claude/settings.json` to say which mailbox it owns:
{ "env": { "CLAUDE_COORD_REPO": "repos" } }
The hook forwards it verbatim as `--repo`, so it inherits every engine rule — including that an explicit override never claims `.origin`. This is a *declaration*, not the old working-directory fallback: the fallback guessed a name from wherever you happened to stand, while this is a value someone wrote down, can read back, and can delete.
Because identity is a *basename*, two checkouts with the same directory name share one mailbox. The first git-derived read records the claiming path in `<repo>/.origin`, and a read from a different path is warned about in the injection. It is a warning rather than a refusal: the same repo moved or re-cloned is the ordinary case. Names beginning with `_` are reserved for engine internals (`_broadcast`) and are refused as repo identities everywhere.
Message format (filename `<UTC-timestamp>-<uniq>-from-<sender>.md`):

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 : '');

View file

@ -1,6 +1,6 @@
{
"name": "repo-mailbox",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"type": "module",
"engines": {

View file

@ -15,7 +15,7 @@ description: >-
covers retiring a broadcast that has become wrong or obsolete: "retract that
broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen",
"den broadcasten er utdatert".
version: "0.6.0"
version: "0.7.0"
---
# coord-send — natural-language front door for inter-repo messages

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');
});