repo-mailbox/scripts/board.sh
Kjell Tore Guttormsen c519ab4994 feat(orders): order queue channel with atomic claim, board ORDRE column
ORDRE 59. A dispatched order used to live only in a scratch prompt file
passed through argv, so it died with the pane it was typed into. Measured
2026-08-17: one order was dispatched three times over 90 minutes before it
was worked, because the first two tabs ran something else and the order
left no trace in the receiving repo at all.

New channel `~/.claude/coord/<repo>/orders/`, beside `inbox/` and never
merged with it. The axis is authorization: inbox content is untrusted
cross-repo data that may never instruct a session (Rule 6), a dispatch
order is operator-authorized work by construction. One channel carrying
both classes would mean either mail that can instruct or orders that
cannot, so the infrastructure is reused and the channel is not.

Four one-verb engines: coord-order-send.sh (write), coord-order-inbox.sh
(read, writes nothing at all), coord-order-claim.sh (atomic claim),
coord-order-done.sh (executed with a commit pointer / --no-commit with a
reason / --return with a reason).

The claim is a rename with no check-then-act step, so of N racing sessions
exactly one finds the source and the rest get ENOENT. The test that proves
it spawns 20 claimers BARRIERED on a start flag - unbarriered children do
not race at all - and runs the identical harness against a deliberately
racy `[ -e src ] && cp && rm` as a known-negative control, which must
produce many winners. Without that control, "exactly one winner" is
indistinguishable from "the race never happened".

Channel separation is pinned structurally, not only behaviourally: no mail
script may contain the string `orders`, with a known-positive control
proving the grep can find. coord-done cannot archive an order and
coord-order-claim cannot claim a message.

board gains an ORDRE column beside INN, counted with the identical idiom
and never summed with it: INN is "others are waiting on YOU", ORDRE is
"work is waiting on this REPO". Claimed orders are excluded - the column
answers what a session can pick up. board.sh --dispatch --order-id emits a
thin starter carrying only the id and the four steps, so the order text has
exactly one home; the id is validated shell-clean and must be pending in
the target's queue.

SessionStart injects the queue as its own block below the mailbox block.
Two channels, two blocks, mail first: it carries Rule 7, and the queue
order is mail -> orders -> STATE's NESTE.

Also folds in dde392d (board prefix-match fix), which landed after the
0.26.0 bump and before any tag. v0.26.0 was never tagged, so 0.27.0 is the
release that carries all of it.

Suites: coord 220, board 237, route 69, orders 97, guard 40; npm test 11/11.
Antakelse 4 (atomic claim) and antakelse 6 (morning --plan-file --dry-run
reports 1 of 1 for the thin starter) both measured, not assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134iB7ipXGgEpv9imYoVmr2
2026-08-17 21:17:17 +02:00

1401 lines
74 KiB
Bash
Executable file

