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

View file

@ -69,8 +69,33 @@
# 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 (debt, then in-progress, then planned, then repos
# with no declared status) and there is no cutoff, so nothing is hidden.
# 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":
#
# 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)
#
# 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. 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.
#
# 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.
# 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
@ -200,7 +225,7 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
# No plan file, so no plan age: ALDER is blank rather than quietly showing
# the commit age under a heading that means something else everywhere else
# in the table. The sort key keeps using it - order is unchanged.
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|(ingen STATE.md)\n' \
printf '5|%06d|%s|-|-|%s|%s|-|%s|%s|-|(ingen STATE.md)\n' \
"$lastd" "$name" "$inbox" "$dirty" "$lastcol" "$d"
continue
fi
@ -264,12 +289,55 @@ printf '%s\n' "$REPOS" | while IFS= read -r d; do
*) bucket=2 ;;
esac
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" "$neste"
# blocked-on is a FIELD, not just the display string above: --plan follows the
# edge to find who a session would release, and the edge cannot be recovered
# from `blocked>X` alone (a repo can be blocked with no target, and a repo can
# name a target the scan never produced). Inserted BEFORE `neste`, never after:
# `neste` is free prose lifted out of a STATE.md and may contain a literal '|'
# from a markdown table, so it has to stay the last field on the line.
printf '%s|%06d|%s|%s|%s|%s|%s|%sd|%s|%s|%s|%s\n' \
"$bucket" "$age" "$name" "$disp" "$cost" "$inbox" "$dirty" "$age" "$lastcol" "$d" \
"${blockedon:--}" "$neste"
done > "${TMPDIR:-/tmp}/board.$$"
RECORDS="${TMPDIR:-/tmp}/board.$$"
trap '/bin/rm -f "$RECORDS" 2>/dev/null' EXIT
UNBLOCKS="${TMPDIR:-/tmp}/board-unblocks.$$"
trap '/bin/rm -f "$RECORDS" "$UNBLOCKS" 2>/dev/null' EXIT
# --- 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"
@ -279,7 +347,7 @@ hdr() {
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 neste; do
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
printf '%-32s %-34s %-14s %4s %4s %6s %6s %s\n' \
"$name" "$status" "$cost" "$inbox" "$dirty" "$age" "$last" "$(trunc "$neste" "$NESTE_WIDTH")"
done
@ -377,7 +445,7 @@ brief() {
# Most-owed first: the repo holding up the most other sessions is read first.
awk -F'|' '$6+0 > 0' "$RECORDS" | sort -t'|' -k6,6nr | \
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir neste; do
while IFS='|' read -r bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
printf ' %-32s INN %-4s %s\n' "$name" "$inbox" "$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
@ -558,24 +626,75 @@ plan_cmd() {
}
plan() {
# Groups are appended in rank order to one file, then numbered in a single
# loop: `while ... < file` keeps the counter in THIS shell, where a pipe into
# while would run it in a subshell and reset every tab number to 1.
# 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.
#
# Each group prefixes its `why` as a new FIRST field, so the sort keys shift
# by one: inbox 6->7, sortkey (ALDER) 2->3. 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 in a STATE.md.
# 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.
#
# 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.
#
# 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
# anything added after it could be swallowed by a stray separator.
pf="${TMPDIR:-/tmp}/board-plan.$$"
: > "$pf"
awk -F'|' -v OFS='|' '$6+0 > 0 {print "inbox:" $6, $0}' "$RECORDS" \
| sort -t'|' -k7,7nr -k3,3n >> "$pf"
awk -F'|' -v OFS='|' '$6+0 == 0 && $4 == "in-progress" {print "in-progress", $0}' "$RECORDS" \
| sort -t'|' -k3,3n >> "$pf"
awk -F'|' -v OFS='|' '$6+0 == 0 && $4 == "planned" {print "planned", $0}' "$RECORDS" \
| sort -t'|' -k3,3n >> "$pf"
awk -F'|' -v OFS='|' '$6+0 == 0 && ($4 == "?" || $4 ~ /^MALFORMED:/) {print "uavklart", $0}' "$RECORDS" \
| sort -t'|' -k3,3n >> "$pf"
awk -F'|' -v OFS='|' '
NR==FNR { ub[$1] = $2 + 0; next }
{
name = $3; status = $4; cost = $5; inbox = $6 + 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
# 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.
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"
print score, why, $0
}
' "$UNBLOCKS" "$RECORDS" \
| sort -t'|' -k1,1nr -k4,4n \
| cut -d'|' -f2- >> "$pf"
# The cutoff, and its disclosure, computed together - they are one feature.
FOCUS_SLUGS=""
@ -599,11 +718,12 @@ 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: innboksgjeld (INN desc), sa in-progress, sa planned, sa uavklart."
echo "# Rekkefolge: score = 40*repo losnet (kjede-rot) + 15*innboks + status + kost."
# "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 ikke skylder svar: done, deferred, blocked, uten STATE.md."
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
@ -643,7 +763,7 @@ plan() {
echo ""
pn=0
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir neste; do
while IFS='|' read -r why bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
[ -n "$name" ] || continue
pn=$((pn + 1))
printf 'tab=%s\n' "$pn"