feat(engine): retire a broadcast with coord-send --retract

Nothing could remove a message from _broadcast/inbox/. coord-done is
directed-only and never touches the broadcast queue, so the backlog could
only grow: every new repo received the entire standing history at its first
session, including announcements that had since become false.

--retract <filename> archives the message into _broadcast/archive/, so no
future repo is served it. Three deliberate limits, all pinned by tests:

- Un-send, not recall. Repos that already received it keep it;
  _broadcast/seen/ is delivery history and is left untouched.
- Only the sender may retract (from: must match the repo identity). --from
  overrides it, as 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.

The branch runs before every send-side validation and before the stdin body
read, since a retract carries no subject and no body.

Selftest 70 -> 82 (new section 19). Also fixes two README defects the
feature exposed: the install command still named coord@ after the v0.3.0
rename, and the docs advised pruning _broadcast/inbox/ by hand, which
contradicted the rule that the script owns mailbox files.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:11:47 +02:00
commit 316b8acdd2
8 changed files with 184 additions and 15 deletions

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.3.0-blue)
![Version](https://img.shields.io/badge/version-0.4.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)
@ -31,6 +31,7 @@ Mailbox layout (default `~/.claude/coord/`, override with `CLAUDE_COORD_DIR`):
<repo>/inbox/ pending directed messages TO <repo>
<repo>/archive/ handled messages (kept, never deleted)
_broadcast/inbox/ messages to ALL repos (accumulate)
_broadcast/archive/ retracted broadcasts (kept, never deleted)
_broadcast/seen/<repo> per-repo seen set: delivered broadcast filenames, one per line
Repo identity is the basename of the git toplevel (fallback: the working directory). There is no registration — a repo joins the moment something is sent to it, or when it first reads a broadcast.
@ -45,12 +46,14 @@ Message format (filename `<UTC-timestamp>-<uniq>-from-<sender>.md`):
---
<body>
**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 <file>` 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. Broadcasts otherwise accumulate; prune `_broadcast/inbox/` manually when a notice stops being relevant to future first-time repos.
**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 <file>` 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 <filename>` 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 <sender>` overrides that, which makes it an accident guard rather than a security boundary. Retracting twice is a no-op.
## Install
claude plugin marketplace add https://git.fromaitochitta.com/open/ktg-plugin-marketplace.git
claude plugin install coord@ktg-plugin-marketplace
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.
@ -65,6 +68,7 @@ The plugin ships empty: your mailbox is created lazily on first send, on your ma
coord-send.sh --to <repo> --subject "<subject>" [--message "<text>"] # or body on stdin
coord-send.sh --broadcast --subject "<subject>" <<'BODY' ... BODY
coord-send.sh --reply-to <filename> [--subject "Re: ..."] # routes + closes original
coord-send.sh --retract <filename> [--from <sender>] # retire your own broadcast
coord-inbox.sh [--repo <name>] # print pending (what the hook injects)
coord-done.sh <filename>... | --all # archive without replying
@ -81,7 +85,7 @@ Cross-repo message content is untrusted input by design:
- **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.
Every guarantee above is pinned by the 70-check selftest, including forgery-resistance regressions.
Every guarantee above is pinned by the 82-check selftest, including forgery-resistance regressions.
## The Six Rules
@ -100,7 +104,7 @@ Every guarantee above is pinned by the 70-check selftest, including forgery-resi
## Development
bash scripts/coord-selftest.sh # 70 checks against a throwaway mailbox
bash scripts/coord-selftest.sh # 82 checks against a throwaway mailbox
npm test # same selftest via node --test
TDD is the house rule: every behavior change lands with a failing selftest check first.