@plot-pm/board 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,13 +2,17 @@
2
2
  # Plot helper: fleet pulse — deterministic extractor for wave/claim state.
3
3
  # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
4
4
  # --no-fetch skip `git fetch`
5
- # --offline same (no network) — used for cheap, ambient pulses.
5
+ # --offline same (no fetch) — used for cheap, ambient pulses.
6
6
  # The fetch also PRUNES remote-tracking refs, so skipping it
7
7
  # keeps whatever stale refs this checkout holds: a branch merged
8
8
  # and deleted upstream may read `wip` rather than `merged`, and
9
9
  # its wave may read blocked. That is the honest answer for a scan
10
10
  # that asked nothing, and the footer says so rather than leaving
11
11
  # it to be discovered.
12
+ # IT DOES NOT SILENCE THE HOST ON THE OFFER PATH. With `--next`
13
+ # or `--list-eligible` the host is asked anyway — see those flags
14
+ # — because naming a branch to claim turns on a fact refs cannot
15
+ # supply. Every other use of the flag asks nothing, as before.
12
16
  # --list-eligible print EVERY claimable branch, one per line (exit 1 if none).
13
17
  # For callers that need the count rather than one item — a dry
14
18
  # run changes nothing, so its answer cannot go stale.
@@ -24,12 +28,27 @@
24
28
  # exit 1 when there is none. Used by /plot-implement to pick work
25
29
  # without re-deriving eligibility. "Nothing to start" is a normal
26
30
  # state — the exit code, not stderr, is what says so.
31
+ # ASKS THE HOST EVEN UNDER `--offline`, and withholds a branch it
32
+ # could not ask about. A merged branch is not claimable, and once
33
+ # a squash merge has rewritten the commits and `--delete-branch`
34
+ # has removed the ref, the host is the only source that can say
35
+ # so. Measured 2026-09-04: ten refs on this estate carry the tip
36
+ # commit `plot: claim <branch>` dated hours AFTER their own merge,
37
+ # all pushed on this offer.
38
+ # --why-nothing print WHY `--next` would be silent, and exit 0 either way:
39
+ # `none` when there is no work left for this plan, or `not-yet`
40
+ # followed by the branches whose landing would open a blocked
41
+ # slice. THE SECOND QUESTION A WORKER ASKS. `--next` exits 1 for
42
+ # two different nothings — a blocked next slice and no next slice
43
+ # — and a worker that cannot tell them apart pays a whole
44
+ # dispatch to reach a slice it was standing next to. Exits 0 with
45
+ # `none` when it cannot tell, so silence is never read as wait.
27
46
  # --stream --json, emitted as it resolves rather than as one document at
28
47
  # the end. One `{"kind":"plan","plan":{...}}` line per plan the
29
48
  # moment that plan is fully derived, then one
30
- # `{"kind":"pulse","pulse":{...}}` line carrying the SAME
49
+ # `{"kind":"reading","reading":{...}}` line carrying the SAME
31
50
  # document --json prints. A consumer that has seen plan lines and
32
- # no pulse line holds a PARTIAL answer — the scan takes 18 s on
51
+ # no reading line holds a PARTIAL answer — the scan takes 18 s on
33
52
  # 84 branches and a board that renders nothing for that long
34
53
  # looks broken. The terminal line is what says the scan finished;
35
54
  # a closed pipe does not, because a killed scan closes it too.
@@ -46,13 +65,48 @@
46
65
  # differs, and a terminal-phase plan lands here too (it is not
47
66
  # approved); the board routes those to DONE by phase before the
48
67
  # verdict is read.
68
+ # Branch states — the word each BRANCH carries, distinct from the wave verdicts
69
+ # above. `open`, `wip`, `merged`, `claimed`, `deferred` and `unknown` are read
70
+ # from git and the host. Two more are read from the plan's `waits:` annotation:
71
+ # waiting the branch names a prerequisite branch that has not merged. A
72
+ # wait with an end: it clears when that branch lands, and the
73
+ # fleet payload carries `waits_on` so a reader sees on WHAT.
74
+ # blocked the branch names a prerequisite the host has never seen a PR
75
+ # for — a typo, or a branch nobody created. A defect in the plan
76
+ # estate, not progress, which is why it is a separate word: the
77
+ # first resolves by waiting, the second by editing the plan.
78
+ # THE SAME WORD AS THE WAVE VERDICT, IN A DIFFERENT VOCABULARY.
79
+ # A wave is `blocked` by an earlier wave; a branch is `blocked`
80
+ # by a prerequisite nobody declared. The two travel in separate
81
+ # JSON fields (`waves[].verdict` vs `branches[].state`) and are
82
+ # counted separately in the footer — `blocked=` is the wave
83
+ # count it has always been, `prereq_missing=` is the branch one.
84
+ # Both apply ONLY where the branch's own state is `open` or `unknown` — a
85
+ # branch carrying work, a claim, or a merge has already been started, and
86
+ # overriding `merged` would stop its wave settling forever. An unreachable host
87
+ # holds the slice at `waiting`; silence is never permission to start, and never
88
+ # evidence of a typo either.
49
89
  # Output: per-plan wave report on stdout, terminated by a machine-countable
50
90
  # summary line:
51
- # summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 merge_detect=pr-merge main=main
91
+ # summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 waiting=1 prereq_missing=0 merge_detect=pr-merge host=ok main=main
92
+ # `blocked` counts WAVES an earlier wave holds; `waiting` and
93
+ # `prereq_missing` count BRANCHES their `waits:` annotation holds.
52
94
  # merge_detect names how merged-and-deleted branches were detected:
53
95
  # pr-merge (exhaustive), truncated (capped walk), none (no conforming
54
96
  # merge commits — a squash/rebase repo, where `open` says nothing about
55
97
  # merging).
98
+ # host says whether the git host ANSWERED, and is what says whether
99
+ # merge_detect can be believed: ok (the list arrived — an EMPTY list is
100
+ # ok, the host answered and there are none), throttled (a spent quota;
101
+ # the same question after the reset will be answered), secondary (a
102
+ # burst refusal, which clears in seconds), failed (any other failure),
103
+ # unasked (no host, or --offline — the question was never put, which is
104
+ # not the same as one that went unanswered).
105
+ # Measured 2026-08-30: a merged branch read `open` and was counted
106
+ # among the unfinished under `merge_detect=pr-merge`, which reads as
107
+ # *asked and answered*, while the host had refused every call. Where
108
+ # host is throttled, secondary or failed, a no-ref branch reads `unknown`
109
+ # rather than `open` and is not offered to --next.
56
110
  # Consumers that only need counts (the /plot-fleet pulse log, the
57
111
  # board) read that one line and never re-count the body.
58
112
  # --json additionally carries, per PLAN, `phase` — the plan's own
@@ -123,12 +177,29 @@
123
177
  # crashed pulse costs nothing — the next pulse re-derives the truth. Nothing
124
178
  # here creates a branch, pushes a ref, or starts a worker.
125
179
  #
126
- # ONE exception to "writes nothing": --log-pulse appends a pulse line to each
127
- # reported plan (see below). That is a LOG, not state deleting the whole log
128
- # changes no behaviour, because the next run re-derives everything. The flag
129
- # defaults OFF precisely so internal callers (plot-implement, plot-dispatch,
130
- # which invoke --next) can never amend a plan as a side effect of asking what
131
- # to work on; /plot-fleet, the human-facing command, passes it every run.
180
+ # TWO exceptions to "writes nothing", and NEITHER IS STATE. The test both pass
181
+ # is the same one: delete what they wrote and no behaviour changes, because the
182
+ # next run re-derives everything.
183
+ #
184
+ # 1. --log-pulse appends a pulse line to each reported plan (see below). That is
185
+ # a LOG. The flag defaults OFF precisely so internal callers (plot-implement,
186
+ # plot-dispatch, which invoke --next) can never amend a plan as a side effect
187
+ # of asking what to work on; /plot-fleet, the human-facing command, passes it
188
+ # every run.
189
+ #
190
+ # 2. `.plot/state/last-pulse.json` — the bridge, written by `write_bridge` on
191
+ # the success path of `--stream` (what the board spawns) and `--log-pulse`
192
+ # (what /plot-pulse passes). Those are the two callers that produce a pulse
193
+ # for somebody to READ; plain `--json` is a query and records nothing. That
194
+ # is a CACHE WITH AN EXPIRY: `pulse-bridge.ts` discards it after 15 minutes
195
+ # and on a version mismatch, so it can only ever be a shortcut to an answer
196
+ # this script re-derives anyway. It is machine-local and gitignored.
197
+ #
198
+ # Added 2026-09-06. The board wrote this file and the scan did not, so a
199
+ # repository with no board had nothing to diff against and every pulse read
200
+ # as the first one — while `DESIGN-process.md` §1 requires the fleet to work
201
+ # with no board at all. The component that PRODUCES a pulse is the one that
202
+ # records it.
132
203
  #
133
204
  # Wave eligibility (the one rule this script encodes):
134
205
  # A wave is ELIGIBLE when every non-deferred branch in every PRIOR wave is
@@ -159,20 +230,52 @@ cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
159
230
  do_fetch=1
160
231
  next_only=0
161
232
  list_all=0
233
+ why_nothing=0
162
234
  loose=0
163
235
  log_pulse=0
164
236
  as_json=0
237
+ # Whether the JSON document is ASSEMBLED, which is not the same question as
238
+ # whether it is PRINTED. `--json`/`--stream` need it to print; `--log-pulse`
239
+ # needs it to write the bridge and prints prose. Measured 2026-09-06 on this
240
+ # estate: assembling it costs 14.7 s against 5.9 s for prose alone, because the
241
+ # branch objects carry `merge-tree` conflict sets. So the two meanings are
242
+ # separated rather than folded, and a plain `/plot-fleet-scan.sh` pays neither.
243
+ build_doc=0
244
+ # Whether this run RECORDS the pulse it produced — `.plot/state/last-pulse.json`.
245
+ # A third question again: `--json` assembles a document and records nothing,
246
+ # because it is a query. Only the two callers that produce a pulse for somebody
247
+ # to read set this — `--stream` (the board) and `--log-pulse` (/plot-pulse).
248
+ record=0
165
249
  stream=0
166
250
  slug=""
167
251
  while [ $# -gt 0 ]; do
168
252
  case "$1" in
169
253
  --no-fetch|--offline) do_fetch=0 ;;
170
254
  --loose) loose=1 ;;
171
- --log-pulse) log_pulse=1 ;;
255
+ # `--log-pulse` ALSO ASSEMBLES THE DOCUMENT, because this is the flag that
256
+ # means *this pulse records itself*. It already appends a line to each plan;
257
+ # it now also writes `.plot/state/last-pulse.json`, so `/plot-pulse` in a
258
+ # repository with no board accumulates the history a delta needs. Without
259
+ # that, every pulse on a boardless repo is a first one forever.
260
+ --log-pulse) log_pulse=1; build_doc=1; record=1 ;;
172
261
  --next) next_only=1 ;;
173
262
  --list-eligible) next_only=1; list_all=1 ;;
174
- --json) as_json=1 ;;
175
- --stream) as_json=1; stream=1 ;;
263
+ # THE SECOND QUESTION, and it borrows `--next`'s population deliberately.
264
+ # A caller asks this having just been told nothing, so it must be told
265
+ # about the SAME plans `--next` was silent over — a terminal plan admitted
266
+ # here would answer `not-yet` about work somebody decided was not needed.
267
+ --why-nothing) next_only=1; why_nothing=1 ;;
268
+ # `--json` ASSEMBLES BUT DOES NOT RECORD, and the two flags differ here for
269
+ # a reason. `--stream` is what the BOARD spawns (`fleet.ts:2694`) and
270
+ # `--log-pulse` is what `/plot-pulse` passes: both produce a pulse somebody
271
+ # is reading, so both record one. Plain `--json` is a machine-readable
272
+ # QUERY — `--next`'s neighbours ask it to find out what to work on — and a
273
+ # query that left a file behind would make the scan write on a path no
274
+ # caller asked to record. Two reconcile tests assert exactly that and
275
+ # caught this: `conflicts: the scan writes NOTHING` and `fleet: scan is
276
+ # read-only`, both of which drive `--json`.
277
+ --json) as_json=1; build_doc=1 ;;
278
+ --stream) as_json=1; stream=1; build_doc=1; record=1 ;;
176
279
  -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
