#!/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//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): # # # # status planned | in-progress | blocked | deferred | done # blocked-on or - (only meaningful with status=blocked) # next-cost /, 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. # # --plan is a THIRD rendering of that same scan, and the only one that takes a # position: it answers which repos to open a tab for today and in what order. # It prints key=value blocks, not prose, because it has two consumers - the # operator pasting commands, and a separate repo driving a terminal from it. # Ordering is deterministic and there is no cutoff, so nothing is hidden. It is # FIVE ORDERED GROUPS, each a lookup over a field the scan already read: # # 1. chain-root credit - repos released transitively (see below) # 2. unhandled inbox messages, most-owed first, WHATEVER the status # 3. planned # 4. in-progress # 5. undeclared status ('?' / MALFORMED) - last, and labelled # # Within a group: the quantity that group is about (released, then owed), then a # Sonnet next-cost (cheap to move under quota pressure), then oldest plan first. # # 0.19.0 shipped a weighted score here instead, and 0.20.0 replaced it. The score # expressed one thing this cannot - "owes one message AND releases two others" as # a single quantity - and the operator accepted losing that. What a score could # not do was hold still for the second consumer: re-tuning 40 against 15 here # would silently reorder a parser living in another repo, and no test in THIS one # could catch it. Groups 3 and 4 are inverted from every version before 0.20.0 # (planned above in-progress), also by operator decision: a decided but unstarted # step is what converts a decision into motion; live work is already moving. # # CHAIN-ROOT CREDIT is the term that needed blocked-on to stop being a display # string. For every blocked repo the edge is followed transitively to the ROOT # of the chain - the first repo not itself blocked - and only the root is # credited. Opening a blocked repo releases nobody, since its own next step is # by definition waiting; opening the root releases everything behind it. A # cycle, a blocked-on naming an unscanned repo, and a blocked repo with no # target all credit NOBODY: inventing a root there would look like a working # plan while sending the operator to the wrong repo. # # Debt is deliberately UNCAPPED and never excluded. A cap was proposed with the # credit and declined by the operator: owing a reply is the other axis from a # repo's own next step, and answering is often what unblocks a chain. Being one # group below chain-root credit is NOT a cap - the debtor keeps its tab, its # most-owed-first position among the other debtors, and its `why=inbox:N`. A # change that dropped a debtor from the plan would be the declined cap wearing # this decision as a disguise, and section 12 of the selftest pins that half. # # why= names the GROUP that put the repo in the plan. Under the score it named # the dominant TERM, because a score has no single rule that admitted a repo; # under groups the admitting rule is the group, so the two coincide wherever the # chain-root group applies and the field is unchanged for both consumers. # Read-only like the rest: --plan writes nothing, in the repo or the mailbox. # # --focus "" narrows --plan to the repos whose STATE.md DECLARES a # matching topic marker (`: `), and is the only cutoff this # format has. It is therefore required to report what it held back: the same # run prints fokus= (the slugs the prose resolved to), fokus_droppet= (how # many blocks the cutoff removed), fokus_utenfor= (the repos that MENTION a # resolved slug with no marker line, named - that class is where the decisive # find came from), and fokus_rekkevidde= (how many STATE.md were searched; # board opens no other file). Each surviving block carries fokus_treff=, the # declaration it survived on. Prose matching no declared slug prints the FULL # plan plus fokus_ikke_brukt= - never an empty one, since the prose arrives # verbatim from the operator and a typo must not empty the morning. # # Usage: board.sh [--roots [,...]] [--plain] [--brief|--plan] # [--focus ""] # 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 PLAN=0 FOCUS="" # 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 ;; # Three renderings of one scan, so exactly one may be selected: last wins. --brief) BRIEF=1; PLAN=0; shift ;; --plan) PLAN=1; BRIEF=0; shift ;; # Raw operator prose, forwarded verbatim by the driver: it does not # tokenize, match or normalize, so every bit of that work is here. Same # `shift 2` guard as --roots, for the same bash 3.2 reason. --focus) [ $# -ge 2 ] || { echo "board: --focus requires a value" >&2; exit 2; } FOCUS="$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 /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 '^', 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. Anchored to the # heading form (measured 26/27 real repos, decided 2026-08-02) so a prose # line that merely mentions the word is never mistaken for the marker. # # A comment is tracked to its CLOSER, not just recognised on its opening line. # Skipping only lines that start with the opener left every continuation line # of a wrapped comment looking like prose, so the excerpt became comment # internals - the board line and the route line both wrap easily, and this # repo was the one of 28 that hit it, held down only by writing them on one # line. What this does NOT do, and cannot: an HTML comment body may not # contain the closer at all, so a route rationale that quotes it still ends # its own comment early. That is the format, not a defect left here. neste="$(awk ' /^#+[[:space:]].*NESTE/ { flag=1; next } flag { if (incomment) { if ($0 ~ /-->/) incomment=0; next } if ($0 ~ /^[[:space:]]*$/) next if ($0 ~ /^[[:space:]]*/) incomment=1; 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 # blocked-on is a FIELD, not just the display string above: --plan follows the # edge to find who a session would release, and the edge cannot be recovered # from `blocked>X` alone (a repo can be blocked with no target, and a repo can # name a target the scan never produced). Inserted BEFORE `neste`, never after: # `neste` is free prose lifted out of a STATE.md and may contain a literal '|' # from a markdown table, so it has to stay the last field on the line. printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s\n' \ "$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \ "${blockedon:--}" "$neste" done > "${TMPDIR:-/tmp}/board.$$" RECORDS="${TMPDIR:-/tmp}/board.$$" UNBLOCKS="${TMPDIR:-/tmp}/board-unblocks.$$" trap '/bin/rm -f "$RECORDS" "$UNBLOCKS" 2>/dev/null' EXIT # --- Chain-root credit ----------------------------------------------------- # For every blocked repo, walk `blocked-on` up to the ROOT of the chain - the # first repo that is not itself blocked - and credit that root with one repo # released. The credit is transitive and lands ONLY on the root, because that # is the only node where opening a session changes anything: every other link # is by definition waiting on someone further up. # # Three cases must credit NOBODY rather than credit a guess: # - a cycle (A waits on B waits on A): no root exists, so the hop limit is # the terminating condition, not a safety net that also happens to fire. # - a blocked-on naming a repo the scan never produced: nothing validates # that field, so a typo is the expected case. There is no directory to # open, so a tab for it would be unrunnable. # - a blocked repo with no target at all (`blocked-on=-`). # Silently inventing a root in any of these is worse than skipping it: the plan # would look correct and send the operator to the wrong repo. : > "$UNBLOCKS" awk -F'|' '$4 ~ /^blocked/ && $11 != "-" && $11 != "" {print $11}' "$RECORDS" \ | while read -r cr_target; do cr_cur="$cr_target"; cr_hops=0; cr_root="" while [ "$cr_hops" -lt 20 ]; do cr_line="$(awk -F'|' -v n="$cr_cur" '$3==n {print; exit}' "$RECORDS")" [ -n "$cr_line" ] || break # dangling target: no root cr_st="$(printf '%s' "$cr_line" | cut -d'|' -f4)" case "$cr_st" in blocked*) cr_nxt="$(printf '%s' "$cr_line" | cut -d'|' -f11)" [ -n "$cr_nxt" ] && [ "$cr_nxt" != "-" ] || break cr_cur="$cr_nxt"; cr_hops=$((cr_hops + 1)) ;; *) cr_root="$cr_cur"; break ;; esac done [ -n "$cr_root" ] && printf '%s\n' "$cr_root" done | sort | uniq -c | awk '{print $2 "|" $1}' > "$UNBLOCKS" 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 blockedon 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. # Shared by --brief and --plan, because there is one route line grammar and it # gets one reader. Two no-command cases, and callers must keep them apart: # exit 1 - no route line at all # exit 0, empty out - a route line route.sh rejects (a typo'd trait value) # Neither may become a guessed command, and neither may become a bare command # marker: a driver reading `command=` would type an empty line into a live pane. route_cmd_for() { rc_line="$(grep -m1 '^