coord-count.sh gains a fourth TSV column: "-" when a mailbox has .origin (a real session has read it via SessionStart), otherwise the age in whole days of the oldest pending message. .origin is only written by coord-inbox.sh's non---repo path, so its absence means the mailbox is never reached by normal injection — a genuine dead letter, not merely slow. board.sh's --brief surfaces mailboxes past the 3-day threshold as a new "ALDRI LEST" section, mirroring the existing orphan-mailbox listing. This is WP1d's detection half only (per .claude's coord bestilling 2026-08-14); the action half (report-to-sender / retract) is unapproved design, not built here. coord-selftest.sh: 191 -> 197 checks. board-selftest.sh: 175 -> 178 checks (net +3; section 16 adds 3 new fixtures on top of the existing 175 baseline, some pre-existing counts shift with the trailing column). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0194eV8b6BXNv6aKLovP8TP6
133 lines
6.4 KiB
Bash
Executable file
133 lines
6.4 KiB
Bash
Executable file
#!/bin/bash
|
|
# coord-count.sh - count PENDING directed messages per mailbox WITHOUT
|
|
# delivering anything. Prints one "<mailbox>\t<pending>\t<debt>\t<origin_age>"
|
|
# line per mailbox that has unhandled mail, sorted by name; prints nothing when
|
|
# none do.
|
|
#
|
|
# TWO INTEGERS, NOT ONE. <pending> is every unhandled message; <debt> is the
|
|
# subset whose sender declared it expects a reply (frontmatter reply-expected,
|
|
# 0.11.0). Replacing the first with the second was the obvious reading of "count
|
|
# debt rather than unarchived messages", and it is wrong here: board.sh counts
|
|
# the same inbox files itself, so a debt-only count would put two different
|
|
# numbers under one name with nothing to reconcile them - and a mailbox holding
|
|
# only notices would read as empty while its messages keep being re-injected.
|
|
#
|
|
# <origin_age> (WP1d, .claude 2026-08-14): "-" when the mailbox has a .origin
|
|
# file, otherwise the age in whole days of its OLDEST pending message.
|
|
# coord-inbox.sh writes .origin only from a REAL session's own SessionStart
|
|
# (REPO_PATH resolved via git rev-parse, never when --repo is passed
|
|
# explicitly), so a mailbox with no .origin has NEVER been reached by the
|
|
# normal per-repo injection - pending mail there is a dead letter, not merely
|
|
# slow. This script only reports the raw age; judging it against a threshold
|
|
# is board.sh's job, the same split as <pending> vs <debt> above.
|
|
#
|
|
# WHY THIS IS NOT coord-inbox.sh --repo <x>: reading IS delivery. The read path
|
|
# prints a broadcast and then records it as seen, so asking it "what is pending
|
|
# for x" would consume x's broadcast backlog as a side effect - once, silently,
|
|
# and unrecoverably (the seen set is delivery history, and retraction
|
|
# deliberately leaves it alone). This script only counts files.
|
|
#
|
|
# It keys on MAILBOXES, not on repos: it enumerates $COORD/* and never scans a
|
|
# filesystem for checkouts. A repo without a mailbox has no pending messages by
|
|
# definition - it is not missing from the count, it is absent from the domain.
|
|
#
|
|
# Usage: coord-count.sh [--exclude <mailbox>]
|
|
# --exclude <mailbox> omit one mailbox (the caller's own, whose inbox is
|
|
# already injected in full).
|
|
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
|
|
# Exit: always 0 - this runs at session start and must never fail one.
|
|
# ASCII only, bash 3.2 safe.
|
|
set -u
|
|
export LC_ALL=C
|
|
|
|
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
|
|
|
|
EXCLUDE=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
|
|
--exclude) [ $# -ge 2 ] || { echo "coord-count: --exclude requires a value" >&2; exit 2; }
|
|
EXCLUDE="$2"; shift 2 ;;
|
|
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
# Lenient but not silent, exactly as coord-inbox.sh: failing here would fail
|
|
# a SessionStart over a stray flag, and staying silent would make a typo
|
|
# look like a working invocation. The hook discards stderr.
|
|
*) echo "coord-count: unknown argument: $1 (ignored)" >&2; shift ;;
|
|
esac
|
|
done
|
|
|
|
[ -d "$COORD" ] || exit 0
|
|
|
|
# Does this message owe a reply? Absent field means YES: every message written
|
|
# before 0.11.0 lacks it, so absence has to keep 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 silence a real debt by claiming
|
|
# "reply-expected: no" at column 0. That is stricter than the grep -m1 the older
|
|
# fields use, where frontmatter-comes-first happens to save them. A file without
|
|
# two '---' terminators has no frontmatter to trust, so it counts as debt.
|
|
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
|
|
}
|
|
|
|
# Glob expansion under LC_ALL=C is already name-sorted. An unmatched glob
|
|
# expands to the literal pattern, which fails the -d test and is skipped.
|
|
# Two patterns, not dotglob: a bare "$COORD"/* never matches a dot-prefixed
|
|
# directory (e.g. .claude, a real repo's mailbox), and dotglob would also hand
|
|
# back "." and ".." plus stray dotfiles like .DS_Store - both filtered here
|
|
# only by luck of also failing -d. ".[!.]*" matches exactly the hidden
|
|
# directories, excluding "." and "..".
|
|
for d in "$COORD"/* "$COORD"/.[!.]*; do
|
|
[ -d "$d" ] || continue
|
|
name="$(basename "$d")"
|
|
# Reserved engine namespace (_broadcast): storage, not a correspondent.
|
|
case "$name" in _*) continue ;; esac
|
|
[ -n "$EXCLUDE" ] && [ "$name" = "$EXCLUDE" ] && continue
|
|
[ -d "$d/inbox" ] || continue
|
|
# *.md is the message grammar; a stray file must not inflate a total the
|
|
# operator reads as "replies owed".
|
|
# oldest_ts captures only the FIRST message whose filename matches the
|
|
# timestamp grammar. That is safe because the glob above is already
|
|
# name-sorted under LC_ALL=C (see the comment on it), and the grammar's
|
|
# timestamp prefix sorts identically to chronological order - so the first
|
|
# match encountered is the oldest, without a second pass or a full sort.
|
|
n=0; owed=0; oldest_ts=""
|
|
for m in "$d/inbox"/*.md; do
|
|
[ -e "$m" ] || continue
|
|
n=$((n + 1))
|
|
owes_reply "$m" && owed=$((owed + 1))
|
|
if [ -z "$oldest_ts" ]; then
|
|
mts="${m##*/}"
|
|
mts="${mts%%-*}"
|
|
case "$mts" 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)
|
|
oldest_ts="$mts" ;;
|
|
esac
|
|
fi
|
|
done
|
|
[ "$n" -gt 0 ] || continue
|
|
# "-" means either .origin exists (claimed, never a dead-letter candidate
|
|
# regardless of age) or the age could not be read (fail-safe, not
|
|
# fail-open - an unreadable age must never be treated as old, matching
|
|
# coord-sweep.sh's identical rule for the same filename grammar).
|
|
origin_age="-"
|
|
if [ ! -f "$d/.origin" ] && [ -n "$oldest_ts" ]; then
|
|
oldest_epoch="$(date -u -j -f '%Y%m%dT%H%M%SZ' "$oldest_ts" '+%s' 2>/dev/null)"
|
|
case "$oldest_epoch" in
|
|
[0-9]*)
|
|
now_epoch="$(date -u +%s)"
|
|
age_days=$(( (now_epoch - oldest_epoch) / 86400 ))
|
|
[ "$age_days" -ge 0 ] && origin_age="$age_days"
|
|
;;
|
|
esac
|
|
fi
|
|
# Absent, not zero: the question is "who has unhandled mail", and a list of
|
|
# zeroes answers a different one at every reader's expense. A mailbox holding
|
|
# only notices IS listed, with a debt of 0 - it has mail that will be
|
|
# re-injected until someone closes it, which is the thing worth knowing.
|
|
printf '%s\t%s\t%s\t%s\n' "$name" "$n" "$owed" "$origin_age"
|
|
done
|
|
|
|
exit 0
|