177
280
  *) slug="$1" ;;
178
281
  esac
@@ -188,11 +291,13 @@ PREFIX_RE=$(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
188
291
  | tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' -)
189
292
  [ -n "$PREFIX_RE" ] || PREFIX_RE="idea|feature|bug|docs|infra"
190
293
 
294
+ # `default_branch` repairs an unresolvable origin/HEAD before answering. The
295
+ # scan derives every branch's state from `origin/<main>`, so a symref naming a
296
+ # branch that does not exist makes every one of them unreadable at once.
297
+ # shellcheck source=plot-default-branch.sh
298
+ . "$script_dir/plot-default-branch.sh"
191
299
  MAIN=$(cfg "Main branch")
192
- if [ -z "$MAIN" ]; then
193
- MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
194
- fi
195
- [ -n "$MAIN" ] || MAIN="main"
300
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
196
301
 
197
302
  # A FAILED FETCH IS A FACT, not a shrug. The old line was
198
303
  # `git fetch ... 2>/dev/null` with its status discarded: a GitHub 503, a
@@ -390,8 +495,39 @@ fi
390
495
  # host would be lying in the direction of a slow ambient pulse. Without a
391
496
  # backend — or offline — the lookup is simply never attempted and every branch
392
497
  # answers exactly as it did before.
498
+ #
499
+ # THE OFFER IS THE EXCEPTION, AND IT IS NARROW. `--next` and `--list-eligible`
500
+ # do not report an estate; they name a branch a worker is about to CLAIM by
501
+ # pushing a ref. That question cannot be answered from refs alone, because the
502
+ # fact it turns on — did this branch's PR merge — is one only the host holds
503
+ # once a squash merge has rewritten the commits and `--delete-branch` has taken
504
+ # the ref away.
505
+ #
506
+ # WHAT `--offline` ACTUALLY PROMISES IS NO FETCH. Everything else followed from
507
+ # `do_fetch` because the two were one flag, and that conflation is the defect.
508
+ # Measured 2026-09-04 on this estate, from `plot-worker-loop.sh`'s own call:
509
+ #
510
+ # plot-fleet-scan.sh --offline --next the-domain-owns-the-agent-lifecycle
511
+ # → feature/an-agent-declares-what-it-is
512
+ #
513
+ # whose PR #679 merged at 20:46 the previous evening. The same scan WITHOUT
514
+ # `--offline` read that branch `merged`, its slice `complete`, and named
515
+ # nothing. Five of that plan's eight branches moved between the two readings.
516
+ # Ten refs across four days carry the tip commit `plot: claim <branch>` dated
517
+ # hours AFTER their own merge — this offer is what pushed every one of them.
518
+ #
519
+ # THE AMBIENT PULSE IS UNTOUCHED. `--offline` without `--next` still asks
520
+ # nothing, so the 5 s board poll and the cheap operator glance keep the cost
521
+ # they were given the flag for. The call is one repo-wide `pr-list`, already
522
+ # cached per run, and it is paid only by a caller asking to be handed work —
523
+ # which is about to spend a worker on the answer.
524
+ #
525
+ # NO BACKEND STILL MEANS NO LOOKUP. A repo with no git host has no merge state
526
+ # to withhold on, and refusing there would stop `--next` in every fixture and
527
+ # every hostless checkout. The `backend` test below is what keeps that case
528
+ # working, and it is the only condition the offer path drops.
393
529
  HOST_LOOKUP_OK=0
394
- if [ "$do_fetch" = 1 ] \
530
+ if { [ "$do_fetch" = 1 ] || [ "$next_only" = 1 ]; } \
395
531
  && [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
396
532
  HOST_LOOKUP_OK=1
397
533
  fi
@@ -482,10 +618,36 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
482
618
  # already performs on Bitbucket, so the join and the per-branch lookup cannot
483
619
  # disagree about the same branch.
484
620
  PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
621
+
622
+ # WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
623
+ # computed and threw away until 2026-08-30.
624
+ #
625
+ # ok — the list arrived. An EMPTY list is `ok`: the host answered and
626
+ # there are none. Collapsing that into a failure would trade a
627
+ # silent wrong answer for a noisy one and break every repo that
628
+ # genuinely has no PRs.
629
+ # throttled — a SPENT QUOTA (`plot-host.sh` exit 5). Nothing is broken; the
630
+ # same question after the reset will be answered.
631
+ # secondary — a burst refusal (`plot-host.sh` exit 6). Nothing is broken
632
+ # either, and it clears in seconds rather than minutes.
633
+ # failed — any other failure (exit 3, or anything unclassified).
634
+ # unasked — no host to ask, or --offline WITHOUT `--next`. Not a
635
+ # degradation: the scan was never going to ask, and saying
636
+ # `failed` would report a fault where there is a configuration.
637
+ # `--offline --next` DOES ask, so it reaches one of the words
638
+ # above rather than this one.
639
+ #
640
+ # THREE WORDS FOR THREE FAILURES BECAUSE THEY ASK FOR DIFFERENT RESPONSES.
641
+ # `throttled` says wait for the reset; `secondary` says retry shortly and run
642
+ # fewer at once; `failed` says look. An operator told to wait out an outage
643
+ # loses exactly the time waiting was meant to save — and one told to wait out a
644
+ # secondary limit waits minutes for a ceiling that cleared in seconds.
645
+ HOST_VERDICT=unasked
646
+
485
647
  prefill_pr_states() {
486
648
  [ "$HOST_LOOKUP_OK" = 1 ] || return 0
487
649
  [ -n "$HOST_STATE_CACHE" ] || return 0
488
- local js br st key
650
+ local js br st key rc
489
651
  # Exit code first: non-zero is a transport failure and its stdout is not an
490
652
  # answer. A failed list leaves the cache EMPTY, so every branch falls through
491
653
  # to the unanswerable `-` rather than to a fabricated "no PR".
@@ -497,8 +659,91 @@ prefill_pr_states() {
497
659
  # configured), and the parsing below already skips fields the response does
498
660
  # not contain. The BEHAVIOUR change is in `pr_ready`, which now reads the
499
661
  # check rollup from the cache rather than making a per-branch host call.
500
- js=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
501
- </dev/null 2>/dev/null) || return 0
662
+ #
663
+ # THE CODE IS KEPT, not just tested. This guard was always correct — a failed
664
+ # list prefills nothing — and until 2026-08-30 it never fired, because
665
+ # `pr-list` swallowed its own failure and exited 0 with empty stdout. Now
666
+ # that it can fail, WHICH failure it was is a fact worth carrying: exit 5 is
667
+ # a rate limit and exit 3 is anything else, and the summary reports the
668
+ # difference rather than degrading silently.
669
+ # STDOUT TO A FILE so stderr can be captured separately — see the verdict
670
+ # below. It lives in `HOST_STATE_CACHE`, which already has an EXIT trap, so
671
+ # this adds no second cleanup path. When mktemp -d failed the cache is "",
672
+ # and /dev/null keeps the call working with the text simply unavailable.
673
+ host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
674
+ host_list_out="${host_list_out:-/dev/null}"
675
+ host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
676
+ </dev/null 2>&1 >"$host_list_out"); rc=$?
677
+ js=$(cat "$host_list_out" 2>/dev/null)
678
+ if [ "$rc" -ne 0 ]; then
679
+ # THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
680
+ # never put" (see HOST_VERDICT above: *not a degradation, the scan was
681
+ # never asking*), and a host that cannot be ASKED AT ALL belongs there
682
+ # rather than among answers that failed to arrive.
683
+ #
684
+ # THE EXIT CODE CANNOT SEPARATE THEM. `plot-host.sh:849` is explicit
685
+ # that a missing token is *"a CONFIG error the op cannot proceed past
686
+ # — exit 3, never"* exit 4, so an unauthenticated CLI and a genuine
687
+ # mid-answer failure arrive with the SAME status. The stderr text is
688
+ # the only thing that distinguishes them, and it is already in hand:
689
+ # a second probe call would cost another fork for a fact this one
690
+ # already produced.
691
+ #
692
+ # WHY IT MATTERS: `failed` makes every branch `unknown`, and `unknown`
693
+ # is not startable — so a checkout with no host credentials reports an
694
+ # estate on which nothing can be started. Measured 2026-08-31:
695
+ # `packages/board/test/claimed.test.mjs`, whose header says it *"never
696
+ # touches a network or a git host"*, read `eligible: 0` where it had
697
+ # always read 1. The branch was startable; the scan had stopped being
698
+ # able to say so.
699
+ case "$rc" in
700
+ 5) HOST_VERDICT=throttled ;;
701
+ 6) HOST_VERDICT=secondary ;;
702
+ 4) HOST_VERDICT=unasked ;;
703
+ *) case "$host_err" in
704
+ # THE WORDING IS MEASURED, NOT GUESSED. An earlier version of this
705
+ # list matched auth/login/credential and MISSED the message CI
706
+ # actually emits:
707
+ #
708
+ # "gh: To use GitHub CLI in a GitHub Actions workflow, set the
709
+ # GH_TOKEN environment variable."
710
+ #
711
+ # which contains none of those words. The fix passed locally — where
712
+ # `gh` is authenticated and this branch never runs — and failed on
713
+ # every CI run. Measured 2026-08-31 against a stubbed `gh`.
714
+ #
715
+ # So the patterns name what the CLIs SAY when they have no identity:
716
+ # a token, a login, a credential, or authentication. Anything else is
717
+ # a real failure and stays `failed`, because widening this to a
718
+ # catch-all would turn every host outage into "nobody asked".
719
+ *TOKEN*|*token*|*auth*|*Auth*|*AUTH*|*login*|*Login*|*credential*|*Credential*|*"not logged"*)
720
+ HOST_VERDICT=unasked ;;
721
+ # NO REMOTE IS A CONFIGURATION, NOT A FAULT — the same reading as a
722
+ # missing token one line up, reached by the same route: `plot-host.sh`
723
+ # exits 3 for both, because both are "the op cannot proceed", and
724
+ # only the text separates them.
725
+ #
726
+ # `backend` ANSWERS `github` IN A REPO WITH NO REMOTE, which is what
727
+ # makes this reachable at all: the key is read from config, and a
728
+ # repo can name a host it has no repository on. So the backend test
729
+ # that gates the lookup cannot catch this case, and the failure
730
+ # arrives here instead.
731
+ #
732
+ # Measured 2026-09-04 against `test/reconcile/dispatch.test.mjs`'s
733
+ # `noRemote` fixture: `--list-eligible` withheld a branch nobody had
734
+ # started, because a host that does not exist read as one that
735
+ # failed. `failed` makes every branch `unknown` and `unknown` is not
736
+ # claimable — the right refusal about the wrong thing. There is no
737
+ # merge state to withhold on where there is no remote to hold it.
738
+ *"no git remotes"*|*"no remote"*)
739
+ HOST_VERDICT=unasked ;;
740
+ *) HOST_VERDICT=failed ;;
741
+ esac ;;
742
+ esac
743
+ return 0
744
+ fi
745
+ # The list arrived. An empty one arrived too — that is the whole distinction.
746
+ HOST_VERDICT=ok
502
747
  # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
503
748
  # that is a correctness-of-cost property rather than a style preference:
504
749
  # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
