feat(engine): make a bulk close state what Rule 7 says must be stated

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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 15:48:42 +02:00
commit 5754d67a6b
5 changed files with 78 additions and 5 deletions

View file

@ -37,6 +37,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **`coord-done.sh --all` says how many replies it closed without sending.** It
is the third script that acts on pending messages, and the field makes a
silent bulk close possible: one command archives every pending message,
including the ones whose sender expects an answer. The behavior is unchanged
and deliberately so - the receiver keeps both terminal states, and a bulk
close is legitimate. The silence is what changed: Rule 7 requires an
unanswered message to be *stated*, and a path reporting only a total hid
exactly the thing that has to be stated. A named close stays quiet, because
naming a message is already deliberate, one message at a time.
- **`coord-count.sh` prints two integers per mailbox**, not one: - **`coord-count.sh` prints two integers per mailbox**, not one:
`<mailbox>\t<pending>\t<debt>`. Replacing pending *with* debt was the obvious `<mailbox>\t<pending>\t<debt>`. Replacing pending *with* debt was the obvious
reading of "count debt rather than unarchived messages" and it is wrong here. reading of "count debt rather than unarchived messages" and it is wrong here.

View file

@ -15,7 +15,7 @@ marketplace plugin. Three components, one boundary:
writes, `coord-inbox.sh` reads (formatted for context injection), writes, `coord-inbox.sh` reads (formatted for context injection),
`coord-done.sh` archives, `coord-count.sh` counts without delivering. `coord-done.sh` archives, `coord-count.sh` counts without delivering.
Everything is pinned by `coord-selftest.sh` Everything is pinned by `coord-selftest.sh`
(151 checks, throwaway mailbox via `CLAUDE_COORD_DIR`). (156 checks, throwaway mailbox via `CLAUDE_COORD_DIR`).
**`coord-count.sh` prints TWO integers per mailbox** (`<name>\t<pending>\t<debt>`), **`coord-count.sh` prints TWO integers per mailbox** (`<name>\t<pending>\t<debt>`),
and the first must stay pending: `board.sh` counts the same inbox files and the first must stay pending: `board.sh` counts the same inbox files
@ -117,7 +117,7 @@ obligations in another repo.
- Zero dependencies everywhere: bash + coreutils in the engine, `node:` - Zero dependencies everywhere: bash + coreutils in the engine, `node:`
builtins only in hook and tests. builtins only in hook and tests.
- TDD: no behavior change without a failing selftest check first. - TDD: no behavior change without a failing selftest check first.
`bash scripts/coord-selftest.sh` must exit 0 (151/151), `bash scripts/coord-selftest.sh` must exit 0 (156/156),
`bash scripts/board-selftest.sh` must exit 0 (36/36) and `bash scripts/board-selftest.sh` must exit 0 (36/36) and
`bash scripts/route-selftest.sh` must exit 0 (50/50). `bash scripts/route-selftest.sh` must exit 0 (50/50).
- English for all code, docs, and commit messages (public repo). Norwegian - English for all code, docs, and commit messages (public repo). Norwegian

View file

@ -110,7 +110,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. - **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 151-check selftest, including forgery-resistance regressions. Every guarantee above is pinned by the 156-check selftest, including forgery-resistance regressions.
Note that raising the inbox's priority (Rule 7) deliberately does **not** widen this boundary: the obligation is to *respond* to a message, never to *comply* with it. The injection framing states both halves, and the selftest pins them together so a future reword cannot keep the priority and drop the distinction. Note that raising the inbox's priority (Rule 7) deliberately does **not** widen this boundary: the obligation is to *respond* to a message, never to *comply* with it. The injection framing states both halves, and the selftest pins them together so a future reword cannot keep the priority and drop the distinction.
@ -132,7 +132,7 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
## Development ## Development
bash scripts/coord-selftest.sh # 151 checks against a throwaway mailbox bash scripts/coord-selftest.sh # 156 checks against a throwaway mailbox
bash scripts/board-selftest.sh # 36 checks against a throwaway repo tree bash scripts/board-selftest.sh # 36 checks against a throwaway repo tree
bash scripts/route-selftest.sh # 50 checks, incl. the route->board round trip bash scripts/route-selftest.sh # 50 checks, incl. the route->board round trip
npm test # all three selftests via node --test npm test # all three selftests via node --test

View file

