repo-mailbox/scripts/board.sh
Kjell Tore Guttormsen 61e224ccc3 feat(board): cross-repo attention board as script + skill (0.9.0)
The mailbox answers "who wrote to me"; it never answered "which repo
deserves the next session". board.sh scans every discovered repo and reads
three sources each: the STATE.md next-step block with its optional board
line, git status, and that repo's pending mailbox count. Read-only by
construction, pinned by board-selftest.sh (28 checks).

It ships here rather than as a personal script because the mailbox is one
of its three inputs and the two carry the same axis distinction: a pending
count means others are waiting on that repo, while who a repo waits ON comes
only from its board line, since the message format has no reply-to field.
Splitting the board from the mailbox would put that distinction in two
places. It also lets the skill resolve the engine through CLAUDE_PLUGIN_ROOT
like every other script here, instead of depending on a file that exists
only in ~/.claude/scripts (a directory with no remote and no backup).

The skill is a ranking, not a report: re-runs the board every invocation
because counts drift, ranks by what unblocks the most and what is cheapest
to move, then names one repo, the rule that fired, and the real next action
read from that repo's STATE.md. Never the table. Not wired into session
start, which would spend context on repos the session is not in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MubwdTi88yu4hVLAFG1LbM
2026-07-28 21:15:03 +02:00

222 lines
9 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
# ~/.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/xhigh -->
#
# status planned | in-progress | blocked | deferred | done
# blocked-on <repo-name> or - (only meaningful with status=blocked)
# next-cost <model>/<effort> from the global model rubric, e.g. opus/xhigh
#
# ATTENTION AXIS, NOT THE REGISTER AXIS. This status vocabulary is deliberately
# NOT the 7-token vocabulary in ~/.claude/coord/register.md. That one describes
# a TOPIC's status inside a repo (has this repo adopted OKF?); this one
# describes a REPO's own next step (does this need me?). `not-applicable` is
# meaningless here and `partial` carries register Regel 1 (eier + neste-steg),
# which does not transfer. Conflating the two axes is the AS#5 defect class
# register.md was written to close - keep them separate.
#
# Usage: board.sh [--roots <dir>[,<dir>...]] [--plain]
# 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
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 ;;
--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. Measured 2026-07-26: STATE.md is tracked in 7 of 25 repos, so a plain
# `git worktree add ~/repos/feature-x` lands a depth-1 sibling that CARRIES a
# 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
if [ ! -f "$state" ]; then
last="$(git -C "$d" log -1 --format=%ct 2>/dev/null)"
if [ -n "$last" ]; then age=$(( (NOW - last) / 86400 )); else age=-1; fi
printf '5|%06d|%s|-|-|%s|%s|%sd|(ingen STATE.md)\n' \
"$age" "$name" "$inbox" "$dirty" "$age"
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')"
[ -n "$neste" ] || neste="(tom NESTE-blokk)"
neste="$(trunc "$neste" "$NESTE_WIDTH")"
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\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$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 %s\n' \
"REPO" "STATUS" "KOST" "INN" "DRT" "ALDER" "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 neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$neste"
done
}
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."
[ "$(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