coord-done.sh was the third script that acts on pending messages and the only one that did not learn the field. --all archives every pending message in one call, including the ones whose sender declared it expects a reply - the exact outcome Rule 7 exists to prevent, now reachable with no friction and no trace. The behavior stays. The receiver keeps both terminal states by design (section 20), and a bulk close is legitimate; refusing would move a decision that belongs to the operator into the script. What was wrong was the silence: Rule 7 requires leaving a message unanswered to be STATED, and a command reporting only a total made the thing that has to be stated invisible. So --all now names the number it closed without a reply, and only then - a line that always fires is one nobody reads. A named close stays quiet, because naming a message is already deliberate, one message at a time. coord-selftest 151 -> 156. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016iJoZVmU2guTEZcMghk88z
96 lines
4.1 KiB
Bash
Executable file
96 lines
4.1 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
|
|
# --all reports how many of the messages it closed had a sender expecting a
|
|
# reply (frontmatter reply-expected, 0.11.0). It does not refuse: the receiver
|
|
# keeps both terminal states by design, and a bulk close is legitimate. But
|
|
# Rule 7 requires leaving a message unanswered to be STATED, and a one-command
|
|
# path reporting only a total makes the thing that has to be stated invisible.
|
|
# The script owes the operator the fact, not a veto.
|
|
# 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
|
|
owed=0
|
|
# Same rule as coord-count.sh: absent means a reply IS expected, and the read is
|
|
# bounded to the frontmatter block so an untrusted body cannot mark itself
|
|
# closeable. Duplicated rather than shared - each script must run standalone.
|
|
owes_reply() {
|
|
[ "$(head -1 "$1" 2>/dev/null)" = "---" ] || return 0
|
|
[ "$(grep -c '^---$' "$1" 2>/dev/null)" -ge 2 ] || return 0
|
|
sed -n '2,/^---$/p' "$1" 2>/dev/null | grep -q '^reply-expected: no$' && return 1
|
|
return 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
|
|
# Read BEFORE the move: after it the file is no longer at this path.
|
|
owes_reply "$f" && owed=$((owed + 1))
|
|
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
|
|
|
|
# Only on the bulk path, and only when there is something to state: naming a
|
|
# message is a deliberate act one message at a time, and a line that always
|
|
# fires is one nobody reads.
|
|
if [ "$ALL" -eq 1 ] && [ "$owed" -gt 0 ]; then
|
|
echo "coord-done: $moved message(s) archived for $REPO ($owed of them expected a reply, and got none - state that to the operator)"
|
|
else
|
|
echo "coord-done: $moved message(s) archived for $REPO"
|
|
fi
|
|
exit 0
|