@@ -844,8 +1089,16 @@ terminal_learn() { # $1=branch $2=state
844
1089
  # branch that is live — in flight, claimed, or with work on the floor — never
845
1090
  # arrives here and therefore cannot be cached however the cache is filled. The
846
1091
  # invariant is structural rather than a check that could be forgotten.
1092
+ #
1093
+ # SETS `_merged_by_host_state` TO THE WORD IT DECIDED ON, and that is what the
1094
+ # readings carry. The boolean is still the answer to this function's own
1095
+ # question; the word is what `branch_readings` reports, because the rule needs
1096
+ # to tell `CLOSED` from `NONE` from `-` and a yes/no cannot. Set before every
1097
+ # return, so a caller reading it never sees the previous branch's answer.
1098
+ _merged_by_host_state='-'
847
1099
  merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
848
1100
  local st
1101
+ _merged_by_host_state='-'
849
1102
  # Git has already been consulted to get here (no ref) and `terminal_cached`
850
1103
  # asks it again about the plan and the tip. Only the round trip is skipped.
851
1104
  if st=$(terminal_cached "$1"); then
@@ -858,11 +1111,13 @@ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
858
1111
  # It is re-derived, not merely echoed: reaching here means git was asked
859
1112
  # again this pass and still agrees — no ref, same plan, same tip.
860
1113
  terminal_learn "$1" "$st"
1114
+ _merged_by_host_state="$st"
861
1115
  [ "$st" = "MERGED" ]
862
1116
  return
863
1117
  fi
864
1118
  st=$(host_pr_state "$1" --ask)
865
1119
  terminal_learn "$1" "$st"
1120
+ _merged_by_host_state="$st"
866
1121
  [ "$st" = "MERGED" ]
867
1122
  }
868
1123
 
@@ -883,6 +1138,52 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
883
1138
  case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
884
1139
  }
885
1140
 
1141
+ # ---------------------------------------------------------------------------
1142
+ # A SLICE THAT WAITS ON ANOTHER PLAN'S BRANCH
1143
+ # ---------------------------------------------------------------------------
1144
+ #
1145
+ # Slice eligibility is computed PER PLAN, so nothing here compares two plans —
1146
+ # and that is the defect this answers. Measured 2026-09-01, twice in one
1147
+ # session: the fleet offered a branch as claimable whose prerequisite lived in
1148
+ # a different plan and had not merged, and the only thing between a worker and
1149
+ # premature work was a paragraph in a brief.
1150
+ #
1151
+ # THE RUN-SCOPED CACHE, NEVER `terminal_cached`. There are two caches and only
1152
+ # one can answer this. `terminal_cached` is keyed by `TERMINAL_PLAN_OID` — the
1153
+ # plan revision the answer was derived under — and returns nothing unless that
1154
+ # oid is the plan CURRENTLY being walked. A prerequisite belongs to another plan
1155
+ # by construction, so its entry can never validate. `HOST_STATE_CACHE` is keyed
1156
+ # by branch name alone through `cache_key`, which makes it the only cache whose
1157
+ # key this question can form. `host_pr_state` reads and writes it before asking
1158
+ # the host, so a prerequisite the run visits for its own plan costs NOTHING the
1159
+ # second time.
1160
+ #
1161
+ # SO THE COST IS BOUNDED AND STATED: free where the prerequisite is visited
1162
+ # elsewhere in the run, ONE host call per run otherwise — never one per pass.
1163
+ #
1164
+ # THE QUESTION IS ASKED OF THE HOST, NOT OF THE REFS, and an earlier draft of
1165
+ # this rule would have deadlocked on SUCCESS. `plot-release-refs.sh` deletes the
1166
+ # remote refs of a delivered plan's merged branches, so a prerequisite that
1167
+ # COMPLETED eventually has no ref — and "no ref → blocked" would hold the
1168
+ # waiting slice forever because its dependency succeeded. A merged PR outlives
1169
+ # the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
1170
+ # refs, and why this reads the same source.
1171
+ #
1172
+ # WHAT THE HOST SAID, AND NOT WHAT IT MEANS. This function answered
1173
+ # `waiting` / `blocked` / `""` until the derivation moved: the three answers and
1174
+ # the reason `NONE` is the only one that means `blocked` are `waitVerdict` in
1175
+ # `packages/domain/src/rules/branch-state.ts`, with a test per case. What stays
1176
+ # here is the READING and the cost argument above it, which is a fact about
1177
+ # this script's host budget rather than about what a wait means.
1178
+ #
1179
+ # `--ask` because the prerequisite is precisely the branch the repo-wide list
1180
+ # may legitimately omit: its plan may be delivered and its ref gone. The bound
1181
+ # is the same one PR #216 set — ABSENT branches, not all branches — and the
1182
+ # cache above keeps it at one call per run.
1183
+ waits_pr_state() { # $1=prerequisite branch → OPEN|MERGED|CLOSED|NONE|-
1184
+ host_pr_state "$1" --ask
1185
+ }
1186
+
886
1187
  # Modification time of a path, in epoch seconds, following symlinks — or "" when
887
1188
  # it cannot be read.
888
1189
  #
@@ -2401,9 +2702,15 @@ for line in sys.stdin:
2401
2702
  # run of tabs collapses to one separator and only the LAST field
2402
2703
  # may be optional. "-" stands in for empty everywhere, so no run
2403
2704
  # can form.
2705
+ # THE PREREQUISITE, from the `waits_on` key the parser emits —
2706
+ # never re-parsed from the annotation. The key is ABSENT on a branch that
2707
+ # declares nothing (`plot-plan-meta.sh` promises "a branch name or
2708
+ # nothing — never a blank string"), and "-" stands in here for the
2709
+ # same tab-collapse reason every other middle column does.
2404
2710
  print("\t".join(clean(x) for x in [
2405
2711
  "W", f, str(i), ref, str(b.get("deferred")).lower(),
2406
2712
  (b.get("deferred_reason") or "-"),
2713
+ (b.get("waits_on") or "-"),
2407
2714
  name or "-", b.get("claimed") or "-"]))
2408
2715
  ' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
2409
2716
 