#!/bin/bash
# board.sh - cross-repo attention board. Answers the one question no single
# repo's STATE.md can: across ALL repos, which have a live next step, which are
# blocked and on whom, which owe someone a reply, and what each costs to
# advance. Read-only: never writes to a repo, a STATE.md, or the mailbox.
#
# Sources (all pre-existing, nothing invented):
# STATE.md "NESTE" block - the next step, per repo (canonical)
# STATE.md board line - optional machine-readable field (see below)
# git status --porcelain - uncommitted risk
# git log -1 --format=%ct - when anything last landed (the SISTE column)
# ~/.claude/coord/<repo>/inbox - UNHANDLED INBOUND: others addressed this
# repo and it has not processed them. This is an
# obligation the repo owes outward - NOT evidence
# that the repo is waiting on anyone. The mailbox
# format has no reply-to/thread field, so
# outbound waiting is not derivable from it at
# all; that is exactly what blocked-on carries.
#
# Board line (optional, one per STATE.md, directly under the NESTE heading):
#
# <!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/xhigh -->
#
# status planned | in-progress | blocked | deferred | done
# blocked-on <repo-name> or - (only meaningful with status=blocked)
# next-cost <model>/<effort>, the model spelled EXACTLY as the global rubric
# spells it: Sonnet 5/xhigh, Opus 5/high. The parser below accepts
# any spelling, but this field is compared across repos by eye, so
# one form is the whole point - and a second spelling documented
# here is how a field with no write path drifts.
# The field now HAS a write path: route.sh emits it, and the set of
# legal values is that script's row table - not this comment, which
# shows the form only. `route.sh --help` is the authority; the two
# ends are pinned together by route-selftest.sh section 6.
#
# TWO AGE COLUMNS, ONE MEANING EACH. ALDER is the STATE.md mtime - when the
# plan was last touched - and is blank for a repo that has none. SISTE is the
# last commit, read for every repo. They were one column once, and it meant
# whichever of the two the repo's branch happened to compute: a repo WITH a
# STATE.md showed only its plan's age, so one that had not committed in a year
# was indistinguishable from one worked on this morning. Both are evidence for
# the reader and neither is a ranking input - the buckets and the sort are
# unchanged by this column.
#
# ATTENTION AXIS, NOT A TOPIC AXIS. This status vocabulary is deliberately NOT
# the vocabulary a cross-repo TOPIC register uses. A topic register answers
# "what is this repo's status on subject X (has it adopted convention Y?)";
# this line answers "does this REPO's own next step need me?". Topic tokens do
# not transfer: `not-applicable` is meaningless about a repo's next step, and a
# topic-level `partial` carries an ownership-and-next-step rule that belongs to
# the register, not here. Conflating the two axes is a real defect class - the
# board reads only its own axis, so keep them separate.
#
# --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 "<prose>" narrows --plan to the repos whose STATE.md DECLARES a
# matching topic marker (`<slug>: <status>`), 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 <name> --prompt-file <abs path>
# --target-pane <yes|no>
# --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 <dir>[,<dir>...]] [--plain]
# [--brief|--plan|--inbox-plan|--dispatch ...]
# [--focus "<prose>"]
# 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_ORDER=""
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 ;;
--order-id) [ $# -ge 2 ] || { echo "board: --order-id requires a value" >&2; exit 2; }
D_ORDER="$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 <root>/feature-x` lands a depth-1 sibling that
# can CARRY its own STATE.md - a -d test drops it silently. Kept identical in
# the rollup builder (catalog) on purpose: two readers, one name.
REPOS=""
# Split on comma via IFS + `set --` rather than an unquoted $(...) expansion:
# unquoted word-splitting would also split roots containing spaces. Arg parsing
# is finished above, so clobbering the positional parameters is safe here.
OLD_IFS="$IFS"; IFS=','
set -- $ROOTS
IFS="$OLD_IFS"
for root in "$@"; do
[ -d "$root" ] || continue
for entry in "$root"/*; do
[ -d "$entry" ] || continue
if [ -e "$entry/.git" ]; then
REPOS="$REPOS
$entry"
else
for child in "$entry"/*; do
[ -e "$child/.git" ] || continue
REPOS="$REPOS
$child"
done
fi
done
done
[ -n "$(printf '%s' "$REPOS" | tr -d '[:space:]')" ] || exit 0
# --- Collect one record per repo -------------------------------------------
# Record: bucket|sortkey|name|status|cost|inbox|dirty|age|last|dir|blockedon|orders|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
# PENDING orders only, counted with the identical idiom as INN so the two
# columns are computed the same way and can be compared at a glance. They are
# never summed and never merged: INN is "others are waiting on YOU" (an
# outgoing obligation), ORDRE is "work is waiting on this REPO" (incoming
# authorized work). Claimed orders are deliberately out - the column answers
# "what can a session pick up here", and one already in flight cannot be.
#
# Bounded gap, stated rather than closed: an order addressed to a mailbox
# with no matching directory in the scanned roots is invisible here, exactly
# as mail to such a name is invisible in INN. coord-count.sh is the cross-
# check for the mail half; there is deliberately no join built for orders.
orders=0
if [ -d "$COORD/$name/orders" ]; then
orders="$(ls "$COORD/$name/orders"/*.md 2>/dev/null | wc -l | tr -d ' ')"
[ -n "$orders" ] || orders=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|-|%s|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d" "$orders"
continue
fi
mtime="$(stat -f %m "$state" 2>/dev/null)"
if [ -n "$mtime" ]; then age=$(( (NOW - mtime) / 86400 )); else age=0; fi
# Anchored to the exact comment form, NOT a substring search: unanchored
# 'board:' also matches prose like "dashboard: ..." and -m1 would let a
# lookalike higher up the file win over the real line.
line="$(grep -m1 '^<!-- board:' "$state" 2>/dev/null)"
status=""; blockedon=""; cost=""
if [ -n "$line" ]; then
# Captures to the next ';' or the closing '-->', 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:]]*<!--/) { if ($0 !~ /-->/) 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.
# `orders` sits between blockedon and neste, never after it: `neste` is free
# prose lifted out of a STATE.md and may contain a literal '|', so it has to
# stay the last field on the line.
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \
"${blockedon:--}" "$orders" "$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 %6s %4s %6s %6s %s\n' \
"REPO" "STATUS" "KOST" "INN" "ORDRE" "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 orders neste; do
printf '%-32s %-34s %-14s %4s %6s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$orders" "$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 '^<!-- route:' "$1/STATE.md" 2>/dev/null)"
[ -n "$rc_line" ] || return 1
# Captures to the next ';' or '-->', 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 orders 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 <name>" >&2; exit 2; }
# Two forms, exactly one of them. --order-id is the THIN STARTER: the order
# text lives in the recipient's queue (its canonical home, written by
# coord-order-send.sh), and the pasted line only points at it. --prompt-file
# is the older scratch form, kept because it still works when there is no
# queue to write to. Both at once has no meaning - the session would be told
# two things - so it is refused rather than silently preferring one.
if [ -n "$D_ORDER" ] && [ -n "$D_PROMPT" ]; then
echo "board: use either --order-id (the order lives in the queue) or --prompt-file (scratch), not both" >&2; exit 2
fi
if [ -z "$D_ORDER" ] && [ -z "$D_PROMPT" ]; then
echo "board: --dispatch requires --order-id <id> or --prompt-file <path> - the order the session is dispatched ON" >&2; exit 2
fi
[ -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.
if [ -n "$D_PROMPT" ]; then
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; }
else
# The order id is the new injection surface. The prompt-file form's path
# rule exists because the path is expanded inside the emitted command while
# the file's CONTENTS are not; the starter form drops the path and
# interpolates the ID into command=/paste= instead, so the same rule applies
# to it for the same reason.
case "$D_ORDER" in
*[!A-Za-z0-9._-]*) echo "board: --order-id is not shell-clean; it is expanded inside the command: $D_ORDER" >&2; exit 2 ;;
esac
# And it must actually BE in the queue. Emitting a command that tells a
# session to claim an order that is not there is the same defect class as
# the empty prompt file: a dispatch that verifies green and hands the far
# end nothing to work on. Pending only - an order already claimed is
# someone else's, and a starter for it would send two sessions at one task.
[ -f "${CLAUDE_COORD_DIR:-$HOME/.claude/coord}/$D_REPO/orders/$D_ORDER.md" ] || {
echo "board: no PENDING order $D_ORDER in $D_REPO's queue - write it first (coord-order-send.sh --to $D_REPO ...), and check it has not already been claimed" >&2; exit 2; }
fi
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'|' -f13-)"
[ -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").
if [ -n "$D_PROMPT" ]; then
d_full="$d_cmd \"\$(cat $D_PROMPT)\""
else
# The THIN STARTER. It carries no order text at all, deliberately: the order
# has exactly one home (the queue), so a starter that repeated it would be a
# second copy free to drift from the first - and the copy in argv is the one
# that dies with the pane. What it does carry is the four steps, because a
# session handed only an id would have to guess the protocol.
#
# ASCII and no shell metacharacters: this string is emitted INSIDE the
# double quotes of the pasted command, so a quote, a $ or a backtick here
# would be evaluated by the operator's shell. The order BODY has no such
# restriction - it never passes through a shell at all.
d_full="$d_cmd \"ORDRE $D_ORDER ligger i dette repoets ordrekoe. Gjor dette i rekkefolge: (1) claim den - coord-order-claim $D_ORDER - claim skriver ut hele ordreteksten, som bare bor der. (2) Sammenlign ordren mot STATE.md sin NESTE-blokk og uttal avviket i ditt FORSTE svar. (3) Utfor ordren. (4) Lukk den - coord-order-done $D_ORDER --commit HASH - eller gi den tilbake med --return --reason hvis den ikke kan utfores.\""
fi
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 <fil> --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 <fil> --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 <dir>` 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 orders 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] = $13
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."
# The two mailbox columns are adjacent and easy to read as one quantity, so the
# legend states the distinction where they are read - they measure opposite
# directions and are never summed.
echo "ORDRE = ventende ordrer (arbeid venter paa REPOET). INN og ORDRE summeres ALDRI."
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