The board line's cost field had two specifications and one of them was this script. `--help` prescribed a versionless `sonnet/xhigh`; the global model rubric it points at spells the model `Sonnet 5 / xhigh`. A session following either was correct by its own source, so the field drifted with nobody making a mistake. Measured across a real 44-repo tree: 14 board lines carrying eight distinct strings, and not one of them the form documented here - the only spelling nobody used was ours. The help now prescribes the rubric's, which is also what most lines already carry. The parser is untouched and stays lenient: it reads to `;` or `-->` so the rubric's spacing survives, and the fixtures still vary the spelling on purpose to pin that. Read-side leniency was never the defect; it is what makes a documentation-only fix sufficient. No normalization at read, deliberately. Board lines are rewritten every session by convention, so an unambiguous spec self-heals the existing lines in about one session per active repo - mapping variants would be permanent code migrating data that migrates itself. Nothing compares the field mechanically today either: KOST is a display column and the board sorts on a numeric bucket key, so the defect was real but latent. Two checks (28 -> 30) pin the documented form, red first, because cleaning the data cannot fix a spec that regenerates the divergence every session. Validation at write has no write path to attach to: nothing in this repo emits a board line outside the fixtures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
226 lines
9.3 KiB
Bash
Executable file
226 lines
9.3 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 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.
|
|
#
|
|
# 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.
|
|
#
|
|
# 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. 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
|
|
|
|
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
|