@@ -2617,13 +2924,18 @@ if [ ${#plans[@]} -eq 0 ]; then
2617
2924
  # the same answer whether the plans are all claimed or there are no plans at
2618
2925
  # all. Exiting 0 here would hand a caller an EMPTY branch name as if it were
2619
2926
  # valid work.
2927
+ # `--why-nothing` answers here too, and it answers `none`. No plans means no
2928
+ # slice can ever become eligible, which is the honest end of a worker's life
2929
+ # — and a wait with nothing to wait on is the one failure this flag exists to
2930
+ # prevent.
2931
+ [ "$why_nothing" = 1 ] && { echo "none"; exit 0; }
2620
2932
  [ "$next_only" = 1 ] && exit 1
2621
2933
  # A MACHINE CONSUMER FALLS THROUGH. An empty estate is a COMPLETE answer, and
2622
2934
  # this branch used to end the run before the emitter — so `--json` and
2623
2935
  # `--stream` were ignored entirely here and a consumer got human prose on
2624
- # stdout. Under `--stream` that meant no terminal `pulse` line, and the
2936
+ # stdout. Under `--stream` that meant no terminal `reading` line, and the
2625
2937
  # board's contract (":3407": *"a consumer that has seen `plan` lines and no
2626
- # `pulse` line has a PARTIAL answer and must say so"*) made it report a
2938
+ # `reading` line has a PARTIAL answer and must say so"*) made it report a
2627
2939
  # complete answer as a scan failure — forever, because the next scan said the
2628
2940
  # same. Measured 2026-08-28 against a board installed from npm: *"fleet scan
2629
2941
  # ended without a terminal pulse line"*, `ready:false`, every pulse.
@@ -2638,7 +2950,7 @@ if [ ${#plans[@]} -eq 0 ]; then
2638
2950
  # the scan globbed it; pointing a reader at the index would now send them to
2639
2951
  # look for the cause of an empty list in a directory nothing consults.
2640
2952
  echo "No plans found in ${PLAN_DIR}."
2641
- echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 main=$MAIN"
2953
+ echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 waiting=0 prereq_missing=0 main=$MAIN"
2642
2954
  exit 0
2643
2955
  fi
2644
2956
  fi
@@ -2733,8 +3045,39 @@ EOF
2733
3045
  echo "$total $n"
2734
3046
  }
2735
3047
 
2736
- branch_state() {
2737
- local br="$1"
3048
+ # WHAT WAS READ OF ONE BRANCH — ten tab-separated fields, and no decision.
3049
+ #
3050
+ # `branch_state()` UNTIL THIS SLICE, and every line of git archaeology below is
3051
+ # its own, unchanged. What went is the `if` chain that merged these readings
3052
+ # into a state word: that lives in `@plot-pm/domain`'s `branchState`, which the
3053
+ # caller asks once per plan through `plot-branch-state.mjs`. The script gathers;
3054
+ # the rule decides.
3055
+ #
3056
+ # THE REF CHECK STAYS IN FRONT, and it still does its job here. It is no longer
3057
+ # a `return`, so the ordering is preserved a different way: `mergeSubjectFound`
3058
+ # is READ ONLY where there is no ref, and reported `false` otherwise. See the
3059
+ # comment on that reading below — it is the same argument the returns carried.
3060
+ #
3061
+ # `-` IS THE ABSENT MARKER, per the field-order rule the caller documents: a
3062
+ # run of tabs collapses into one separator under `read`, so no field is ever
3063
+ # empty. Nothing here is optional, so nothing can shift.
3064
+ #
3065
+ # EIGHT FIELDS, NOT TEN. The two the plan states — the prerequisite's name and
3066
+ # what the host said about it — are appended by the caller, because reading the
3067
+ # second costs a host round trip and the scan spends it only where it could
3068
+ # change the answer. The rule reports which states those are; see the caller.
3069
+ #
3070
+ # THE DEFAULT BRANCH'S TIP IS READ ONCE PER RUN, not once per branch. It does
3071
+ # not move while the scan runs — every fact below is derived from the ref batch
3072
+ # taken at the start — and `remote_ref_oid` forks an `awk`, so asking per branch
3073
+ # would put one process per branch back on the 5 s pulse path. That is the
3074
+ # per-branch tail this script has repeatedly been thinned to remove.
3075
+ MAIN_TIP=$(remote_ref_oid "$MAIN")
3076
+ [ -n "$MAIN_TIP" ] || MAIN_TIP="-"
3077
+
3078
+ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3079
+ local br="$1" _bs_deferred="$2" _bs_subject=false _bs_ahead=0 _bs_real=0 _bs_tip
3080
+ local _bs_main="$MAIN_TIP"
2738
3081
  # THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
2739
3082
  #
2740
3083
  # A branch name can be reused: merge `bug/flaky`, delete it, then recreate it
@@ -2745,9 +3088,16 @@ branch_state() {
2745
3088
  #
2746
3089
  # The merge lookup is safe only BY PLACEMENT — it lives in the no-ref arm,
2747
3090
  # and a recreated branch has a ref, so it never reaches the lookup and takes
2748
- # the ancestry path below instead. Moving the lookup to the top reads like a
2749
- # cheap early answer and would silently report in-flight work as `merged`,
2750
- # opening the next wave on it. A test in fleet.test.mjs pins this ordering.
3091
+ # the ancestry path below instead. Moving the lookup out of this `if` reads
3092
+ # like a cheap early answer and would silently report in-flight work as
3093
+ # `merged`, opening the next wave on it. A test in fleet.test.mjs pins this
3094
+ # ordering, and `branch-state.test.ts` pins what the rule makes of it.
3095
+ #
3096
+ # THE PLACEMENT IS NOW A READING RATHER THAN A RETURN, and it holds the same
3097
+ # way: a branch WITH a ref reports `mergeSubjectFound=false` whatever main
3098
+ # says about its name, so the stale subject never reaches the rule at all.
3099
+ # The rule's own comment says it may not be consulted before the ref check;
3100
+ # this is the half of that contract the caller owes.
2751
3101
  if ! remote_ref_exists "$br"; then
2752
3102
  # No ref carries two meanings and this used to answer `open` for both: a
2753
3103
  # branch never started, and a branch merged with its ref deleted at merge.
@@ -2757,16 +3107,69 @@ branch_state() {
2757
3107
  # `merged` is already the state that settles a wave, so the arithmetic does
2758
3108
  # not change and no new state enters the vocabulary. Where no evidence
2759
3109
  # exists — squash merges, a hand-rewritten subject, a branch genuinely
2760
- # never started — today's `open` stands. The fix may only move a branch
2761
- # from `open` to `merged`, and only on positive evidence.
2762
- merged_by_subject "$br" && { echo "merged"; return; }
3110
+ # never started — today's `open` stands. The evidence may only move a branch
3111
+ # from `open` to `merged`, and only when it is positive.
3112
+ merged_by_subject "$br" && _bs_subject=true
2763
3113
  # No merge commit names it — which is the ordinary case under a squash
2764
3114
  # merge, not an exotic one. The local walk is now out of evidence, so the
2765
3115
  # host is asked. It may only ever move this branch from `open` to `merged`:
2766
- # a miss, a CLOSED PR, or a host that cannot answer all fall through to the
2767
- # `open` below, exactly as before this call existed.
2768
- merged_by_host "$br" && { echo "merged"; return; }
2769
- echo "open"; return
3116
+ # a miss, a CLOSED PR, or a host that cannot answer all leave the reading
3117
+ # as it was, exactly as before this call existed.
3118
+ #
3119
+ # ASKED ONLY HERE, and that bound is the whole of PR #216: this arm is
3120
+ # reached only for a branch with NO REF, so the per-branch host cost is
3121
+ # bounded by ABSENT branches rather than by all of them. Hoisting the call
3122
+ # out of this `if` to "gather uniformly" would put 22 round trips back into
3123
+ # every scan on this repo. The reading is `-` for every branch that has a
3124
+ # ref, and the rule never reaches its PR arm for one that does.
3125
+ #
3126
+ # THE TERMINAL CACHE WRAPS IT, and stays here rather than moving inward.
3127
+ # The cache is about how OFTEN a question is asked; the rule is about what
3128
+ # the answer MEANS. `merged_by_host` consults it, so a terminal branch is
3129
+ # asked once and its answer is reused across pulses — 26 of 54 branches on
3130
+ # this estate, measured 2026-08-19.
3131
+ #
3132
+ # THE WORD, NOT THE BOOLEAN. `merged_by_host` answers its own yes/no and
3133
+ # leaves the state word it decided on in `_merged_by_host_state`, which is
3134
+ # what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
3135
+ # cannot. `|| true` because a not-merged answer is an ordinary reading and
3136
+ # `set -e` must not read it as a failure.
3137
+ merged_by_host "$br" || true
3138
+ # `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
3139
+ # With no ref, the host is the only remaining source, so when it could not
3140
+ # be asked that claim was never earned — and the branch measured on
3141
+ # 2026-08-30 was merged while reading `open`, counted among the unfinished
3142
+ # under `merge_detect=pr-merge`, which reads as *asked and answered*.
3143
+ #
3144
+ # `unknown` IS OUTSTANDING, exactly as `open` is — the `*)` arm of the wave
3145
+ # arithmetic counts both, so no wave verdict moves and the degradation
3146
+ # direction is untouched: an unreachable host still answers *not merged*,
3147
+ # and silence is still never permission.
3148
+ #
3149
+ # WHAT IT DOES CHANGE IS CLAIMABILITY, and that is the fix rather than a
3150
+ # side effect. `--next` offers branches whose state is `open`, so an
3151
+ # `unknown` branch is not handed out — which is right, because "nobody has
3152
+ # started this" is precisely the claim that went unverified. Handing out a
3153
+ # merged branch is what actually happened.
3154
+ #
3155
+ # GATED ON THE THREE FAILURES ONLY, never on "not ok". `unasked` — no host
3156
+ # configured, or --offline — must keep reading `open`: the scan was never
3157
+ # going to ask, so nothing was lost, and flipping every unstarted branch to
3158
+ # `unknown` on every offline scan would be a far larger change than the
3159
+ # defect. A question that was not put is not a question that went
3160
+ # unanswered.
3161
+ #
3162
+ # `secondary` GATES LIKE THE OTHER TWO, and its faster recovery is no reason
3163
+ # to exempt it: the question was PUT and went unanswered, so this scan has
3164
+ # no more evidence than a throttled one does. What the two limits differ in
3165
+ # is what to DO about it, which is the note above and not this reading.
3166
+ #
3167
+ # THE THREE WORDS TRAVEL AS THEMSELVES. `HOST_VERDICT` is reported rather
3168
+ # than collapsed into a boolean, so the rule keeps `unasked` apart from
3169
+ # `failed` — the distinction the whole readings shape exists for.
3170
+ printf '%s\t-\t%s\t%s\t%s\t%s\t0\t0' \
3171
+ "$_bs_deferred" "$_bs_main" "$_bs_subject" "$HOST_VERDICT" "$_merged_by_host_state"
3172
+ return
2770
3173
  fi
2771
3174
  # A CLAIM is a branch whose only commits beyond main are claim commits —
2772
3175
  # empty markers a dispatcher pushed to take the work. They must be real
@@ -2778,65 +3181,115 @@ branch_state() {
2778
3181
  # already computing and discarding, at one extra spawn per branch.
2779
3182
  local _bs_counts
2780
3183
  _bs_counts=$(real_commits_beyond_main "$br")
2781
- ahead=${_bs_counts%% *}
2782
- real=${_bs_counts##* }
2783
- if [ "${ahead:-0}" -gt 0 ]; then
2784
- [ "${real:-0}" = "0" ] && { echo "claimed"; return; }
2785
- # Real work that main does not yet contain: `wip`, and ONLY `wip`.
2786
- #
2787
- # This arm once asked `merge-base --is-ancestor origin/$br origin/$MAIN`
2788
- # here "has the work already landed?" and returned `merged` when it did.
2789
- # That question was already answered by the `ahead` count above it and could
2790
- # never fire: `ahead > 0` means `$br` carries at least one commit unreachable
2791
- # from `$MAIN`, and a branch with such a commit CANNOT be an ancestor of
2792
- # `$MAIN`, so `--is-ancestor` was false on every branch that reached it. It
2793
- # was one git spawn per `wip` branch spent to re-derive a fact already in
2794
- # hand the per-branch tail this plan set out to thin — and its `merged`
2795
- # was dead code that changed no verdict.
2796
- #
2797
- # The landed-work case is not lost; it is answered ONE LEVEL UP. A branch
2798
- # whose commits are all in `$MAIN` counts `ahead = 0` and falls through to
2799
- # the `merged` below, and a merge that deleted the ref never reaches here at
2800
- # all (the no-ref arm returns first). If a future change makes `ahead`
2801
- # something other than "commits `$MAIN` lacks", THIS is the invariant that
2802
- # would break the ancestry must move back, not be missed.
2803
- #
2804
- # A RESURRECTED REF BREAKS THE PREMISE ABOVE, and the join already knows.
2805
- # The reasoning "a merge that deleted the ref never reaches here" holds only
2806
- # while the ref STAYS deleted. `delete_branch_on_merge` is on, so the host
2807
- # removes it and a worktree that still holds the branch can push it back
2808
- # afterwards, which a fleet does routinely. The ref then exists again while
2809
- # the work is on `$MAIN` under a DIFFERENT commit, because a squash merge
2810
- # rewrites it: `ahead > 0` (the pre-squash commits are unreachable from
2811
- # `$MAIN`), `real > 0` (they are real work), and this arm calls finished
2812
- # work `wip`.
2813
- #
2814
- # Measured 2026-08-23: `bug/done-holds-finished-plans-only`, PR #356 merged,
2815
- # read `wip` for three hours. Its wave reported "3 merged, the rest not yet"
2816
- # over four merged branches and never completed, so the plan sat in
2817
- # Development with nothing left to do.
2818
- #
2819
- # `wip` is the WORST of the wrong answers, which is why this earns a check
2820
- # rather than a note: it means *an agent is working here*, so a leftover
2821
- # worktree reads as an occupied desk and the row asks a reader to wait for
2822
- # something that finished.
2823
- #
2824
- # FREE, and that is what licenses it HERE. The state comes from the cache
2825
- # `prefill_pr_states` already filled from ONE repo-wide `pr-list`, so this
2826
- # adds no host call — asking per branch on this arm would put 22 calls back
2827
- # into every scan on this repo and undo the change that removed them. Where
2828
- # the list did not arrive the cache is empty, `host_pr_state` answers `-`,
2829
- # and the local walk decides exactly as it does today.
2830
- #
2831
- # ONLY `MERGED` MAY OVERRIDE the walk, and only toward `merged`. `OPEN`
2832
- # means a PR exists for work still in flight which is what `wip` already
2833
- # says — and `CLOSED` or `NONE` are not evidence that anything landed.
2834
- if [ "$(host_pr_state "$br")" = MERGED ]; then echo "merged"; return; fi
2835
- echo "wip"; return
2836
- fi
2837
- # Nothing of its own. NOT a claim: that shape is indistinguishable from
3184
+ _bs_ahead=${_bs_counts%% *}
3185
+ _bs_real=${_bs_counts##* }
3186
+ # Real work that main does not yet contain is `wip`, and ONLY `wip` — the
3187
+ # rule says so; this reading is what lets it.
3188
+ #
3189
+ # This arm once asked `merge-base --is-ancestor origin/$br origin/$MAIN`
3190
+ # here "has the work already landed?" — and returned `merged` when it did.
3191
+ # That question was already answered by the `ahead` count above it and could
3192
+ # never fire: `ahead > 0` means `$br` carries at least one commit unreachable
3193
+ # from `$MAIN`, and a branch with such a commit CANNOT be an ancestor of
3194
+ # `$MAIN`, so `--is-ancestor` was false on every branch that reached it. It
3195
+ # was one git spawn per `wip` branch spent to re-derive a fact already in
3196
+ # hand the per-branch tail this plan set out to thin and its `merged`
3197
+ # was dead code that changed no verdict.
3198
+ #
3199
+ # The landed-work case is not lost; it is answered by the TIP COMPARISON. A
3200
+ # branch whose commits are all in `$MAIN` counts `ahead = 0`, and a merge that
3201
+ # deleted the ref takes the no-ref arm above. If a future change makes `ahead`
3202
+ # something other than "commits `$MAIN` lacks", THAT is the invariant that
3203
+ # would break the ancestry must move back, not be missed.
3204
+ #
3205
+ # A RESURRECTED REF BREAKS THE PREMISE ABOVE, and the host is what closes it.
3206
+ # The reasoning "a merge that deleted the ref never reaches here" holds only
3207
+ # while the ref STAYS deleted. `delete_branch_on_merge` is on, so the host
3208
+ # removes it — and a worktree that still holds the branch can push it back
3209
+ # afterwards, which a fleet does routinely. The ref then exists again while
3210
+ # the work is on `$MAIN` under a DIFFERENT commit, because a squash merge
3211
+ # rewrites it: `ahead > 0`, `real > 0`, and the walk alone would call finished
3212
+ # work `wip`.
3213
+ #
3214
+ # Measured 2026-08-23: `bug/done-holds-finished-plans-only`, PR #356 merged,
3215
+ # read `wip` for three hours. Its wave reported "3 merged, the rest not yet"
3216
+ # over four merged branches and never completed, so the plan sat in
3217
+ # Development with nothing left to do.
3218
+ #
3219
+ # `wip` is the WORST of the wrong answers, which is why this earns a reading
3220
+ # rather than a note: it means *an agent is working here*, so a leftover
3221
+ # worktree reads as an occupied desk and the row asks a reader to wait for
3222
+ # something that finished.
3223
+ #
3224
+ # FREE, and that is what licenses reading it for EVERY branch with a ref. The
3225
+ # state comes from the cache `prefill_pr_states` already filled from ONE
3226
+ # repo-wide `pr-list`, so this adds no host call — no `--ask` here, which is
3227
+ # what keeps the 22 round trips out. Where the list did not arrive the cache
3228
+ # is empty, `host_pr_state` answers `-`, and the local walk decides exactly as
3229
+ # it does today.
3230
+ #
3231
+ # ONLY `MERGED` MAY OVERRIDE the walk, and only toward `merged` the rule's
3232
+ # business, not this function's. `OPEN` means a PR exists for work still in
3233
+ # flight, and `CLOSED` or `NONE` are not evidence that anything landed.
3234
+ #
3235
+ # Nothing of its own is NOT a claim: that shape is indistinguishable from
2838
3236
  # merged work, which is exactly why claims carry a commit.
2839
- echo "merged"
3237
+ #
3238
+ # ZERO AHEAD CARRIES TWO SHAPES, and only one of them is landed work:
3239
+ #
3240
+ # | shape | ancestry says | truth |
3241
+ # |----------------|-------------------------|---------------|
3242
+ # | behind main | is an ancestor → merged | merged |
3243
+ # | reset to main | is an ancestor → merged | holds nothing |
3244
+ #
3245
+ # A branch pointing AT the default branch is trivially an ancestor of it, so
3246
+ # every ancestry test passes — right for the case that arm was built for (a
3247
+ # squash merge leaves the branch behind, and its work IS on main), and wrong
3248
+ # for a branch that was reset, where the same shape means it holds NOTHING.
3249
+ #
3250
+ # Measured 2026-08-29: `feature/one-deliver-rule-decides-in-the-domain` was
3251
+ # reset to `origin/main` so a worker could rebuild it, its PR (#511) having
3252
+ # been CLOSED, never merged. Seconds later the scan reported the branch
3253
+ # `merged`, completed its wave, and opened `Transitions` on the strength of
3254
+ # work that does not exist. `merged` is the state that SETTLES a wave, so
3255
+ # this error does not stall the fleet — it advances it onto a seam nobody
3256
+ # wrote, which is the worse direction.
3257
+ #
3258
+ # THE DISCRIMINATOR IS THE OTHER DIRECTION, and it is why BOTH TIPS are
3259
+ # reported rather than a verdict about them. A branch with zero commits ahead
3260
+ # is either equal to the default branch or a strict ancestor of it, so
3261
+ # "behind = 0" and "tip = main tip" are the same predicate. Compared as OIDs
3262
+ # because BOTH ARE ALREADY IN HAND from the ref batch — a `rev-list --count`
3263
+ # would re-derive it at one spawn per branch, the per-branch tail this scan
3264
+ # has repeatedly been thinned to remove.
3265
+ #
3266
+ # OFFLINE, AND DELIBERATELY SO. No `--ask` is added here:
3267
+ # `a-throttled-host-says-so` measured `plot-pr-merged.sh` answering *not
3268
+ # merged* for three genuinely merged branches while throttled, and this
3269
+ # reading must not inherit that failure mode.
3270
+ #
3271
+ # The squash path is untouched and must stay so — its mirror defect (a
3272
+ # squash-merged branch reading `open`) is a separate plan, and a fix for one
3273
+ # can break the other. A squash-merged branch is BEHIND main and its tips
3274
+ # differ; a squash-merged branch whose ref was pushed back counts `ahead > 0`.
3275
+ _bs_tip=$(remote_ref_oid "$br")
3276
+ [ -n "$_bs_tip" ] || _bs_tip="-"
3277
+ printf '%s\t%s\t%s\tfalse\t%s\t%s\t%s\t%s' \
3278
+ "$_bs_deferred" "$_bs_tip" "$_bs_main" "$HOST_VERDICT" "$(host_pr_state "$br")" \
3279
+ "${_bs_ahead:-0}" "${_bs_real:-0}"
3280
+ }
3281
+
3282
+ # THE RULE, ASKED ONCE PER PLAN. `branchState` lives in `@plot-pm/domain` and
3283
+ # this is how the scan reaches it: readings in, one `state<TAB>needs` line per
3284
+ # branch out, in the order they were given.
3285
+ #
3286
+ # A MISSING OR SILENT ARTIFACT REFUSES, exactly as the verdicts call does.
3287
+ # There is no shell fallback: a second implementation kept "just in case" is
3288
+ # the duplication this adoption removes, and it would be the copy nobody tests.
3289
+ # `plot-deliver.sh` fails the same way for the same reason.
3290
+ ask_branch_states() { # stdin=readings → one `state<TAB>needs` line per branch
3291
+ node "$script_dir/board/plot-branch-state.mjs" 2>/dev/null \
3292
+ || { echo "error: cannot read branch states — run 'pnpm build:board'." >&2; exit 2; }
2840
3293
  }
2841
3294
 
2842
3295
  # Prose is suppressed by BOTH alternate output modes. --json accumulates the
@@ -2920,8 +3373,17 @@ if [ "$next_only" != 1 ] && [ "$as_json" != 1 ]; then
2920
3373
  fi
2921
3374
 
2922
3375
  n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred=0
3376
+ # Two BRANCH counters beside the wave ones above. `n_blocked` counts waves an
3377
+ # earlier wave holds; these count branches their `waits:` annotation holds, and
3378
+ # the footer keeps the two words apart for that reason.
3379
+ n_waiting=0 n_prereq_missing=0
2923
3380
  claimable=()
2924
3381
  plan_files=()
3382
+ # `--why-nothing`'s input: one `verdict<TAB>name:state|...` line per slice, in
3383
+ # plan order. Accumulated in the SAME loop that renders the branches, so the
3384
+ # outlook reads the verdicts a caller's `--next` just acted on rather than a
3385
+ # second derivation that could disagree with them.
3386
+ outlook_lines=""
2925
3387
 
2926
3388
  plan_idx=-1
2927
3389
  for plan in "${plans[@]}"; do
@@ -3038,24 +3500,141 @@ for plan in "${plans[@]}"; do
3038
3500
  # only safe because of that same rule: it is "-" when absent, never "". The
3039
3501
  # tabs inside it are replaced with spaces by the shim above, for the same
3040
3502
  # reason. It cannot go last; `claim` already is.
3041
- states=""
3042
- while IFS=$'\t' read -r idx br deferred why wname claim; do
3503
+ #
3504
+ # `waits` the branch a slice waits on is a MIDDLE column under the same
3505
+ # rule, and it is a branch name so it carries no tab of its own. It sits
3506
+ # between `why` and the wave name, which moved the wave name to field 7: the
3507
+ # `awk` that reads it below was updated with this line and the two must move
3508
+ # together.
3509
+ # PASS 1a: THE READINGS. Every branch of this plan, gathered and not judged.
3510
+ #
3511
+ # `branch_readings` is the git archaeology that used to end in an `if` chain.
3512
+ # It now ends in eight tab-separated readings, and the two the plan states —
3513
+ # the prerequisite's name and what the host said about it — are appended here.
3514
+ #
3515
+ # THE PREREQUISITE'S PR STATE IS NOT READ YET, and `?` says so. `-` is taken:
3516
+ # `host_pr_state` answers it for a host that could not be reached, and the
3517
+ # rule reads that as `unreadable` and answers `waiting`, because silence is
3518
+ # not evidence in either direction. The two were one marker until CI ran the
3519
+ # corpus with no token, where every prerequisite answers `-` and every waiting
3520
+ # branch read `open`. Reading it
3521
+ # costs a host round trip (`waits_pr_state` passes `--ask`, because a delivered
3522
+ # prerequisite's ref is gone and only its PR outlives it), and the scan spends
3523
+ # that only where the answer could change the branch's state. Which states
3524
+ # those are IS the precedence, so the rule reports it rather than this loop
3525
+ # deciding it — see pass 1c.
3526
+ readings=""
3527
+ order=""
3528
+ while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3043
3529
  [ -n "$br" ] || continue
3044
- if [ "$deferred" = "true" ]; then st="deferred"; else st=$(branch_state "$br"); fi
3045
- states+="$idx $br $st $deferred $why $wname $claim"$'\n'
3530
+ # "-" is the absent marker the shim writes, for the tab-collapse reason
3531
+ # above. Normalized here so everything downstream tests emptiness.
3532
+ [ "$waits" = "-" ] && waits=""
3533
+ readings+="$(branch_readings "$br" "$deferred") ${waits:--} ?"$'\n'
3534
+ order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
3046
3535
  done <<< "$wave_lines"
3047
3536
 
3048
- # Pass 2: wave verdicts. A wave is complete when none of its non-deferred
3049
- # branches is outstanding; eligible when all PRIOR waves are complete.
3537
+ # PASS 1b: THE DECISION, and it is not made here.
3538
+ #
3539
+ # `branchState` lives in `@plot-pm/domain` and this script asks it. The eight
3540
+ # words and the precedence that merges them — a plan's `deferred:` over
3541
+ # everything git says, the ref check before the merge lookup, a prerequisite
3542
+ # over `open` and `unknown` and nothing else — are one implementation now,
3543
+ # with a test per case, shared with every component that has to agree about
3544
+ # what a branch is.
3545
+ #
3546
+ # ONE CALL PER PLAN, not per branch, for the reason pass 2b gives: the board
3547
+ # polls this scan every five seconds against ~40 plans, and a process per
3548
+ # branch is the per-branch tail this script has repeatedly been thinned to
3549
+ # remove.
3550
+ #
3551
+ # A MISSING OR SILENT ARTIFACT REFUSES, exactly as the verdicts call does.
3552
+ # There is no shell fallback: a second implementation kept "just in case" is
3553
+ # the duplication this adoption removes, and it would be the copy nobody
3554
+ # tests.
3555
+ branch_answers=$(printf '%s' "$readings" | ask_branch_states)
3556
+ [ "$(printf '%s\n' "$branch_answers" | grep -c .)" = "$(printf '%s' "$readings" | grep -c .)" ] \
3557
+ || { echo "error: branch states did not answer for every branch of $plan_base." >&2; exit 2; }
3558
+
3559
+ # PASS 1c: THE PREREQUISITES THE RULE ASKED FOR, and only those.
3560
+ #
3561
+ # The second column of each answer is the rule's own
3562
+ # `REPLACEABLE_BY_PREREQUISITE` — `1` where this branch names a prerequisite
3563
+ # whose state has not been read and where reading it could change the answer.
3564
+ # `deferred` outranks it, and so does any state meaning work exists: `wip`,
3565
+ # `claimed` and `merged` all say the branch was started, and overriding
3566
+ # `merged` would stop its wave settling FOREVER. That reasoning now sits in
3567
+ # `branch-state.ts` with a test per case; this loop only spends the calls it
3568
+ # is told to.
3569
+ #
3570
+ # SO THE SECOND ASK IS PAID ONLY WHEN SOMETHING IS FLAGGED, and the bound is
3571
+ # the flagged branches rather than the annotated ones: a `waits:` branch that
3572
+ # already reads `wip`, `claimed`, `merged` or `deferred` costs nothing.
3573
+ refill=""
3574
+ needs_refill=0
3575
+ answer_i=0
3576
+ while IFS= read -r rd_line; do
3577
+ [ -n "$rd_line" ] || continue
3578
+ answer_i=$((answer_i + 1))
3579
+ IFS=$'\t' read -r _st needs \
3580
+ <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
3581
+ waits_br=$(printf '%s' "$rd_line" | cut -f9)
3582
+ if [ "$needs" = "1" ] && [ "$waits_br" != "-" ]; then
3583
+ needs_refill=1
3584
+ # `--ask` because the prerequisite is precisely the branch the repo-wide
3585
+ # list may legitimately omit: its plan may be delivered and its ref gone.
3586
+ # `host_pr_state`'s run cache keeps this at one call per prerequisite per
3587
+ # run, never one per pass.
3588
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br")"$'\n'
3589
+ else
3590
+ refill+="$rd_line"$'\n'
3591
+ fi
3592
+ done <<< "$readings"
3593
+
3594
+ if [ "$needs_refill" = 1 ]; then
3595
+ branch_answers=$(printf '%s' "$refill" | ask_branch_states)
3596
+ [ "$(printf '%s\n' "$branch_answers" | grep -c .)" = "$(printf '%s' "$refill" | grep -c .)" ] \
3597
+ || { echo "error: branch states did not answer for every branch of $plan_base." >&2; exit 2; }
3598
+ fi
3599
+
3600
+ # PASS 1d: the record every loop below reads, with the decided state in it.
3601
+ #
3602
+ # "-" GOES BACK IN, for the reason it was there in the first place: this
3603
+ # record is re-read by two more `read` loops below, and an EMPTY middle
3604
+ # column collapses its tab into its neighbour's and shifts every later
3605
+ # field left. `$claim` is the only field allowed to be last and optional.
3606
+ states=""
3607
+ answer_i=0
3608
+ while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3609
+ [ -n "$br" ] || continue
3610
+ answer_i=$((answer_i + 1))
3611
+ st=$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p" | cut -f1)
3612
+ states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
3613
+ done <<< "$order"
3614
+
3615
+ # Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
3616
+ # not settled. A reading, and the whole of what this script contributes to the
3617
+ # verdict: which branches count as settled depends on `--loose` and on a host
3618
+ # round trip, both of which are adaptation. What a wave therefore IS is the
3619
+ # domain's, asked once for the whole plan in pass 2b.
3050
3620
  wave_ids=$(printf '%s' "$states" | cut -f1 | sort -un)
3051
- prior_ok=1
3621
+ wave_readings=""
3622
+ # ONE LINE PER SLICE, in the order `wave_ids` yields them — the same shape as
3623
+ # `wave_readings` above and `wave_verdicts` below, walked by the same index.
3624
+ # NOT an associative array: macOS ships bash 3.2, which has none, and
3625
+ # `test/reconcile/mergequeue.test.mjs` refuses one.
3626
+ wave_degraded_list=""
3052
3627
  for wid in $wave_ids; do
3053
- wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $6; exit}')
3054
- [ "$wname" = "-" ] && wname=""
3055
3628
  outstanding=0
3056
3629
  _loose_degraded_branches=""
3057
- while IFS=$'\t' read -r idx br st deferred why nm claim; do
3630
+ wave_states=""
3631
+ while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
3058
3632
  [ "$idx" = "$wid" ] || continue
3633
+ # EVERY branch, including the deferred ones, and in the order the render
3634
+ # loop below will walk them — the claimable flags come back positionally,
3635
+ # so the two walks must agree. `outstanding` skips deferred branches; this
3636
+ # does not, because a deferred branch still occupies a position.
3637
+ wave_states+="${wave_states:+|}$st"
3059
3638
  [ "$st" = "deferred" ] && continue
3060
3639
  # strict (default): only a merged branch is settled.
3061
3640
  # loose: pushed work counts too — buys throughput, pays in rebase risk.
@@ -3075,43 +3654,42 @@ for plan in "${plans[@]}"; do
3075
3654
  *) outstanding=$((outstanding + 1)) ;;
3076
3655
  esac
3077
3656
  done <<< "$states"
3657
+ wave_readings+="$outstanding $plan_phase $wave_states"$'\n'
3658
+ wave_degraded_list+="$_loose_degraded_branches"$'\n'
3659
+ done
3078
3660
 
3079
- # `eligible` IS A CLAIM ABOUT STARTABILITY, not about wave ordering alone.
3080
- #
3081
- # Measured 2026-08-27: every one-wave plan in `not-started` on the live
3082
- # board read `eligible`, and `plot-dispatch.sh` refused all six of them
3083
- # *"plan '<slug>' is still Draft on <ref> nothing may be dispatched."*
3084
- # Six of six. Both components were correct and they were answering
3085
- # different questions: this computed *no earlier wave blocks this one*,
3086
- # and the reader took it to mean *I can start this*. Those coincide only
3087
- # for an approved plan.
3088
- #
3089
- # THE PHASE IS ALREADY IN HAND. `$plan_phase` was parsed above for the
3090
- # terminal grouping, so consulting it here costs no read and no host call
3091
- # — the fix adds a test, not a lookup.
3092
- #
3093
- # AN ALLOWLIST OF ONE GOOD PHASE, mirroring `plot-dispatch.sh`'s own gate
3094
- # (`case "$gate_phase" in approved) ;;`) rather than testing for `draft`.
3095
- # A denylist is the blocklist-collapse shape this codebase keeps removing:
3096
- # `design` is documented as a phase whose work cannot yet be handed over,
3097
- # and `UNKNOWN`/`NONE` are unreadable answers. Under a `draft`-only test
3098
- # each of those would inherit the good word. The scan and the dispatcher
3099
- # now refuse the same set, which is the disagreement this removes.
3100
- #
3101
- # ORDERING IS STILL COMPUTED FIRST, and `complete` still outranks it: a
3102
- # wave whose branches have all merged IS complete whatever its plan says,
3103
- # because that is a statement about work that already landed, not an
3104
- # invitation to start any. Only the word a reader ACTS on is withheld.
3105
- #
3106
- # NOT `blocked`, deliberately. That word means *an earlier wave has not
3107
- # landed* — an ordering fact that resolves by merging work. This resolves
3108
- # by a person approving the plan. Folding both into one word would rebuild
3109
- # the ambiguity one level down, and `blocked by <wave> — 1 branch` is a
3110
- # sentence a row in this state cannot truthfully complete.
3111
- if [ "$outstanding" -eq 0 ]; then verdict="complete"
3112
- elif [ "$plan_phase" != "approved" ]; then verdict="unapproved"
3113
- elif [ "$prior_ok" -eq 1 ]; then verdict="eligible"
3114
- else verdict="blocked"; fi
3661
+ # Pass 2b: THE DECISION, and it is not made here.
3662
+ #
3663
+ # `sliceVerdict` lives in `@plot-pm/domain` and this script asks it. The four
3664
+ # words and the order they are decided in `complete` outranking everything,
3665
+ # approval being part of `eligible`, `unapproved` kept apart from `blocked`
3666
+ # because they resolve differently are all one implementation now, shared
3667
+ # with every other component that has to agree about what may be started.
3668
+ #
3669
+ # ONE CALL PER PLAN, not per wave. The verdicts of a plan's waves are a FOLD:
3670
+ # each depends on whether every wave before it is complete, so the whole
3671
+ # ordered list is the unit the rule takes. That also keeps the cost at one
3672
+ # process per plan on a path the board polls every five seconds.
3673
+ #
3674
+ # A MISSING OR SILENT ARTIFACT REFUSES. There is no shell fallback: a second
3675
+ # implementation kept "just in case" is the duplication this adoption removes,
3676
+ # and it would be the copy nobody tests. `plot-deliver.sh` fails the same way
3677
+ # for the same reason.
3678
+ wave_verdicts=$(printf '%s' "$wave_readings" \
3679
+ | node "$script_dir/board/plot-verdicts.mjs" 2>/dev/null) \
3680
+ || { echo "error: cannot read slice verdicts — run 'pnpm build:board'." >&2; exit 2; }
3681
+ [ "$(printf '%s\n' "$wave_verdicts" | grep -c .)" = "$(printf '%s' "$wave_readings" | grep -c .)" ] \
3682
+ || { echo "error: slice verdicts did not answer for every slice of $plan_base." >&2; exit 2; }
3683
+
3684
+ verdict_i=0
3685
+ for wid in $wave_ids; do
3686
+ verdict_i=$((verdict_i + 1))
3687
+ IFS=$'\t' read -r verdict wave_claimable \
3688
+ <<< "$(printf '%s\n' "$wave_verdicts" | sed -n "${verdict_i}p")"
3689
+ branch_i=0
3690
+ _loose_degraded_branches=$(printf '%s' "$wave_degraded_list" | sed -n "${verdict_i}p")
3691
+ wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $7; exit}')
3692
+ [ "$wname" = "-" ] && wname=""
3115
3693
 
3116
3694
  [ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
3117
3695
  # A degradation that says nothing is indistinguishable from a bug.
@@ -3124,12 +3702,29 @@ for plan in "${plans[@]}"; do
3124
3702
  echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
3125
3703
  fi
3126
3704
  json_branches=""
3127
- while IFS=$'\t' read -r idx br st deferred why nm claim; do
3705
+ # The outlook's reading of this slice, built alongside the render. EVERY
3706
+ # branch including the deferred ones, in the plan's order — the rule needs
3707
+ # `deferred` to tell a branch that will never move from one that has not
3708
+ # moved yet.
3709
+ outlook_branches=""
3710
+ while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
3128
3711
  [ "$idx" = "$wid" ] || continue
3712
+ outlook_branches+="${outlook_branches:+|}$br:$st"
3129
3713
  [ "$claim" = "-" ] && claim=""
3130
3714
  [ "$why" = "-" ] && why=""
3715
+ [ "$waits" = "-" ] && waits=""
3131
3716
  n_branches=$((n_branches + 1))
3132
3717
  case "$st" in
3718
+ # WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
3719
+ # later without saying what would have to happen first, which is the
3720
+ # whole of what this state adds over `open`.
3721
+ waiting) n_waiting=$((n_waiting + 1))
3722
+ note="waiting on $waits" ;;
3723
+ # A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
3724
+ # and answered, because that is what separates this from `waiting`: a
3725
+ # host that could not be asked holds the branch at `waiting` instead.
3726
+ blocked) n_prereq_missing=$((n_prereq_missing + 1))
3727
+ note="blocked — no PR found for $waits" ;;
3133
3728
  # The REASON, where the plan recorded one. A bare `deferred` beside a
3134
3729
  # branch with no commits reads as two unrelated facts when the first is
3135
3730
  # the reason for the second, and the sentence that says so was already
@@ -3138,14 +3733,27 @@ for plan in "${plans[@]}"; do
3138
3733
  claimed) n_claimed=$((n_claimed + 1)); note="claimed${claim:+ ($claim)}" ;;
3139
3734
  merged) note="merged" ;;
3140
3735
  wip) note="in progress" ;;
3736
+ # THE HOST COULD NOT BE ASKED, so nothing is claimed about the PR. The
3737
+ # sentence says which question went unanswered rather than naming a
3738
+ # state, because a reader chasing `open` looks for work that was never
3739
+ # started — the reaper's `unlanded work` made exactly that mistake about
3740
+ # a merged branch, in a claim about CONTENT.
3741
+ unknown) note="unknown — PR could not be read ($HOST_VERDICT host)" ;;
3141
3742
  *) note="open" ;;
