repo-mailbox/scripts/coord-done.sh
Kjell Tore Guttormsen 1d62b073f8 fix(engine)!: identity is derived, never invented
Four defects that all reduce to the same thing: the engine trusted a name it
had no business trusting.

_broadcast is now 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>` walked in the side entrance
and archived a broadcast out of the queue - a full unauthenticated retract of
an announcement for every repo that had not read it yet. The rule reserves the
whole `_` prefix rather than one literal, so a later `_seen` or `_config`
cannot reopen the hole, and it is enforced in every CLI: a rule held in three
of four places is not a rule.

The pwd fallback 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 basename(pwd) silently handed it the identity "repos". That is not
hypothetical - mail was delivered under exactly that name. git toplevel or an
explicit --from/--repo are now the only two sources. The write paths refuse
and say so; the read path declines silently, because the hook runs it at every
session start and must never fail a session.

Two checkouts with the same directory name still share one mailbox - re-keying
identity would break every existing mailbox and the readable `--to <repo>`
addressing. Instead the first git-derived read records the claiming path in
<repo>/.origin, and a read from elsewhere is warned about in the injection. A
warning, not a refusal: the same repo moved or re-cloned is the ordinary case.
The warning goes in the injection because the hook discards stderr, and a
warning nobody can see is not a warning. The collision is live in this tree:
claude-code-100x is nested inside a repo of the same name.

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 has to be redelivery, never loss.

The hook stops resolving identity altogether. It was the fourth copy of the
rule and the only one that runs in production, so passing --repo bypassed the
engine's guards exactly where they mattered and suppressed the collision check
along with them. It is now the pure wrapper the boundary rule always claimed
it was, pinned by two behavioral tests rather than by reading the source.

Selftest 93 -> 116; three node tests cover the hook.

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

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

70 lines
2.7 KiB
Bash
Executable file

#!/bin/bash
# coord-done.sh - mark directed coordination message(s) as handled by moving
# them from this repo's inbox to archive (kept, never deleted). Idempotent and
# fail-safe. ASCII only, bash 3.2 safe.
#
# Usage:
# coord-done.sh <basename>... mark the named message(s) handled
# coord-done.sh --all mark all pending directed messages handled
# coord-done.sh [--repo <name>] <basename>...
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
set -u
export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
REPO=""
ALL=0
# Indexed array, never a space-joined string: basenames may contain any
# character except "/", and re-splitting would make them un-archivable.
NAMES=()
while [ $# -gt 0 ]; do
case "$1" in
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
--repo) [ $# -ge 2 ] || { echo "coord-done: --repo requires a value" >&2; exit 2; }
REPO="$2"; shift 2 ;;
--all) ALL=1; shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) NAMES+=("$1"); shift ;;
esac
done
# git toplevel or an explicit --repo, never basename(pwd): see the identity
# note in coord-send.sh. Guessing here archives messages out of a mailbox the
# caller does not own.
if [ -z "$REPO" ]; then
REPO="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
fi
[ -z "$REPO" ] && { echo "coord-done: cannot resolve repo (not inside a git repo); pass --repo <repo>" >&2; exit 2; }
# _broadcast is not a repo, and this is the door the sender check in
# coord-send.sh does not cover: archiving out of _broadcast/inbox retires an
# announcement for every repo that has not read it yet - an unauthenticated
# retract. Retiring a broadcast is coord-send --retract, which checks the sender.
case "$REPO" in
_*) echo "coord-done: $REPO is a reserved engine namespace, not a repo; retire a broadcast with coord-send --retract" >&2; exit 2 ;;
esac
INBOX="$COORD/$REPO/inbox"
ARCHIVE="$COORD/$REPO/archive"
[ -d "$INBOX" ] || { echo "coord-done: no inbox for $REPO"; exit 0; }
moved=0
archive_one() {
case "$1" in */*|.|..|"") echo "coord-done: invalid name: $1" >&2; return 1 ;; esac
if [ -e "$INBOX/$1" ]; then
mkdir -p "$ARCHIVE" 2>/dev/null && mv "$INBOX/$1" "$ARCHIVE/" 2>/dev/null && moved=$((moved + 1))
fi
}
if [ "$ALL" -eq 1 ]; then
for f in "$INBOX"/*.md; do
[ -e "$f" ] || continue
archive_one "$(basename "$f")"
done
else
# bash 3.2 + set -u: expanding an empty array errors, so guard on length.
[ "${#NAMES[@]}" -eq 0 ] && { echo "coord-done: name(s) or --all required" >&2; exit 2; }
for b in "${NAMES[@]}"; do archive_one "$b"; done
fi
echo "coord-done: $moved message(s) archived for $REPO"
exit 0