repo-mailbox/scripts/coord-count.sh
Kjell Tore Guttormsen db57b43f50 fix(coord-count): distinguish claimed from unmeasured, fix Linux date parse
Review finding 11 (docs/2026-08-14-confident-zero-review.md), tier 1,
prioritized by .claude 2026-08-14T21:24:16Z coord message.

Two bugs collapsed into one token. Column 4 of coord-count.sh (the WP1d
origin-age column) printed "-" for both "has .origin" (claimed, not a
dead-letter candidate) and "age could not be measured" - a consumer could
not tell "not a dead letter" from "not measured". Fixed by reserving "-"
for claimed only and introducing "?" for could-not-measure; board.sh's
sole column-4 consumer (awk '$4 != "-" && $4+0 >= 3') needed no change,
since awk's numeric coercion already treats "?" as 0 and filters it out
the same way "-" always was.

Second, the age computation itself used `date -u -j -f ...`, a BSD-only
invocation. Measured directly (Ubuntu 24.04, GNU coreutils 9.4, real
coord-count.sh, unmodified) rather than reasoned about: GNU date rejects
-j outright ("invalid option -- 'j'", exit 1), so every mailbox printed
"-" on Linux regardless of actual dead-letter status - WP1d detection was
silently inert on the one platform this public plugin cannot assume away.
Fixed with a one-time `date --version` flavor check (measured: exits 0
with a GNU banner on GNU date, exits nonzero with "illegal option" on BSD
date) branching to `date -u -d <RFC-3339-string>` on GNU, unchanged
`-j -f` on BSD. The RFC 3339 acceptance is documented (GNU Coreutils
manual, "Options for date"), not live-measured, since Colima was removed
from this machine mid-session before that specific sub-claim could be
re-verified live.

coord-selftest.sh section 31 (e) previously pinned the bug (asserted "-"
for an ungrammatical filename); updated to assert "?", plus a new
contrast check that claimed and could-not-measure are always different
tokens. Section 32 pins the GNU branch with a PATH shim for `date` that
replays only measured facts (the --version and -j behaviors above) rather
than a speculative mock.

All four selftest suites green: coord 200/200, board 178/178 (regression,
unaffected as predicted), route 69/69, guard 21/21.

Out of scope, reported not fixed: coord-sweep.sh:77 and both selftests'
fixture generators use `date -v-Nd` (BSD-only, same class) - the suite
itself cannot run on Linux yet. No CI exists in this repo to catch that
drift automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F1mzsaUmjqFHArbArG7EpF
2026-08-14 23:44:01 +02:00

161 lines
8 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
# 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