In a linked worktree basename(git toplevel) is the worktree's name, so the background child's report was signed `from: dispatch-bg-form` (measured by the PM on741ada8) - a mailbox no session holds. - skills/dispatch: the starter reports by full path with `coord-send.sh --from <repo> --to <sender>`; board-selftest §19b gains the check (red on741ada8) plus a known-negative control that runs the same predicate against the old "via coord-send" wording. - coord-send.sh: a DERIVED sender inside a linked worktree exits 2, naming --from and the repo the worktree belongs to. Chose refusal over a warning because stderr is where a session least looks and the mail would already be delivered, and over a silent redirect for the reason the retired ktg-plugin-marketplace address is rejected rather than redirected. Linked-ness is git's own test (git-dir != git-common-dir), not ".git is a file", because a submodule has that too. coord-selftest section 39: 9 checks, 3 red before the fix; controls for submodule, main checkout, explicit --from and a worktree named like its repo. - Six other scripts derive identity the same way; named as a bounded gap in CLAUDE.md, not changed (not ordered). Suites under /bin/bash 3.2: coord 266, board 497, route 73, orders 116, guard 54 = 1006 (was 995). npm test 12/12. Order 20260923T082933Z-466405184-from-.claude. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
17 KiB
Bash
Executable file
327 lines
17 KiB
Bash
Executable file
#!/bin/bash
|
|
# coord-send.sh - deliver an inter-repo coordination message into the local
|
|
# mailbox (~/.claude/coord). Model-invoked; no network, no external service.
|
|
#
|
|
# Usage:
|
|
# 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.
|
|
# --fyi marks the message as expecting no reply (frontmatter reply-expected: no).
|
|
# Absent, a reply IS expected: every message written before the field existed
|
|
# lacks it, so absence has to keep meaning what it always meant, and a
|
|
# forgotten flag then over-counts debt instead of hiding it. The receiver is
|
|
# still free to close either kind with coord-done - the field declares what the
|
|
# sender expects, it does not oblige anyone. A broadcast is always
|
|
# reply-expected: no, because --reply-to resolves inside the recipient own
|
|
# mailbox and a broadcast never lands there: there is no reply path to promise.
|
|
# --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).
|
|
# Inside a linked git worktree the default is refused (exit 2): the toplevel's
|
|
# basename there is the worktree's name, not the repo's - pass --from.
|
|
#
|
|
# Exit: 0 delivered, 1 delivered but --reply-to's original could NOT be closed
|
|
# (the reply is sent; do not re-send it, close the original by hand),
|
|
# 2 usage/IO error, nothing written. ASCII only, bash 3.2 safe.
|
|
set -u
|
|
export LC_ALL=C
|
|
|
|
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
|
|
|
|
TO=""; BROADCAST=0; SUBJECT=""; FROM=""; MESSAGE=""; HAVE_MESSAGE=0; REPLYTO=""; REPLY_ORIG=""
|
|
RETRACT=""; FYI=0
|
|
|
|
# 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.
|
|
require_value() {
|
|
if [ "$2" -lt 2 ]; then echo "coord-send: $1 requires a value" >&2; exit 2; fi
|
|
}
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--to) require_value --to $#; TO="$2"; shift 2 ;;
|
|
--broadcast) BROADCAST=1; shift ;;
|
|
--fyi) FYI=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 ;;
|
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) echo "coord-send: unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
# --- Resolve sender / self identity ---
|
|
# git toplevel or an explicit --from, and nothing else. basename(pwd) used to
|
|
# be the last resort, but every global surface (~/repos, $HOME) is a directory
|
|
# without a repo, and the fallback quietly handed one an identity like "repos" -
|
|
# a real message was delivered under exactly that name. An invented identity is
|
|
# worse than none: it signs mail as a repo that does not exist and, on the read
|
|
# side, opens a mailbox that may belong to someone else. Refuse and say how.
|
|
DERIVED_FROM=0
|
|
if [ -z "$FROM" ]; then
|
|
FROM="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null)"
|
|
DERIVED_FROM=1
|
|
fi
|
|
if [ -z "$FROM" ]; then
|
|
echo "coord-send: cannot resolve sender identity (not inside a git repo); pass --from <repo> to choose one explicitly" >&2
|
|
exit 2
|
|
fi
|
|
# The same rule for a LINKED WORKTREE: its toplevel basename is the worktree's
|
|
# directory name (<repo>/.claude/worktrees/<name> for a background session), so
|
|
# the derived sender is invented. git's own test decides linked-ness - git-dir
|
|
# differs from git-common-dir - because ".git is a file" is also true of a
|
|
# submodule, whose basename IS its name. A worktree directory that happens to
|
|
# carry the repo's own name derives correctly and passes. Fails OPEN when git
|
|
# cannot answer: the refusal needs a positive measurement.
|
|
if [ "$DERIVED_FROM" -eq 1 ]; then
|
|
WT_GD="$(git rev-parse --path-format=absolute --git-dir 2>/dev/null)"
|
|
WT_CD="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)"
|
|
if [ -n "$WT_GD" ] && [ -n "$WT_CD" ] && [ "$WT_GD" != "$WT_CD" ]; then
|
|
WT_REPO="$(basename "$(dirname "$WT_CD")")"
|
|
if [ "$FROM" != "$WT_REPO" ]; then
|
|
echo "coord-send: refusing to sign as '$FROM': that is a linked git worktree's directory name, not a repo name; pass --from explicitly (this worktree belongs to: --from $WT_REPO)" >&2
|
|
exit 2
|
|
fi
|
|
fi
|
|
fi
|
|
# A leading _ is reserved for engine internals (_broadcast today; the rule
|
|
# reserves the namespace so a later _seen or _config cannot reopen the hole).
|
|
case "$FROM" in
|
|
_*) echo "coord-send: invalid sender identity: $FROM (names starting with _ are reserved for the engine)" >&2; exit 2 ;;
|
|
esac
|
|
|
|
# Frontmatter is line-oriented: a CR/LF inside a field would inject extra
|
|
# frontmatter lines or a premature '---' terminator. Collapse newlines to
|
|
# spaces and drop remaining control characters. Send-side input hygiene;
|
|
# the read side independently treats all content as untrusted.
|
|
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
|
|
echo "coord-send: --reply-to cannot be combined with --to/--broadcast" >&2; exit 2
|
|
fi
|
|
case "$REPLYTO" in */*|.|..|"") echo "coord-send: invalid --reply-to name: $REPLYTO" >&2; exit 2 ;; esac
|
|
REPLY_ORIG="$COORD/$FROM/inbox/$REPLYTO"
|
|
[ -e "$REPLY_ORIG" ] || REPLY_ORIG="$COORD/$FROM/archive/$REPLYTO"
|
|
if [ ! -e "$REPLY_ORIG" ]; then
|
|
echo "coord-send: --reply-to message not found for $FROM: $REPLYTO" >&2; exit 2
|
|
fi
|
|
TO="$(grep -m1 '^from:' "$REPLY_ORIG" 2>/dev/null | sed 's/^from:[[:space:]]*//')"
|
|
[ -z "$TO" ] && { echo "coord-send: cannot resolve sender of $REPLYTO" >&2; exit 2; }
|
|
if [ -z "$SUBJECT" ]; then
|
|
osub="$(grep -m1 '^subject:' "$REPLY_ORIG" 2>/dev/null | sed 's/^subject:[[:space:]]*//')"
|
|
SUBJECT="Re: $osub"
|
|
fi
|
|
fi
|
|
SUBJECT="$(sanitize_field "$SUBJECT")"
|
|
|
|
# --- Validate target: exactly one of --to / --broadcast (reply mode sets --to above) ---
|
|
if [ "$BROADCAST" -eq 1 ] && [ -n "$TO" ]; then
|
|
echo "coord-send: use either --to <repo> or --broadcast, not both" >&2; exit 2
|
|
fi
|
|
if [ "$BROADCAST" -eq 0 ] && [ -z "$TO" ]; then
|
|
echo "coord-send: missing --to <repo> / --broadcast / --reply-to" >&2; exit 2
|
|
fi
|
|
if [ "$BROADCAST" -eq 0 ]; then
|
|
# _* rather than the single literal _broadcast: the reserved namespace is a
|
|
# rule, so a future internal directory is covered the day it is added.
|
|
# Exact . and .. only, not a `.*` prefix match: a dot-prefixed name is a real
|
|
# repo (basename of a git toplevel under a hidden directory, e.g. ~/.claude),
|
|
# and the guard's job is to stop path traversal, not every hidden name.
|
|
case "$TO" in
|
|
*/*|.|..|_*) echo "coord-send: invalid target repo name: $TO" >&2; exit 2 ;;
|
|
esac
|
|
# --to is the one line-oriented field that is REFUSED rather than sanitized,
|
|
# and the asymmetry with FROM/SUBJECT above is deliberate: --to is also the
|
|
# destination DIRECTORY name ("$COORD/$TO/inbox"). Collapsing a newline to a
|
|
# space would deliver the message to a mailbox the sender never named, which
|
|
# is the same misdelivery the retired ktg-plugin-marketplace address is
|
|
# rejected rather than redirected to avoid. Measured before this guard
|
|
# existed, both with exit 0 and a "delivered" line: a newline injects its
|
|
# payload INSIDE the frontmatter block (silencing the reply-expected: yes the
|
|
# engine itself wrote, since coord-count reads the first match), and a tab
|
|
# gives coord-count five tab-separated fields where its contract is four, so
|
|
# a consumer reads the mailbox name and the pending count off by one column.
|
|
# board.sh consumes that TSV. Reply mode resolves TO from the original's
|
|
# from: line - untrusted cross-repo input - so this must sit AFTER that
|
|
# resolution, covering the one target name nobody typed.
|
|
case "$TO" in
|
|
*[[:cntrl:]]*)
|
|
echo "coord-send: --to contains a control character: $(sanitize_field "$TO") (a target name is also the mailbox directory name, so it is refused, never sanitized)" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
# Retired address (operator decision 2026-08-15, catalog's H4 reply
|
|
# archived 2026-08-15T16:27:51Z): ktg-plugin-marketplace is a polyrepo
|
|
# DIRECTORY, not a git repo, so basename(git toplevel) can never resolve to
|
|
# it and no session was ever able to hold this identity naturally. REJECT,
|
|
# not a silent redirect to catalog - a redirect delivers mail somewhere the
|
|
# sender does not believe it landed, which is the same misdelivery defect
|
|
# this closes (2 messages sat undelivered 2 days on this exact
|
|
# misaddressing before catalog's H4 count caught it). Only --to is retired;
|
|
# --from is untouched, since the defect was mail ARRIVING here, not mail
|
|
# claiming to originate here.
|
|
case "$TO" in
|
|
ktg-plugin-marketplace)
|
|
echo "coord-send: ktg-plugin-marketplace is a retired coord address (it is a polyrepo directory, not a git repo - no session can ever hold that identity); send to --to catalog instead" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
fi
|
|
if [ -z "$SUBJECT" ]; then
|
|
echo "coord-send: missing --subject" >&2; exit 2
|
|
fi
|
|
|
|
# --- Body: --message or stdin ---
|
|
if [ "$HAVE_MESSAGE" -eq 1 ]; then BODY="$MESSAGE"; else BODY="$(cat)"; fi
|
|
if [ -z "$BODY" ]; then
|
|
echo "coord-send: empty message body (pass --message or pipe text on stdin)" >&2; exit 2
|
|
fi
|
|
|
|
# --- Resolve destination ---
|
|
if [ "$BROADCAST" -eq 1 ]; then
|
|
TARGET_LABEL="broadcast"; DEST_DIR="$COORD/_broadcast/inbox"
|
|
else
|
|
TARGET_LABEL="$TO"; DEST_DIR="$COORD/$TO/inbox"
|
|
fi
|
|
mkdir -p "$DEST_DIR" 2>/dev/null || { echo "coord-send: cannot create $DEST_DIR" >&2; exit 2; }
|
|
|
|
# The filename must be shell-clean: it round-trips through coord-inbox's
|
|
# reply/resolve hints and coord-done's positional args. Sanitize the sender
|
|
# for the filename only; the raw name stays in the from: frontmatter.
|
|
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
SAFE_FROM="$(printf '%s' "$FROM" | tr -c 'A-Za-z0-9._-' '-')"
|
|
FNAME="${TS}-$$${RANDOM}-from-${SAFE_FROM}.md"
|
|
DEST="$DEST_DIR/$FNAME"
|
|
DATE_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
|
|
# Temp file lives INSIDE the destination dir (dot-prefixed so the inbox
|
|
# *.md glob never sees it): the final mv is then a same-filesystem rename,
|
|
# so readers never observe a half-written message.
|
|
REPLY_EXPECTED=yes
|
|
[ "$FYI" -eq 1 ] && REPLY_EXPECTED=no
|
|
[ "$BROADCAST" -eq 1 ] && REPLY_EXPECTED=no
|
|
|
|
TMP="$(mktemp "$DEST_DIR/.coord-send.XXXXXX" 2>/dev/null)"
|
|
[ -n "$TMP" ] || { echo "coord-send: cannot create temp file in $DEST_DIR" >&2; exit 2; }
|
|
{
|
|
echo "---"
|
|
echo "from: $FROM"
|
|
echo "to: $TARGET_LABEL"
|
|
echo "subject: $SUBJECT"
|
|
echo "date: $DATE_ISO"
|
|
# Fifth field, appended after the historic four so an older reader that stops
|
|
# at the ones it knows is unaffected. A broadcast is pinned to "no" whether or
|
|
# not --fyi was passed: that is not a defaulted value but the absence of a
|
|
# reply path (see the header), and a message that claimed otherwise would be
|
|
# asking for something the engine cannot deliver.
|
|
echo "reply-expected: $REPLY_EXPECTED"
|
|
echo "---"
|
|
printf '%s\n' "$BODY"
|
|
} > "$TMP"
|
|
if ! mv "$TMP" "$DEST" 2>/dev/null; then
|
|
/bin/rm -f "$TMP" 2>/dev/null; echo "coord-send: write failed" >&2; exit 2
|
|
fi
|
|
|
|
echo "coord-send: delivered to $TARGET_LABEL ($FNAME)"
|
|
|
|
# A broadcast reaches every repo INCLUDING the sender, so mark it seen for the
|
|
# sender now: the announcing repo already knows its own news, and re-injecting
|
|
# it would burn operator attention at every session start. Keyed by the RAW
|
|
# sender name, because coord-inbox keys the seen file by the unsanitized repo
|
|
# name - using SAFE_FROM here would silently miss for names like "my repo".
|
|
# Raw means it must be a safe path component: skip rather than escape the dir.
|
|
if [ "$BROADCAST" -eq 1 ]; then
|
|
case "$FROM" in
|
|
*/*|.|..) : ;;
|
|
*) SEEN_DIR="$COORD/_broadcast/seen"
|
|
mkdir -p "$SEEN_DIR" 2>/dev/null &&
|
|
printf '%s\n' "$FNAME" >> "$SEEN_DIR/$FROM" 2>/dev/null ;;
|
|
esac
|
|
fi
|
|
|
|
# --- Reply mode: mark the original handled ---
|
|
# The handled claim is asserted against GROUND TRUTH - is the original still
|
|
# pending in the inbox - and not against the call having been made. It used to
|
|
# print unconditionally with coord-done's output discarded, which made the one
|
|
# line a session relies on to close a reply debt false at the moment it was
|
|
# printed (review finding 9, 2026-08-14: stub coord-done exiting 1, original
|
|
# untouched, no archive/, and coord-send still exited 0 saying "marked
|
|
# handled"). A false success in the transport is worse than a loud failure:
|
|
# every reply had to be verified by hand afterwards, so the exit code carried
|
|
# no information at all.
|
|
#
|
|
# CHECKING THE EXIT CODE ALONE IS NOT ENOUGH, and this is the half a later
|
|
# session is most likely to simplify away. coord-done exits 0 when it archives
|
|
# NOTHING - an unknown name is idempotently fine by its own contract
|
|
# (coord-done.sh:54, :70) - so a nonzero-exit test still certifies a message
|
|
# that never moved. Selftest section 34(b) is that exact case.
|
|
#
|
|
# The path is recomputed rather than reusing $REPLY_ORIG, which resolves to the
|
|
# inbox OR the archive (:129-130). Replying to an already-archived original is
|
|
# legitimate and moves nothing; testing $REPLY_ORIG would warn on every one of
|
|
# those (section 34(d)).
|
|
#
|
|
# Exit 1, not 2: the reply WAS delivered and re-sending it would duplicate it.
|
|
# The distinct status says "delivered, original not closed" - 2 stays the
|
|
# nothing-was-written status it has always been.
|
|
if [ -n "$REPLY_ORIG" ]; then
|
|
"$(dirname "$0")/coord-done.sh" --repo "$FROM" "$REPLYTO" >/dev/null 2>&1
|
|
DONE_RC=$?
|
|
if [ "$DONE_RC" -eq 0 ] && [ ! -e "$COORD/$FROM/inbox/$REPLYTO" ]; then
|
|
echo "coord-send: original ($REPLYTO) marked handled"
|
|
else
|
|
echo "coord-send: the reply was delivered, but the original ($REPLYTO) is STILL PENDING in $FROM's inbox (coord-done exit $DONE_RC) - it is NOT handled; close it by hand: coord-done $REPLYTO" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
exit 0
|