#!/bin/bash # coord-count.sh - count PENDING directed messages per mailbox WITHOUT # delivering anything. Prints one "\t\t\t" # line per mailbox that has unhandled mail, sorted by name; prints nothing when # none do. # # TWO INTEGERS, NOT ONE. is every unhandled message; 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. # # (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 vs above. # # WHY THIS IS NOT coord-inbox.sh --repo : 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 ] # --exclude 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 # GNU/BSD date flavor, detected once per run (not per mailbox): BSD date # rejects --version outright (exit nonzero, "illegal option" - measured on # this machine); GNU date supports it and prints a version banner (exit 0 - # measured directly against Ubuntu 24.04 / GNU coreutils 9.4). The origin_age # column below needs this because BSD's `date -j -f` and GNU's `date -d` # share no common invocation - GNU date has no -j at all (measured: "date: # invalid option -- 'j'", exit 1), which is why every mailbox printed "-" # (now "?", see the F11a comment below) on Linux before this branch existed. # coord-selftest.sh section 32 pins the GNU branch via a PATH shim that # replays these measured facts. DATE_IS_GNU=0 date --version >/dev/null 2>&1 && DATE_IS_GNU=1 # 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 .origin exists (claimed, never a dead-letter candidate # regardless of age). "?" means unclaimed but 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 - # and, critically, must never be reported as the SAME token as claimed # (review finding 11, 2026-08-14: both used to print "-", collapsing "not a # dead-letter candidate" and "not measured" into one token a consumer could # not tell apart). Only a real computed age is neither. origin_age="-" if [ ! -f "$d/.origin" ]; then origin_age="?" if [ -n "$oldest_ts" ]; then if [ "$DATE_IS_GNU" -eq 1 ]; then # Compact grammar (YYYYMMDDTHHMMSSZ) expanded to the RFC 3339 form # GNU date documents as always parseable by -d regardless of locale. # Bash 3.2 substring expansion, no external command needed. oldest_iso="${oldest_ts:0:4}-${oldest_ts:4:2}-${oldest_ts:6:2}T${oldest_ts:9:2}:${oldest_ts:11:2}:${oldest_ts:13:2}Z" oldest_epoch="$(date -u -d "$oldest_iso" '+%s' 2>/dev/null)" else oldest_epoch="$(date -u -j -f '%Y%m%dT%H%M%SZ' "$oldest_ts" '+%s' 2>/dev/null)" fi 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 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