fix(board): plan/brief use actual debt, not raw pending mail
board.sh --plan group 2 and --brief conflated every unhandled inbox message with an obligation to reply, including ones the sender declared reply-expected: no (a notice, not a request). Reported by morning-driver (2026-08-11), independently reproduced against the live mailbox on 2026-08-13: 27 of 72 pending messages were notices. Both paths now join against coord-count.sh's owed column instead, so a done/deferred/blocked repo whose only mail is FYI no longer gets a plan tab, and --brief no longer counts a notice as an obligation. The table's raw INN column is unchanged by design. While extending that join with a second lookup file, found and fixed a more severe, independent defect: the existing $UNBLOCKS/$RECORDS join used the NR==FNR awk idiom, which silently empties the entire plan whenever the first file is empty -- i.e. whenever the repo tree has zero blocked repos, a common, ordinary state, not an edge case. Verified against the shipped 0.21.0 script. Fixed by matching on FILENAME instead of cumulative line counts, for both lookup files. Also repoints the README's governance link at repo-standard's canonical GOVERNANCE.md (was pointing at the marketplace's copy), per org-ops D11. board-selftest.sh: 142 -> 150 checks. Version 0.21.0 -> 0.22.0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGWMPskXBsTjMrrQ2GofFx
This commit is contained in:
parent
b02c2880e3
commit
03e712a423
10 changed files with 263 additions and 27 deletions
|
|
@ -611,6 +611,67 @@ mkrepo "$ROOT/repo-done-owes"
|
|||
mkdir -p "$CLAUDE_COORD_DIR/repo-done-owes/inbox"
|
||||
echo "msg" > "$CLAUDE_COORD_DIR/repo-done-owes/inbox/2026-msg1-from-w.md"
|
||||
|
||||
# repo-done-fyi: done, with pending mail, but every message declares
|
||||
# reply-expected: no (a notice, not a request). Every OTHER fixture message in
|
||||
# this suite is written with no frontmatter at all, which owes_reply() in
|
||||
# coord-count.sh treats as owing by default - so pending and owed have been
|
||||
# equal in every prior fixture, and a raw-pending-as-debt bug is invisible to
|
||||
# this suite without a genuine FYI-only mailbox. Measured on the live mailbox
|
||||
# 2026-08-11 by morning-driver: 14 of 22 unhandled messages were notices.
|
||||
mkrepo "$ROOT/repo-done-fyi"
|
||||
{
|
||||
echo "# STATE - repo-done-fyi"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=done; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||
printf 'Ferdig %s to varsler ligger i innboksen, ingen skylder svar.\n' "$EMDASH"
|
||||
} > "$ROOT/repo-done-fyi/STATE.md"
|
||||
mkdir -p "$CLAUDE_COORD_DIR/repo-done-fyi/inbox"
|
||||
for n in 1 2; do
|
||||
{
|
||||
echo "---"
|
||||
echo "from: w"
|
||||
echo "to: repo-done-fyi"
|
||||
echo "reply-expected: no"
|
||||
echo "---"
|
||||
echo "fyi only"
|
||||
} > "$CLAUDE_COORD_DIR/repo-done-fyi/inbox/2026-msg$n-from-w.md"
|
||||
done
|
||||
|
||||
# repo-blocked-mixed: blocked, 3 pending messages but only 2 owe a reply - the
|
||||
# third declares reply-expected: no. repo-blocked-owes (above) cannot prove
|
||||
# ranking uses OWED rather than raw pending, because every one of its messages
|
||||
# happens to owe; this fixture separates the two counts (3 pending, 2 owed).
|
||||
mkrepo "$ROOT/repo-blocked-mixed"
|
||||
{
|
||||
echo "# STATE - repo-blocked-mixed"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=blocked; blocked-on=-; next-cost=Opus 5/high -->"
|
||||
printf 'Blokkert %s tre meldinger, bare to skylder svar.\n' "$EMDASH"
|
||||
} > "$ROOT/repo-blocked-mixed/STATE.md"
|
||||
mkdir -p "$CLAUDE_COORD_DIR/repo-blocked-mixed/inbox"
|
||||
echo "msg" > "$CLAUDE_COORD_DIR/repo-blocked-mixed/inbox/2026-msg1-from-w.md"
|
||||
echo "msg" > "$CLAUDE_COORD_DIR/repo-blocked-mixed/inbox/2026-msg2-from-w.md"
|
||||
{
|
||||
echo "---"
|
||||
echo "from: w"
|
||||
echo "to: repo-blocked-mixed"
|
||||
echo "reply-expected: no"
|
||||
echo "---"
|
||||
echo "fyi only"
|
||||
} > "$CLAUDE_COORD_DIR/repo-blocked-mixed/inbox/2026-msg3-from-w.md"
|
||||
|
||||
# A fresh --brief, taken after repo-done-fyi and repo-blocked-mixed exist:
|
||||
# BRIEF (above) predates both fixtures, so it cannot exercise them.
|
||||
BRIEF3="$("$BOARD" --roots "$ROOT" --brief 2>/dev/null)"
|
||||
|
||||
printf '%s' "$BRIEF3" | grep -q 'repo-done-fyi'; [ $? -ne 0 ]
|
||||
check "brief excludes a repo whose pending mail is all FYI (no debt)" $?
|
||||
|
||||
printf '%s' "$BRIEF3" | grep -q 'repo-blocked-mixed'
|
||||
check "brief includes a repo with mixed mail (it has 2 owed)" $?
|
||||
printf '%s' "$BRIEF3" | grep -qE 'repo-blocked-mixed[[:space:]]+INN[[:space:]]+2([[:space:]]|$)'
|
||||
check "brief's INN count is owed messages, not raw pending (3 pending, 2 owed)" $?
|
||||
|
||||
PLAN="$("$BOARD" --roots "$ROOT" --plan 2>/dev/null)"; rc=$?
|
||||
check "--plan exits 0" "$rc"
|
||||
|
||||
|
|
@ -695,6 +756,22 @@ printf '%s\n' "$PLAN" | grep -A4 '^repo=repo-done-owes$' | grep -q '^status=done
|
|||
check "a done repo in the plan still reports its real status" $?
|
||||
printf '%s\n' "$PLAN" | grep -m1 '^# Utelatt' | grep -q 'skylder svar'
|
||||
check "the header states the exclusions are conditional on owing nothing" $?
|
||||
|
||||
# The header above already says "skylder svar" (owes a reply) - a done repo
|
||||
# whose pending mail is 100% notices does not owe a reply, so it is excluded
|
||||
# on the SAME rule as repo-wt, not a new one. Pending (2) must not stand in
|
||||
# for owed (0).
|
||||
printf '%s' "$PLAN" | grep -q '^repo=repo-done-fyi$'; [ $? -ne 0 ]
|
||||
check "plan excludes a done repo whose pending mail is all FYI (owed 0, pending 2)" $?
|
||||
|
||||
# blocked-with-debt (repo-blocked-owes, above) stays planned on its debt; this
|
||||
# fixture proves the COUNT that seats and ranks it is the owed count, not the
|
||||
# raw pending count - 3 pending, 2 owed.
|
||||
printf '%s' "$PLAN" | grep -q '^repo=repo-blocked-mixed$'
|
||||
check "a blocked repo with mixed mail is still planned on its owed messages" $?
|
||||
printf '%s\n' "$PLAN" | grep -A4 '^repo=repo-blocked-mixed$' | grep -q '^why=inbox:2$'
|
||||
check "why=inbox:N counts owed messages, not raw pending (3 pending, 2 owed)" $?
|
||||
|
||||
printf '%s' "$PLAN" | grep -q '^repo=repo-d$'; [ $? -ne 0 ]
|
||||
check "plan excludes a deferred repo" $?
|
||||
printf '%s' "$PLAN" | grep -q '^repo=plug-x$'; [ $? -ne 0 ]
|
||||
|
|
@ -1144,6 +1221,35 @@ check "--brief still exits 0 after the ranking change" $?
|
|||
[ "$n2_tab" -ge "$n_tab" ]
|
||||
check "the grouped plan hides nothing an earlier ordering showed" $?
|
||||
|
||||
# --- 13. Zero blocked repos does not empty the plan (NR==FNR regression) ---
|
||||
# Isolated root, not the shared fixture tree above: that tree has carried a
|
||||
# blocked repo (repo-blocked-owes, plug-x, ...) since the chain-root feature
|
||||
# shipped, which is exactly the state that kept this bug invisible to every
|
||||
# check above it. $UNBLOCKS is empty only when NO repo in the scan is blocked
|
||||
# - an ordinary tree state, reproduced here with a root that has none at all.
|
||||
# Verified against the shipped 0.21.0 script before this fix existed: this
|
||||
# exact fixture produced "0 tabber" - solo-repo, with an unhandled inbox
|
||||
# message and nothing blocking anything, vanished from its own plan.
|
||||
NOBLOCK_ROOT="$(mktemp -d)"
|
||||
NOBLOCK_COORD="$(mktemp -d)"
|
||||
mkrepo "$NOBLOCK_ROOT/solo-repo"
|
||||
{
|
||||
echo "# STATE - solo-repo"
|
||||
printf '## %s NESTE %s START HER\n' "$HAND" "$EMDASH"
|
||||
echo "<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->"
|
||||
echo "Ordinary work, nothing blocked anywhere in this tree."
|
||||
} > "$NOBLOCK_ROOT/solo-repo/STATE.md"
|
||||
mkdir -p "$NOBLOCK_COORD/solo-repo/inbox"
|
||||
echo "msg" > "$NOBLOCK_COORD/solo-repo/inbox/2026-msg1-from-w.md"
|
||||
|
||||
NOBLOCK_PLAN="$(CLAUDE_COORD_DIR="$NOBLOCK_COORD" "$BOARD" --roots "$NOBLOCK_ROOT" --plan 2>/dev/null)"
|
||||
printf '%s' "$NOBLOCK_PLAN" | grep -q '^repo=solo-repo$'
|
||||
check "zero blocked repos in the tree does not empty the plan (NR==FNR regression)" $?
|
||||
printf '%s\n' "$NOBLOCK_PLAN" | grep -A3 '^repo=solo-repo$' | grep -q '^why=inbox:1$'
|
||||
check "the surviving repo still ranks correctly with an empty chain-root lookup" $?
|
||||
|
||||
/bin/rm -rf "$NOBLOCK_ROOT" "$NOBLOCK_COORD" 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "board-selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
|
|
|
|||
|
|
@ -327,7 +327,32 @@ done > "${TMPDIR:-/tmp}/board.$$"
|
|||
|
||||
RECORDS="${TMPDIR:-/tmp}/board.$$"
|
||||
UNBLOCKS="${TMPDIR:-/tmp}/board-unblocks.$$"
|
||||
trap '/bin/rm -f "$RECORDS" "$UNBLOCKS" 2>/dev/null' EXIT
|
||||
OWED="${TMPDIR:-/tmp}/board-owed.$$"
|
||||
trap '/bin/rm -f "$RECORDS" "$UNBLOCKS" "$OWED" 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.
|
||||
: > "$OWED"
|
||||
if { [ "$BRIEF" -eq 1 ] || [ "$PLAN" -eq 1 ]; } && [ -f "$SELFDIR/coord-count.sh" ]; then
|
||||
ow_tab="$(printf '\t')"
|
||||
bash "$SELFDIR/coord-count.sh" 2>/dev/null \
|
||||
| awk -F"$ow_tab" -v OFS='|' '{print $1, $3}' > "$OWED"
|
||||
fi
|
||||
|
||||
# --- Chain-root credit -----------------------------------------------------
|
||||
# For every blocked repo, walk `blocked-on` up to the ROOT of the chain - the
|
||||
|
|
@ -449,8 +474,21 @@ brief_orphans() {
|
|||
}
|
||||
|
||||
brief() {
|
||||
n_owe="$(awk -F'|' '$6+0 > 0' "$RECORDS" | wc -l | tr -d ' ')"
|
||||
tot_msg="$(awk -F'|' '$6+0 > 0 {s+=$6} END{print s+0}' "$RECORDS")"
|
||||
# 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."
|
||||
|
|
@ -469,9 +507,12 @@ brief() {
|
|||
fi
|
||||
|
||||
# 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 blockedon neste; do
|
||||
printf ' %-32s INN %-4s %s\n' "$name" "$inbox" "$status"
|
||||
awk -F'|' -v OFS='|' -v OWF="$OWED" '
|
||||
FILENAME==OWF { ow[$1] = $2 + 0; next }
|
||||
{ name = $3; owed = (name in ow) ? ow[name] : 0; if (owed > 0) print owed, $0 }
|
||||
' "$OWED" "$RECORDS" | sort -t'|' -k1,1nr | \
|
||||
while IFS='|' read -r owed bucket sortkey name status cost inbox dirty age last dir blockedon neste; do
|
||||
printf ' %-32s INN %-4s %s\n' "$name" "$owed" "$status"
|
||||
# Wrapped, not cut - the whole line is the point, but a 500-character one
|
||||
# is unreadable in a file nobody watched being written. Locale is set for
|
||||
# the same reason trunc sets it: under LC_ALL=C fold counts BYTES and can
|
||||
|
|
@ -699,10 +740,23 @@ plan() {
|
|||
# anything added after it could be swallowed by a stray separator.
|
||||
pf="${TMPDIR:-/tmp}/board-plan.$$"
|
||||
: > "$pf"
|
||||
awk -F'|' -v OFS='|' '
|
||||
NR==FNR { ub[$1] = $2 + 0; next }
|
||||
# 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; inbox = $6 + 0
|
||||
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.
|
||||
|
|
@ -712,7 +766,7 @@ plan() {
|
|||
# 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") || \
|
||||
keep = (owed > 0) || (u > 0) || (status == "in-progress") || \
|
||||
(status == "planned") || (status == "?") || (status ~ /^MALFORMED:/)
|
||||
if (!keep) next
|
||||
|
||||
|
|
@ -720,18 +774,18 @@ plan() {
|
|||
# `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" }
|
||||
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" "$RECORDS" \
|
||||
' "$UNBLOCKS" "$OWED" "$RECORDS" \
|
||||
| sort -t'|' -k1,1n -k2,2nr -k3,3n -k6,6n \
|
||||
| cut -d'|' -f4- >> "$pf"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue