#!/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. # # --inbox-plan is a FOURTH rendering, ordered by morning-driver # (20260814T175317Z, corrected 20260814T180854Z) as a fix for its OWN # regression, not a feature request: --plan answers "which repos deserve a # tab today" (admission, ranking, a cap); --inbox-plan answers "which repos # have unhandled post" (population, no judgement, no cap) - and deriving the # second from the first was always a shortcut, one that broke the moment # --plan's admission narrowed in 0.22.0. It is a superset of --plan's # candidates, not a complement: an already-admitted repo still gets a block. # # ONE BLOCK PER NAME WITH PENDING MAIL, independent of --plan's admission # gate entirely. Each block carries `class=` distinguishing the three reasons # a block might be unopenable, per the work order's explicit ask - a repo # without STATE.md and a mailbox without a directory are NOT the same fact # for the operator, and collapsing them into one undifferentiated "kan ikke # aapnes" is the defect this mode exists to fix: # repo - dir, .git and STATE.md all present; may still lack a # route line, in which case command_missing= says so # (same route_cmd_for() every other rendering uses). # no-state - a scanned git repo with no STATE.md at all, so no NESTE # and no route line can ever exist for it. # orphan-mailbox - a mailbox name with no matching directory anywhere in # the scanned roots; there is no dir= to cd into. # `pending=` and `owed=` surface coord-count.sh's two counts directly (its # debt field, not re-derived) so a caller can tell a request from a notice # without recomputing it - the second explicit ask. # # --dispatch is a FIFTH rendering, and the only one that carries a task INTO a # repo rather than reporting on it: "start a session in repo X, on order Y, at # cost Z". It is here, and not in a script of its own, because the block format # has exactly ONE generator and this file already is it - a second emitter of # tab=/repo=/dir=/command=/paste= would be two copies of one format drifting # apart. Read-only is untouched: every check is a read, and the two writes a # dispatch needs (the prompt file, the plan file) stay with the caller, exactly # as brief-nightly.sh owns the briefing's write. # # board.sh --dispatch --repo --prompt-file # --target-pane # --path P --verification V --reversibility R --scope S # --rationale "why" # # Model, effort and --advisor come from route.sh's row table, never from a # --model/--effort pair: the advisor flag is a property of the ROW, and a # dispatch that took the model directly would have no honest source for it. # --target-pane is a MEASUREMENT (morning --probe-panes) and has no default - # see the dispatch() comment for what happens when it is guessed. # # Usage: board.sh [--roots [,...]] [--plain] # [--brief|--plan|--inbox-plan|--dispatch ...] # [--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 INBOX_PLAN=0 FOCUS="" DISPATCH=0 D_REPO=""; D_PROMPT=""; D_PANE="" D_PATH=""; D_VER=""; D_REV=""; D_SCOPE=""; D_RAT="" # 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 ;; # Four renderings of one scan, so exactly one may be selected: last wins. --brief) BRIEF=1; PLAN=0; INBOX_PLAN=0; shift ;; --plan) PLAN=1; BRIEF=0; INBOX_PLAN=0; shift ;; --inbox-plan) INBOX_PLAN=1; BRIEF=0; PLAN=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 ;; # Dispatch rendering: one repo, one prompt file, one startup command. Same # `shift 2` guard as every other value flag, for the same bash 3.2 reason. --dispatch) DISPATCH=1; BRIEF=0; PLAN=0; INBOX_PLAN=0; shift ;; --repo) [ $# -ge 2 ] || { echo "board: --repo requires a value" >&2; exit 2; } D_REPO="$2"; shift 2 ;; --prompt-file) [ $# -ge 2 ] || { echo "board: --prompt-file requires a value" >&2; exit 2; } D_PROMPT="$2"; shift 2 ;; --target-pane) [ $# -ge 2 ] || { echo "board: --target-pane requires a value" >&2; exit 2; } D_PANE="$2"; shift 2 ;; --path) [ $# -ge 2 ] || { echo "board: --path requires a value" >&2; exit 2; } D_PATH="$2"; shift 2 ;; --verification) [ $# -ge 2 ] || { echo "board: --verification requires a value" >&2; exit 2; } D_VER="$2"; shift 2 ;; --reversibility) [ $# -ge 2 ] || { echo "board: --reversibility requires a value" >&2; exit 2; } D_REV="$2"; shift 2 ;; --scope) [ $# -ge 2 ] || { echo "board: --scope requires a value" >&2; exit 2; } D_SCOPE="$2"; shift 2 ;; --rationale) [ $# -ge 2 ] || { echo "board: --rationale requires a value" >&2; exit 2; } D_RAT="$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-[a-z-] byte: a prefix class stops early on a value that is one byte # past a valid token ("done2" -> "done") and silently accepts it as that # token, or on a case variant ("Planned") and captures empty - the exact # bugs F3 closes. The case statement below still does an EXACT match, so # anything out of vocabulary lands in MALFORMED with its real value intact. status="$(printf '%s' "$line" | sed -n 's/.*status=\([^;>]*\).*/\1/p' \ | sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" 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. 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.$$" OWED="${TMPDIR:-/tmp}/board-owed.$$" ALLMAIL="${TMPDIR:-/tmp}/board-allmail.$$" DEADLETTERS="${TMPDIR:-/tmp}/board-deadletters.$$" trap '/bin/rm -f "$RECORDS" "$UNBLOCKS" "$OWED" "$ALLMAIL" "$DEADLETTERS" 2>/dev/null' EXIT # --- Owed lookup (name -> messages that actually owe a reply) -------------- # `inbox` (field 6 of RECORDS) is raw pending-file count and stays that way - # it is what the TABLE means by INN, "what is the state of every repo". Group # 2 of --plan and the whole of --brief mean something narrower, "who is # waiting on you", and a pending message is not automatically that: a sender # can declare reply-expected: no. Measured on the live mailbox 2026-08-13: 27 # of 72 pending messages (37.5%) are notices, not requests - conflating the two # admitted done/deferred/blocked repos into --plan on FYI mail alone and # mislabeled --brief's own "repo som skylder svar" line with the same number. # coord-count.sh already separates them (pending, then debt) for exactly this # reason; this is that value, joined by name like chain-root credit above. # # Built only for --brief and --plan, the only two consumers: the plain table # view scans a repo tree and prints raw INN by design, never touching the # mailbox beyond what RECORDS already read, and paying an extra coord-count.sh # subprocess on every invocation would tax that path for a lookup it never # uses. # HAVE_COUNT, computed once: a missing sibling (the 0.12.1 deployed-copy # incident shape - review finding 1, 2026-08-14) must never read as "coord- # count.sh ran and found nothing". Every site below that would otherwise # treat an empty $OWED/$DEADLETTERS/$ALLMAIL as a confident zero checks this # flag first and warns instead - see brief(), plan(), inbox_plan(). HAVE_COUNT=1 [ -f "$SELFDIR/coord-count.sh" ] || HAVE_COUNT=0 : > "$OWED" if { [ "$BRIEF" -eq 1 ] || [ "$PLAN" -eq 1 ]; } && [ "$HAVE_COUNT" -eq 1 ]; then ow_tab="$(printf '\t')" bash "$SELFDIR/coord-count.sh" 2>/dev/null \ | awk -F"$ow_tab" -v OFS='|' '{print $1, $3}' > "$OWED" fi # --- Dead-letter lookup (name -> age in days), --brief only ---------------- # coord-count.sh's fourth column (WP1d, .claude 2026-08-14) reports the age in # days of the oldest pending message in a mailbox with no .origin - a mailbox # no session has EVER read via the normal SessionStart path. "-" (has .origin, # or age unreadable) is filtered out here; only mailboxes actually past the # 3-day threshold reach $DEADLETTERS, so brief_deadletters() below never has to # re-parse the raw column or re-apply the threshold itself. : > "$DEADLETTERS" if [ "$BRIEF" -eq 1 ] && [ "$HAVE_COUNT" -eq 1 ]; then dl_tab="$(printf '\t')" bash "$SELFDIR/coord-count.sh" 2>/dev/null \ | awk -F"$dl_tab" -v OFS='|' '$4 != "-" && $4+0 >= 3 {print $1, $4}' > "$DEADLETTERS" fi # --- All-mail lookup (name -> pending, owed), --inbox-plan only ------------ # --inbox-plan's whole population is "every name coord-count.sh reports", so # unlike $OWED (which only needs the debt column) this keeps BOTH integers - # pending is the population test, owed is the per-block ask #2 marker. Built # only for --inbox-plan, the only consumer, for the same reason $OWED is # gated: an extra coord-count.sh subprocess on every table/plain invocation # would tax a path that never reads it. : > "$ALLMAIL" if [ "$INBOX_PLAN" -eq 1 ] && [ "$HAVE_COUNT" -eq 1 ]; then am_tab="$(printf '\t')" bash "$SELFDIR/coord-count.sh" 2>/dev/null \ | awk -F"$am_tab" -v OFS='|' '{print $1, $2, $3}' > "$ALLMAIL" fi # --- 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 '^', NOT to the first non-[a-z-] byte: a # prefix class silently truncates "known2" to "known", which route.sh's own # exact-match validation then ACCEPTS - a safely-worded but WRONG command # instead of the refusal this defect (F4) exists to force. route.sh still # does the real rejection; this only stops the value from being mangled # into something valid before it gets there. rc_p="$(printf '%s' "$rc_line" | sed -n 's/.*path=\([^;>]*\).*/\1/p' \ | sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" rc_v="$(printf '%s' "$rc_line" | sed -n 's/.*verification=\([^;>]*\).*/\1/p' \ | sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" rc_r="$(printf '%s' "$rc_line" | sed -n 's/.*reversibility=\([^;>]*\).*/\1/p' \ | sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" rc_s="$(printf '%s' "$rc_line" | sed -n 's/.*scope=\([^;>]*\).*/\1/p' \ | sed -e 's/--$//' -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')" bash "$ROUTE" --path "$rc_p" --verification "$rc_v" \ --reversibility "$rc_r" --scope "$rc_s" --rationale brief 2>/dev/null \ | sed -n 's/^command=//p' return 0 } brief_cmd() { if bc_cmd="$(route_cmd_for "$1")"; then if [ -n "$bc_cmd" ]; then printf '$ %s' "$bc_cmd" else printf 'KOST: %s (route-linjen kunne ikke tolkes)' "$2" fi else printf 'KOST: %s (ingen route-linje - kjor route-skillen i det repoet)' "$2" fi } # The repo scan and the mailbox are two different populations, and the gap # between them is silent by default. board.sh discovers git REPOS; a mailbox # can carry a name no scan will ever produce - a declared non-git surface # (CLAUDE_COORD_REPO, e.g. ~/repos itself) or a checkout outside the roots. # Such a mailbox is invisible in every column this script prints, so a briefing # that only walks the scan answers "who is waiting on you" with a number it # quietly knows is short. Measured on the real mailbox: 11 repos / 21 messages # against coord-count's 12 mailboxes / 22 pending, the missing one being the # declared surface `repos`. # # coord-count.sh is the right source and the only safe one: it counts without # delivering, where coord-inbox.sh would mark broadcasts seen just by looking. brief_orphans() { if [ "$HAVE_COUNT" -ne 1 ]; then echo "" echo "UTENFOR REPO-SKANNEN: kan ikke sjekke - coord-count.sh mangler." return 0 fi bo_tab="$(printf '\t')" bo_out="$(bash "$SELFDIR/coord-count.sh" 2>/dev/null \ | awk -F"$bo_tab" '$2+0>0 {print $1"'"$bo_tab"'"$2}' \ | while IFS="$bo_tab" read -r bo_name bo_n; do [ -n "$bo_name" ] || continue awk -F'|' -v n="$bo_name" '$3==n {f=1} END{exit !f}' "$RECORDS" \ || printf ' %-32s INN %s\n' "$bo_name" "$bo_n" done)" [ -n "$bo_out" ] || return 0 echo "" echo "UTENFOR REPO-SKANNEN - postkasser uten et repo i treet:" printf '%s\n' "$bo_out" echo "Disse har ingen STATE.md og derfor intet neste steg. En deklarert flate" echo "(CLAUDE_COORD_REPO) eller et checkout utenfor scan-roten." } # WP1d detection half (.claude, 2026-08-14): $DEADLETTERS already carries only # the mailboxes past the 3-day threshold, pre-filtered - this function only # renders. A mailbox here has NO .origin, meaning no session has ever read it # via the normal per-repo injection; the message sits there regardless of # whether it also happens to have a repo in the scan tree (dead-letter-repo, # claimed-repo in board-selftest.sh section 16 both do). The action half # (report to sender / retract) is unapproved design and is not built here. brief_deadletters() { if [ "$HAVE_COUNT" -ne 1 ]; then echo "" echo "ALDRI LEST: kan ikke sjekke - coord-count.sh mangler." return 0 fi [ -s "$DEADLETTERS" ] || return 0 echo "" echo "ALDRI LEST - postkasser uten .origin med post eldre enn 3 dogn:" awk -F'|' '{printf " %-32s %s dogn\n", $1, $2}' "$DEADLETTERS" echo "Ingen sesjon har noensinne kjort coord-inbox for disse - posten blir" echo "aldri injisert av den vanlige SessionStart-veien." } brief() { # OWED, not raw pending (field 6): this briefing is titled "repo som skylder # et svar" (repos that owe a reply), and a pending message that declared # reply-expected: no is not that - it is a notice. Joined by FILENAME, never # NR==FNR (see plan()'s comment on the same join): $OWED can legitimately be # empty on a debt-free day, and NR==FNR misroutes an entire subsequent file # when the file before it was empty. n_owe="$(awk -F'|' -v OWF="$OWED" ' FILENAME==OWF { ow[$1] = $2 + 0; next } { name = $3; if ((name in ow) && ow[name] > 0) print } ' "$OWED" "$RECORDS" | wc -l | tr -d ' ')" tot_msg="$(awk -F'|' -v OWF="$OWED" ' FILENAME==OWF { ow[$1] = $2 + 0; next } { name = $3; if ((name in ow) && ow[name] > 0) s += ow[name] } END { print s+0 } ' "$OWED" "$RECORDS")" echo "BRIEFING $(date '+%Y-%m-%d %H:%M') - repo som skylder et svar" echo "Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall." echo "" # A missing coord-count.sh sibling (review finding 1, 2026-08-14 - the # 0.12.1 deployed-copy incident shape) must never be read as "checked, zero # debt": $OWED is empty for the same reason it would be on a genuinely # debt-free day, and continuing into the branch below actively mislabeled a # reply-owing repo as FYI-only (inbox>0, owed==0 looks identical either way). if [ "$HAVE_COUNT" -ne 1 ]; then echo "ADVARSEL: coord-count.sh mangler - kan ikke avgjore hvem som skylder svar." echo "Gjeldstall er IKKE null, bare ikke beregnet. Se ${SELFDIR}/coord-count.sh." brief_orphans brief_deadletters return 0 fi if [ "${n_owe:-0}" -eq 0 ]; then # Only the debt claim, never "no repo has unhandled inbox": n_owe counts # OWED repos since 0.22.0, so this branch can fire while a repo still # holds FYI-only mail - real, re-injected, just not an obligation. The # comment this replaces already named the two claims as different; the # code used to make both anyway. echo "Ingen repo skylder noen et svar i dag." fyi_only="$(awk -F'|' -v OWF="$OWED" ' FILENAME==OWF { ow[$1] = $2 + 0; next } { name = $3; inbox = $6 + 0; owed = (name in ow) ? ow[name] : 0 if (inbox > 0 && owed == 0) print name } ' "$OWED" "$RECORDS")" if [ -n "$fyi_only" ]; then echo "Disse har bare FYI-post (ingen skylder svar, men posten er reell):" printf '%s\n' "$fyi_only" | sed 's/^/ /' fi brief_orphans brief_deadletters echo "" echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA" echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke" echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra." return 0 fi # Most-owed first: the repo holding up the most other sessions is read first. awk -F'|' -v OFS='|' -v OWF="$OWED" ' FILENAME==OWF { ow[$1] = $2 + 0; next } { name = $3; owed = (name in ow) ? ow[name] : 0; if (owed > 0) print owed, $0 } ' "$OWED" "$RECORDS" | sort -t'|' -k1,1nr | \ while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon neste; do printf ' %-32s INN %-4s %s\n' "$name" "$owed" "$status" # Wrapped, not cut - the whole line is the point, but a 500-character one # is unreadable in a file nobody watched being written. Locale is set for # the same reason trunc sets it: under LC_ALL=C fold counts BYTES and can # split a multibyte character into mojibake. # Trailing newline via '%s\n': fold copies its input's lack of one, and the # command line below would then start on the tail of the NESTE text. printf '%s\n' "$neste" | LC_ALL=en_US.UTF-8 fold -s -w 84 \ | sed -e '1s/^/ NESTE: /' -e '2,$s/^/ /' printf ' %s\n\n' "$(brief_cmd "$dir" "$cost")" done printf '%s repo skylder svar, %s meldinger totalt.\n' "$n_owe" "$tot_msg" brief_orphans brief_deadletters echo "" echo "MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA" echo "staar kun i dets egen board-linje (blocked-on) - postkassen har ikke" echo "noe reply-to-felt, saa utgaaende venting er ikke utledbar derfra." } if [ "$BRIEF" -eq 1 ]; then brief exit 0 fi # --- Dispatch rendering (--dispatch) --------------------------------------- # The FOURTH rendering, and the only one that carries a task INTO another repo: # "start a session in repo X, on order Y, at cost Z". It lives here because the # block format has exactly ONE generator and this file already is it - a second # emitter of tab=/repo=/dir=/command=/paste= would be two copies of one format, # which is the drift defect this repo's CLAUDE.md warns about, and a second # place to get `paste=` wrong. # # Read-only holds. Everything below is a read; the two FILE WRITES a dispatch # needs - the prompt file and the plan file - stay with the caller, exactly as # brief-nightly.sh already owns the briefing's write. # # THE COST COMES FROM route.sh's ROW TABLE, never from a --model/--effort pair # handed in. That is not tidiness: `--advisor opus` is a property of the ROW, # two rows share a model/effort pair while differing on it, and the CLI accepts # a wrong advisor silently (it drops it for a Fable main model without failing). # A dispatch that took the model directly would have no source for that flag, # and both available guesses are wrong in the same direction - a session that # looks peer-reviewed without being. Scoring the four traits is the caller's # judgement; turning them into a command is this lookup. dispatch() { # --target-pane is REQUIRED and has no default, the same rule route.sh's # --last-effort carries: it is a MEASUREMENT of the world (does the target # repo already have a Ghostty pane), and this script must never learn to look # for a terminal. Defaulting would be worst at "no": that is the plan-file # form, and morning's plan_drop_open (morning:1788) silently drops a plan # block for a repo that already has a pane - "0 of 1", which reads as a # broken plan file. Measured four times on one day, 2026-08-16, by two # different repos. case "$D_PANE" in yes|no) ;; "") echo "board: --dispatch requires --target-pane yes|no - measure it (morning --probe-panes), never guess it" >&2; exit 2 ;; *) echo "board: --target-pane must be yes or no (got: $D_PANE)" >&2; exit 2 ;; esac [ -n "$D_REPO" ] || { echo "board: --dispatch requires --repo " >&2; exit 2; } [ -n "$D_PROMPT" ] || { echo "board: --dispatch requires --prompt-file - the order the session is dispatched ON" >&2; exit 2; } [ -n "$D_RAT" ] || { echo "board: --dispatch requires --rationale (route.sh requires it, and for the same reason: a score with no stated why cannot be audited)" >&2; exit 2; } # The prompt PATH is what has to be shell-clean, not the prompt CONTENT. # Verified directly 2026-08-16: "$(cat f)" hands the file's bytes to the # session as ONE argv element with no re-evaluation, so $(...), backticks, # quotes and UTF-8 in the BODY are inert - which is exactly why the prompt # goes in argv instead of being inlined. The path sits inside those quotes # and IS evaluated, so a space or a quote there splits the command. # Absolute, because a relative path resolves against whatever directory the # pane happens to be in - which for the paste-only form is a repo this # script never sees. case "$D_PROMPT" in /*) ;; *) echo "board: --prompt-file must be an absolute path (a relative one resolves against the pane's directory, not this one): $D_PROMPT" >&2; exit 2 ;; esac case "$D_PROMPT" in *[!A-Za-z0-9/._-]*) echo "board: --prompt-file path is not shell-clean; it is expanded inside the command: $D_PROMPT" >&2; exit 2 ;; esac [ -f "$D_PROMPT" ] || { echo "board: no prompt file at $D_PROMPT" >&2; exit 2; } # test -s, not test -e: an empty prompt file starts a session and then tells # it nothing, which from the far end is indistinguishable from a session # waiting for a Go that will never come. [ -s "$D_PROMPT" ] || { echo "board: the prompt file is empty: $D_PROMPT (a dispatched session would be started and told nothing)" >&2; exit 2; } d_rec="$(awk -F'|' -v n="$D_REPO" '$3==n {print; exit}' "$RECORDS")" [ -n "$d_rec" ] || { echo "board: no repo named '$D_REPO' in the scanned roots ($ROOTS)" >&2; exit 2; } d_status="$(printf '%s' "$d_rec" | cut -d'|' -f4)" d_dir="$(printf '%s' "$d_rec" | cut -d'|' -f10)" d_neste="$(printf '%s' "$d_rec" | cut -d'|' -f12-)" [ -d "$d_dir" ] || { echo "board: the directory for $D_REPO does not exist: $d_dir" >&2; exit 2; } # Rejected traits REFUSE. Degrading to a command without them is the worst # available outcome: a command missing --advisor is indistinguishable from a # row that legitimately has none. d_route="$(bash "$ROUTE" --path "$D_PATH" --verification "$D_VER" \ --reversibility "$D_REV" --scope "$D_SCOPE" --rationale "$D_RAT" 2>&1)" || { echo "board: route.sh rejected the traits, so there is no command to give:" >&2 printf '%s\n' "$d_route" >&2 exit 2 } d_cmd="$(printf '%s\n' "$d_route" | sed -n 's/^command=//p')" d_cost="$(printf '%s\n' "$d_route" | sed -n 's/^next-cost=//p')" [ -n "$d_cmd" ] || { echo "board: route.sh produced no command for those traits" >&2; exit 2; } # The prompt in argv is the whole point. A bare model+effort command forces # the operator to type Go, and the session then has to GUESS its task out of # STATE.md instead of being handed it - delivered bare twice on 2026-08-16 # before the operator corrected it ("gi meg alltid komplette # oppstartskommandoer for nye sesjoner"). d_full="$d_cmd \"\$(cat $D_PROMPT)\"" if [ "$D_PANE" = "no" ]; then printf '# DISPATCH %s - plan-file form (target has no pane)\n' "$D_REPO" printf '# Write this whole output to a file and run: morning --plan-file --no-go\n' printf 'form=plan-file\n' printf '\n' printf 'tab=1\n' printf 'repo=%s\n' "$D_REPO" printf 'dir=%s\n' "$d_dir" printf 'why=dispatch\n' printf 'status=%s\n' "$d_status" printf 'neste=%s\n' "$d_neste" printf 'next-cost=%s\n' "$d_cost" printf 'command=%s\n' "$d_full" printf 'paste=cd %s && %s\n' "$d_dir" "$d_full" printf '\n' # --no-go semantics, in the OUTPUT and not only in a comment: it stops the # Go MESSAGE, never the work. morning:806 is exact - "--no-go says nothing # is typed once the startup command is in" - so the startup command, prompt # in argv and all, is typed regardless and the session starts working by # itself. An operator decided on the opposite premise once already # (2026-08-16) and it had to be corrected before the run. printf 'note=--no-go stops only the follow-up Go message. The startup command - prompt in argv included - is typed regardless (morning:806), so the dispatched session starts working on its own.\n' # What the dry-run does and does not prove, measured rather than assumed # (2026-08-16, this dispatch run through the real morning): it validates # that the block parses and that a command comes out - "opening: 1 of 1". # It does NOT validate the pane question. Run from a Claude session there # is no tty, so morning prints "window: unknown (no terminal to measure), # assuming an empty window" and plan_drop_open never fires at all. A gate # built on the dry-run would therefore pass the self-dispatch case every # single time, which is the case it was supposed to catch. printf 'note=Verify before running: morning --plan-file --dry-run must report 1 of 1. That proves the block parses and yields a command - it does NOT prove the target has no pane: run from a Claude session there is no tty, morning assumes an empty window and plan_drop_open never fires (measured 2026-08-16).\n' printf 'note=The pane question is answered by morning --probe-panes, whose DIR column works without a tty. That measurement is what --target-pane carries.\n' else printf '# DISPATCH %s - paste-only form (target ALREADY has a pane)\n' "$D_REPO" printf '# No plan file, deliberately: run from a real terminal, morning plan_drop_open\n' printf '# (morning:1788) drops a block whose repo already has a pane and reports\n' printf '# "0 of 1", which reads as a broken plan file. Hand the operator the line\n' printf '# instead. (Without a tty morning cannot see the panes at all, so a dry-run\n' printf '# from a Claude session would report 1 of 1 and prove nothing.)\n' printf 'form=paste-only\n' printf 'repo=%s\n' "$D_REPO" printf 'dir=%s\n' "$d_dir" printf 'next-cost=%s\n' "$d_cost" # No cd: one repo per terminal tab, and the operator is already standing in # that one. A cd here is noise at best and a session started in the wrong # directory at worst. printf 'paste=%s\n' "$d_full" printf '\n' printf 'note=Run this in the existing tab AFTER /exit - it starts a new session in the pane that is already there.\n' printf 'note=Deliberately no tab= key: a block carrying one is a plan block, and a plan block for this repo is exactly what plan_drop_open discards.\n' printf 'note=morning --relaunch bypasses the filter but opens a SECOND tab beside the existing one (morning:1784), which is rarely what is wanted when the operator is already sitting there.\n' fi } if [ "$DISPATCH" -eq 1 ]; then dispatch exit 0 fi # --- Focus resolution (--focus) -------------------------------------------- # --focus is the first CUTOFF this format has ever had, and the plan documents # at length that it has none: it takes one position (the order), it hides # nothing, and it LABELS what it cannot rank rather than dropping it. A filter # that stayed silent about what fell outside it would break that property # outright, so the report is not a refinement of the feature - it is the # condition the feature was allowed to exist under. # # The topic marker grammar belongs to the register, not here. board.sh only # ever READS a declaration, and reads it from STATE.md alone: the slug # vocabulary is whatever the scanned STATE.md files themselves declare, so no # new file is opened and the "STATE.md and nothing else" invariant survives. # It also means the reader must accept the marker as operators actually write # it - bold, backticked, bulleted - because the strict form is what the # register's own grep looks for, and the single most consequential repo in the # measurement behind this feature was invisible to exactly that grep. FOCUS_STATUS='planned|in-progress|partial|blocked|deferred|done|not-applicable' # The emphasis is optional on BOTH halves. Accepting `**slug**: done` but not # `slug: **done**` is not a rule, only whichever example happened to be in front # of us - and the second form is in live use, where it makes a repo that HAS # declared look silent. focus_marker_re() { printf '^[[:space:]]*[-*]?[[:space:]]*\**`?%s`?\**:[[:space:]]+\**(%s)\**([[:space:]]|$)' \ "$1" "$FOCUS_STATUS" } # Every slug declared anywhere in the scanned tree. Field 10 of RECORDS is the # repo directory; a repo with no STATE.md simply contributes nothing. focus_slugs() { awk -F'|' '{print $10}' "$RECORDS" | while read -r fs_d; do [ -n "$fs_d" ] && [ -f "$fs_d/STATE.md" ] || continue grep -hE "$(focus_marker_re '[a-z0-9][a-z0-9-]*')" "$fs_d/STATE.md" 2>/dev/null done | sed -E 's/^[[:space:]]*[-*]?[[:space:]]*\**`?([a-z0-9][a-z0-9-]*)`?\**:.*/\1/' \ | sort -u } # Prose -> slugs. Two normalisations, because both readings occur: the operator # types a slug ("some-subject-guard") or a bare word out of their own day # ("guard"). Matching is never narrowed to one winner - an ambiguous phrase # widens the answer and every slug it resolved to is named in the output, # because silently picking one would make the cutoff lie about its own size. focus_resolve() { fr_words=" $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' ' ') " fr_parts=" $(printf '%s' "$fr_words" | tr '-' ' ') " focus_slugs | while read -r fr_s; do [ -n "$fr_s" ] || continue case "$fr_words" in *" $fr_s "*) printf '%s\n' "$fr_s"; continue ;; esac for fr_p in $(printf '%s' "$fr_s" | tr '-' ' '); do # Two-character parts match far too much prose to be evidence of intent. [ "${#fr_p}" -ge 3 ] || continue case "$fr_parts" in *" $fr_p "*) printf '%s\n' "$fr_s"; break ;; esac done done | sort -u } # Does this repo DECLARE any of the resolved slugs? (survives the cutoff) focus_declares() { for fd_s in $FOCUS_SLUGS; do grep -qE "$(focus_marker_re "$fd_s")" "$1/STATE.md" 2>/dev/null && return 0 done return 1 } # What a surviving block survived ON. This is the per-block evidence that # closed `topics=`: the need was real, but a field in all 27 blocks on every # day the operator has no focus is noise, while the same fact inside a focused # run is the reason the block is there. focus_evidence() { for fe_s in $FOCUS_SLUGS; do fe_line="$(grep -m1 -E "$(focus_marker_re "$fe_s")" "$1/STATE.md" 2>/dev/null)" if [ -n "$fe_line" ]; then # Reported unwrapped: the emphasis is markdown the operator typed, not # part of the token, and "**done**" in a key=value field reads as a value. fe_st="$(printf '%s' "$fe_line" | sed -E "s/.*:[[:space:]]+\**($FOCUS_STATUS)\**.*/\1/")" printf '%s: %s\n' "$fe_s" "$fe_st" return 0 fi done return 1 } # The held-back population: STATE.md MENTIONS a resolved slug and declares no # marker line for it. The wording is a constraint, not a style choice - this # is a fact about text found in a file, and board.sh has no grounds whatever # for a claim about relevance, so it says "nevner" and never "dekker". The # class is NAMED rather than counted, because in the measurement that produced # this feature the decisive find - a heavy consumer pinning the library in its # build file - appeared only once the population was enumerated. Reasoning # about it had missed it entirely. focus_heldback() { awk -F'|' '{print $3 "|" $10}' "$RECORDS" | while IFS='|' read -r fh_n fh_d; do [ -n "$fh_d" ] && [ -f "$fh_d/STATE.md" ] || continue focus_declares "$fh_d" && continue for fh_s in $FOCUS_SLUGS; do if grep -qF -- "$fh_s" "$fh_d/STATE.md" 2>/dev/null; then printf '%s\n' "$fh_n" break fi done done } # --- Day-plan rendering (--plan) ------------------------------------------- # A THIRD rendering of the same scan, built on exactly the argument --brief was: # it is a lookup over data the scan already holds, it costs zero model calls, # and route.sh already derives the per-repo command. The table says what the # state of every repo is; the briefing says who is waiting; the plan says which # repos to open a tab for today, in what order, with which command. # # key=value blocks, not prose, because the plan has TWO consumers: the operator # pasting commands, and a separate repo driving a terminal from it. Prose would # make the rendered FORMAT an API, and no test in this repo could hold it stable # for a consumer living in another one. Comment lines all start with '#', so a # consumer drops them with one rule. # # ORDER IS THE POSITION THIS RENDERING TAKES, and it is the only one it takes - # there is no cutoff, so nothing is hidden. Five groups, all deterministic over # fields the scan already read: # 1. chain-root credit - repos this one releases transitively, most first. # Above debt because it is the only move that frees sessions other than # your own; a blocked repo is never credited, since its own next step is # by definition waiting. # 2. INN > 0, most-owed first, WHATEVER the status. A message owed is an # obligation to another session. Excluding `blocked` is about a repo's own # next step, which by definition cannot be moved; answering is a different # axis, and is frequently what unblocks it. # 3. planned - a decided but unstarted next step. # 4. in-progress - live work, oldest plan first. # 5. '?' and MALFORMED - no declared status. Planned LAST, and labelled. The # table already prints a MERK line about repos with no board line; a plan # that dropped them silently would repeat exactly that defect. # Groups 3 and 4 are in that order by operator decision at 0.20.0, inverted from # every earlier version: turning a decision into motion is the slow step, and # live work is already moving. Deciding it the other way is a policy change, not # a sort fix. # Excluded: done, deferred, blocked-without-debt, and repos with no STATE.md and # no debt - a tab that cannot be moved is not a plan entry. # Two lines for two consumers, and they are not redundant. A driver cd's the # pane itself and then types the command, so it needs them apart; the operator # needs ONE thing to select and paste, because assembling `cd ` from one # field and the command from another is precisely where a tab ends up started # in the wrong repo. paste= is emitted only alongside command= - `paste=cd X && ` # with nothing after it would run the cd and then a bare newline, leaving the # operator in the right directory with no session and no error. plan_cmd() { if pc_cmd="$(route_cmd_for "$1")"; then if [ -n "$pc_cmd" ]; then printf 'command=%s\n' "$pc_cmd" printf 'paste=cd %s && %s\n' "$1" "$pc_cmd" else printf 'command_missing=route-linjen kunne ikke tolkes (kost: %s)\n' "$2" fi else printf 'command_missing=ingen route-linje - kjor route-skillen der (kost: %s)\n' "$2" fi } plan() { # FIVE ORDERED GROUPS, not a weighted score. Each group is a lookup over a # field the scan already read, so two readers can disagree about the order but # never about how it was produced - and a selftest can pin the outcome rather # than a coefficient. # # THE COST THIS FORM ACCEPTS, recorded because it is real and was argued at # length before it was chosen: a group order cannot express "this repo owes one # message AND releases two others" as a single quantity. The 0.19.0 score could, # and that was its point. What it could not do was stay stable for a consumer in # another repo - re-tuning 40 against 15 here silently reorders `morning`, # `--brief` and `brief-nightly`, and no test in THIS repo can hold a weighted # ranking still for a parser in that one. The operator weighed both and chose # the lookup (2026-08-03). This paragraph exists so a later session reads the # objection as ANSWERED rather than as an unfixed defect and "restores" a score. # # Group 1 is chain-root credit, and it sits ABOVE debt: opening the root of a # blocked chain is the only move that releases sessions other than your own. # Group 3 is `planned` ABOVE group 4 `in-progress` - inverted from every prior # version by operator decision the same day. The reason is that a decided but # unstarted next step is the step that turns a decision into motion, while live # work is already moving. It is a policy call, not a sort that drifted. # # Debt is neither capped nor excluded - it is group 2, ranked most-owed-first, # WHATEVER the status. A cap was proposed and declined; being outranked by a # chain root is not a cap, because the debtor keeps its tab, its position among # the other debtors, and its `why=inbox:N`. # # `cheap` carries the one property 0.19.0 added that no one asked to remove: # under quota pressure a Sonnet row moves for less. It is the LAST tiebreak, # below the group's own magnitude and above age, so it separates repos that are # otherwise equal and can never lift one past another that owes or releases # more. # # The three sort keys are prefixed as fields 1-3 and stripped again afterwards; # `why` then becomes field 1 exactly as every previous version left it, so the # read loop below and both consumers see an unchanged block. Prefixing rather # than appending is deliberate - `neste` is the last field and is free prose, so # anything added after it could be swallowed by a stray separator. pf="${TMPDIR:-/tmp}/board-plan.$$" : > "$pf" # Both lookup files are matched by FILENAME, never by NR==FNR: that idiom # silently breaks when the FIRST file is empty (zero blocked repos is a real, # common tree state) - FNR then stays equal to NR for the entire NEXT file # too, not just its first line, so every record in it is misrouted into the # ub[] branch and dropped via `next`. Measured against 0.21.0: one in-progress # repo with an unhandled inbox message, zero blocked repos anywhere, produced # "0 tabber". FILENAME comparison never depends on how many lines a prior # file contributed, empty or not. awk -F'|' -v OFS='|' -v UBF="$UNBLOCKS" -v OWF="$OWED" ' FILENAME==UBF { ub[$1] = $2 + 0; next } FILENAME==OWF { ow[$1] = $2 + 0; next } { name = $3; status = $4; cost = $5 # OWED, not raw pending (field 6): a message can decline a reply # (reply-expected: no), and that declaration is what group 2 is FOR - an # obligation to another session, not merely a file in the inbox. owed = (name in ow) ? ow[name] : 0 # A blocked repo is credited with nothing even if something waits on it: # it cannot move its own next step, so opening it releases no one. The # credit already went to the root of its chain. blockedp = (status ~ /^blocked/) u = (!blockedp && (name in ub)) ? ub[name] : 0 # Membership is unchanged: a repo that releases someone is planned even # when it owes nothing and its own status would otherwise exclude it, and # done, deferred, blocked and no-STATE repos with no debt stay out. keep = (owed > 0) || (u > 0) || (status == "in-progress") || \ (status == "planned") || (status == "?") || (status ~ /^MALFORMED:/) if (!keep) next # The group IS the reason, so why= is the group and needs no separate rule. # `mag` orders within a group by the quantity that group is about - released # in group 1, messages owed in group 2 - and is 0 where the group has no # quantity, leaving those to the tiebreaks below. if (u > 0) { grp = 1; mag = u; why = "unblocks:" u } else if (owed > 0) { grp = 2; mag = owed; why = "inbox:" owed } else if (status == "planned") { grp = 3; mag = 0; why = "planned" } else if (status == "in-progress") { grp = 4; mag = 0; why = "in-progress" } else { grp = 5; mag = 0; why = "uavklart" } # Cheap-to-move first under quota pressure. Sorted ascending, so 0 = cheap. cheap = (cost ~ /[Ss]onnet/) ? 0 : 1 print grp, mag, cheap, why, $0 } ' "$UNBLOCKS" "$OWED" "$RECORDS" \ | sort -t'|' -k1,1n -k2,2nr -k3,3n -k6,6n \ | cut -d'|' -f4- >> "$pf" # The cutoff, and its disclosure, computed together - they are one feature. FOCUS_SLUGS="" fp_applied=0 fp_before="$(awk 'END{print NR+0}' "$pf")" if [ -n "$FOCUS" ]; then FOCUS_SLUGS="$(focus_resolve "$FOCUS" | tr '\n' ' ')" [ -n "$(printf '%s' "$FOCUS_SLUGS" | tr -d ' ')" ] && fp_applied=1 fi if [ "$fp_applied" -eq 1 ]; then fp_names="${TMPDIR:-/tmp}/board-focusnames.$$" fp_kept="${TMPDIR:-/tmp}/board-planfocus.$$" awk -F'|' '{print $3 "|" $10}' "$RECORDS" | while IFS='|' read -r fp_n fp_d; do [ -n "$fp_d" ] && [ -f "$fp_d/STATE.md" ] || continue focus_declares "$fp_d" && printf '%s\n' "$fp_n" done > "$fp_names" awk -F'|' 'NR==FNR{keep[$0]=1;next} keep[$4]' "$fp_names" "$pf" > "$fp_kept" /bin/rm -f "$fp_names" 2>/dev/null mv "$fp_kept" "$pf" fi echo "# PLAN $(date '+%Y-%m-%d %H:%M') - en blokk per tab, i den rekkefolgen" echo "# Kilder: STATE.md (NESTE + route-linje), git, coord-innboks. 0 modellkall." echo "# Rekkefolge: 5 grupper - kjede-rot, innboks, planned, in-progress, uavklart." # "uten gjeld" governs the WHOLE list, not just the token nearest to it: a # done or deferred repo that owes mail IS planned, and the real tree has two. # Read the other way this line calls its own tab 4 a bug. echo "# Utelatt naar repoet verken skylder svar eller losner noen: done, deferred," echo "# blocked, uten STATE.md. Et blokkert repo faar aldri kreditt - den gaar til rota." # Emitted as key=value, not as a '#' comment, because the format's second # consumer drops every comment line by rule - a disclosure written as a # comment would reach the operator on the terminal path and vanish on the # driver path, which is the one case where the cutoff is applied unseen. # Same rule applies to the coord-count.sh warning below (review finding 1, # 2026-08-14): a missing sibling makes `owed` 0 for every repo above, which # can silently drop a done/deferred debtor from group 2 - `keep` itself is # intentionally left unchanged (a policy change to a format the operator # decided has a second consumer elsewhere), but the driver must be able to # see that the omission happened rather than read a shorter list as complete. [ "$HAVE_COUNT" -eq 1 ] || printf 'advarsel=coord-count.sh mangler - gjeldsvekting (gruppe 2, inbox:N) er ikke beregnet\n' if [ -n "$FOCUS" ]; then fp_state="$(awk -F'|' '{print $10}' "$RECORDS" | while read -r fp_sd; do [ -n "$fp_sd" ] && [ -f "$fp_sd/STATE.md" ] && echo x done | wc -l | tr -d ' ')" if [ "$fp_applied" -eq 1 ]; then printf 'fokus=%s\n' "$(printf '%s' "$FOCUS_SLUGS" | sed 's/[[:space:]]*$//' | tr ' ' ',')" printf 'fokus_droppet=%s av %s blokker\n' \ "$((fp_before - $(awk 'END{print NR+0}' "$pf")))" "$fp_before" fp_hb="$(focus_heldback | sort -u)" fp_hbn="$(printf '%s' "$fp_hb" | grep -c . | tr -d ' ')" # Nobody held back is an answer, and it has to LOOK like one. sed cannot # supply the placeholder: with an empty string there is no input line for # a substitution to run on, so the field would end at a bare colon - the # exact shape command_missing= exists to keep out of this format. fp_hbl="$(printf '%s' "$fp_hb" | tr '\n' ' ' | sed 's/[[:space:]]*$//')" [ -n "$fp_hbl" ] || fp_hbl="(ingen)" # "nevner", never "dekker": this states what was found in a file, and # says which files were searched rather than implying it searched repos. # One repo in the real measurement carries its strongest evidence in a # README, which this scan never opens. printf 'fokus_utenfor=%s repo nevner %s uten markorlinje: %s\n' \ "$fp_hbn" "$(printf '%s' "$FOCUS_SLUGS" | sed 's/[[:space:]]*$//' | tr ' ' ',')" \ "$fp_hbl" printf 'fokus_rekkevidde=sokt i %s STATE.md - board leser ingen andre filer\n' "$fp_state" else # No declared slug matched. The full plan is printed: the driver forwards # operator prose verbatim, so a typo must not silently produce a morning # with no tabs at all. printf 'fokus_ikke_brukt=%s traff ingen deklarert slug i %s STATE.md - hele planen vises\n' \ "$FOCUS" "$fp_state" fi fi echo "" pn=0 while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon neste; do [ -n "$name" ] || continue pn=$((pn + 1)) printf 'tab=%s\n' "$pn" printf 'repo=%s\n' "$name" # Absolute, and the driver must cd into it explicitly: a new terminal pane # inherits its anchor's working directory, so a plan that omitted this would # look right and point at the wrong repo. printf 'dir=%s\n' "$dir" printf 'why=%s\n' "$why" if [ "$fp_applied" -eq 1 ]; then fp_ev="$(focus_evidence "$dir")" && printf 'fokus_treff=%s\n' "$fp_ev" fi printf 'status=%s\n' "$status" printf 'neste=%s\n' "$neste" plan_cmd "$dir" "$cost" echo "" done < "$pf" /bin/rm -f "$pf" 2>/dev/null printf '# %s tabber.\n' "$pn" # An orphan mailbox has no directory to cd into, so it cannot BE a tab - but # omitting it lets the plan claim a completeness it knows it lacks. Reported # as commentary, reusing the briefing's single copy of that cross-check. po="$(brief_orphans)" if [ -n "$po" ]; then printf '%s\n' "$po" | sed -e 's/^/# /' -e 's/^# *$/#/' fi echo "# MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA" echo "# staar kun i dets egen board-linje (blocked-on)." } if [ "$PLAN" -eq 1 ]; then plan exit 0 fi # --- Inbox-plan rendering (--inbox-plan) ------------------------------------ # See the file-header comment for the full rationale. One block per NAME with # pending>0 in $ALLMAIL, joined against $RECORDS by FILENAME (never NR==FNR - # the plan()'s own regression comment above explains why: the first file being # empty silently misroutes the entire second file's lines, and $RECORDS here # is never empty but the general idiom is worth staying consistent about). # # class is read off $RECORDS' own bucket, not recomputed: bucket 5 is exactly # "no STATE.md" (see the discovery loop's own `printf '5|...'` line above), and # a name absent from $RECORDS entirely never had a directory in the scan. inbox_plan() { ipf="${TMPDIR:-/tmp}/board-inboxplan.$$" awk -F'|' -v OFS='|' -v RCF="$RECORDS" ' FILENAME==RCF { name = $3 rc_bucket[name] = $1; rc_status[name] = $4; rc_cost[name] = $5 rc_dir[name] = $10; rc_neste[name] = $12 next } { name = $1; pend = $2 + 0; owed = $3 + 0 if (name in rc_bucket) { cls = (rc_bucket[name] == 5) ? "no-state" : "repo" dir = rc_dir[name]; status = rc_status[name]; cost = rc_cost[name] neste = rc_neste[name] } else { cls = "orphan-mailbox"; dir = "-"; status = "-"; cost = "-"; neste = "-" } print owed, pend, name, cls, dir, status, cost, neste } ' "$RECORDS" "$ALLMAIL" | sort -t'|' -k1,1nr -k2,2nr -k3,3 > "$ipf" echo "# INBOX-PLAN $(date '+%Y-%m-%d %H:%M') - en blokk per repo/mailbox med uhaandtert post" echo "# Kilder: coord-count.sh (pending+owed), STATE.md, git. 0 modellkall." echo "# Populasjon: alt med pending>0, uavhengig av --plan sin admisjon - ingen tak," echo "# ingen dom. Et repo --plan alt admitterer er fortsatt med (overmengde, ikke komplement)." echo "# class=repo (STATE.md finnes) | no-state (repo finnes, STATE.md mangler) |" echo "# orphan-mailbox (ingen katalog for postkassen i det skannede omraadet)." echo "# pending=meldinger totalt, owed=derav skylder svar (resten er FYI)." echo "" # Review finding 1 (2026-08-14): a missing coord-count.sh sibling leaves # $ALLMAIL empty, and the population below is entirely $ALLMAIL rows - the # measured effect was 1 block silently becoming 0, not an empty population # rendered as such. key=value, not '#', for the same reason as plan()'s # advarsel= line: the driver consumer drops comment lines by rule. [ "$HAVE_COUNT" -eq 1 ] || printf 'advarsel=coord-count.sh mangler - populasjonen er ikke beregnet, ikke tom\n' ipn=0 while IFS='|' read -r owed pend name cls dir status cost neste; do [ -n "$name" ] || continue ipn=$((ipn + 1)) printf 'tab=%s\n' "$ipn" printf 'repo=%s\n' "$name" printf 'dir=%s\n' "$dir" printf 'class=%s\n' "$cls" printf 'pending=%s\n' "$pend" printf 'owed=%s\n' "$owed" printf 'status=%s\n' "$status" printf 'neste=%s\n' "$neste" case "$cls" in # Same route_cmd_for()/plan_cmd() every other rendering uses - one copy # of "how a command is derived from a route line", not a second guess. repo) plan_cmd "$dir" "$cost" ;; # No STATE.md means no route line can exist - naming that plainly is # the whole point of a separate class, not a guessed command. no-state) printf 'command_missing=ingen STATE.md - repoet finnes, men har ingen plan\n' ;; orphan-mailbox) printf 'command_missing=ingen katalog for denne postkassen i det skannede omraadet\n' ;; esac echo "" done < "$ipf" /bin/rm -f "$ipf" 2>/dev/null printf '# %s blokker.\n' "$ipn" echo "# MERK: INN teller hva ANDRE venter paa fra deg. Hva et repo venter PAA" echo "# staar kun i dets egen board-linje (blocked-on)." } if [ "$INBOX_PLAN" -eq 1 ]; then inbox_plan exit 0 fi 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