feat(board): rank --plan on one score, with transitive chain-root credit

blocked-on was scanned and then used for a single display string; it never
reached the ordering. A blocked chain's root could therefore rank below the
repos waiting on it - measured on the real tree, the root opened at tab 14
while the two repos it held up opened at tabs 5 and 8.

--plan now ranks on one score instead of four hard buckets, because four
groups cannot express "this repo owes one message and releases two others":

    40 x repos released transitively + 15 x inbox
    +10 in-progress / +5 planned / +2 undeclared, +3 for a Sonnet row

Chain-root credit follows blocked-on transitively to the first repo that is
not itself blocked and credits only that root. Opening a blocked repo
releases nobody - its own next step is by definition waiting. A cycle, a
blocked-on naming an unscanned repo, and a blocked repo with no target all
credit NOBODY: inventing a root there yields a plan that looks correct and
sends the operator to the wrong repo.

Debt is deliberately uncapped. A cap was proposed alongside the credit and
declined: owing a reply is the other axis from a repo's own next step, and
answering is often what unblocks a chain. The weight carries that decision -
15 per message exceeds the sum of every other bonus (13), so one message
more still outranks any combination of status and cost. At the originally
proposed 12 a tiebreak could flip the debt order, which is the cap arriving
through the back door.

blocked-on becomes a record field, inserted BEFORE neste so no existing field
index moves: neste is free prose lifted from a STATE.md and may carry a
literal '|' from a markdown table, so it stays last.

why= now names the dominant term rather than the first rule that matched, so
a block says what opening it would release instead of repeating inbox:N.

Format is otherwise unchanged for both declared consumers: tab=, repo=, dir=,
why=, status=, neste=, command=/command_missing=, paste= and the fokus_*
header all keep their names and positions.

board-selftest 118 -> 134 (chain, cycle, dangling-edge and format fixtures
written first, all failing before the change).
This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 22:38:40 +02:00
commit e5e9cbe847
10 changed files with 404 additions and 51 deletions

View file

