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

@ -1,6 +1,6 @@
{
"name": "repo-mailbox",
"version": "0.3.0",
"version": "0.4.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,40 @@ 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.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

View file

@ -14,7 +14,7 @@ marketplace plugin. Three components, one boundary:
grammar, frontmatter, delivery, archiving, the seen set. `coord-send.sh`
writes, `coord-inbox.sh` reads (formatted for context injection),
`coord-done.sh` archives. Everything is pinned by `coord-selftest.sh`
(70 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
(82 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
- **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`
@ -35,7 +35,7 @@ and frames it; the send side sanitizes line-oriented fields.
- 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 (70/70).
`bash scripts/coord-selftest.sh` must exit 0 (82/82).
- English for all code, docs, and commit messages (public repo). Norwegian
trigger aliases in the skill description are deliberate.
- Conventional Commits: `type(scope): description`.
@ -66,6 +66,13 @@ uncommitted. When that happens, commit the catalog's `marketplace.json` +
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. `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.
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.

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.

View file

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

View file

@ -235,6 +235,49 @@ printf '%s' "$aerr" | grep -q 'unknown argument: --bogus-flag'; check "inbox: un
aout="$("$INBOX" --repo argrepo --bogus-flag 2>/dev/null)"; rc=$?
[ "$rc" -eq 0 ] && printf '%s' "$aout" | grep -q 'ARG-BODY'; check "inbox: unknown argument still reads the inbox and exits 0" $?
# 19. A broadcast can be retired. Without this, the broadcast backlog grows
# monotonically: every NEW repo receives the whole history at its first session,
# including announcements that have since become false. Retract is sender-side
# ("un-send"), archives rather than deletes, and is NOT recall - repos that
# already received the message keep it. Every invocation reads from /dev/null so
# a regression that falls through to the stdin body read fails instead of hanging.
"$SEND" --broadcast --from retractor --subject "oops" --message "RETRACT-ME-BODY" >/dev/null
rbase="$(basename "$(ls "$CLAUDE_COORD_DIR"/_broadcast/inbox/*-from-retractor.md 2>/dev/null | head -1)")"
"$SEND" --broadcast --from keeper --subject "keep" --message "KEEP-BC-BODY" >/dev/null
nr="$("$SEND" --retract "$rbase" --from someone-else </dev/null 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$nr" | grep -q 'sent by retractor'; check "retract by a non-sender is refused" $?
[ -e "$CLAUDE_COORD_DIR/_broadcast/inbox/$rbase" ]; check "refused retract leaves the broadcast pending" $?
vo="$("$SEND" --retract </dev/null 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$vo" | grep -q 'requires a value'; check "retract: missing value is a usage error" $?
uo="$("$SEND" --retract no-such-broadcast.md --from retractor </dev/null 2>&1)"; rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$uo" | grep -q 'not found'; check "retract of an unknown broadcast exits 2" $?
bad_rc=0
for bad in . .. sub/dir; do
bo="$("$SEND" --retract "$bad" --from retractor </dev/null 2>&1)"; brc=$?
{ [ "$brc" -eq 2 ] && printf '%s' "$bo" | grep -q 'invalid --retract'; } || bad_rc=1
done
[ "$bad_rc" -eq 0 ]; check "retract rejects . / .. / path names as invalid" $?
comb_rc=0
for combo in --broadcast "--to:x" "--reply-to:y"; do
cflag="${combo%%:*}"; cval="${combo#*:}"
if [ "$cval" = "$combo" ]; then
co="$("$SEND" --retract "$rbase" "$cflag" --from retractor </dev/null 2>&1)"; crc=$?
else
co="$("$SEND" --retract "$rbase" "$cflag" "$cval" --from retractor </dev/null 2>&1)"; crc=$?
fi
{ [ "$crc" -eq 2 ] && printf '%s' "$co" | grep -q 'cannot be combined'; } || comb_rc=1
done
[ "$comb_rc" -eq 0 ]; check "retract cannot be combined with --to/--broadcast/--reply-to" $?
ro="$("$SEND" --retract "$rbase" --from retractor </dev/null 2>&1)"; rc=$?
[ "$rc" -eq 0 ]; check "retract by the sender exits 0" $?
[ ! -e "$CLAUDE_COORD_DIR/_broadcast/inbox/$rbase" ]; check "retracted broadcast leaves the broadcast inbox" $?
[ -e "$CLAUDE_COORD_DIR/_broadcast/archive/$rbase" ]; check "retracted broadcast is archived, never deleted" $?
fout="$("$INBOX" --repo fresh-reader)"
[ "$(printf '%s' "$fout" | grep -c 'RETRACT-ME-BODY')" -eq 0 ]; check "retracted broadcast is never delivered to a new repo" $?
printf '%s' "$fout" | grep -q 'KEEP-BC-BODY'; check "unretracted broadcasts still reach that repo" $?
io="$("$SEND" --retract "$rbase" --from retractor </dev/null 2>&1)"; rc=$?
[ "$rc" -eq 0 ] && printf '%s' "$io" | grep -q 'already retracted'; check "re-retracting an already retracted broadcast is a no-op (exit 0)" $?
echo "----"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View file

@ -6,9 +6,13 @@
# coord-send.sh --to <repo> --subject "<subject>" [--from <repo>] [--message "<text>"]
# coord-send.sh --broadcast --subject "<subject>" [--from <repo>] [--message "<text>"]
# coord-send.sh --reply-to <file> [--subject "Re: ..."] [--from <repo>] [--message "<text>"]
# coord-send.sh --retract <file> [--from <repo>]
# Body comes from --message, or from stdin (heredoc) when --message is omitted.
# --reply-to <basename> replies to a message in THIS repo's inbox/archive: it
# routes to the original sender and marks the original handled (coord-done).
# --retract <basename> retires one of YOUR OWN broadcasts: it is archived out of
# the broadcast queue so no future repo receives it. This is un-send, not
# recall - repos that already received it are unaffected.
# --from overrides the sender/self identity (default: basename of git toplevel/cwd).
#
# Exit: 0 delivered, 2 usage/IO error. ASCII only, bash 3.2 safe.
@ -18,6 +22,7 @@ export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
TO=""; BROADCAST=0; SUBJECT=""; FROM=""; MESSAGE=""; HAVE_MESSAGE=0; REPLYTO=""; REPLY_ORIG=""
RETRACT=""
# bash 3.2: `shift 2` past the end of $# is a no-op, so a trailing value-flag
# without its value would loop forever. Every two-arg flag must check first.
@ -30,6 +35,7 @@ while [ $# -gt 0 ]; do
--to) require_value --to $#; TO="$2"; shift 2 ;;
--broadcast) BROADCAST=1; shift ;;
--reply-to) require_value --reply-to $#; REPLYTO="$2"; shift 2 ;;
--retract) require_value --retract $#; RETRACT="$2"; shift 2 ;;
--subject) require_value --subject $#; SUBJECT="$2"; shift 2 ;;
--from) require_value --from $#; FROM="$2"; shift 2 ;;
--message) require_value --message $#; MESSAGE="$2"; HAVE_MESSAGE=1; shift 2 ;;
@ -52,6 +58,46 @@ fi
sanitize_field() { printf '%s' "$1" | tr '\r\n' ' ' | tr -d '\000-\037'; }
FROM="$(sanitize_field "$FROM")"
# --- Retract mode: retire one of our own broadcasts ---
# A broadcast has no per-repo owner that can close it: coord-done is directed-only
# and never touches _broadcast/. Without this branch a stale announcement stays in
# the queue and is delivered to every FUTURE repo forever, so the backlog can only
# grow. Runs before every send-side validation and before the stdin body read: a
# retract carries no subject and no body.
# The sender check is an accident guard, not a security boundary - --from
# redefines identity here exactly as it does everywhere else in this script. It
# exists so a wrong filename cannot silently retire another repo's announcement.
if [ -n "$RETRACT" ]; then
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ] || [ -n "$REPLYTO" ]; then
echo "coord-send: --retract cannot be combined with --to/--broadcast/--reply-to" >&2; exit 2
fi
case "$RETRACT" in */*|.|..|"") echo "coord-send: invalid --retract name: $RETRACT" >&2; exit 2 ;; esac
BC_INBOX="$COORD/_broadcast/inbox"
BC_ARCHIVE="$COORD/_broadcast/archive"
if [ ! -e "$BC_INBOX/$RETRACT" ]; then
# Idempotent like coord-done: retracting twice is a no-op, not an error.
if [ -e "$BC_ARCHIVE/$RETRACT" ]; then
echo "coord-send: broadcast already retracted ($RETRACT)"; exit 0
fi
echo "coord-send: --retract broadcast not found: $RETRACT" >&2; exit 2
fi
ORIG_FROM="$(grep -m1 '^from:' "$BC_INBOX/$RETRACT" 2>/dev/null | sed 's/^from:[[:space:]]*//')"
if [ "$ORIG_FROM" != "$FROM" ]; then
echo "coord-send: refusing to retract a broadcast sent by ${ORIG_FROM:-unknown} (you are $FROM); pass --from ${ORIG_FROM:-<sender>} if that is really you" >&2
exit 2
fi
# Archived, never deleted (coord-done's rule), and the move stays inside
# _broadcast/ so it is a same-filesystem rename: no half-retracted state.
mkdir -p "$BC_ARCHIVE" 2>/dev/null || { echo "coord-send: cannot create $BC_ARCHIVE" >&2; exit 2; }
if ! mv "$BC_INBOX/$RETRACT" "$BC_ARCHIVE/$RETRACT" 2>/dev/null; then
echo "coord-send: retract failed for $RETRACT" >&2; exit 2
fi
# _broadcast/seen/* is deliberately left alone: those entries are delivery
# history, and one naming an archived file is inert.
echo "coord-send: retracted broadcast ($RETRACT); repos that already received it are unaffected"
exit 0
fi
# --- Reply mode: resolve target + default subject from the original message ---
if [ -n "$REPLYTO" ]; then
if [ "$BROADCAST" -eq 1 ] || [ -n "$TO" ]; then

View file

@ -11,8 +11,11 @@ description: >-
triggers on Norwegian phrasings: "send info til alle repo", "varsle repo X", "gi
beskjed til Y og Z", "kringkast at …", "svar på coord-meldingen", "når Z er ferdig,
varsle X". Trigger even when the user names a repo plus something to convey without
saying "coord" explicitly — routing a message to another repo IS this skill.
version: "0.3.0"
saying "coord" explicitly — routing a message to another repo IS this skill. Also
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.4.0"
---
# coord-send — natural-language front door for inter-repo messages
@ -58,6 +61,9 @@ Interface (body comes from a quoted heredoc so nothing in it is shell-expanded):
<message body>
BODY
# retire one of THIS repo's own broadcasts (no subject, no body)
"$CSEND" --retract <broadcast-filename>
Sender identity (`--from`) defaults to the basename of the current git toplevel, so
you almost never set it. Exit 0 = delivered; exit 2 = usage/IO error (read stderr and
fix the arguments rather than retrying blindly).
@ -80,11 +86,34 @@ fix the arguments rather than retrying blindly).
- **All repos**`--broadcast`. Prefer this only when the notice genuinely concerns
everyone, because broadcasts accumulate and every future first-time repo sees the
standing backlog. For a bounded, known set, loop `--to` instead so unrelated future
repos don't inherit it.
repos don't inherit it. A broadcast that later turns out wrong can be retired with
`--retract` (see below), but only for repos that have not received it yet.
- **A reply to something received**`--reply-to <filename>`. Use the filename from
the injected inbox/archive; it resolves the sender and closes the original in one
step.
## Retracting a broadcast
When a broadcast has become wrong or obsolete, retire it — don't send a correction
and leave the original standing, because every future first-time repo would receive
both. `--retract <broadcast-filename>` archives it out of the delivery queue.
Three things to be honest about when you report it:
- **It is un-send, not recall.** Repos that already received the broadcast keep it.
Retraction only stops delivery to repos that haven't seen it yet. If the old news
actively misleads someone who already got it, a correcting broadcast is still
needed — retraction alone does not reach them.
- **Only the sender may retract.** The `from:` field must match this repo's identity.
If the announcing repo has since been renamed, its old identity no longer resolves,
so pass `--from <old-sender>` explicitly (read the sender off the file). Say that
you did.
- **Nothing is deleted.** The message moves to `_broadcast/archive/`. Never remove a
mailbox file by hand — the script owns the filename grammar and delivery guarantees.
Find the filename in `~/.claude/coord/_broadcast/inbox/` (or from the injected
`--- broadcast: <filename> ---` line) rather than guessing it.
## Composing the message
Derive a short, specific `--subject` from the intent if the user didn't give one
@ -138,3 +167,9 @@ pending notice to the user.
**Example 5 — reply**
Input: "svar på coord-meldingen fra repo-x at vi tar det"
Action: `--reply-to <that-message-filename>`, body acknowledging and stating the plan.
**Example 6 — retract**
Input: "den gamle kringkastingen om plugin-navnet er utdatert, trekk den"
Action: locate the file in `_broadcast/inbox/`, then `--retract <that-filename>`
(adding `--from <old-sender>` if this repo has been renamed since). Report that
repos which already received it are unaffected.