#!/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//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 / from the global model rubric, e.g. opus/xhigh # # 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 [,...]] [--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 /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 '^', 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:]]*