3142
3743
  esac
3143
- if [ "$verdict" = "eligible" ] && [ "$st" = "open" ]; then
3744
+ # WHETHER A WORKER MAY TAKE THIS BRANCH the domain's answer, read
3745
+ # positionally from the flags the same call returned. It was
3746
+ # `[ "$verdict" = "eligible" ] && [ "$st" = "open" ]` here, a second
3747
+ # statement of `isClaimable` sitting one loop away from the verdict it
3748
+ # depends on. `--next` acts on this immediately by pushing a ref, so the
3749
+ # two must not be free to disagree.
3750
+ branch_i=$((branch_i + 1))
3751
+ if [ "${wave_claimable:$((branch_i - 1)):1}" = "1" ]; then
3144
3752
  n_eligible=$((n_eligible + 1))
3145
3753
  claimable+=("$br")
3146
3754
  fi
3147
3755
  [ "$quiet" = 1 ] || echo " $br — $note"
3148
- if [ "$as_json" = 1 ]; then
3756
+ if [ "$build_doc" = 1 ]; then
3149
3757
  # The INTERNAL state ($st), never the prose label ($note): the board
3150
3758
  # must not parse a string that exists for humans to read.
3151
3759
  json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
@@ -3154,6 +3762,16 @@ for plan in "${plans[@]}"; do
3154
3762
  # branch is not deferred, and "" where it is deferred with nothing
