repo-mailbox/scripts/coord-sweep.sh
Kjell Tore Guttormsen 459c9feec0 feat(sweep): close the aged FYI backlog without a human in the loop
A notice needs no answer, but it is still re-injected at every session
start until someone closes it by hand. Across enough repositories that
hand-closing IS the manual work the mailbox was meant to remove, and the
pending count -- the operator's only signal -- drowns in messages that
were never going to be acted on: 9 of 22 pending messages across 12
mailboxes were pure notices when this was written.

coord-sweep.sh closes exactly one mechanically decidable class:
reply-expected: no, older than a grace window (default 14 days). A
message that owes a reply is never touched, at any age, with any flag --
answering it would mean deciding something on the receiving repo's
behalf, which is the one thing this system exists to prevent.

Four properties are load-bearing, not incidental:

- Dry-run is the default, inverted from the rest of the engine. The
  others print or deliver; this one destroys pending state, so the safe
  direction has to be what you get by forgetting a flag.
- Closing goes through coord-done.sh --repo, never mv, so the archive
  layout and the _broadcast refusal stay in one place.
- Age is read from the filename prefix, never the file. An unreadable
  age is never treated as old: fail-safe, not fail-open.
- Every closure is logged with sender and subject. Directed messages
  have no seen-tracking, so the sweep cannot tell "seen and ignored"
  from "never delivered" -- a notice can be closed unread, and the log
  is the only thing standing between that and silent data loss.

The reply-expected read is bounded to the frontmatter block, matching
coord-count.sh: a body line claiming it at column 0 is untrusted
cross-repo input and must not close its own message.

No scheduler, no launchd unit, no skill front door -- the script does
nothing until invoked.

Selftest 159 -> 182. The log check caught a real defect during
development: the first implementation read from/subject AFTER
coord-done.sh had moved the file, logging empty values and quietly
defeating the only safeguard the design has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uwcak9j4m9JijDKeFmptww
2026-08-01 22:00:42 +02:00

167 lines
7.3 KiB
Bash
Executable file