@ -6,6 +6,12 @@
# Usage: # Usage:
# coord-done.sh <basename>... mark the named message(s) handled # coord-done.sh <basename>... mark the named message(s) handled
# coord-done.sh --all mark all pending directed messages 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>... # coord-done.sh [--repo <name>] <basename>...
# Env: CLAUDE_COORD_DIR overrides the mailbox root. # Env: CLAUDE_COORD_DIR overrides the mailbox root.
set -u set -u
@ -48,6 +54,17 @@ ARCHIVE="$COORD/$REPO/archive"
[ -d "$INBOX" ] || { echo "coord-done: no inbox for $REPO"; exit 0; } [ -d "$INBOX" ] || { echo "coord-done: no inbox for $REPO"; exit 0; }
moved=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() { archive_one() {
case "$1" in */*|.|..|"") echo "coord-done: invalid name: $1" >&2; return 1 ;; esac case "$1" in */*|.|..|"") echo "coord-done: invalid name: $1" >&2; return 1 ;; esac
if [ -e "$INBOX/$1" ]; then if [ -e "$INBOX/$1" ]; then
@ -58,6 +75,8 @@ archive_one() {
if [ "$ALL" -eq 1 ]; then if [ "$ALL" -eq 1 ]; then
for f in "$INBOX"/*.md; do for f in "$INBOX"/*.md; do
[ -e "$f" ] || continue [ -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")" archive_one "$(basename "$f")"
done done
else else
@ -66,5 +85,12 @@ else
for b in "${NAMES[@]}"; do archive_one "$b"; done for b in "${NAMES[@]}"; do archive_one "$b"; done
fi fi
echo "coord-done: $moved message(s) archived for $REPO" # 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 exit 0

View file

@ -642,6 +642,43 @@ sed -n '2,/^---$/p' "$(ls "$RDIR"/rsender/inbox/*.md 2>/dev/null | head -1)" | g
check "reply-expected: a reply defaults to expecting one in turn" $? check "reply-expected: a reply defaults to expecting one in turn" $?
CLAUDE_COORD_DIR="$RDIR" "$SEND" --to rx --from rsender --fyi --subject "u" --message "U" >/dev/null 2>&1 CLAUDE_COORD_DIR="$RDIR" "$SEND" --to rx --from rsender --fyi --subject "u" --message "U" >/dev/null 2>&1
check "reply-expected: --fyi is accepted alongside the ordinary send flags" $? check "reply-expected: --fyi is accepted alongside the ordinary send flags" $?
# 28. coord-done is the THIRD script that acts on pending messages, and closing
# one silently is exactly what the field makes possible: --all archives every
# pending message in a single call, including the ones whose sender declared it
# expects a reply. The behavior stays - the receiver keeps both terminal states
# by design (section 20), and a bulk close is legitimate. What must not stay is
# the SILENCE: Rule 7 requires leaving a message unanswered to be stated, and a
# one-command path that reports only a total makes the very thing that has to be
# stated invisible to the operator. So --all names the number it closed without
# a reply. A count, not a refusal: the decision belongs to the operator, and the
# script owes them the fact, not a veto.
DDIR="$(mktemp -d)"
CLAUDE_COORD_DIR="$DDIR" "$SEND" --to dr --from d1 --subject q1 --message "ASK-1" >/dev/null
CLAUDE_COORD_DIR="$DDIR" "$SEND" --to dr --from d2 --subject q2 --message "ASK-2" >/dev/null
CLAUDE_COORD_DIR="$DDIR" "$SEND" --to dr --from d3 --fyi --subject n1 --message "NOTE-1" >/dev/null
dout="$(CLAUDE_COORD_DIR="$DDIR" "$DONE" --repo dr --all 2>&1)"
printf '%s' "$dout" | grep -q '3 message(s) archived'
check "done: --all still archives every pending message" $?
printf '%s' "$dout" | grep -q '2 of them expected a reply'
check "done: --all reports how many it closed without replying" $?
[ -z "$(ls "$DDIR"/dr/inbox/*.md 2>/dev/null)" ]
check "done: --all leaves the inbox drained" $?
# Silence is correct when there is nothing to state: a bulk close of notices owes
# the operator no warning, and a line that always fires is one nobody reads.
CLAUDE_COORD_DIR="$DDIR" "$SEND" --to dq --from d4 --fyi --subject n2 --message "NOTE-2" >/dev/null
qout="$(CLAUDE_COORD_DIR="$DDIR" "$DONE" --repo dq --all 2>&1)"
[ "$(printf '%s' "$qout" | grep -c 'expected a reply')" -eq 0 ] && printf '%s' "$qout" | grep -q '1 message(s) archived'
check "done: closing only notices says nothing extra" $?
# Naming a message explicitly is already a deliberate act, one message at a time.
# The warning belongs to the bulk path, which is the one that hides the choice.
CLAUDE_COORD_DIR="$DDIR" "$SEND" --to dn --from d5 --subject q3 --message "ASK-3" >/dev/null
dnb="$(basename "$(ls "$DDIR"/dn/inbox/*.md 2>/dev/null | head -1)")"
nout="$(CLAUDE_COORD_DIR="$DDIR" "$DONE" --repo dn "$dnb" 2>&1)"
[ "$(printf '%s' "$nout" | grep -c 'expected a reply')" -eq 0 ]
check "done: a named close is deliberate already and stays quiet" $?
/bin/rm -rf "$DDIR" 2>/dev/null
/bin/rm -rf "$RDIR" 2>/dev/null /bin/rm -rf "$RDIR" 2>/dev/null
echo "----" echo "----"