3155
3763
  # recorded — the flag says which of those two a reader is looking at.
3156
3764
  json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
3765
+ # WHAT THIS BRANCH WAITS ON, straight from the plan's `waits:`
3766
+ # annotation. "" where the branch declares nothing, which is the answer
3767
+ # every branch gave before this field existed.
3768
+ #
3769
+ # THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
3770
+ # says. A branch whose prerequisite has MERGED reports `waits_on` with
3771
+ # its ordinary state — the declaration is still a fact about the plan,
3772
+ # and a reader who sees a cleared dependency learns why the slice is
3773
+ # now startable. Consumers test `state`, never the presence of this.
3774
+ json_branches+=",\"waits_on\":\"$(json_str "$waits")\""
3157
3775
  json_branches+=",\"claimed\":\"$(json_str "$claim")\""
3158
3776
  # What this machine knows and the refs do not. Absent everywhere else:
3159
3777
  # `local_dirty:false` and `local_worktree:""` are what a branch checked
@@ -3333,16 +3951,21 @@ for plan in "${plans[@]}"; do
3333
3951
  fi
3334
3952
  done <<< "$states"
3335
3953
 
3336
- if [ "$as_json" = 1 ]; then
3954
+ if [ "$build_doc" = 1 ]; then
3337
3955
  json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