#!/bin/bash
# coord-sweep.sh - close the FYI backlog across every mailbox on this machine,
# deterministically and without a model. Archives directed messages whose sender
# declared reply-expected: no and whose filename timestamp is older than a grace
# window. Prints one line per message and a summary; writes a log line per
# closure.
#
# WHY THIS EXISTS. A notice needs no answer, but it is still re-injected at every
# session start until someone closes it by hand. Across enough repositories that
# hand-closing IS the manual work the mailbox was supposed to remove, and the
# pending count - the operator's only signal - drowns in messages that were never
# going to be acted on. This closes exactly that class and nothing else.
#
# ONE MECHANICALLY DECIDABLE CLASS, NEVER A JUDGEMENT. A message that owes a
# reply is never touched, at any age, with any flag. Answering it would mean
# deciding something on the receiving repo's behalf, which is the one thing this
# system exists to keep a session from doing. If that ever needs to change, the
# answer is a session in that repo, not a wider net here.
#
# THE LOG IS NOT OPTIONAL, and the reason is a real gap: a directed message has
# no seen-tracking (only broadcasts do), so this script cannot tell "seen and
# ignored" from "never delivered". A notice to a repo left unopened for the whole
# window is closed UNREAD. That is the accepted tradeoff, and the log is the only
# thing standing between it and a silent disappearance.
#
# DRY-RUN IS THE DEFAULT, inverted from every other script here. The others print
# or deliver; this one destroys pending state, so the safe direction has to be the
# one you get by forgetting a flag.
#
# Usage: coord-sweep.sh [--write] [--days <n>] [--log <path>]
# --write actually close. Without it nothing is archived and no log is
# written - the run only reports what it would have done.
# --days <n> grace window in days (default 14). 0 means "any age".
# --log <path> log file (default $CLAUDE_COORD_DIR/_sweep.log).
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
# Exit: 0 on success, including when there is nothing to close. 2 on usage error.
# ASCII only, bash 3.2 safe.
set -u
export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
DIR="$(cd "$(dirname "$0")" && pwd)"
# No `${VAR:-fallback}` on an engine path, ever: a fallback silently routes
# through whatever happens to sit at the alternate location instead of failing
# loud. That defect shipped twice here (board.sh, then coord-send.sh).
DONE="$DIR/coord-done.sh"
WRITE=0
DAYS=14
LOG=""
while [ $# -gt 0 ]; do
case "$1" in
--write) WRITE=1; shift ;;
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
--days) [ $# -ge 2 ] || { echo "coord-sweep: --days requires a value" >&2; exit 2; }
DAYS="$2"; shift 2 ;;
--log) [ $# -ge 2 ] || { echo "coord-sweep: --log requires a value" >&2; exit 2; }
LOG="$2"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
# Strict, unlike coord-inbox.sh. That one is lenient because failing a
# SessionStart over a stray flag is worse than ignoring it; this one is not
# on that path and closes messages, so a typo must stop it.
*) echo "coord-sweep: unknown argument: $1" >&2; exit 2 ;;
esac
done
case "$DAYS" in
''|*[!0-9]*) echo "coord-sweep: --days must be a non-negative integer: $DAYS" >&2; exit 2 ;;
esac
[ -x "$DONE" ] || { echo "coord-sweep: cannot find coord-done.sh at $DONE" >&2; exit 2; }
[ -n "$LOG" ] || LOG="$COORD/_sweep.log"
# Cutoff as a plain 14-digit number, so the comparison is integer arithmetic
# rather than string collation. BSD date (macOS); a failure here must stop the
# run, because a missing cutoff would otherwise read as "close everything".
CUTOFF="$(date -u -v-"${DAYS}"d +%Y%m%d%H%M%S 2>/dev/null)"
case "$CUTOFF" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]) ;;
*) echo "coord-sweep: could not compute a cutoff date" >&2; exit 2 ;;
esac
NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
[ -d "$COORD" ] || { echo "coord-sweep: nothing to close (no mailbox root at $COORD)"; exit 0; }
# Does this message owe a reply? Absent field means YES: every message written
# before 0.11.0 lacks it, so absence keeps meaning what it always meant. The read
# is bounded to the frontmatter block - a body line is untrusted cross-repo input
# and must not be able to close its own message by claiming "reply-expected: no"
# at column 0. Duplicated from coord-count.sh rather than shared: each script
# here 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
}
# Frontmatter field for the log line. Control characters are stripped because
# this is untrusted content on its way into a file the operator reads.
field() {
sed -n '2,/^---$/p' "$2" 2>/dev/null | grep "^$1: " | head -1 \
| sed "s/^$1: //" | tr -d '\000-\037' | cut -c1-120
}
CLOSED=0
for d in "$COORD"/*; do
[ -d "$d" ] || continue
name="$(basename "$d")"
# Reserved engine namespace. _broadcast is storage, not a correspondent, and
# archiving out of it would retire an announcement for every repo that has not
# read it yet - an unauthenticated retract. That is coord-send --retract's job,
# and it checks the sender.
case "$name" in _*) continue ;; esac
[ -d "$d/inbox" ] || continue
for m in "$d/inbox"/*.md; do
[ -e "$m" ] || continue
base="$(basename "$m")"
# Age comes free from the filename prefix; nothing inside the file is
# trusted for it. A name the grammar does not produce has no readable age,
# and an unreadable age must never be treated as old - fail-safe, not
# fail-open.
ts="${base%%-*}"
case "$ts" in
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z) ;;
*) continue ;;
esac
num="$(printf '%s' "$ts" | tr -dc '0-9')"
[ "$num" -lt "$CUTOFF" ] || continue
owes_reply "$m" && continue
if [ "$WRITE" -eq 1 ]; then
# Read the fields BEFORE closing: coord-done.sh moves the file to archive,
# so a log line built afterwards describes a path that no longer exists and
# silently logs empty values - which defeats the only safeguard this script
# has against a notice vanishing unread.
mfrom="$(field from "$m")"
msubj="$(field subject "$m")"
# Through the engine, never `mv`: coord-done.sh owns the filename grammar
# and the archive layout, and it refuses _broadcast on its own.
if "$DONE" --repo "$name" "$base" >/dev/null 2>&1; then
printf '%s\t%s\t%s\tfrom=%s\tsubject=%s\n' \
"$NOW" "$name" "$base" "$mfrom" "$msubj" >> "$LOG"
echo "closed: $name/$base"
CLOSED=$((CLOSED + 1))
else
echo "coord-sweep: failed to close $name/$base" >&2
fi
else
echo "would close: $name/$base"
CLOSED=$((CLOSED + 1))
fi
done
done
if [ "$CLOSED" -eq 0 ]; then
echo "coord-sweep: nothing to close (cutoff ${CUTOFF}, ${DAYS} days)"
elif [ "$WRITE" -eq 1 ]; then
echo "coord-sweep: $CLOSED message(s) closed (cutoff ${CUTOFF}, ${DAYS} days). Log: $LOG"
else
echo "coord-sweep: $CLOSED message(s) would close (cutoff ${CUTOFF}, ${DAYS} days). Re-run with --write."
fi
exit 0