ALDER carried two readings depending on which branch a repo fell into: STATE.md mtime for repos that had one, last-commit age for repos that did not. The consequence was one-sided -- a repo WITH a STATE.md reported only how recently its plan was touched, so one that had not committed in a year looked exactly as alive as one worked on this morning, and no column contradicted it. Now each column means one thing. ALDER is the plan's age and is blank where there is no plan; SISTE is the last commit, read for every repo, and blank where there are no commits -- a day count there would be a fabricated reading, which is what the old -1d was. The four ranking rules stay four: the new column is evidence, never sort input. The sort key was already a separate field from the displayed age, so bucket 5 keeps ordering by commit age while showing nothing in ALDER. The fixture tree was missing the only case that discriminates -- a repo with a STATE.md AND an old last commit -- so the checks land with it, plus its mirror image (old plan, fresh commit) to pin that the two ages read independently and that neither became the sort key. Backdating sets GIT_COMMITTER_DATE, not --date=: %ct is the committer date, and an author-only backdate would have left the red test failing for the wrong reason. board-selftest: 30 -> 36 checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QS6gYYgYgJAz7VjcFHEzbb
253 lines
11 KiB
Bash
Executable file
253 lines
11 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.
|
|
#
|
|
# 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
|
|
|
|
# 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|(ingen STATE.md)\n' \
|
|
"$lastd" "$name" "$inbox" "$dirty" "$lastcol"
|
|
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|%s\n' \
|
|
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$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 neste; do
|
|
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
|
|
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$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."
|
|
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
|