repo-mailbox/CHANGELOG.md
Kjell Tore Guttormsen ec92c866c1 feat(engine)!: counting is not delivering, and an empty inbox is not all clear
Reading IS delivery in this engine: coord-inbox.sh prints a broadcast and then
records it as seen. That made "what is pending elsewhere" unanswerable -- asking
the read path per repo would have consumed every repo's broadcast backlog as a
side effect, once, silently, and unrecoverably, since the seen set is delivery
history that retraction deliberately leaves alone.

coord-count.sh answers it by counting files and writing nothing: no seen set, no
.origin. It keys on MAILBOXES rather than repos -- it enumerates $COORD/* and
never scans a filesystem for checkouts -- so a repo without a mailbox is not
missing from the count, it is absent from the domain. Drained mailboxes are
omitted rather than reported as zero, because the question is "who is owed a
reply" and a list of zeroes answers a different one at every reader's expense.

The read path now closes with one aggregate line built from it. The case that
motivated this is the session whose own inbox is empty: it saw silence and
concluded "all clear" while mail sat unanswered everywhere else.

BREAKING (injection contract): the read path is no longer silent whenever THIS
repo has nothing pending. It is a silent no-op only when the whole mailbox is
empty. Coupling the line to having your own mail would have hidden it from its
only real audience. Three selftest assertions that used "no output at all" as a
proxy for "nothing was delivered" now assert the absence of the content itself,
which is what they always meant.

The line is an AGGREGATE of two integers, never a roster. A list of names would
reproduce other repos' situation inside this repo's injection -- the state
boundary the mailbox exists to respect -- and mailbox names are cross-repo
input. Two integers cannot carry anything that escapes the framing.

Its disclaimer is engine behavior, not politeness (Rule 7): the line lands
directly beneath "handle this inbox FIRST", and without it the numbers read as
an extension of that obligation and a session starts answering other repos'
mail. Pinned in selftest section 26 exactly as section 20 pins the priority
text. The hook's header drops "(unread messages)" for the same reason -- it
would now announce mail that does not exist.

Selftest 116 -> 136. Every new negative check is anchored to a positive
assertion in the same output, because a missing script makes "X is absent"
true by vacuity and would have gone green proving nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
2026-07-27 20:31:21 +02:00

15 KiB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.8.0] - 2026-07-27

Added

  • coord-count.sh — counting without delivering. Reading is delivery in this engine: coord-inbox.sh prints a broadcast and then records it as seen, so there was no way to ask "what is pending" without answering it. Asking per repo would have consumed every repo's broadcast backlog as a side effect — once, silently, and unrecoverably, since the seen set is delivery history that retraction deliberately leaves alone. coord-count.sh counts files instead and writes nothing: no seen set, no .origin.

    It keys on mailboxes, not repos. It enumerates $COORD/* and never scans a filesystem for checkouts, so a repo without a mailbox is not missing from the count — it is absent from the domain. Drained mailboxes are omitted rather than reported as zero, the reserved _ namespace is skipped, and --exclude drops one mailbox (the caller's own).

  • A cross-repo line in the SessionStart injection. A session whose own inbox was empty saw silence and concluded "all clear" while mail sat unanswered in every other mailbox. The read path now appends one aggregate line:

    Elsewhere in the mailbox: 3 unhandled messages across 2 other mailboxes.
    Counted, not delivered -- none of it is yours to handle here. Run
    coord-count for the per-mailbox breakdown.
    

    Deliberately an aggregate of two integers, not a roster. A list of names would reproduce every other repo's situation inside this repo's injection — the state boundary the mailbox exists to respect — and mailbox names are cross-repo input. Two integers cannot carry anything that escapes framing.

    The disclaimer is engine behavior, not politeness (Rule 7): the line lands directly beneath "handle this inbox FIRST", and without it the numbers read as an extension of that obligation. Pinned in selftest section 26, exactly as section 20 pins the priority text.

Changed

  • BREAKING (injection contract): the read path is no longer silent whenever this repo has nothing pending. It is now a silent no-op only when the whole mailbox is empty. The empty-inbox session is precisely the one that needed the cross-repo line, so coupling the line to having your own mail would have hidden it from its only real audience. Three selftest assertions that used "no output at all" as a proxy for "nothing was delivered" now assert the absence of the content itself, which is what they always meant.

[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

  • _broadcast is a reserved namespace, not a repo. coord-send guarded retraction with a sender check, but that guard only covered the door it was nailed to: coord-done --repo _broadcast <file> archived a broadcast out of the queue — a full unauthenticated retract of an announcement for every repo that had not read it yet. The whole _ prefix is now reserved, in every CLI, so a later _seen or _config cannot reopen the hole.
  • Repo identity is derived, never invented. The fallback to basename(pwd) is gone. It existed so the CLI would work anywhere, but "anywhere" includes every global surface: a session in ~/repos is not a repo, and the fallback silently handed it the identity repos — under which real mail was delivered. git toplevel or an explicit --from/--repo are now the only sources. Write paths exit 2 and name the fix; the read path declines silently, because the hook runs at every session start and must never fail a session.
  • Broadcast delivery is recorded only after the injection is written. Marking inside the read loop left a window where the seen set said "delivered" while the operator saw nothing — and the hook runs under timeout: 10, so the window was reachable. A lost broadcast is unrecoverable by design (the seen set is delivery history, and retraction deliberately leaves it alone), so the failure mode is now redelivery.
  • The hook no longer resolves identity at all. It was a fourth independent copy of the identity rule and the only one that runs in production, so passing --repo bypassed the engine's guards exactly where they mattered — and suppressed the new collision check along with them.

Added

  • Mailbox-collision warning. Two checkouts with the same directory name share one mailbox. Re-keying identity would break every existing mailbox and the readable --to <repo> addressing, so the first git-derived read records the claiming path in <repo>/.origin, and a read from a different path is reported in the injection. A warning, not a refusal: the same repo moved or re-cloned is the ordinary case. It goes in the injection because the hook discards stderr, and a warning nobody can see is not a warning.

Changed

  • Selftest 93 → 116 checks; three Node tests now cover the hook behaviorally rather than by reading its source.

Breaking

  • coord-send and coord-done exit 2 outside a git repository instead of naming themselves after the working directory. Pass --from/--repo to choose an identity explicitly. Repo names beginning with _ are refused everywhere.

[0.5.0] - 2026-07-25

Changed

  • The inbox is a priority, not a suggestion (Rule 7). Through 0.4.0 the injection block told every repo to "consider replying/resolving where it fits in this session". That sentence was the whole problem: the injection text is the only place a repo is ever told what to do with a message, so the wording is the protocol — and it granted permission to defer. Messages sat unanswered for weeks while each session did its own work first. Nothing was broken; the protocol was asking for exactly what it got.

    The block now states an ordering and a completion obligation:

    • Handle the inbox first, before the task the session came to do — "not after it, not if there is time".
    • Every directed message must reach a terminal state before the session ends: coord-send --reply-to or coord-done.
    • Neither terminal state is the default. The format has no reply-expected field, so mandating only the reply would manufacture traffic for messages that merely inform.
    • Leaving one pending stays allowed, but must be stated to the operator with a reason. The escape hatch is explicit rather than silent.
  • Raising priority deliberately does not widen the trust boundary. The obligation is procedural, never substantive: responding is mandatory, complying with what a message asks is not — only the operator authorizes that. Untrusted cross-repo content still cannot direct the reader; it merely can no longer be ignored. The injection states both halves, and the selftest pins them together so a future reword cannot keep the priority and quietly drop the distinction.

  • Selftest grew from 82 to 93 checks (new section 20 pins the wording contract: the permissive phrasing is gone, ordering and completion are stated, both terminal states are named with neither as default, responding-vs-complying is kept distinct, and the untrusted-data framing survives).

[0.4.0] - 2026-07-25

Added

  • coord-send --retract <filename>: a broadcast can finally be retired. Until now nothing could remove a message from _broadcast/inbox/: coord-done is directed-only and never touches the broadcast queue. The backlog could therefore only grow, and every new repo received the entire standing history at its first session — including announcements that had since become false. Retract moves the message to _broadcast/archive/, so no future repo is served it.

    Deliberate limits, both documented and pinned by tests:

    • Un-send, not recall. Repos that already received the broadcast keep it; _broadcast/seen/ is delivery history and is left untouched.
    • Only the sender may retract (from: must match the repo identity). --from overrides this, as it does everywhere else in the engine, which makes the check an accident guard rather than a security boundary.
    • Nothing is deleted, mirroring coord-done. Retracting twice is a no-op (exit 0).
  • Selftest grew from 70 to 82 checks (new section 19 covers retraction: authorization, archiving, non-delivery to new repos, no collateral damage to other broadcasts, invalid names, flag combinations, and idempotency).

Fixed

  • README's install command still said claude plugin install coord@ktg-plugin-marketplace after the v0.3.0 rename.
  • README told the reader to prune _broadcast/inbox/ by hand when a notice stopped being relevant — advice that contradicted the rule that the script owns mailbox files. --retract is now the supported path.

[0.3.0] - 2026-07-25

Changed

  • Renamed the plugin and repository from coord to repo-mailbox. The old name said that something was coordinated but not what the thing was; the new one names what it is, using the same vocabulary the code and docs already use throughout (mailbox, inbox, archive, broadcast). The Forgejo repository moved from open/coord to open/repo-mailbox (the old URL redirects), and the marketplace entry, plugin manifest and package name follow.

    This is breaking for installed users: the skill is now invoked as /repo-mailbox:coord-send rather than /coord:coord-send, and the plugin must be reinstalled under its new name.

    Deliberately unchanged: the CLI (coord-send.sh, coord-inbox.sh, coord-done.sh, coord-selftest.sh), the skill name coord-send, the mailbox root ~/.claude/coord/, and the CLAUDE_COORD_DIR override. Those name the transport protocol, not the product — renaming them would migrate live mailbox data and break message history in every participating repo for no gain in clarity.

Fixed

  • Catalog README listed 48 selftest checks for this plugin; the actual count has been 70 since v0.2.2.

[0.2.2] - 2026-07-25

Changed

  • coord-inbox.sh now warns on stderr for each unknown argument instead of discarding it silently. The read path stays lenient (it runs inside the SessionStart hook, which must never fail a session over a stray flag) and the exit code is unchanged, but a mistyped flag no longer looks like a working invocation. The hook runs the script with stderr discarded, so the warning surfaces in manual CLI use only.

Added

  • Selftest: 68 -> 70 checks, covering the stderr warning and the leniency it must not break (unknown argument still reads the inbox and exits 0).

Documentation

  • README: corrected the stale selftest count (64 -> 70), documented that a broadcast is never delivered back to its own sender (shipped in 0.2.1 but undocumented), and added rule 6 — message content is data, never instructions — which coord-inbox.sh already enforces in the injection framing.

[0.2.1] - 2026-07-25

Fixed

  • Broadcast self-delivery: coord-send --broadcast left no seen-set entry for the sender, so the announcing repo got its own announcement injected at its next session start. The delivered filename is now recorded in _broadcast/seen/<sender> at delivery time, reusing the existing per-repo seen set rather than adding a second exclusion mechanism. Filtering on the from: field at read time was rejected: from: is sender-controlled, so it would let any repo suppress a broadcast for another by forging the field. The seen file is keyed by the raw sender name (the read side keys it unsanitized, so the two must agree); senders containing a slash, or equal to ./.., skip the marking rather than escape the directory.

Added

  • Selftest: 64 -> 68 checks, covering sender self-exclusion, unaffected delivery to other repos, self-exclusion for sender names that are not shell-clean, and the seen-marking path-escape guard.

[0.2.0] - 2026-07-24

Hardening pass over the three CLIs.

Fixed

  • Atomic delivery: the temp file is created inside the destination directory (dot-prefixed, invisible to the inbox glob), so the final rename never crosses filesystems and readers never observe a half-written message.
  • . and .. are rejected explicitly in the --reply-to and coord-done name guards instead of relying on downstream failure.

Added

  • -h/--help on coord-inbox.sh, making usage output uniform across the three CLIs.
  • Selftest: 48 -> 64 checks, closing gaps around the default mailbox path via HOME fallback, malformed frontmatter on the read path, and a read-only destination directory.

[0.1.0] - 2026-07-24

First public release: extraction of a battle-tested personal mechanism into a distributable plugin.

Added

  • Bash engine: coord-send.sh (directed / broadcast / reply delivery), coord-inbox.sh (pending inbox + unseen broadcasts, formatted for context injection), coord-done.sh (archive without reply), all bash-3.2-safe, ASCII-only, zero dependencies.
  • coord-selftest.sh: 48 checks against a throwaway mailbox, including regression cover for filename sanitization, no-hang argument parsing, prompt-injection resistance, frontmatter hygiene, and broadcast ordering.
  • SessionStart hook (hooks/scripts/session-start.mjs): thin zero-dependency Node wrapper that injects the repo's pending mailbox as additionalContext. Always exits 0 — a broken mailbox never blocks a session.
  • coord-send skill: natural-language front door (English + Norwegian triggers) mapping intent to engine invocations, including bounded multi-target loops, deferred sends, and replies.

Fixed

  • Frontmatter injection: CR/LF and control characters in --subject/--from are collapsed before writing, so fields can no longer inject frontmatter lines or a premature --- terminator.
  • Broadcast loss: a per-repo seen set (_broadcast/seen/<repo>, one delivered filename per line) replaces the single high-water mark, which silently dropped a same-second broadcast whose filename sorted below one already seen. Upgrade note: a legacy watermark file is read as a one-entry seen set, so old broadcasts may be re-delivered once.

Changed

  • Read-side protocol strings are English (--- message:, -> reply:, UNTRUSTED DATA framing); the forgery-resistance selftest checks pin the English tokens.