#!/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 (debt, then in-progress, then planned, then repos # with no declared status) and there is no cutoff, so nothing is hidden. # 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. neste="$(awk ' /NESTE/ { flag=1; next } flag { if ($0 ~ /^[[:space:]]*$/) next if ($0 ~ /^[[:space:]]*