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:
parent
917a902172
commit
e5e9cbe847
10 changed files with 404 additions and 51 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"description": "Local mailbox for coordination between Claude Code sessions in different repositories. Directed messages and broadcasts as plain Markdown files on your own disk, injected as context at session start. Local, private, no network.",
|
||||
"author": {
|
||||
"name": "Kjell Tore Guttormsen"
|
||||
|
|
|
|||
45
CHANGELOG.md
45
CHANGELOG.md
|
|
@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.19.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Chain-root credit in `board.sh --plan`.** `blocked-on` was scanned and then
|
||||
used for one display string (`blocked>X`); it never reached the ordering. For
|
||||
every `blocked` repo the edge is now followed **transitively** to the root of
|
||||
the chain — the first repo not itself blocked — and only that root is
|
||||
credited with the repos it holds up. Opening a blocked repo releases nobody;
|
||||
opening the root releases everything behind it. Measured on the real tree: the
|
||||
root of the one live chain moved from tab 14 to tab 1, ahead of the two repos
|
||||
waiting on it.
|
||||
- **`why=unblocks:N`.** `why=` now names the *dominant* scoring term rather than
|
||||
the first rule that matched, so a block says what opening it would release.
|
||||
Previously every planned block read `inbox:N` — the same sentence repeated.
|
||||
- **`blocked-on` is 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.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--plan` ranks on one score, not four hard buckets.** Four groups could not
|
||||
express "this repo owes one message and releases two others", which is exactly
|
||||
how a chain's root ended up below the repos waiting on it:
|
||||
|
||||
40 x repos released transitively + 15 x inbox
|
||||
+10 in-progress / +5 planned / +2 undeclared, +3 for a Sonnet row
|
||||
|
||||
Every term is a lookup over a field the scan already read, so the order stays
|
||||
reproducible and costs no model call.
|
||||
- **`--plan` header lines** now state the score and that a blocked repo never
|
||||
receives credit. The format is otherwise **unchanged**: `tab=`, `repo=`,
|
||||
`dir=`, `why=`, `status=`, `neste=`, `command=`/`command_missing=`, `paste=`
|
||||
and the `fokus_*` header all keep their names and positions.
|
||||
|
||||
### Not done, deliberately
|
||||
|
||||
- **No cap on inbox debt.** Proposed alongside 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 precisely what unblocks a chain. The debt weight is 15 —
|
||||
above the sum of every other bonus (13) — so one message more still outranks
|
||||
any combination of status and cost, and the bonuses only separate repos that
|
||||
owe the same. At the proposed 12 they could have flipped it, which would have
|
||||
weakened the debt order through a tiebreak.
|
||||
|
||||
## [0.18.0] - 2026-08-02
|
||||
|
||||
### Added
|
||||
|
|
|
|||
43
CLAUDE.md
43
CLAUDE.md
|
|
@ -54,7 +54,7 @@ marketplace plugin. Three components, one boundary:
|
|||
- **Board (`scripts/board.sh`):** cross-repo attention board. Reads STATE.md
|
||||
next-step blocks + board lines, `git status`, and mailbox pending counts, and
|
||||
prints one line per repo. Read-only by construction: it writes to no repo, no
|
||||
STATE.md and no mailbox. Pinned by `board-selftest.sh` (89 checks).
|
||||
STATE.md and no mailbox. Pinned by `board-selftest.sh` (134 checks).
|
||||
|
||||
**It lives here because the mailbox is one of its three inputs, and it carries
|
||||
the same axis distinction the mailbox does.** A pending count means *others
|
||||
|
|
@ -85,15 +85,36 @@ marketplace plugin. Three components, one boundary:
|
|||
**`board.sh --plan` is the THIRD rendering, and the only one that takes a
|
||||
position.** It answers which repos to open a tab for today, in what order,
|
||||
with which command. The position it takes is the ORDER and nothing else -
|
||||
there is no cutoff, so the plan hides nothing, and every rule is a lookup over
|
||||
fields the scan already read. Debt ranks first regardless of status, and that
|
||||
is the rule most likely to be "fixed" into a defect: excluding `blocked` or
|
||||
`done` is a claim about a repo's OWN next step, which by definition cannot be
|
||||
moved, while owing a reply is the other axis entirely - answering is often
|
||||
what unblocks it. Measured on the real tree at 0.16.0, two of 26 planned repos
|
||||
were `done` with an unhandled inbox. Repos with no board line rank last and
|
||||
are LABELLED rather than dropped, because the table already prints a MERK line
|
||||
about them and a plan that omitted them silently would repeat that defect.
|
||||
there is no cutoff, so the plan hides nothing, and every term is a lookup over
|
||||
fields the scan already read. Since 0.19.0 it is ONE score rather than four
|
||||
buckets, because four groups cannot express "this repo owes one message and
|
||||
releases two others" - which is how a chain's root ended up ranked below the
|
||||
repos waiting on it.
|
||||
|
||||
**Debt is never excluded and never capped, and that is the rule most likely to
|
||||
be "fixed" into a defect.** Excluding `blocked` or `done` is a claim about a
|
||||
repo's OWN next step, which by definition cannot be moved, while owing a reply
|
||||
is the other axis entirely - answering is often what unblocks it. Measured on
|
||||
the real tree at 0.16.0, two of 26 planned repos were `done` with an unhandled
|
||||
inbox. A cap on debt was proposed with the chain credit at 0.19.0 and DECLINED
|
||||
by the operator for the same reason. The weight carries that decision: 15 per
|
||||
message is above the sum of every other bonus (13), so one message more still
|
||||
outranks any combination of status and cost. Lowering it to 12 - the value
|
||||
originally proposed - lets a tiebreak flip the debt order, which is the cap
|
||||
arriving through the back door.
|
||||
|
||||
**Chain-root credit lands on the ROOT and nowhere else.** For every `blocked`
|
||||
repo the `blocked-on` edge is followed transitively to the first repo that is
|
||||
not itself blocked. Crediting a blocked repo would open a tab that cannot move;
|
||||
crediting only the direct blocker leaves a two-hop chain's root uncredited,
|
||||
which is the shape the real tree actually had. A cycle, a `blocked-on` naming
|
||||
an unscanned repo, and a blocked repo with no target must all credit NOBODY:
|
||||
inventing a root there produces a plan that looks correct and sends the
|
||||
operator to the wrong repo.
|
||||
|
||||
Repos with no board line rank last and are LABELLED rather than dropped,
|
||||
because the table already prints a MERK line about them and a plan that
|
||||
omitted them silently would repeat that defect.
|
||||
|
||||
It renders `key=value` blocks, not prose, because it has two consumers: the
|
||||
operator, and a driver repo consuming the plan. Prose would make the rendered
|
||||
|
|
@ -251,7 +272,7 @@ obligations in another repo.
|
|||
builtins only in hook and tests.
|
||||
- TDD: no behavior change without a failing selftest check first.
|
||||
`bash scripts/coord-selftest.sh` must exit 0 (183/183),
|
||||
`bash scripts/board-selftest.sh` must exit 0 (89/89) and
|
||||
`bash scripts/board-selftest.sh` must exit 0 (134/134) and
|
||||
`bash scripts/route-selftest.sh` must exit 0 (73/73).
|
||||
- English for all code, docs, and commit messages (public repo). Norwegian
|
||||
trigger aliases in the skill description are deliberate.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
*AI-generated: all code produced by Claude Code through dialog-driven development.*
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
|
@ -107,7 +107,7 @@ The reply/resolve hints the hook injects (`-> reply: coord-send --reply-to …
|
|||
|
||||
It makes **zero model calls**, which is the point rather than a detail. Under subscription auth a headless session draws from the same quota pool as interactive work, and `--max-budget-usd` is a runaway brake rather than a pre-flight gate — measured against 2.1.220, it aborts *after* the first turn, never before it. `board.sh --brief` writes nothing; the file write lives in `brief-nightly.sh`, which renders to a temp file and renames it into place, and refuses to replace a good briefing with an empty render. `launchd/` holds a sample agent that runs it nightly; it points at a checkout, never at the version-pinned plugin cache.
|
||||
|
||||
**`board.sh --plan` renders the day plan**, a *third* rendering of that same scan and the only one that takes a position: which repos to open a tab for today, in what order, and the command to start each. The order is the position, and there is no cutoff — nothing is hidden, and four deterministic rules decide it. Repos owing mail come first, most-owed first, *whatever their status*: excluding `blocked` or `done` is a statement about a repo's own next step, which by definition cannot be moved, while owing an answer is a different axis and answering is often what unblocks it. Then live work (`in-progress`), then `planned`, then last and labelled, the repos with no board line at all — the table already prints a note about those, so a plan that dropped them silently would repeat exactly that defect. Still zero model calls, still read-only, and still cross-checked against `coord-count.sh`.
|
||||
**`board.sh --plan` renders the day plan**, a *third* rendering of that same scan and the only one that takes a position: which repos to open a tab for today, in what order, and the command to start each. The order is the position, and there is no cutoff — nothing is hidden, and one deterministic score decides it: `40 ×` repos released transitively, `15 ×` unhandled inbox messages, plus small bonuses for live work and for a cheap `next-cost` row. Four hard buckets preceded it and could not express "this repo owes one message and releases two others" — which is how a blocked chain's root ended up ranked *below* the repos waiting on it. **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, since its own next step is by definition waiting. A cycle, or a `blocked-on` naming a repo the scan never produced, credits nobody rather than inventing a root — a plan that looks correct while sending you to the wrong repo is worse than one that says nothing. Repos owing mail still rank high *whatever their status*, and debt is deliberately **uncapped**: excluding `blocked` or `done` is a statement about a repo's own next step, which cannot be moved, while owing an answer is a different axis and answering is often what unblocks it. Repos with no board line come last and labelled — the table already prints a note about those, so a plan that dropped them silently would repeat exactly that defect. `why=` names the dominant term, so a block reads `unblocks:2` rather than the `inbox:N` every block used to repeat. Still zero model calls, still read-only, and still cross-checked against `coord-count.sh`.
|
||||
|
||||
**`--focus "<prose>"` narrows that plan to one subject, and reports what it held back.** It is the only cutoff this format has, which is why the report is a condition of the feature rather than a refinement of it: `--plan` documents that it hides nothing and labels what it cannot rank, so a silent filter would break a property already written down. The same run prints the subjects the prose resolved to, how many blocks were removed, how many `STATE.md` were searched, and — named, not counted — the repos that *mention* a resolved subject without declaring a marker line. That last class is enumerated because it is where the misses live: a repo can be a heavy participant and never have written a marker, and no string measurement finds it until the held-back population is listed. Each surviving block carries the declaration it survived on. Prose matching nothing prints the *full* plan and says so, since the phrase arrives verbatim from a person and a typo must not empty the day. The subject vocabulary is read from the scanned `STATE.md` themselves, so the "reads `STATE.md` and no other file" invariant is untouched.
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ Note that raising the inbox's priority (Rule 7) deliberately does **not** widen
|
|||
## Development
|
||||
|
||||
bash scripts/coord-selftest.sh # 183 checks against a throwaway mailbox
|
||||
bash scripts/board-selftest.sh # 89 checks against a throwaway repo tree
|
||||
bash scripts/board-selftest.sh # 134 checks against a throwaway repo tree
|
||||
bash scripts/route-selftest.sh # 73 checks, incl. the route->board round trip
|
||||
npm test # all three selftests via node --test
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "repo-mailbox",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
172
scripts/board.sh
172
scripts/board.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ description: >-
|
|||
user names no repo and no tool — choosing *between* repos is this skill. Not for
|
||||
"where were we" inside the current repo: that is this repo's own STATE.md,
|
||||
already injected at session start.
|
||||
version: "0.18.0"
|
||||
version: "0.19.0"
|
||||
---
|
||||
|
||||
# board — which repo deserves the next session
|
||||
|
|
@ -139,10 +139,29 @@ and it has its own rendering:
|
|||
This is the one case where a list *is* the answer and the no-dumping rule does not
|
||||
apply — the user asked for the day, and a day has more than one repo in it. Pass
|
||||
the plan through with a short framing line; do not re-rank it, re-order it, or trim
|
||||
it. The order is the engine's position and it is deterministic: debt first
|
||||
(most-owed first, whatever the status), then `in-progress`, then `planned`, then
|
||||
repos with no board line. Substituting your own judgement for that order makes the
|
||||
plan unreproducible and costs the property that makes it trustworthy.
|
||||
it. The order is the engine's position and it is deterministic — one score, not
|
||||
four groups:
|
||||
|
||||
40 x repos released transitively (chain-root credit)
|
||||
15 x unhandled inbox messages
|
||||
+10 in-progress / +5 planned / +2 no declared status
|
||||
+3 when next-cost names a Sonnet row
|
||||
|
||||
**Chain-root credit is the term worth understanding before you explain an order
|
||||
to the operator.** For every `blocked` repo the engine follows `blocked-on` to
|
||||
the ROOT of the chain and credits only the root, transitively. Opening a blocked
|
||||
repo releases nobody — its next step is by definition waiting; opening the root
|
||||
releases everything behind it. A cycle or a `blocked-on` naming an unscanned repo
|
||||
credits nobody. This is engine rule 1 above, now computed rather than eyeballed.
|
||||
|
||||
Debt is **uncapped** by decision: owing a reply is the other axis from a repo's
|
||||
own next step, and answering is often what unblocks a chain. At 15 per message a
|
||||
repo owing one more still outranks any combination of status and cost bonuses.
|
||||
|
||||
`why=` names the **dominant** term, so a block can read `why=unblocks:2` even
|
||||
though the repo also owes mail. Read it as "what opening this releases", not as
|
||||
the only reason it qualified. Substituting your own judgement for the order makes
|
||||
the plan unreproducible and costs the property that makes it trustworthy.
|
||||
|
||||
Two things to say out loud when you hand it over:
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ description: >-
|
|||
covers retiring a broadcast that has become wrong or obsolete: "retract that
|
||||
broadcast", "that announcement is outdated, pull it", "trekk tilbake kringkastingen",
|
||||
"den broadcasten er utdatert".
|
||||
version: "0.18.0"
|
||||
version: "0.19.0"
|
||||
---
|
||||
|
||||
# coord-send — natural-language front door for inter-repo messages
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ description: >-
|
|||
the operator names no model and no tool — choosing the model for the next
|
||||
session IS this skill. Not for choosing which REPO gets the next session:
|
||||
that is the `board` skill.
|
||||
version: "0.18.0"
|
||||
version: "0.19.0"
|
||||
---
|
||||
|
||||
# route — what the next session should run with
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue