repo-mailbox/scripts/board.sh
Kjell Tore Guttormsen 9dd24c3446 feat(brief): render the nightly cross-repo briefing without a model
The operator has more repos than they can hold in their head, and the
question that actually costs them is "who is waiting on me, and what does
answering cost". board.sh already scans for it; nothing rendered it in a
form an unattended job could leave behind.

--brief is a second RENDERING of that scan, never a second scan. It prints
NESTE uncut, because the 38-character cut is the table column's property
and not the record's - the value used to be truncated at record-build time,
which left the cut string as the only copy. Each startup command is derived
by CALLING route.sh with that repo's own four traits; next-cost alone
cannot produce it, since the advisor flag is a property of the ROW and two
rows can share a model/effort pair while differing on it. A repo with no
route line is told so rather than handed a guess.

It cross-checks itself against coord-count.sh, and that is the substance of
the change rather than a nicety. The repo scan and the mailbox are two
different populations: a mailbox can carry a name no scan will ever produce
- a declared non-git surface (CLAUDE_COORD_REPO, e.g. ~/repos) or a
checkout outside the roots. Measured on the real mailbox: 11 repos / 21
messages in the briefing against coord-count's 12 / 22, the missing one
being the declared surface `repos`. A briefing that only walked the scan
would answer "who is waiting on you" with a number it quietly knew was
short.

Zero model calls, which was the deciding property. Measured against 2.1.220
under subscription auth: --max-budget-usd DOES bite (terminal_reason
budget_exhausted, exit 1), but it aborts AFTER turn one - floor ~0.25
USD-equivalent per turn on claude-opus-5[1m]. It is a runaway brake, not a
pre-flight gate, so a nightly claude -p job would draw on the same quota
pool as interactive work every night. Determinism removes the question.

board.sh stays read-only: the file write lives in brief-nightly.sh, which
renders to a temp file and renames it into place, and treats an EMPTY
render as a FAILED one - board prints nothing when its scan roots do not
exist, which is what a mistyped path or a moved home looks like, and a
plain `> file` redirect would destroy yesterday's briefing on a bad launchd
environment.

The launchd template carries placeholders, not absolute paths: this repo is
mirrored publicly and a plist is the one file here that would need a home
directory in it. It points at a checkout rather than the plugin cache,
which is version-pinned and would break silently on the next bump.

board-selftest 36 -> 49. One check pins a defect caught only by eye against
the real tree: fold copies its input's missing trailing newline, which ran
the command onto the tail of the NESTE prose and produced a briefing whose
commands could not be copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017orCFDkmp88fLnqDR3chdJ
2026-08-01 22:38:46 +02:00

386 lines
18 KiB
Bash
Executable file

#!/bin/bash
# board.sh - cross-repo attention board. Answers the one question no single
# repo's STATE.md can: across ALL repos, which have a live next step, which are
# blocked and on whom, which owe someone a reply, and what each costs to
# advance. Read-only: never writes to a repo, a STATE.md, or the mailbox.
#
# Sources (all pre-existing, nothing invented):
# STATE.md "NESTE" block - the next step, per repo (canonical)
# STATE.md board line - optional machine-readable field (see below)
# git status --porcelain - uncommitted risk
# git log -1 --format=%ct - when anything last landed (the SISTE column)
# ~/.claude/coord/<repo>/inbox - UNHANDLED INBOUND: others addressed this
# repo and it has not processed them. This is an
# obligation the repo owes outward - NOT evidence
# that the repo is waiting on anyone. The mailbox
# format has no reply-to/thread field, so
# outbound waiting is not derivable from it at
# all; that is exactly what blocked-on carries.
#
# Board line (optional, one per STATE.md, directly under the NESTE heading):
#
# <!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/xhigh -->
#
# status planned | in-progress | blocked | deferred | done
# blocked-on <repo-name> or - (only meaningful with status=blocked)
# next-cost <model>/<effort>, the model spelled EXACTLY as the global rubric
# spells it: Sonnet 5/xhigh, Opus 5/high. The parser below accepts
# any spelling, but this field is compared across repos by eye, so
# one form is the whole point - and a second spelling documented
# here is how a field with no write path drifts.
# The field now HAS a write path: route.sh emits it, and the set of
# legal values is that script's row table - not this comment, which
# shows the form only. `route.sh --help` is the authority; the two
# ends are pinned together by route-selftest.sh section 6.
#
# TWO AGE COLUMNS, ONE MEANING EACH. ALDER is the STATE.md mtime - when the
# plan was last touched - and is blank for a repo that has none. SISTE is the
# last commit, read for every repo. They were one column once, and it meant
# whichever of the two the repo's branch happened to compute: a repo WITH a
# STATE.md showed only its plan's age, so one that had not committed in a year
# was indistinguishable from one worked on this morning. Both are evidence for
# the reader and neither is a ranking input - the buckets and the sort are
# unchanged by this column.
#
# ATTENTION AXIS, NOT A TOPIC AXIS. This status vocabulary is deliberately NOT
# the vocabulary a cross-repo TOPIC register uses. A topic register answers
# "what is this repo's status on subject X (has it adopted convention Y?)";
# this line answers "does this REPO's own next step need me?". Topic tokens do
# not transfer: `not-applicable` is meaningless about a repo's next step, and a
# topic-level `partial` carries an ownership-and-next-step rule that belongs to
# the register, not here. Conflating the two axes is a real defect class - the
# board reads only its own axis, so keep them separate.
#
# --brief is a SECOND RENDERING of the same scan, never a second scan. The
# table answers "what is the state of every repo"; the briefing answers the
# narrower question an unattended nightly job can answer without judgement:
# which repos have an unhandled inbox, what their next step says IN FULL, and
# the exact command to start a session there. The 38-char cut is the table
# column's property, not the record's, so the briefing prints NESTE uncut. Each
# command is derived by CALLING route.sh with that repo's own four traits -
# next-cost alone cannot yield it, since the advisor flag is a property of the
# ROW. A repo with no route line is told so rather than handed a guess.
#
# --brief is still read-only: it writes nothing. The file write lives in
# brief-nightly.sh, which renders to a temp file and renames it into place, and
# refuses to overwrite a good briefing with an empty render.
#
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain] [--brief]
# Env: CLAUDE_COORD_DIR overrides the mailbox root.
# BOARD_ROOTS overrides the default scan roots.
# ASCII only, bash 3.2 safe.
set -u
export LC_ALL=C
COORD="${CLAUDE_COORD_DIR:-$HOME/.claude/coord}"
ROOTS="${BOARD_ROOTS:-$HOME/repos}"
NESTE_WIDTH=38
BRIEF=0
# Sibling calculator, invoked rather than reimplemented: the rubric that turns
# four traits into a model has exactly one copy, and it is route.sh's row
# table. Bare form on purpose - a ${VAR:-fallback} here is the 0.12.1 defect.
SELFDIR="$(cd "$(dirname "$0")" && pwd)"
ROUTE="$SELFDIR/route.sh"
while [ $# -gt 0 ]; do
case "$1" in
# bash 3.2: `shift 2` past the end of $# is a no-op -> would loop forever.
--roots) [ $# -ge 2 ] || { echo "board: --roots requires a value" >&2; exit 2; }
ROOTS="$2"; shift 2 ;;
--brief) BRIEF=1; shift ;;
--plain) shift ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "board: unknown argument: $1 (ignored)" >&2; shift ;;
esac
done
NOW="$(date +%s)"
# Truncate to N CHARACTERS (not bytes). A byte cut splits multibyte prose and
# emits mojibake; macOS `cut -c` is character-aware under a UTF-8 locale.
trunc() { printf '%s' "$1" | LC_ALL=en_US.UTF-8 cut -c1-"$2"; }
# --- Discovery: git repos at depth 1, plus depth 2 under polyrepo dirs ------
# A directory that is itself a git repo is one repo; a directory that is not
# but contains git repos is a polyrepo container (the plugin marketplace) and
# contributes its children, never itself.
#
# "Is a repo" tests .git with -e, not -d: a worktree or submodule has .git as a
# FILE. A plain `git worktree add <root>/feature-x` lands a depth-1 sibling that
# can CARRY its own STATE.md - a -d test drops it silently. Kept identical in
# the rollup builder (catalog) on purpose: two readers, one name.
REPOS=""
# Split on comma via IFS + `set --` rather than an unquoted $(...) expansion:
# unquoted word-splitting would also split roots containing spaces. Arg parsing
# is finished above, so clobbering the positional parameters is safe here.
OLD_IFS="$IFS"; IFS=','
set -- $ROOTS
IFS="$OLD_IFS"
for root in "$@"; do
[ -d "$root" ] || continue
for entry in "$root"/*; do
[ -d "$entry" ] || continue
if [ -e "$entry/.git" ]; then
REPOS="$REPOS
$entry"
else
for child in "$entry"/*; do
[ -e "$child/.git" ] || continue
REPOS="$REPOS
$child"
done
fi
done
done
[ -n "$(printf '%s' "$REPOS" | tr -d '[:space:]')" ] || exit 0
# --- Collect one record per repo -------------------------------------------
# Record: bucket|sortkey|name|status|cost|inbox|dirty|age|neste
RECORDS=""
MALFORMED=""
printf '%s\n' "$REPOS" | while IFS= read -r d; do
[ -n "$d" ] || continue
name="$(basename "$d")"
state="$d/STATE.md"
dirty="$(git -C "$d" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
[ -n "$dirty" ] || dirty=0
inbox=0
if [ -d "$COORD/$name/inbox" ]; then
inbox="$(ls "$COORD/$name/inbox"/*.md 2>/dev/null | wc -l | tr -d ' ')"
[ -n "$inbox" ] || inbox=0
fi
# Read for EVERY repo, not just the STATE-less ones: a repo whose plan file
# is fresh can still have been silent for a year, and that is precisely the
# repo no other column reports. A repo with no commits at all has no reading
# to give - printing a day count there would be a fabricated one.
lastct="$(git -C "$d" log -1 --format=%ct 2>/dev/null)"
if [ -n "$lastct" ]; then
lastd=$(( (NOW - lastct) / 86400 )); lastcol="${lastd}d"
else
lastd=-1; lastcol="-"
fi
if [ ! -f "$state" ]; then
# No plan file, so no plan age: ALDER is blank rather than quietly showing
# the commit age under a heading that means something else everywhere else
# in the table. The sort key keeps using it - order is unchanged.
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d"
continue
fi
mtime="$(stat -f %m "$state" 2>/dev/null)"
if [ -n "$mtime" ]; then age=$(( (NOW - mtime) / 86400 )); else age=0; fi
# Anchored to the exact comment form, NOT a substring search: unanchored
# 'board:' also matches prose like "dashboard: ..." and -m1 would let a
# lookalike higher up the file win over the real line.
line="$(grep -m1 '^<!-- board:' "$state" 2>/dev/null)"
status=""; blockedon=""; cost=""
if [ -n "$line" ]; then
status="$(printf '%s' "$line" | sed -n 's/.*status=\([a-z-]*\).*/\1/p')"
blockedon="$(printf '%s' "$line" | sed -n 's/.*blocked-on=\([A-Za-z0-9._-]*\).*/\1/p')"
# Value runs to the next ';' or the closing '-->', NOT to the first
# non-lowercase byte: the rubric names models "Sonnet 5 / xhigh", so a
# lowercase-only class silently drops spec-conformant values to "?".
cost="$(printf '%s' "$line" | sed -n 's/.*next-cost=\([^;>]*\).*/\1/p' \
| sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
fi
case "$status" in
planned|in-progress|blocked|deferred|done) ;;
"") status="?" ;;
*) status="MALFORMED:$status" ;;
esac
[ -n "$cost" ] || cost="?"
# First content line under the NESTE heading: skip blanks, HTML comments and
# the heading itself; strip markdown bold/bullet noise.
neste="$(awk '
/NESTE/ { flag=1; next }
flag {
if ($0 ~ /^[[:space:]]*$/) next
if ($0 ~ /^[[:space:]]*<!--/) next
if ($0 ~ /^#/) next
print; exit
}' "$state" 2>/dev/null \
| sed -e 's/^[[:space:]]*>[[:space:]]*//' -e 's/\*\*//g' \
-e 's/^[[:space:]]*[-*][[:space:]]*//' -e 's/^[[:space:]]*//' -e 's/`//g')"
# Stored WHOLE. Truncation is a property of the table's 38-char column, so it
# belongs to that renderer alone - the briefing is a second rendering of this
# same record and exists precisely to carry the line uncut. Cutting here once
# meant the only copy of the text was the cut one.
[ -n "$neste" ] || neste="(tom NESTE-blokk)"
disp="$status"
if [ "$status" = "blocked" ] && [ -n "$blockedon" ] && [ "$blockedon" != "-" ]; then
disp="blocked>$blockedon"
fi
case "$status" in
blocked) bucket=1 ;;
MALFORMED:*) bucket=2 ;;
in-progress|planned|"?") bucket=2 ;;
deferred) bucket=3 ;;
done) bucket=4 ;;
*) bucket=2 ;;
esac
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" "$neste"
done > "${TMPDIR:-/tmp}/board.$$"
RECORDS="${TMPDIR:-/tmp}/board.$$"
trap '/bin/rm -f "$RECORDS" 2>/dev/null' EXIT
hdr() {
printf '\n%s\n' "$1"
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"REPO" "STATUS" "KOST" "INN" "DRT" "ALDER" "SISTE" "NESTE"
}
rows() {
awk -F'|' -v b="$1" '$1==b' "$RECORDS" | sort -t'|' -k2,2n | \
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
done
}
# --- Briefing rendering (--brief) ------------------------------------------
# The startup command for one repo, derived from that repo's OWN route line by
# calling route.sh. Deriving it from next-cost instead would not work even in
# principle: the advisor flag is a property of the ROW, and two rows can share
# a model/effort pair while differing on it. A repo with no route line gets its
# next-cost printed and is told where the command comes from - a guessed
# command would read as authoritative while being a guess, which is worse than
# no command at all.
brief_cmd() {
bc_state="$1/STATE.md"
bc_line="$(grep -m1 '^<!-- route:' "$bc_state" 2>/dev/null)"
if [ -z "$bc_line" ]; then
printf 'KOST: %s (ingen route-linje - kjor route-skillen i det repoet)' "$2"
return
fi
bc_p="$(printf '%s' "$bc_line" | sed -n 's/.*path=\([a-z-]*\).*/\1/p')"
bc_v="$(printf '%s' "$bc_line" | sed -n 's/.*verification=\([a-z-]*\).*/\1/p')"
bc_r="$(printf '%s' "$bc_line" | sed -n 's/.*reversibility=\([a-z-]*\).*/\1/p')"
bc_s="$(printf '%s' "$bc_line" | sed -n 's/.*scope=\([a-z-]*\).*/\1/p')"
bc_cmd="$(bash "$ROUTE" --path "$bc_p" --verification "$bc_v" \
--reversibility "$bc_r" --scope "$bc_s" --rationale brief 2>/dev/null \
| sed -n 's/^command=//p')"
if [ -n "$bc_cmd" ]; then
printf '$ %s' "$bc_cmd"
else
printf 'KOST: %s (route-linjen kunne ikke tolkes)' "$2"
fi
}
# The repo scan and the mailbox are two different populations, and the gap
# between them is silent by default. board.sh discovers git REPOS; a mailbox
# can carry a name no scan will ever produce - a declared non-git surface
# (CLAUDE_COORD_REPO, e.g. ~/repos itself) or a checkout outside the roots.
# Such a mailbox is invisible in every column this script prints, so a briefing
# that only walks the scan answers "who is waiting on you" with a number it
# quietly knows is short. Measured on the real mailbox: 11 repos / 21 messages
# against coord-count's 12 mailboxes / 22 pending, the missing one being the
# declared surface `repos`.
#
# coord-count.sh is the right source and the only safe one: it counts without
# delivering, where coord-inbox.sh would mark broadcasts seen just by looking.
brief_orphans() {
bo_count="$SELFDIR/coord-count.sh"
[ -f "$bo_count" ] || return 0
bo_tab="$(printf '\t')"
bo_out="$(bash "$bo_count" 2>/dev/null \
| awk -F"$bo_tab" '$2+0>0 {print $1"'"$bo_tab"'"$2}' \
| while IFS="$bo_tab" read -r bo_name bo_n; do
[ -n "$bo_name" ] || continue
awk -F'|' -v n="$bo_name" '$3==n {f=1} END{exit !f}' "$RECORDS" \
|| printf ' %-32s INN %s\n' "$bo_name" "$bo_n"
done)"
[ -n "$bo_out" ] || return 0
echo ""
echo "UTENFOR REPO-SKANNEN - postkasser uten et repo i treet:"
printf '%s\n' "$bo_out"
echo "Disse har ingen STATE.md og derfor intet neste steg. En deklarert flate"
echo "(CLAUDE_COORD_REPO) eller et checkout utenfor scan-roten."
}
brief() {
n_owe="$(awk -F'|' '$6+0 > 0' "$RECORDS" | wc -l | tr -d ' ')"
tot_msg="$(awk -F'|' '$6+0 > 0 {s+=$6} END{print s+0}' "$RECORDS")"
echo "BRIEFING $(date '+%Y-%m-%d %H:%M') - repo som skylder et svar"
echo "Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall."
echo ""
if [ "${n_owe:-0}" -eq 0 ]; then
echo "Ingen repo har uhaandtert innboks. Ingen skylder noen et svar i dag."
# Still checked: "no repo owes" and "no mailbox is pending" are different
# claims, and only the second one is the good news it reads as.
brief_orphans
echo ""
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
return 0
fi
# Most-owed first: the repo holding up the most other sessions is read first.
awk -F'|' '$6+0 > 0' "$RECORDS" | sort -t'|' -k6,6nr | \
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
printf ' %-32s INN %-4s %s\n' "$name" "$inbox" "$status"
# Wrapped, not cut - the whole line is the point, but a 500-character one
# is unreadable in a file nobody watched being written. Locale is set for
# the same reason trunc sets it: under LC_ALL=C fold counts BYTES and can
# split a multibyte character into mojibake.
# Trailing newline via '%s\n': fold copies its input's lack of one, and the
# command line below would then start on the tail of the NESTE text.
printf '%s\n' "$neste" | LC_ALL=en_US.UTF-8 fold -s -w 84 \
| sed -e '1s/^/ NESTE: /' -e '2,$s/^/ /'
printf ' %s\n\n' "$(brief_cmd "$dir" "$cost")"
done
printf '%s repo skylder svar, %s meldinger totalt.\n' "$n_owe" "$tot_msg"
brief_orphans
echo ""
echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA"
echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke"
echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra."
}
if [ "$BRIEF" -eq 1 ]; then
brief
exit 0
fi
count() { awk -F'|' -v b="$1" '$1==b' "$RECORDS" | wc -l | tr -d ' '; }
echo "BOARD - tverr-repo oppmerksomhetstavle ($(awk 'END{print NR}' "$RECORDS") repo)"
echo "INN = uhaandtert innboks (andre venter paa DEG). DRT = ukommiterte filer."
echo "ALDER = dager siden STATE.md endret. SISTE = dager siden siste commit."
[ "$(count 1)" -gt 0 ] && { hdr "BLOKKERT (venter paa ekstern avhengighet)"; rows 1; }
[ "$(count 2)" -gt 0 ] && { hdr "AKTIV (reelt neste steg)"; rows 2; }
[ "$(count 3)" -gt 0 ] && { hdr "UTSATT (deferred - bevisst valg, ikke venting)"; rows 3; }
[ "$(count 4)" -gt 0 ] && { hdr "FERDIG"; rows 4; }
[ "$(count 5)" -gt 0 ] && { hdr "UTEN STATE.md (sovende / ubestemt tilstand)"; rows 5; }
# Obligations and risk read across buckets, so they get their own roll-up.
tot_in="$(awk -F'|' '{s+=$6} END{print s+0}' "$RECORDS")"
tot_dirty="$(awk -F'|' '{s+=$7} END{print s+0}' "$RECORDS")"
n_mal="$(grep -c 'MALFORMED' "$RECORDS" 2>/dev/null | tr -d ' ')"
n_nofield="$(awk -F'|' '$4=="?"' "$RECORDS" | wc -l | tr -d ' ')"
printf '\nSUM: %s uhaandterte innboks-meldinger, %s ukommiterte filer.\n' "$tot_in" "$tot_dirty"
[ "${n_mal:-0}" -gt 0 ] && printf 'ADVARSEL: %s repo har MALFORMED status-token (utenfor det lukkede settet).\n' "$n_mal"
[ "${n_nofield:-0}" -gt 0 ] && printf 'MERK: %s repo mangler board-linje - status/kost er ukjent (?), NESTE-utdrag brukes.\n' "$n_nofield"
exit 0