feat(board)!: rank --plan on five ordered groups, planned above in-progress

Replaces the weighted score shipped in 0.19.0 with five lookups: chain-root
credit, unhandled inbox, planned, in-progress, undeclared status. Within a
group: that group's own quantity, then a Sonnet next-cost, then oldest plan.

The score's objection is accepted, not forgotten, and is written into board.sh
and CLAUDE.md so a later session reads it as decided rather than as an unfixed
defect: a group order cannot express "owes one message AND releases two others"
as one quantity. What the score could not do was hold still for the format's
second consumer - re-tuning one weight against another silently reorders a
parser in another repo, and no test here can catch that.

planned now ranks above in-progress, inverted by the same decision: converting a
decision into motion is the slow step; live work is already moving.

Debt stays uncapped and never excluded. One group below chain-root credit is not
the cap declined at 0.19.0 - the debtor keeps its tab, its most-owed-first
position, and its why=inbox:N. Pinned by a discriminating fixture the score
would fail: a root releasing one repo outranks a repo owing four.

board-selftest 134 -> 138. Suite 183 + 138 + 73 = 394.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ULuFCPMNYAPNN3pAjsXQ
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 06:55:21 +02:00
commit d0a5ffe515
10 changed files with 287 additions and 118 deletions

View file

@ -70,13 +70,25 @@
# 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
# ONE score, not four buckets, because four groups cannot express "this repo
# owes one message and releases two others":
# FIVE ORDERED GROUPS, each a lookup over a field the scan already read:
#
# 40 x repos released transitively (chain-root credit, see below)
# 15 x unhandled inbox messages
# +10 in-progress / +5 planned / +2 undeclared status
# +3 when next-cost names a Sonnet row (cheap to move under quota pressure)
# 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
@ -87,15 +99,18 @@
# 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. 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. 15 per message is above the sum
# of every other bonus (13), so one message more still outranks any combination
# of status and cost - the bonuses separate repos that owe the SAME.
# 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 DOMINANT term rather than the first rule that matched, because
# with a score there is no single rule that put a repo in the plan - and a why=
# that always read `inbox:N` was the same sentence on every block.
# 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
@ -592,17 +607,25 @@ focus_heldback() {
# 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. Four rules, all deterministic over
# there is no cutoff, so nothing is hidden. Five groups, all deterministic over
# fields the scan already read:
# 1. INN > 0, most-owed first, WHATEVER the status. A message owed is an
# 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.
# 2. in-progress - live work, oldest plan first.
# 3. planned.
# 4. '?' and MALFORMED - no declared status. Planned LAST, and labelled. The
# 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
@ -626,25 +649,43 @@ plan_cmd() {
}
plan() {
# ONE score, not four hard buckets. Four groups could rank a repo by what it
# owes or by whether its own work is live, but could never express "this repo
# owes one message and releases two others" - the case the four-bucket order
# got exactly backwards, ranking a chain's root below the repos waiting on it.
# 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.
#
# Every term is a lookup over a field the scan already read, so the order stays
# reproducible and no term needs a model call. The weights are judgement and
# are meant to be re-tuned against measurement, which is why they sit here
# alone rather than spread through four sort invocations.
# 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.
#
# Debt is deliberately NOT capped. A cap was proposed and declined by the
# operator: owing a reply is the OTHER axis from a repo's own next step, and
# answering is often precisely what unblocks a chain. A big mailbox therefore
# still outranks a small one, all else equal.
# 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.
#
# The score is prefixed as field 1 and stripped again after sorting; `why`
# then becomes field 1 exactly as the four-bucket 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
# 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"
@ -658,43 +699,31 @@ plan() {
blockedp = (status ~ /^blocked/)
u = (!blockedp && (name in ub)) ? ub[name] : 0
# 15 per message, not the 12 the order proposed. The weights were
# explicitly delegated, and 12 has a measurable defect: the status and
# cost bonuses sum to 13, so they could outrank a repo owing one message
# MORE than another. That is the debt-ordering rule weakening through a
# tiebreak, which is the one thing the declined cap would also have done.
# At 15 a single message outweighs every non-unblock bonus combined, so
# within the debt group the order is still most-owed-first, and the
# bonuses do what they are for: separating repos that owe the same.
score = 40 * u + 15 * inbox
if (status == "in-progress") score += 10
else if (status == "planned") score += 5
else if (status == "?" || status ~ /^MALFORMED:/) score += 2
# Cheap-to-move first under quota pressure, and only ever a tiebreak.
if (cost ~ /[Ss]onnet/) score += 3
# Membership is unchanged except for one addition: a repo that releases
# someone is planned even when it owes nothing and its own status would
# otherwise exclude it. Everything else keeps the old rule, so done,
# deferred, blocked and no-STATE repos with no debt stay out.
# 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 = (inbox > 0) || (u > 0) || (status == "in-progress") || \
(status == "planned") || (status == "?") || (status ~ /^MALFORMED:/)
if (!keep) next
# why= names the DOMINANT term, not the first rule that happened to match:
# with a score there is no single rule that "put the repo in the plan",
# and a why= that always said inbox: would be the uninformative line this
# field was changed to fix.
if (u > 0 && 40 * u >= 15 * inbox) why = "unblocks:" u
else if (inbox > 0) why = "inbox:" inbox
else if (status == "in-progress" || status == "planned") why = status
else why = "uavklart"
# 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 (inbox > 0) { grp = 2; mag = inbox; why = "inbox:" inbox }
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" }
print score, why, $0
# 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" "$RECORDS" \
| sort -t'|' -k1,1nr -k4,4n \
| cut -d'|' -f2- >> "$pf"
| 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=""
@ -718,7 +747,7 @@ plan() {
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: score = 40*repo losnet (kjede-rot) + 15*innboks + status + kost."
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.