3338
3956
  json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
3339
3957
  fi
3340
3958
 
3959
+ outlook_lines+="$verdict $outlook_branches"$'\n'
3960
+
3341
3961
  n_waves=$((n_waves + 1))
3342
- [ "$verdict" = "complete" ] || prior_ok=0
3962
+ # `prior_ok` used to be carried here — the ordering half of the verdict,
3963
+ # threaded through the render loop. It moved with the rule: `sliceVerdicts`
3964
+ # folds the plan's waves in order, so nothing downstream has to remember
3965
+ # what the wave before it decided.
3343
3966
  [ "$verdict" = "blocked" ] && n_blocked=$((n_blocked + 1))
3344
3967
  done
3345
- if [ "$as_json" = 1 ]; then
3968
+ if [ "$build_doc" = 1 ]; then
3346
3969
  # ONE composition, two destinations — the property that makes --stream and
3347
3970
  # --json say the same thing rather than agreeing by inspection. A second
3348
3971
  # `printf` shaped like this one would be a second implementation of the
@@ -3362,7 +3985,7 @@ for plan in "${plans[@]}"; do
3362
3985
  # THE STREAM'S POINT: this plan is fully derived, so a consumer can render
3363
3986
  # it now rather than when the eighty-fourth branch resolves. Emitted as one
3364
3987
  # line so a reader can split on newlines without parsing incrementally, and
3365
- # tagged so the terminal `pulse` line cannot be mistaken for another plan.
3988
+ # tagged so the terminal `reading` line cannot be mistaken for another plan.
3366
3989
  #
3367
3990
  # Flushed by `printf` on a line of its own: a consumer reading this stream
3368
3991
  # is reading it BECAUSE the whole document takes 18 s, so buffering the
@@ -3374,6 +3997,37 @@ for plan in "${plans[@]}"; do
3374
3997
  [ "$quiet" = 1 ] || echo
3375
3998
  done
3376
3999
 
4000
+ # --why-nothing: WHICH nothing, for a caller `--next` has just gone silent on.
4001
+ #
4002
+ # IT ANSWERS BEFORE `--next` DOES, and that ordering is the flag's whole
4003
+ # contract. `--why-nothing` sets `next_only` so it walks the same population,
4004
+ # but it must not fall into the block below: that block exits 1 on an empty
4005
+ # `claimable[]`, and a worker reading exit 1 from the flag it asked for a WORD
4006
+ # from learns nothing it did not already know. Measured 2026-09-03 on this
4007
+ # estate: `--why-nothing <slug>` printed nothing and exited 1 for every real
4008
+ # plan, because #642 shipped the parse, the accumulator and the rule and never
4009
+ # wired the emitter on this path. Only the empty-estate arm at ":2827" ever
4010
+ # answered.
4011
+ #
4012
+ # `available` IS REPORTED RATHER THAN HIDDEN. The rule reports it when a
4013
+ # claimable branch exists, which means this scan and the caller's `--next`
4014
+ # disagree — a real state (a branch landed in the seconds between the two
4015
+ # calls) and one a waiting caller should act on rather than sleep through.
4016
+ #
4017
+ # EXIT 0 EITHER WAY, INCLUDING WHEN THE RULE CANNOT BE ASKED. A silent or
4018
+ # missing artifact answers `none`, the same conservative default ":2827" takes:
4019
+ # `none` ends a wait, and a wait that cannot end is the one failure this flag
4020
+ # exists to prevent. That is the opposite of every other caller of
4021
+ # `plot-verdicts.mjs`, which refuses on a missing artifact — those callers
4022
+ # decide what may be STARTED and a wrong answer claims a branch; this one
4023
+ # decides whether a worker sleeps, and the wrong answer is the sleep.
4024
+ if [ "$why_nothing" = 1 ]; then
4025
+ printf '%s' "$outlook_lines" \
4026
+ | node "$script_dir/board/plot-verdicts.mjs" outlook 2>/dev/null \
4027
+ || echo "none"
4028
+ exit 0
4029
+ fi
4030
+
3377
4031
  # --next: name ONE branch a worker may claim, or stay silent with exit 1.
3378
4032
  # "Nothing to start" is a normal state, not a failure — the exit code is what
3379
4033
  # distinguishes it from a name, so callers can branch on it without parsing.
@@ -3393,7 +4047,7 @@ fi
3393
4047
  # pulse re-derives everything from git.
3394
4048
  if [ "$log_pulse" = 1 ]; then
3395
4049
  stamp=$(date -u +%Y-%m-%dT%H:%MZ)
3396
- line="<!-- pulse: $stamp — waves=$n_waves eligible=$n_eligible claimed=$n_claimed blocked=$n_blocked deferred=$n_deferred -->"
4050
+ line="<!-- pulse: $stamp — waves=$n_waves eligible=$n_eligible claimed=$n_claimed blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing -->"
3397
4051
  for pf in ${plan_files[@]+"${plan_files[@]}"}; do
3398
4052
  real=$(cd "$(dirname "$pf")" && readlink "$(basename "$pf")" 2>/dev/null || true)
3399
4053
  target=$([ -n "$real" ] && echo "$(dirname "$pf")/$real" || echo "$pf")
@@ -3409,28 +4063,123 @@ if [ "$log_pulse" = 1 ]; then
3409
4063
  done
3410
4064
  fi
3411
4065
 
3412
- # --json: the same derivation as the prose above, rendered for machines. It is
3413
- # an OUTPUT MODE and nothing more — it composes with --offline/--no-fetch/
3414
- # --loose rather than implying any of them, so the board's data depends on what
3415
- # it asked for, not on how it asked. --next wins over it (handled above): that
3416
- # is a different question with a one-line answer.
3417
- if [ "$as_json" = 1 ]; then
3418
- # --stream wraps the SAME document in one tagged line rather than emitting a
3419
- # second, smaller one. The terminal object is what proves the scan finished:
3420
- # a consumer that has seen `plan` lines and no `pulse` line has a PARTIAL
3421
- # answer and must say so — which is the whole distinction this mode adds, and
3422
- # the reason the end is marked rather than inferred from the pipe closing.
3423
- # A killed scan closes the pipe too.
3424
- [ "$stream" = 1 ] && printf '{"kind":"pulse","pulse":'
4066
+ # THE PULSE RECORDS ITSELF `.plot/state/last-pulse.json`, the bridge.
4067
+ #
4068
+ # WHY THE SCAN AND NOT THE BOARD. `fleet.ts:2804` was the ONLY writer until
4069
+ # 2026-09-06, and this script named the file zero times so `/plot-pulse` in a
4070
+ # repository with no board had nothing to diff against, and every pulse was a
4071
+ # first one forever. `DESIGN-process.md` §1 requires the fleet to work with no
4072
+ # board at all. The scan produces the pulse; the component that produces one
4073
+ # records it. The board's write is not removed and becomes redundant: the board
4074
+ # spawns THIS script, so a scan that writes the bridge writes it on the board's
4075
+ # path too, from inside the same run.
4076
+ #
4077
+ # INSIDE THE SUCCESS PATH, WHICH IS THE PROPERTY THAT HAD TO SURVIVE THE MOVE.
4078
+ # `fleet.ts:2800`: *"A scan that failed must not overwrite the last good answer
4079
+ # — the only thing standing between a `--watch` restart and an empty board."*
4080
+ # So this is called where the scan has finished deriving and is about to say so,
4081
+ # never from a trap and never at exit. A killed scan leaves the previous file
4082
+ # whole.
4083
+ #
4084
+ # THE FORMAT IS `pulse-bridge.ts`'s AND EVERY FIELD HERE IS ITS REQUIREMENT.
4085
+ # `version` must equal `BRIDGE_VERSION` or `:193` returns null and the board
4086
+ # renders an empty page with no error — a silent failure, which is why the
4087
+ # board's own test reads a file this script wrote rather than one a fixture
4088
+ # invented. `at` is epoch MILLISECONDS and is checked against
4089
+ # `BRIDGE_MAX_AGE_MS` (15 min), and a file from the future is rejected outright
4090
+ # rather than clamped, so the clock must be the same one the board reads.
4091
+ #
4092
+ # THE FOUR MAPS ARE EMPTY AND THAT IS HONEST. `ages`, `approvedAt` and
4093
+ # `ideaPlans` are computed by the board on its own timers, and `branchUrlBase`
4094
+ # comes from its settings; the scan knows none of them. `readBridge` rebuilds
4095
+ # each with `toMap`, which yields an empty Map for anything it cannot use, and
4096
+ # `branchUrlBase` falls back to `''`. So a scan-written bridge serves the rows,
4097
+ # the verdicts and the counts, with unknown ages and no branch links — degraded
4098
+ # in the direction the board already handles, and overwritten by the board's own
4099
+ # richer write seconds later on the refresh it always issues.
4100
+ #
4101
+ # TEMP FILE PLUS RENAME, carrying the pid, exactly as `writeBridge` does and for
4102
+ # the same reason: `rename` is atomic within a filesystem, so a board reading
4103
+ # while this writes sees the old file whole or the new file whole. The pid is in
4104
+ # the temp name because two scans on one repo — routine here — must not collide
4105
+ # on one temp file and hand the reader the torn payload the rename exists to
4106
+ # prevent.
4107
+ #
4108
+ # EVERY FAILURE IS SWALLOWED, the rule `writeBridge` states: a read-only
4109
+ # checkout, a full disk, a `.plot` nobody may write to. None of that is a reason
4110
+ # for a pulse to fail, and the cost of the miss is exactly the behaviour before
4111
+ # this existed.
4112
+ #
4113
+ # IT IS A CACHE WITH AN EXPIRY, NEVER A RECORD. Deleting it changes no
4114
+ # behaviour, because the next pulse re-derives everything — which is what keeps
4115
+ # this inside the script's stateless design rather than beside it.
4116
+ # The previous pulse's document, read BEFORE `write_bridge` replaces it.
4117
+ #
4118
+ # EMPTY MEANS NO FILE, and that is a fact about the FILE rather than about its
4119
+ # contents — it is what separates a first run from a history that exists and
4120
+ # cannot be used. A file that is present but truncated, or written in a shape
4121
+ # this build does not know, arrives here as its own bytes and the renderer
4122
+ # decides; a file that was never there arrives as "".
4123
+ #
4124
+ # READ ONCE, EARLY, because `write_bridge` overwrites it on the success path and
4125
+ # the delta compares against what was there when this scan started.
4126
+ PREVIOUS_PULSE=""
4127
+ read_previous_pulse() {
4128
+ local root file
4129
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
4130
+ [ -n "$root" ] || return 0
4131
+ file="$root/.plot/state/last-pulse.json"
4132
+ [ -f "$file" ] || return 0
4133
+ # A HUGE OR UNREADABLE FILE COSTS THE DELTA, NEVER THE PULSE. Every failure
4134
+ # here leaves PREVIOUS_PULSE empty, which reads as a first run — the same
4135
+ # answer the scan gave before this existed.
4136
+ PREVIOUS_PULSE=$(cat "$file" 2>/dev/null | tr -d '\n') || PREVIOUS_PULSE=""
4137
+ return 0
4138
+ }
4139
+
4140
+ write_bridge() {
4141
+ [ "$record" = 1 ] || return 0
4142
+ [ -n "$reading_doc" ] || return 0
4143
+ # The bridge belongs to the REPOSITORY, not to the directory the scan was run
4144
+ # from. Every other path here is relative to the cwd because the board spawns
4145
+ # this from the root; the board reads the bridge at `repoRoot`, so this asks
4146
+ # git rather than assuming the two agree.
4147
+ local root file tmp
4148
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
4149
+ [ -n "$root" ] || return 0
4150
+ file="$root/.plot/state/last-pulse.json"
4151
+ mkdir -p "$root/.plot/state" 2>/dev/null || return 0
4152
+ tmp="$file.$$.tmp"
4153
+ # `at` is when the scan COMPLETED, which is what `pulse-bridge.ts:81` asks for
4154
+ # — "NOT when it was written" — and the two are the same instant here.
4155
+ printf '{"version":1,"at":%s,"pulse":%s,"ages":[],"branchUrlBase":"","approvedAt":[],"ideaPlans":[]}' \
4156
+ "$(( $(date +%s) * 1000 ))" "$reading_doc" > "$tmp" 2>/dev/null || {
4157
+ rm -f "$tmp" 2>/dev/null
4158
+ return 0
4159
+ }
4160
+ mv -f "$tmp" "$file" 2>/dev/null || rm -f "$tmp" 2>/dev/null
4161
+ return 0
4162
+ }
4163
+
4164
+ # THE READING, COMPOSED ONCE. Two consumers read it — `--json`/`--stream` print
4165
+ # it, and `write_bridge` records it — and composing it twice is how the printed
4166
+ # document and the recorded one start to disagree. Assembled only when
4167
+ # `build_doc` is on, because the branch objects inside `$json_plans` cost
4168
+ # `merge-tree` per unlanded branch.
4169
+ if [ "$build_doc" = 1 ]; then
4170
+ # BEFORE THE DOCUMENT IS COMPOSED AND LONG BEFORE IT IS WRITTEN. The delta
4171
+ # compares against the pulse that was on disk when this scan started, and
4172
+ # `write_bridge` replaces that file on the success path below.
4173
+ read_previous_pulse
3425
4174
  # `read_ref` is the ref this document was derived from; `local_head` is the
