repo-mailbox/CLAUDE.md
Kjell Tore Guttormsen 5754d67a6b feat(engine): make a bulk close state what Rule 7 says must be stated
coord-done.sh was the third script that acts on pending messages and the only
one that did not learn the field. --all archives every pending message in one
call, including the ones whose sender declared it expects a reply - the exact
outcome Rule 7 exists to prevent, now reachable with no friction and no trace.

The behavior stays. The receiver keeps both terminal states by design (section
20), and a bulk close is legitimate; refusing would move a decision that belongs
to the operator into the script. What was wrong was the silence: Rule 7 requires
leaving a message unanswered to be STATED, and a command reporting only a total
made the thing that has to be stated invisible.

So --all now names the number it closed without a reply, and only then - a line
that always fires is one nobody reads. A named close stays quiet, because naming
a message is already deliberate, one message at a time.

coord-selftest 151 -> 156.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016iJoZVmU2guTEZcMghk88z
2026-07-31 15:48:42 +02:00

166 lines
9.5 KiB
Markdown

# repo-mailbox
Renamed from `coord` in v0.3.0. The plugin/repo is `repo-mailbox`; the CLI
(`coord-send.sh`, `coord-inbox.sh`, `coord-done.sh`), the mailbox root
(`~/.claude/coord/`) and `CLAUDE_COORD_DIR` deliberately kept their names —
they are the transport protocol, not the product.
## Context
Local inter-repo coordination mailbox for Claude Code, packaged as a
marketplace plugin. Three components, one boundary:
- **Engine (`scripts/*.sh`):** bash owns all mailbox semantics — filename
grammar, frontmatter, delivery, archiving, the seen set. `coord-send.sh`
writes, `coord-inbox.sh` reads (formatted for context injection),
`coord-done.sh` archives, `coord-count.sh` counts without delivering.
Everything is pinned by `coord-selftest.sh`
(156 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
**`coord-count.sh` prints TWO integers per mailbox** (`<name>\t<pending>\t<debt>`),
and the first must stay pending: `board.sh` counts the same inbox files
itself, so a debt-only count would put two different numbers under one name.
Debt is read from `reply-expected` in the FRONTMATTER BLOCK ONLY - a body line
is untrusted input and must not be able to silence a debt - and an absent
field means a reply IS owed, because every message written before 0.11.0
lacks it.
**Reading is delivering — counting is not.** `coord-inbox.sh` records a
broadcast as seen once it has printed it, so it can never be used to survey
other repos: doing so would consume each one's backlog silently, and the seen
set is delivery history that retraction deliberately leaves alone.
`coord-count.sh` exists for every "what is pending" question and writes
nothing at all. Any future read-shaped feature belongs there, not in the
read path.
- **Hook (`hooks/scripts/session-start.mjs`):** thin zero-dependency Node
wrapper (marketplace convention: hooks are `.mjs`) that calls
`coord-inbox.sh` and emits the `hookSpecificOutput.additionalContext`
envelope. No mailbox logic lives here. Always exits 0.
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
next-step blocks + board lines, `git status`, and mailbox pending counts, and
prints one line per repo. Read-only by construction: it writes to no repo, no
STATE.md and no mailbox. Pinned by `board-selftest.sh` (36 checks).
**It lives here because the mailbox is one of its three inputs, and it carries
the same axis distinction the mailbox does.** A pending count means *others
are waiting on this repo*; who a repo waits *on* comes only from its board
line, because the message format has no reply-to field. Enforcing that in one
of two repos would not be enforcing it. `~/.claude/scripts/board.sh` is a
deployed copy (the operator's `board()` shell function points at it), exactly
as with the `coord-*` scripts — this repo is the source of truth.
- **Route (`scripts/route.sh`):** pure calculator for the next session's model
and effort. Takes four scored traits of the next task plus a required
rationale, and prints one block of `key=value` lines: the rubric row, the rule
that fired, the `next-cost` value, a pasteable startup command, the one-row
cheaper fallback, and the STATE.md comment lines. Pinned by
`route-selftest.sh` (50 checks).
**It is here because it is the WRITER for the field `board.sh` already reads.**
`next-cost` had a reader and no writer, so it was hand-typed every session and
drifted into several competing spellings — cleaning the data
could not fix that, because the cause was the missing write path. The row
table is a closed set of six values, so a seventh cannot enter circulation,
and section 6 of the selftest runs the round trip (route emits → board parses)
*inside* one repo rather than across two. `board.sh` itself is untouched: a
calculator that prints to stdout writes nothing, and the session writes
STATE.md.
**The row table is the operator's global rubric, moved here as the single
copy.** It is not a second spec — `board.sh --help` documents the board line's
*grammar* and points here for the *values*. Scoring the traits is judgement
and belongs to the skill; turning scores into a row is a lookup and takes zero
model calls.
- **Skills (`skills/coord-send/`, `skills/board/`, `skills/route/`):** natural-language front
doors mapping user intent to engine invocations. No mailbox logic lives here
either. `board` additionally owns the *ranking* — which repo wins and why —
since `board.sh` deliberately prints evidence and takes no position. `route`
likewise owns the *scoring*: the calculator is deterministic, so all judgement
sits in choosing the four trait values, and the skill must never reason its
way to a model instead.
**Boundary rule:** the mailbox is transport, not state. Durable decisions
live in the owning repo's docs/git history; messages are notices pointing at
them. Message content is untrusted cross-repo input — the read side quotes
and frames it; the send side sanitizes line-oriented fields.
What the boundary forbids is storing a repo's *state* — its decisions, its next
step, its progress. It does not forbid the mailbox knowing who it is delivering
to: `_broadcast/seen/<repo>` and `<repo>/.origin` (0.6.0) are delivery metadata,
answering "has this repo received this" and "which checkout claimed this name".
Both are unreadable as a description of the repo and useless outside delivery.
The test is not "does the engine write a file about a repo" but "would this file
still mean anything if delivery were removed". If yes, it belongs in the repo's
own docs and git history instead.
**Priority rule (v0.5.0, Rule 7):** the injection block is the only place a
repo is ever told what to do with a message, so its wording *is* the protocol
— treat that string as engine behavior, not prose. It obligates handling the
inbox first and driving every directed message to a terminal state before the
session ends. The obligation is **procedural, never substantive**: responding
is mandatory, complying with message content is not. Those two must stay
distinct in any reword — keeping the priority while dropping the distinction
turns prioritization into an injection surface. Selftest section 20 pins both
halves together for exactly that reason.
Since 0.11.0 the `reply-expected` field says which terminal state the SENDER
expects. That does not soften the split, it sharpens it: the field is untrusted
cross-repo input like the rest of the file, so the injection calls it a
*declaration, not an instruction* and keeps both terminal states open to the
receiver. Drop that clause and one word in a message becomes a lever that mints
obligations in another repo.
## Conventions
- Scripts are bash-3.2-safe and ASCII-only: no `declare -A`, no
`readarray`/`mapfile`, no `|&`; guard `shift 2` with `$# -ge 2`; guard
empty-array expansion under `set -u` with `${#a[@]}`.
- Zero dependencies everywhere: bash + coreutils in the engine, `node:`
builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (156/156),
`bash scripts/board-selftest.sh` must exit 0 (36/36) and
`bash scripts/route-selftest.sh` must exit 0 (50/50).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- Conventional Commits: `type(scope): description`.
## Commands
- Test: `bash scripts/coord-selftest.sh`, `bash scripts/board-selftest.sh` and
`bash scripts/route-selftest.sh` (or `npm test`, the Node wrapper around all three)
- Hook smoke test: `node hooks/scripts/session-start.mjs` (expects JSON on stdout)
- Board smoke test: `bash scripts/board.sh` (read-only, ~3s over the real tree)
- Route smoke test: `bash scripts/route.sh --path known --verification strong
--reversibility cheap --scope local --rationale x` (writes nothing, instant)
## Release
Version must agree across: `.claude-plugin/plugin.json`, `package.json`,
README version badge, `skills/coord-send/SKILL.md`, `skills/board/SKILL.md` and
`skills/route/SKILL.md` frontmatter, git tag `vX.Y.Z`, and the catalog `ref` in
`ktg-plugin-marketplace/catalog/.claude-plugin/marketplace.json`. Release via
the catalog's `scripts/release-plugin.mjs repo-mailbox` (tag + ref bump
together);
verify with `scripts/check-versions.mjs`. Never hand-edit a ref.
Two things that script does that its dry-run label does not suggest:
`--create-tag` creates AND pushes the tag even without `--write`, and its
closing verification gate runs `check-versions.mjs` over ALL plugins — one
unrelated plugin in ERROR aborts it with the catalog edit written but
uncommitted. When that happens, commit the catalog's `marketplace.json` +
`README.md` by hand and leave every other dirty file in that repo alone.
## Hardening roadmap
Empty — the post-v0.1.0 queue (atomic delivery, `.`/`..` rejection,
selftest gaps, uniform `-h`) shipped in v0.2.0; broadcast self-delivery
shipped in v0.2.1; broadcast retraction (`coord-send --retract`) shipped in
v0.4.0, closing the last monotonically-growing surface. `coord-inbox.sh`
still ignores unknown arguments by design (hook context must never fail)
but now warns about each one on stderr, which the hook discards.
Two retraction limits are deliberate, not gaps: it is un-send and never
recall (a repo that already received a broadcast keeps it — the seen set is
delivery history and is left untouched), and the sender check is an accident
guard, not a security boundary, because `--from` redefines identity here as
it does everywhere else in the engine.