@ -601,8 +601,13 @@ check "every repo owing mail is planned before any that owes none" $?
[ "$(printf '%s\n' "$PLAN" | grep -c '^repo=repo-a$')" -eq 1 ]
check "a repo qualifying under two rules is planned exactly once" $?
printf '%s\n' "$PLAN" | grep -A3 '^repo=repo-a$' | grep -q '^why=inbox:3$'
check "why= names the rule that put the repo in the plan" $?
# repo-a owes 3 messages AND is the blocker plug-x and repo-blocked-owes both
# name. why= reports the DOMINANT term, so it says what a session there would
# release rather than what it owes - the whole reason this field was changed.
# Before chain-root credit existed this read `inbox:3`, and that was the line
# eleven blocks repeated verbatim.
printf '%s\n' "$PLAN" | grep -A3 '^repo=repo-a$' | grep -q '^why=unblocks:2$'
check "why= names the dominant reason, releasing over owing when it dominates" $?
# in-progress outranks planned among the repos owing nothing.
n_g="$(printf '%s\n' "$PLAN" | grep -n '^repo=repo-g$' | cut -d: -f1)"
@ -875,6 +880,149 @@ check "--focus with no value exits 2, like every other flag missing its value" $
"$BOARD" --help 2>/dev/null | grep -q '\-\-focus'
check "--help documents --focus" $?
# --- 12. Chain-root credit in --plan --------------------------------------
# Ordered by what a repo OWES, the plan could never say what OPENING it would
# RELEASE. blocked-on was scanned and then used for one display string only.
#
# The credit goes to the ROOT of the chain and to nothing else, and that is the
# whole rule: opening a blocked repo moves nothing, because its own next step is
# by definition waiting on someone. Opening the root moves everything behind it.
# So the walk is transitive - crediting only the direct blocker would leave a
# two-hop chain with its root uncredited, which is the shape the real tree
# actually had when this was ordered.
#
# Three fixtures, one chain: leaf -> mid -> root.
mkrepo "$ROOT/chain-root"
{
echo "# STATE - chain-root"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
# Deliberately `planned`, not `in-progress`: under the old four-bucket order
# that puts the root BELOW every live repo, so the ranking check below fails
# for the right reason instead of passing on fixture order.
echo "<!-- board: status=planned; blocked-on=-; next-cost=Opus 5/high -->"
printf 'Roten %s ingen gjeld, men holder to repo oppe.\n' "$EMDASH"
} > "$ROOT/chain-root/STATE.md"
mkrepo "$ROOT/chain-mid"
{
echo "# STATE - chain-mid"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=blocked; blocked-on=chain-root; next-cost=Opus 5/high -->"
printf 'Venter p%s chain-root.\n' "$EMDASH"
} > "$ROOT/chain-mid/STATE.md"
mkrepo "$ROOT/chain-leaf"
{
echo "# STATE - chain-leaf"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=blocked; blocked-on=chain-mid; next-cost=Opus 5/high -->"
printf 'Venter p%s chain-mid, som venter p%s roten.\n' "$EMDASH" "$EMDASH"
} > "$ROOT/chain-leaf/STATE.md"
# A cycle is not hypothetical: two repos can each declare they wait on the other
# after a miscommunication, and nobody would notice until the board hung. The
# walk must terminate and credit NOBODY - a cycle has no root by definition.
mkrepo "$ROOT/cyc-a"
{
echo "# STATE - cyc-a"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=blocked; blocked-on=cyc-b; next-cost=Opus 5/high -->"
echo "Venter paa cyc-b."
} > "$ROOT/cyc-a/STATE.md"
mkrepo "$ROOT/cyc-b"
{
echo "# STATE - cyc-b"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=blocked; blocked-on=cyc-a; next-cost=Opus 5/high -->"
echo "Venter paa cyc-a."
} > "$ROOT/cyc-b/STATE.md"
# blocked-on names a repo the scan never produced. Nothing validates that field,
# so a typo is the expected case, not the exotic one - and it must credit
# nothing rather than credit a name that has no directory to open.
mkrepo "$ROOT/dangle"
{
echo "# STATE - dangle"
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
echo "<!-- board: status=blocked; blocked-on=no-such-repo; next-cost=Opus 5/high -->"
echo "Venter paa noe som ikke finnes."
} > "$ROOT/dangle/STATE.md"
P2="$("$BOARD" --roots "$ROOT" --plan 2>/dev/null)"; rc=$?
check "--plan still exits 0 with chain, cycle and dangling fixtures present" "$rc"
printf '%s' "$P2" | grep -q '^repo=chain-root$'
check "the root of a blocked chain is planned even though it owes no mail" $?
printf '%s\n' "$P2" | grep -A3 '^repo=chain-root$' | grep -q '^why=unblocks:2$'
check "the root is credited with the WHOLE subtree it holds up, not one edge" $?
# The exclusion these fixtures must not break: a blocked repo with no debt still
# gets no tab. The credit goes to the root precisely so the blocked repos do not
# need one - crediting them instead would open two tabs that cannot move.
printf '%s' "$P2" | grep -q '^repo=chain-mid$'; [ $? -ne 0 ]
check "a blocked middle link with no debt is still not planned" $?
printf '%s' "$P2" | grep -q '^repo=chain-leaf$'; [ $? -ne 0 ]
check "a blocked leaf with no debt is still not planned" $?
# Unblocking outranks work that neither owes nor releases anything. This is the
# point of the change: repo-h is in-progress and idle, chain-root is in-progress
# and holds two repos up.
n2_cr="$(printf '%s\n' "$P2" | grep -n '^repo=chain-root$' | cut -d: -f1)"
n2_h="$(printf '%s\n' "$P2" | grep -n '^repo=repo-h$' | cut -d: -f1)"
[ -n "$n2_cr" ] && [ -n "$n2_h" ] && [ "$n2_cr" -lt "$n2_h" ]
check "a chain root ranks above live work that releases nothing" $?
# A cycle terminates and credits nobody. Both halves matter: hanging is the
# failure everyone imagines, but silently inventing a root is the one that would
# ship - it looks like a working plan.
printf '%s' "$P2" | grep -q '^repo=cyc-a$'; [ $? -ne 0 ]
check "a cyclic blocked-on chain credits nobody (cyc-a not planned)" $?
printf '%s' "$P2" | grep -q '^repo=cyc-b$'; [ $? -ne 0 ]
check "a cyclic blocked-on chain credits nobody (cyc-b not planned)" $?
printf '%s' "$P2" | grep -q '^why=unblocks:0$'; [ $? -ne 0 ]
check "no repo is ever credited with an empty unblock count" $?
# A dangling blocked-on must not conjure a tab for a name with no directory.
printf '%s' "$P2" | grep -q '^repo=no-such-repo$'; [ $? -ne 0 ]
check "a dangling blocked-on target is never planned into existence" $?
printf '%s' "$P2" | grep -q '^repo=dangle$'; [ $? -ne 0 ]
check "the repo carrying a dangling blocked-on is itself still excluded" $?
# The format is the API. Adding a scoring rule must not move, rename or drop a
# single field - the driver parses these keys positionally-independently, but it
# parses THESE keys.
n2_tab="$(printf '%s\n' "$P2" | grep -c '^tab=')"
n2_rep="$(printf '%s\n' "$P2" | grep -c '^repo=')"
n2_dir="$(printf '%s\n' "$P2" | grep -c '^dir=')"
n2_why="$(printf '%s\n' "$P2" | grep -c '^why=')"
n2_sta="$(printf '%s\n' "$P2" | grep -c '^status=')"
n2_nes="$(printf '%s\n' "$P2" | grep -c '^neste=')"
n2_cmd="$(printf '%s\n' "$P2" | grep -c '^command=')"
n2_cms="$(printf '%s\n' "$P2" | grep -c '^command_missing=')"
[ "$n2_tab" -gt 0 ] && [ "$n2_rep" -eq "$n2_tab" ] && [ "$n2_dir" -eq "$n2_tab" ] \
&& [ "$n2_why" -eq "$n2_tab" ] && [ "$n2_sta" -eq "$n2_tab" ] \
&& [ "$n2_nes" -eq "$n2_tab" ] && [ $(( n2_cmd + n2_cms )) -eq "$n2_tab" ]
check "scored plan still carries every documented field exactly once per block" $?
printf '%s' "$P2" | grep -q '^command=$'; [ $? -ne 0 ]
check "scored plan never emits an empty command= line" $?
# Debt still puts a repo in the plan and still names itself when it is the
# dominant reason. Rejecting the proposed cap on debt was a deliberate call:
# owing a reply is the other axis, and answering is often what unblocks a chain.
printf '%s\n' "$P2" | grep -A3 '^repo=repo-blocked-owes$' | grep -q '^why=inbox:2$'
check "debt still names itself in why= when it is the dominant reason" $?
# --brief is a second RENDERING of the same scan and must survive the change.
"$BOARD" --roots "$ROOT" --brief >/dev/null 2>&1
check "--brief still exits 0 after the scoring change" $?
# The plan must still print the scan whole. There is no cutoff by design, so a
# consumer's own tab cap is the only thing that ever hides a repo.
[ "$n2_tab" -ge "$n_tab" ]
check "the scored plan hides nothing the bucketed plan showed" $?
echo ""
echo "board-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1