3426
4175
  # checkout it was derived ON. A consumer needs both to tell "the board is
3427
4176
  # current" from "the board is current about an old world".
3428
4177
  #
3429
4178
  # `head` repeats `local_head` as an alias for one release. The board reads it
3430
4179
  # today; it goes away once the board reads the pair.
3431
- printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
4180
+ reading_doc=$(printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
3432
4181
  "$(json_str "$MAIN")" "$(json_str "$READ_REF")" "$(json_str "$LOCAL_HEAD")" \
3433
- "$(json_str "$HEAD_SHORT")"
4182
+ "$(json_str "$HEAD_SHORT")")
3434
4183
  # Three more facts about the EVIDENCE, not about the fleet — a consumer that
3435
4184
  # renders the numbers below should be able to say how much to trust them.
3436
4185
  # They answer the question `read_ref` raises: that field names the ref, and
@@ -3439,13 +4188,42 @@ if [ "$as_json" = 1 ]; then
3439
4188
  # `fetch_failed` used to be discarded by `2>/dev/null`, so refs an hour old
3440
4189
  # were reported with the confidence of refs a second old. `plan_source` says
3441
4190
  # whether the plan list came from the ref or fell back to this checkout.
3442
- printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
4191
+ reading_doc+=$(printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
3443
4192
  "$([ "$FETCH_FAILED" = 1 ] && echo true || echo false)" \
3444
- "$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans"
3445
- printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
3446
- "$n_plans" "$n_waves" "$n_branches" "$n_claimed"
3447
- printf '"eligible":%d,"blocked":%d,"deferred":%d,"merge_detect":"%s"}}' \
3448
- "$n_eligible" "$n_blocked" "$n_deferred" "$MERGE_DETECT"
4193
+ "$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans")
4194
+ reading_doc+=$(printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
4195
+ "$n_plans" "$n_waves" "$n_branches" "$n_claimed")
4196
+ # `host` is the EVIDENCE field beside merge_detect, and it is the one that
4197
+ # says whether merge_detect can be believed. Rendered for the machine here
4198
+ # and in the footer for a human; the board reads this rather than parsing
4199
+ # the prose, the rule every other field follows.
4200
+ # `waiting` and `prereq_missing` count BRANCHES, where `blocked` above counts
4201
+ # WAVES. Two vocabularies share the word `blocked` and the footer must not:
4202
+ # a consumer adding the three would double-count nothing, because no branch
4203
+ # is in both and no wave is in either.
4204
+ reading_doc+=$(printf '"eligible":%d,"blocked":%d,"deferred":%d,"waiting":%d,"prereq_missing":%d,"merge_detect":"%s","host":"%s"}}' \
4205
+ "$n_eligible" "$n_blocked" "$n_deferred" "$n_waiting" "$n_prereq_missing" \
4206
+ "$MERGE_DETECT" "$HOST_VERDICT")
4207
+ fi
4208
+
4209
+ # --json: the same derivation as the prose above, rendered for machines. It is
4210
+ # an OUTPUT MODE and nothing more — it composes with --offline/--no-fetch/
4211
+ # --loose rather than implying any of them, so the board's data depends on what
4212
+ # it asked for, not on how it asked. --next wins over it (handled above): that
4213
+ # is a different question with a one-line answer.
4214
+ if [ "$as_json" = 1 ]; then
4215
+ # THE SCAN COMPLETED, so the bridge may be replaced. Before the document is
4216
+ # printed rather than after: a consumer that reads the terminal line and then
4217
+ # kills us must still find the file written.
4218
+ write_bridge
4219
+ # --stream wraps the SAME document in one tagged line rather than emitting a
4220
+ # second, smaller one. The terminal object is what proves the scan finished:
4221
+ # a consumer that has seen `plan` lines and no `reading` line has a PARTIAL
4222
+ # answer and must say so — which is the whole distinction this mode adds, and
4223
+ # the reason the end is marked rather than inferred from the pipe closing.
4224
+ # A killed scan closes the pipe too.
4225
+ [ "$stream" = 1 ] && printf '{"kind":"reading","reading":'
4226
+ printf '%s' "$reading_doc"
3449
4227
  [ "$stream" = 1 ] && printf '}'
3450
4228
  printf '\n'
3451
4229
  exit 0
@@ -3459,6 +4237,35 @@ if [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
3459
4237
  echo " note: merge scan hit its limit of $MERGE_SCAN_LIMIT — older merges were not"
3460
4238
  echo " examined; a branch merged before that point may still read as open."
3461
4239
  fi
4240
+ # A HOST THAT COULD NOT BE ASKED SAYS SO, and says what to do about it.
4241
+ #
4242
+ # `pr-list` is ONE GraphQL call in place of ~186 REST calls — a deliberate and
4243
+ # good trade whose consequence is that throttling takes out EVERY PR answer at
4244
+ # once rather than degrading row by row. So the whole fleet reads unmerged,
4245
+ # every wave stays blocked, and the board shows a busy estate with nothing
4246
+ # eligible: indistinguishable from work genuinely in flight, which is why this
4247
+ # has to be stated rather than left for a reader to infer from a quiet report.
4248
+ #
4249
+ # THE THREE WORDS GET DIFFERENT ADVICE because they need different responses.
4250
+ # A spent quota returns at the reset, minutes away; a secondary limit clears in
4251
+ # seconds and is fixed by running fewer calls at once; an outage clears when
4252
+ # somebody looks at it.
4253
+ if [ "$HOST_VERDICT" = throttled ]; then
4254
+ echo " note: the git host's rate limit was spent, so no PR could be read. Every"
4255
+ echo " branch below reads from local evidence alone — a merged branch whose"
4256
+ echo " ref was deleted reads 'unknown', never 'open', and none was offered"
4257
+ echo " to --next. The budget refills on a clock; re-run in a few minutes."
4258
+ elif [ "$HOST_VERDICT" = secondary ]; then
4259
+ echo " note: the git host refused a burst, not a spent budget, so no PR could be"
4260
+ echo " read. Every branch below reads from local evidence alone and none"
4261
+ echo " was offered to --next. This clears in seconds — re-run shortly, and"
4262
+ echo " run fewer scans at once rather than waiting for a reset."
4263
+ elif [ "$HOST_VERDICT" = failed ]; then
4264
+ echo " note: the git host could not be reached, so no PR could be read. Every"
4265
+ echo " branch below reads from local evidence alone, and a branch whose"
4266
+ echo " PR is unknown reads 'unknown' rather than 'open'. This is not a"
4267
+ echo " rate limit — waiting will not clear it; check the host and auth."
4268
+ fi
3462
4269
  # A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
3463
4270
  # hour-old refs read exactly like a scan of current ones — the same
3464
4271
  # over-confidence, one layer up, that this plan fixes in the plan list.
@@ -3492,5 +4299,33 @@ if [ "$PLAN_SOURCE" != "ref" ]; then
3492
4299
  echo " note: origin/$MAIN could not be read — plans were listed from this"
3493
4300
  echo " checkout instead, so the list is only as current as your last pull."
3494
4301
  fi
4302
+ # THE PROSE PATH'S TERMINAL POINT, and the counterpart to the `--json` write
4303
+ # above. `/plot-pulse` runs this path — it passes `--log-pulse` and no `--json`
4304
+ # — so without a write here the boardless repository the plan names would still
4305
+ # accumulate no history. A no-op unless `--log-pulse` turned assembly on.
4306
+ #
4307
+ # Below every `note:` the report emits and above the sentence that says the scan
4308
+ # finished: a scan killed while printing its notes has not completed, and must
4309
+ # not replace the last good answer.
4310
+ # WHAT CHANGED SINCE THE LAST PULSE, above the line that says this one
4311
+ # finished. The rule is `pulseDelta` in the domain and this prints what it
4312
+ # decided — no comparison happens here, and one written here would be the
4313
+ # second implementation of a rule that already has tests.
4314
+ #
4315
+ # ONLY WHERE A PULSE WAS PRODUCED FOR SOMEBODY TO READ. `build_doc` is what says
4316
+ # the reading was assembled at all, and `record` is what says this run is one of
4317
+ # the two that produce a pulse rather than answer a query. A `--next` caller
4318
+ # asking what to work on is told nothing new.
4319
+ #
4320
+ # EVERY FAILURE IS SILENT AND COSTS ONLY THE DELTA. A missing artifact, an
4321
+ # unreadable previous pulse, a node that will not start — the full report below
4322
+ # is exactly what it was before this existed, which is the degradation this
4323
+ # whole line is optional against.
4324
+ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
4325
+ delta_out=$(printf '%s\n%s\n' "$PREVIOUS_PULSE" "$reading_doc" \
4326
+ | node "$script_dir/board/plot-delta.mjs" 2>/dev/null) || delta_out=""
4327
+ [ -n "$delta_out" ] && { printf '%s\n' "$delta_out"; echo; }
4328
+ fi
4329
+ write_bridge
3495
4330
  echo "Pulse complete. This report is derived — nothing was changed."
3496
- echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred merge_detect=$MERGE_DETECT main=$MAIN"
4331
+ echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing merge_detect=$MERGE_DETECT host=$HOST_VERDICT main=$MAIN"