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
167 lines
7.3 KiB
Bash
Executable file
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
|