# repo-mailbox Local mailbox for coordination between Claude Code sessions in different repos — directed messages and broadcasts injected as context at session start. Local, private, no network. Session A in repo X leaves a message for repo Y; the next session in repo Y gets it injected as context at startup. It is for anyone running an AI coding agent across several repositories on one machine, where a decision in one repo has to reach the next session in another. No server, no daemon, no accounts. > Formerly `coord` (renamed in v0.3.0). The plugin is now `repo-mailbox`; the CLI (`coord-send`, `coord-inbox`, `coord-done`), the mailbox root `~/.claude/coord/` and `CLAUDE_COORD_DIR` keep their names — they are the transport protocol, not the product. > **Solo-maintained, fork-and-own.** This plugin is a starting point, not a vendor product. Issues are welcome as signals; pull requests are not accepted. See the [organisation governance](https://git.fromaitochitta.com/open/repo-standard/src/branch/main/GOVERNANCE.md) for the full model. *AI-generated: all code produced by Claude Code through dialog-driven development.* ![Version](https://img.shields.io/badge/version-0.33.0-blue) ![Hooks](https://img.shields.io/badge/hooks-1-green) ![Skills](https://img.shields.io/badge/skills-4-orange) ![CLI scripts](https://img.shields.io/badge/CLI_scripts-8-blue) ![Selftest checks](https://img.shields.io/badge/selftest_checks-868-blue) --- ## Why This Exists When you work with an AI coding agent across several repositories, the sessions are islands. A decision made in repo X often matters to repo Y — a shared spec changed, a bug another repo depends on got fixed, a gate was re-pinned. Without a channel, you are the messenger: you remember to mention it in the next session, or it gets lost. repo-mailbox is that channel, reduced to the simplest thing that works: a directory of Markdown files on your own disk. Sending is writing a file; receiving is a SessionStart hook that injects your repo's pending messages as context. No server, no daemon, no network, no accounts. **Transport, not state.** A coord message is a notice, not a source of truth. The durable record of any decision lives in the owning repo (its docs, its git history). Messages point at that record; they never replace it. ## Non-goals - **Not a state store.** A message is a notice pointing at a decision, never the decision itself. If a file here starts acting as some repo's state-of-play, it belongs in that repo. - **Not a network protocol.** Everything is files under your `$HOME`. There is no server, no daemon, no sync — two machines do not share a mailbox, and nothing here is reachable from outside the host. - **Not an issue tracker.** No assignees, priorities, due dates or workflow states. A message has a sender, a recipient, a subject, a body, and one bit saying whether a reply is expected. - **Not a conversation.** `--reply-to` routes a reply and closes the original, but nothing in the message format records a thread — there is no reply-to field on a received message, which is why a repo's board line, not the mailbox, says who it is waiting on. - **The board never writes.** `board.sh` reads `STATE.md`, `git status` and mailbox counts, and writes to no repo, no `STATE.md` and no mailbox. Driving a terminal from its day plan deliberately lives outside this repo. - **No CI.** Nothing runs the selftests automatically; the forge has no runner. The substitute is one command from a clean clone — see [Development](#development). ## How It Works Mailbox layout (default `~/.claude/coord/`, override with `CLAUDE_COORD_DIR`): /inbox/ pending directed messages TO /archive/ handled messages (kept, never deleted) _broadcast/inbox/ messages to ALL repos (accumulate) _broadcast/archive/ retracted broadcasts (kept, never deleted) _broadcast/seen/ per-repo seen set: delivered broadcast filenames, one per line 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 `/.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 `--from-.md`): --- from: to: | broadcast subject: date: --- **Lifecycle — deliver until done.** Directed messages are NOT archived on read. They stay pending and are re-injected at every session start (startup, `/clear`, resume) until explicitly marked handled: replying (`--reply-to`) archives the original, or `coord-done ` archives it without a reply. `/clear` never loses a message. Broadcasts are delivered once per repo via the seen set, and never back to their own sender — the announcing repo's seen entry is written at delivery time, so it is not told its own news. **Retracting a broadcast.** Broadcasts otherwise accumulate, and every future first-time repo receives the whole standing backlog — including announcements that have since become false. `coord-send --retract ` retires one: it moves the message out of `_broadcast/inbox/` into `_broadcast/archive/`, so no future repo is served it. This is un-send, **not recall** — repos that already received it are unaffected. Only the original sender may retract (`from:` must match your repo identity); `--from ` overrides that, which makes it an accident guard rather than a security boundary. Retracting twice is a no-op. ## Install ```bash claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git claude plugin install repo-mailbox@ktg-plugin-marketplace ``` The plugin ships empty: your mailbox is created lazily on first send, on your machine, and stays there. ## Usage **Natural language (the `coord-send` skill).** In any session: "tell repo-x the bug is fixed", "broadcast that the spec changed", "reply to that coord message", "when the tests are green, notify repo-y". The skill maps intent to the right `coord-send` invocation, including bounded multi-target loops and deferred sends. **Receiving** is automatic: the SessionStart hook injects your repo's pending inbox and unseen broadcasts as context, with per-message reply/resolve hints. Since v0.5.0 the injection also states the priority contract — handle the inbox before the work the session came to do, and drive every directed message to a terminal state before the session ends (Rule 7). Since v0.8.0 it closes with one aggregate line about mail pending in *other* mailboxes, so an empty inbox no longer reads as "all clear" while messages sit unanswered elsewhere. Two integers, never a roster: naming the other mailboxes would put their situation inside your repo's injection, and the line explicitly disclaims the obligation it sits beneath — those counts are not yours to handle, and counting them delivered nothing. **Choosing between repos (the `board` skill).** "What should I work on?", "who is waiting on me?", "what unblocks the most?" — `board.sh` scans every repo it can find and reads three sources per repo: the STATE.md next-step block and its optional board line, `git status`, and the pending count in that repo's mailbox. The skill runs it, ranks by leverage (what unblocks the most, cheapest first) and answers with one repo and the rule that fired, never the table. Read-only: it writes to no repo, no STATE.md, and no mailbox. The board is deliberately *not* wired into session start — it runs when asked. The mailbox is one of its three inputs, which is why the board lives here. Note the axis: a repo's pending count means *others are waiting on it*, an obligation it owes outward. Who a repo waits *on* comes only from its own board line, because the message format has no reply-to field. **Choosing what to run the next session with (the `route` skill).** The board's cost column has to come from somewhere. `route.sh` is a pure calculator: score four traits of the next task — is the solution path `known`, `partial` or `undetermined`; will `verification` be `strong`, `weak` or `none`; is it `cheap`, `costly` or `one-way` to reverse; is the `scope` `local`, `multi-file` or `cross-cutting` — and it returns the model and effort, a pasteable startup command, and the one-step-cheaper fallback for when quota is tight. Escalation is asymmetric: any single trait escalates, while the cheapest row needs all four at the cheap end. A free-text rationale is required, because that is where a misscore is found later. It lives here because it is the **writer** for the cost field the board already reads. That field had a reader and no writer, so it was retyped by hand every session and drifted into several spellings; the row table is a closed set, so a new spelling cannot enter circulation, and the selftest runs the round trip — route emits, board parses — inside one repo instead of across two. Nothing about `board.sh` changes: a calculator printing to stdout writes nothing, and the session writes its own STATE.md. Scoring is judgement and belongs to the skill; turning scores into a row is a lookup and costs no model calls. One deliberate side effect is worth more than the tokens saved: a next step that cannot be scored `known` or `partial`, with no design phase planned, is an **underspecified task description** — the answer is to rewrite the step, not to upgrade the model. **Handing a task to another session (the `dispatch` skill).** "Start a session in repo X on this order." `board.sh --dispatch` turns that into one line the operator can paste: the order written to a prompt file, the model and effort looked up from the same row table `route.sh` uses, and the prompt passed **in argv** — `claude --model … --effort … "$(cat )"` — so the session is handed its task instead of having to guess it out of STATE.md. It emits one of two forms, and which one is a measurement rather than a preference: a repo with no terminal pane gets a plan block (`morning --plan-file --no-go`), while a repo that already has one gets a bare paste line for that tab, because a plan block for an already-open repo is silently dropped by the driver and reads as a broken plan file. `--target-pane yes|no` is therefore required with no default, exactly as `route.sh` refuses to default `--last-effort`: it is a fact about the world, and this plugin never looks for a terminal itself. Read-only holds — the prompt file and the plan file are written by the caller, never by `board.sh`. **Making the order outlive the tab (the order queue).** A prompt file passed through argv dies with the pane it was typed into, and nothing in the receiving repo records that an order ever arrived. Measured 2026-08-17: one order was dispatched three times over 90 minutes before it was worked, because the first two tabs ran something else. So dispatch now delivers the order into the recipient's own queue — `~/.claude/coord//orders/` — and the pasted line becomes a thin **starter** carrying only the order id. The order text has one home. If the tab is never run, nothing is lost: the order stays pending, is re-injected at every session start in that repo, and shows up in `board`'s ORDRE column, which sits beside INN and is never summed with it — INN is "others are waiting on you", ORDRE is "work is waiting on this repo". Ownership is explicit rather than implied. An order is pending until a session **claims** it, and the claim is a rename with no check-then-act step, so of any number of racing sessions exactly one wins and the rest get a clean refusal. The claiming session owns it until it either closes it with a commit pointer or **returns** it with a reason recorded in the order itself. At claim time the session is told to compare the order against its own `STATE.md` next step and to state any divergence in its first reply — a dispatch that displaces a live next step is a decision, and this makes it an uttered one. A session that claims an order and dies is the one remaining way an order could vanish, so claimed orders stay visible in the injection with their in-flight age; that is a visible-again rule, not a lease timer, because nothing here can know that a session is dead. **CLI.** The engine is twelve user-facing bash scripts in the plugin's `scripts/` directory (plus five selftests); resolve them as `"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude}/scripts/coord-.sh"` (from a terminal, use the plugin's install path): coord-send.sh --to --subject "" [--message ""] # or body on stdin coord-send.sh --to --subject "" --fyi # a notice: no reply expected coord-send.sh --broadcast --subject "" <<'BODY' ... BODY coord-send.sh --reply-to [--subject "Re: ..."] # routes + closes original coord-send.sh --retract [--from ] # retire your own broadcast coord-inbox.sh [--repo ] # print pending (what the hook injects) coord-done.sh ... | --all # archive without replying coord-count.sh [--exclude ] # per mailbox: pending + replies owed, delivering nothing coord-sweep.sh [--write] [--days ] [--log ] # close aged notices machine-wide (dry-run by default) board.sh [--roots [,...]] [--brief|--plan] [--focus ""] # cross-repo attention board (read-only) board.sh --voyage # Voyage briefs in flight (read-only) board.sh --dispatch --repo --order-id \ --target-pane --path ... --rationale "" # startup command for a session in coord-order-send.sh --to --subject "" --prompt-file # deliver a work order into 's queue coord-order-inbox.sh [--repo ] # print the pending queue (what the hook injects) coord-order-claim.sh [--repo ] | --next # claim one order, atomically coord-order-done.sh --commit # executed, with a result pointer coord-order-done.sh --return --reason "" # back to the queue, with the reason brief-nightly.sh # render the briefing to a file, atomically route.sh --path --verification --reversibility \ --scope --rationale "" # model + effort for the next session The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to … | done without reply: coord-done …`) refer to these scripts. **`coord-sweep.sh` is the only script that closes a message without a human in the loop**, and it is bounded to one mechanically decidable class: a directed message whose sender declared `reply-expected: no`, older than a grace window (default 14 days). A message that owes a reply is never touched, at any age, with any flag — answering it would mean deciding something on the receiving repo's behalf. Dry-run is the default, inverted from every other script here, because this is the one that destroys pending state. Every closure appends a line naming the sender and subject: a directed message has no seen-tracking, so the sweep cannot tell "seen and ignored" from "never delivered", and a notice to a repo left unopened for the whole window is closed *unread*. The log is what keeps that from being silent. **Since 0.33.0 that sweep is scheduled, and the schedule is the whole feature — invocation was the gap, not the mechanism.** `coord-sweep.sh` shipped in 0.10.0 and had then never run once: measured 2026-09-03 across 52 mailboxes, 27 pending directed messages, of which 23 were pure notices being re-injected at every session start in repos nobody had opened. The script was correct and unreachable, so nothing new was built — a second mechanism would have been two copies of a policy that already existed. `launchd/com.ktg.repo-mailbox-sweep.plist` runs `--write --days 14` daily at 05:30, half an hour *before* the briefing agent — not because it changes what the briefing reports, but because the briefing scans the same mailbox this mutates, and the two must not overlap. The briefing's *debt* figure is in fact unaffected: since 0.22.0 it is computed from `coord-count.sh`'s `owed` column, and this sweep closes only messages that owe nothing. What the sweep moves is the raw pending count — the table's `INN` column, and the volume every repo gets injected at session start. The window is written out in the plist rather than left to the script's default: it is a policy constant decided on a measured distribution (30 days would have closed 0 messages, 14 closed 7, 7 would have closed 13), and changing a default must never silently change what an unattended job closes every night. Both agents are **templates**, carrying `__CHECKOUT__`/`__HOME__` placeholders rather than absolute paths, because this repo is mirrored publicly. Substitute them at install time: ```bash sed -e "s|__CHECKOUT__|$PWD|g" -e "s|__HOME__|$HOME|g" \ launchd/com.ktg.repo-mailbox-sweep.plist \ > ~/Library/LaunchAgents/com.ktg.repo-mailbox-sweep.plist launchctl load ~/Library/LaunchAgents/com.ktg.repo-mailbox-sweep.plist ``` **`launchctl list` proves an agent is *loaded*, never that it is *right*.** A plist naming a script that does not exist loads cleanly and then silently never runs — there is no output to be wrong and no exit status to read, so the failure looks exactly like a quiet machine. Two separate things close that: `launchctl start