@plot-pm/board 0.9.1 → 0.11.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
@@ -159,6 +213,7 @@ cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
159
213
  do_fetch=1
160
214
  next_only=0
161
215
  list_all=0
216
+ why_nothing=0
162
217
  loose=0
163
218
  log_pulse=0
164
219
  as_json=0
@@ -171,6 +226,11 @@ while [ $# -gt 0 ]; do
171
226
  --log-pulse) log_pulse=1 ;;
172
227
  --next) next_only=1 ;;
173
228
  --list-eligible) next_only=1; list_all=1 ;;
229
+ # THE SECOND QUESTION, and it borrows `--next`'s population deliberately.
230
+ # A caller asks this having just been told nothing, so it must be told
231
+ # about the SAME plans `--next` was silent over — a terminal plan admitted
232
+ # here would answer `not-yet` about work somebody decided was not needed.
233
+ --why-nothing) next_only=1; why_nothing=1 ;;
174
234
  --json) as_json=1 ;;
175
235
  --stream) as_json=1; stream=1 ;;
176
236
  -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
@@ -390,8 +450,39 @@ fi
390
450
  # host would be lying in the direction of a slow ambient pulse. Without a
391
451
  # backend — or offline — the lookup is simply never attempted and every branch
392
452
  # answers exactly as it did before.
453
+ #
454
+ # THE OFFER IS THE EXCEPTION, AND IT IS NARROW. `--next` and `--list-eligible`
455
+ # do not report an estate; they name a branch a worker is about to CLAIM by
456
+ # pushing a ref. That question cannot be answered from refs alone, because the
457
+ # fact it turns on — did this branch's PR merge — is one only the host holds
458
+ # once a squash merge has rewritten the commits and `--delete-branch` has taken
459
+ # the ref away.
460
+ #
461
+ # WHAT `--offline` ACTUALLY PROMISES IS NO FETCH. Everything else followed from
462
+ # `do_fetch` because the two were one flag, and that conflation is the defect.
463
+ # Measured 2026-09-04 on this estate, from `plot-worker-loop.sh`'s own call:
464
+ #
465
+ # plot-fleet-scan.sh --offline --next the-domain-owns-the-agent-lifecycle
466
+ # → feature/an-agent-declares-what-it-is
467
+ #
468
+ # whose PR #679 merged at 20:46 the previous evening. The same scan WITHOUT
469
+ # `--offline` read that branch `merged`, its slice `complete`, and named
470
+ # nothing. Five of that plan's eight branches moved between the two readings.
471
+ # Ten refs across four days carry the tip commit `plot: claim <branch>` dated
472
+ # hours AFTER their own merge — this offer is what pushed every one of them.
473
+ #
474
+ # THE AMBIENT PULSE IS UNTOUCHED. `--offline` without `--next` still asks
475
+ # nothing, so the 5 s board poll and the cheap operator glance keep the cost
476
+ # they were given the flag for. The call is one repo-wide `pr-list`, already
477
+ # cached per run, and it is paid only by a caller asking to be handed work —
478
+ # which is about to spend a worker on the answer.
479
+ #
480
+ # NO BACKEND STILL MEANS NO LOOKUP. A repo with no git host has no merge state
481
+ # to withhold on, and refusing there would stop `--next` in every fixture and
482
+ # every hostless checkout. The `backend` test below is what keeps that case
483
+ # working, and it is the only condition the offer path drops.
393
484
  HOST_LOOKUP_OK=0
394
- if [ "$do_fetch" = 1 ] \
485
+ if { [ "$do_fetch" = 1 ] || [ "$next_only" = 1 ]; } \
395
486
  && [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
396
487
  HOST_LOOKUP_OK=1
397
488
  fi
@@ -482,10 +573,36 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
482
573
  # already performs on Bitbucket, so the join and the per-branch lookup cannot
483
574
  # disagree about the same branch.
484
575
  PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
576
+
577
+ # WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
578
+ # computed and threw away until 2026-08-30.
579
+ #
580
+ # ok — the list arrived. An EMPTY list is `ok`: the host answered and
581
+ # there are none. Collapsing that into a failure would trade a
582
+ # silent wrong answer for a noisy one and break every repo that
583
+ # genuinely has no PRs.
584
+ # throttled — a SPENT QUOTA (`plot-host.sh` exit 5). Nothing is broken; the
585
+ # same question after the reset will be answered.
586
+ # secondary — a burst refusal (`plot-host.sh` exit 6). Nothing is broken
587
+ # either, and it clears in seconds rather than minutes.
588
+ # failed — any other failure (exit 3, or anything unclassified).
589
+ # unasked — no host to ask, or --offline WITHOUT `--next`. Not a
590
+ # degradation: the scan was never going to ask, and saying
591
+ # `failed` would report a fault where there is a configuration.
592
+ # `--offline --next` DOES ask, so it reaches one of the words
593
+ # above rather than this one.
594
+ #
595
+ # THREE WORDS FOR THREE FAILURES BECAUSE THEY ASK FOR DIFFERENT RESPONSES.
596
+ # `throttled` says wait for the reset; `secondary` says retry shortly and run
597
+ # fewer at once; `failed` says look. An operator told to wait out an outage
598
+ # loses exactly the time waiting was meant to save — and one told to wait out a
599
+ # secondary limit waits minutes for a ceiling that cleared in seconds.
600
+ HOST_VERDICT=unasked
601
+
485
602
  prefill_pr_states() {
486
603
  [ "$HOST_LOOKUP_OK" = 1 ] || return 0
487
604
  [ -n "$HOST_STATE_CACHE" ] || return 0
488
- local js br st key
605
+ local js br st key rc
489
606
  # Exit code first: non-zero is a transport failure and its stdout is not an
490
607
  # answer. A failed list leaves the cache EMPTY, so every branch falls through
491
608
  # to the unanswerable `-` rather than to a fabricated "no PR".
@@ -497,8 +614,91 @@ prefill_pr_states() {
497
614
  # configured), and the parsing below already skips fields the response does
498
615
  # not contain. The BEHAVIOUR change is in `pr_ready`, which now reads the
499
616
  # 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
617
+ #
618
+ # THE CODE IS KEPT, not just tested. This guard was always correct — a failed
619
+ # list prefills nothing — and until 2026-08-30 it never fired, because
620
+ # `pr-list` swallowed its own failure and exited 0 with empty stdout. Now
621
+ # that it can fail, WHICH failure it was is a fact worth carrying: exit 5 is
622
+ # a rate limit and exit 3 is anything else, and the summary reports the
623
+ # difference rather than degrading silently.
624
+ # STDOUT TO A FILE so stderr can be captured separately — see the verdict
625
+ # below. It lives in `HOST_STATE_CACHE`, which already has an EXIT trap, so
626
+ # this adds no second cleanup path. When mktemp -d failed the cache is "",
627
+ # and /dev/null keeps the call working with the text simply unavailable.
628
+ host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
629
+ host_list_out="${host_list_out:-/dev/null}"
630
+ host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
631
+ </dev/null 2>&1 >"$host_list_out"); rc=$?
632
+ js=$(cat "$host_list_out" 2>/dev/null)
633
+ if [ "$rc" -ne 0 ]; then
634
+ # THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
635
+ # never put" (see HOST_VERDICT above: *not a degradation, the scan was
636
+ # never asking*), and a host that cannot be ASKED AT ALL belongs there
637
+ # rather than among answers that failed to arrive.
638
+ #
639
+ # THE EXIT CODE CANNOT SEPARATE THEM. `plot-host.sh:849` is explicit
640
+ # that a missing token is *"a CONFIG error the op cannot proceed past
641
+ # — exit 3, never"* exit 4, so an unauthenticated CLI and a genuine
642
+ # mid-answer failure arrive with the SAME status. The stderr text is
643
+ # the only thing that distinguishes them, and it is already in hand:
644
+ # a second probe call would cost another fork for a fact this one
645
+ # already produced.
646
+ #
647
+ # WHY IT MATTERS: `failed` makes every branch `unknown`, and `unknown`
648
+ # is not startable — so a checkout with no host credentials reports an
649
+ # estate on which nothing can be started. Measured 2026-08-31:
650
+ # `packages/board/test/claimed.test.mjs`, whose header says it *"never
651
+ # touches a network or a git host"*, read `eligible: 0` where it had
652
+ # always read 1. The branch was startable; the scan had stopped being
653
+ # able to say so.
654
+ case "$rc" in
655
+ 5) HOST_VERDICT=throttled ;;
656
+ 6) HOST_VERDICT=secondary ;;
657
+ 4) HOST_VERDICT=unasked ;;
658
+ *) case "$host_err" in
659
+ # THE WORDING IS MEASURED, NOT GUESSED. An earlier version of this
660
+ # list matched auth/login/credential and MISSED the message CI
661
+ # actually emits:
662
+ #
663
+ # "gh: To use GitHub CLI in a GitHub Actions workflow, set the
664
+ # GH_TOKEN environment variable."
665
+ #
666
+ # which contains none of those words. The fix passed locally — where
667
+ # `gh` is authenticated and this branch never runs — and failed on
668
+ # every CI run. Measured 2026-08-31 against a stubbed `gh`.
669
+ #
670
+ # So the patterns name what the CLIs SAY when they have no identity:
671
+ # a token, a login, a credential, or authentication. Anything else is
672
+ # a real failure and stays `failed`, because widening this to a
673
+ # catch-all would turn every host outage into "nobody asked".
674
+ *TOKEN*|*token*|*auth*|*Auth*|*AUTH*|*login*|*Login*|*credential*|*Credential*|*"not logged"*)
675
+ HOST_VERDICT=unasked ;;
676
+ # NO REMOTE IS A CONFIGURATION, NOT A FAULT — the same reading as a
677
+ # missing token one line up, reached by the same route: `plot-host.sh`
678
+ # exits 3 for both, because both are "the op cannot proceed", and
679
+ # only the text separates them.
680
+ #
681
+ # `backend` ANSWERS `github` IN A REPO WITH NO REMOTE, which is what
682
+ # makes this reachable at all: the key is read from config, and a
683
+ # repo can name a host it has no repository on. So the backend test
684
+ # that gates the lookup cannot catch this case, and the failure
685
+ # arrives here instead.
686
+ #
687
+ # Measured 2026-09-04 against `test/reconcile/dispatch.test.mjs`'s
688
+ # `noRemote` fixture: `--list-eligible` withheld a branch nobody had
689
+ # started, because a host that does not exist read as one that
690
+ # failed. `failed` makes every branch `unknown` and `unknown` is not
691
+ # claimable — the right refusal about the wrong thing. There is no
692
+ # merge state to withhold on where there is no remote to hold it.
693
+ *"no git remotes"*|*"no remote"*)
694
+ HOST_VERDICT=unasked ;;
695
+ *) HOST_VERDICT=failed ;;
696
+ esac ;;
697
+ esac
698
+ return 0
699
+ fi
700
+ # The list arrived. An empty one arrived too — that is the whole distinction.
701
+ HOST_VERDICT=ok
502
702
  # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
503
703
  # that is a correctness-of-cost property rather than a style preference:
504
704
  # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
@@ -883,6 +1083,66 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
883
1083
  case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
884
1084
  }
885
1085
 
1086
+ # ---------------------------------------------------------------------------
1087
+ # A SLICE THAT WAITS ON ANOTHER PLAN'S BRANCH
1088
+ # ---------------------------------------------------------------------------
1089
+ #
1090
+ # Slice eligibility is computed PER PLAN, so nothing here compares two plans —
1091
+ # and that is the defect this answers. Measured 2026-09-01, twice in one
1092
+ # session: the fleet offered a branch as claimable whose prerequisite lived in
1093
+ # a different plan and had not merged, and the only thing between a worker and
1094
+ # premature work was a paragraph in a brief.
1095
+ #
1096
+ # THE RUN-SCOPED CACHE, NEVER `terminal_cached`. There are two caches and only
1097
+ # one can answer this. `terminal_cached` is keyed by `TERMINAL_PLAN_OID` — the
1098
+ # plan revision the answer was derived under — and returns nothing unless that
1099
+ # oid is the plan CURRENTLY being walked. A prerequisite belongs to another plan
1100
+ # by construction, so its entry can never validate. `HOST_STATE_CACHE` is keyed
1101
+ # by branch name alone through `cache_key`, which makes it the only cache whose
1102
+ # key this question can form. `host_pr_state` reads and writes it before asking
1103
+ # the host, so a prerequisite the run visits for its own plan costs NOTHING the
1104
+ # second time.
1105
+ #
1106
+ # SO THE COST IS BOUNDED AND STATED: free where the prerequisite is visited
1107
+ # elsewhere in the run, ONE host call per run otherwise — never one per pass.
1108
+ #
1109
+ # THE QUESTION IS ASKED OF THE HOST, NOT OF THE REFS, and an earlier draft of
1110
+ # this rule would have deadlocked on SUCCESS. `plot-release-refs.sh` deletes the
1111
+ # remote refs of a delivered plan's merged branches, so a prerequisite that
1112
+ # COMPLETED eventually has no ref — and "no ref → blocked" would hold the
1113
+ # waiting slice forever because its dependency succeeded. A merged PR outlives
1114
+ # the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
1115
+ # refs, and why this reads the same source.
1116
+ #
1117
+ # THREE ANSWERS, and the third is the one that must not be collapsed into the
1118
+ # second:
1119
+ #
1120
+ # | host says | answer | meaning |
1121
+ # |------------------|-----------|------------------------------------------|
1122
+ # | MERGED | "" | cleared — the annotation stops mattering |
1123
+ # | OPEN or CLOSED | waiting | a wait with an end |
1124
+ # | NONE | blocked | no PR ever existed: a typo |
1125
+ # | `-` (unreachable)| waiting | silence is not evidence, in EITHER |
1126
+ # | | | direction: not permission to start, and |
1127
+ # | | | not proof of a typo |
1128
+ #
1129
+ # A CLOSED, UNMERGED PR IS `waiting` RATHER THAN `blocked`: the host has seen
1130
+ # the branch, so nothing is misspelled — somebody withdrew the work, and that
1131
+ # resolves by reopening it, not by editing the plan.
1132
+ waits_state() { # $1=prerequisite branch → "waiting" | "blocked" | ""
1133
+ local st
1134
+ # `--ask` because the prerequisite is precisely the branch the repo-wide list
1135
+ # may legitimately omit: its plan may be delivered and its ref gone. The
1136
+ # bound is the same one PR #216 set — ABSENT branches, not all branches — and
1137
+ # the cache above keeps it at one call per run.
1138
+ st=$(host_pr_state "$1" --ask)
1139
+ case "$st" in
1140
+ MERGED) printf '' ;;
1141
+ NONE) printf 'blocked' ;;
1142
+ *) printf 'waiting' ;;
1143
+ esac
1144
+ }
1145
+
886
1146
  # Modification time of a path, in epoch seconds, following symlinks — or "" when
887
1147
  # it cannot be read.
888
1148
  #
@@ -2401,9 +2661,15 @@ for line in sys.stdin:
2401
2661
  # run of tabs collapses to one separator and only the LAST field
2402
2662
  # may be optional. "-" stands in for empty everywhere, so no run
2403
2663
  # can form.
2664
+ # THE PREREQUISITE, from the `waits_on` key the parser emits —
2665
+ # never re-parsed from the annotation. The key is ABSENT on a branch that
2666
+ # declares nothing (`plot-plan-meta.sh` promises "a branch name or
2667
+ # nothing — never a blank string"), and "-" stands in here for the
2668
+ # same tab-collapse reason every other middle column does.
2404
2669
  print("\t".join(clean(x) for x in [
2405
2670
  "W", f, str(i), ref, str(b.get("deferred")).lower(),
2406
2671
  (b.get("deferred_reason") or "-"),
2672
+ (b.get("waits_on") or "-"),
2407
2673
  name or "-", b.get("claimed") or "-"]))
2408
2674
  ' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
2409
2675
 
@@ -2617,13 +2883,18 @@ if [ ${#plans[@]} -eq 0 ]; then
2617
2883
  # the same answer whether the plans are all claimed or there are no plans at
2618
2884
  # all. Exiting 0 here would hand a caller an EMPTY branch name as if it were
2619
2885
  # valid work.
2886
+ # `--why-nothing` answers here too, and it answers `none`. No plans means no
2887
+ # slice can ever become eligible, which is the honest end of a worker's life
2888
+ # — and a wait with nothing to wait on is the one failure this flag exists to
2889
+ # prevent.
2890
+ [ "$why_nothing" = 1 ] && { echo "none"; exit 0; }
2620
2891
  [ "$next_only" = 1 ] && exit 1
2621
2892
  # A MACHINE CONSUMER FALLS THROUGH. An empty estate is a COMPLETE answer, and
2622
2893
  # this branch used to end the run before the emitter — so `--json` and
2623
2894
  # `--stream` were ignored entirely here and a consumer got human prose on
2624
- # stdout. Under `--stream` that meant no terminal `pulse` line, and the
2895
+ # stdout. Under `--stream` that meant no terminal `reading` line, and the
2625
2896
  # 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
2897
+ # `reading` line has a PARTIAL answer and must say so"*) made it report a
2627
2898
  # complete answer as a scan failure — forever, because the next scan said the
2628
2899
  # same. Measured 2026-08-28 against a board installed from npm: *"fleet scan
2629
2900
  # ended without a terminal pulse line"*, `ready:false`, every pulse.
@@ -2638,7 +2909,7 @@ if [ ${#plans[@]} -eq 0 ]; then
2638
2909
  # the scan globbed it; pointing a reader at the index would now send them to
2639
2910
  # look for the cause of an empty list in a directory nothing consults.
2640
2911
  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"
2912
+ echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 waiting=0 prereq_missing=0 main=$MAIN"
2642
2913
  exit 0
2643
2914
  fi
2644
2915
  fi
@@ -2766,6 +3037,37 @@ branch_state() {
2766
3037
  # a miss, a CLOSED PR, or a host that cannot answer all fall through to the
2767
3038
  # `open` below, exactly as before this call existed.
2768
3039
  merged_by_host "$br" && { echo "merged"; return; }
3040
+ # `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
3041
+ # With no ref, the host is the only remaining source, so when it could not
3042
+ # be asked that claim was never earned — and the branch measured on
3043
+ # 2026-08-30 was merged while reading `open`, counted among the unfinished
3044
+ # under `merge_detect=pr-merge`, which reads as *asked and answered*.
3045
+ #
3046
+ # `unknown` IS OUTSTANDING, exactly as `open` is — the `*)` arm of the wave
3047
+ # arithmetic counts both, so no wave verdict moves and the degradation
3048
+ # direction is untouched: an unreachable host still answers *not merged*,
3049
+ # and silence is still never permission.
3050
+ #
3051
+ # WHAT IT DOES CHANGE IS CLAIMABILITY, and that is the fix rather than a
3052
+ # side effect. `--next` offers branches whose state is `open`, so an
3053
+ # `unknown` branch is not handed out — which is right, because "nobody has
3054
+ # started this" is precisely the claim that went unverified. Handing out a
3055
+ # merged branch is what actually happened.
3056
+ #
3057
+ # GATED ON THE THREE FAILURES ONLY, never on "not ok". `unasked` — no host
3058
+ # configured, or --offline — must keep reading `open`: the scan was never
3059
+ # going to ask, so nothing was lost, and flipping every unstarted branch to
3060
+ # `unknown` on every offline scan would be a far larger change than the
3061
+ # defect. A question that was not put is not a question that went
3062
+ # unanswered.
3063
+ #
3064
+ # `secondary` GATES LIKE THE OTHER TWO, and its faster recovery is no reason
3065
+ # to exempt it: the question was PUT and went unanswered, so this scan has
3066
+ # no more evidence than a throttled one does. What the two limits differ in
3067
+ # is what to DO about it, which is the note below and not this branch.
3068
+ case "$HOST_VERDICT" in
3069
+ throttled|secondary|failed) echo "unknown"; return ;;
3070
+ esac
2769
3071
  echo "open"; return
2770
3072
  fi
2771
3073
  # A CLAIM is a branch whose only commits beyond main are claim commits —
@@ -2836,6 +3138,53 @@ branch_state() {
2836
3138
  fi
2837
3139
  # Nothing of its own. NOT a claim: that shape is indistinguishable from
2838
3140
  # merged work, which is exactly why claims carry a commit.
3141
+ #
3142
+ # ZERO AHEAD CARRIES TWO SHAPES, and only one of them is landed work:
3143
+ #
3144
+ # | shape | ancestry says | truth |
3145
+ # |----------------|-------------------------|---------------|
3146
+ # | behind main | is an ancestor → merged | merged |
3147
+ # | reset to main | is an ancestor → merged | holds nothing |
3148
+ #
3149
+ # A branch pointing AT the default branch is trivially an ancestor of it, so
3150
+ # every ancestry test passes — right for the case this arm was built for (a
3151
+ # squash merge leaves the branch behind, and its work IS on main), and wrong
3152
+ # for a branch that was reset, where the same shape means it holds NOTHING.
3153
+ #
3154
+ # Measured 2026-08-29: `feature/one-deliver-rule-decides-in-the-domain` was
3155
+ # reset to `origin/main` so a worker could rebuild it, its PR (#511) having
3156
+ # been CLOSED, never merged. Seconds later the scan reported the branch
3157
+ # `merged`, completed its wave, and opened `Transitions` on the strength of
3158
+ # work that does not exist. `merged` is the state that SETTLES a wave, so
3159
+ # this error does not stall the fleet — it advances it onto a seam nobody
3160
+ # wrote, which is the worse direction.
3161
+ #
3162
+ # THE DISCRIMINATOR IS THE OTHER DIRECTION. A branch with zero commits ahead
3163
+ # is either equal to the default branch or a strict ancestor of it, so
3164
+ # "behind = 0" and "tip = main tip" are the same predicate. Compared as OIDs
3165
+ # because BOTH ARE ALREADY IN HAND from the ref batch — a `rev-list --count`
3166
+ # would re-derive it at one spawn per branch, the per-branch tail this scan
3167
+ # has repeatedly been thinned to remove.
3168
+ #
3169
+ # OFFLINE, AND DELIBERATELY SO. No host call is added here:
3170
+ # `a-throttled-host-says-so` measured `plot-pr-merged.sh` answering *not
3171
+ # merged* for three genuinely merged branches while throttled, and this
3172
+ # reading must not inherit that failure mode.
3173
+ #
3174
+ # The squash path is untouched and must stay so — its mirror defect (a
3175
+ # squash-merged branch reading `open`) is a separate plan, and a fix for one
3176
+ # can break the other. A squash-merged branch is BEHIND main and reaches the
3177
+ # `merged` below; a squash-merged branch whose ref was pushed back counts
3178
+ # `ahead > 0` and never arrives here at all.
3179
+ local _bs_tip _bs_main
3180
+ _bs_tip=$(remote_ref_oid "$br")
3181
+ _bs_main=$(remote_ref_oid "$MAIN")
3182
+ if [ -n "$_bs_tip" ] && [ "$_bs_tip" = "$_bs_main" ]; then
3183
+ # It points AT the default branch: no work of its own, and none of its own
3184
+ # landed. `open` is what this scan already says for work not yet done, so
3185
+ # no new state enters the vocabulary and the wave arithmetic is unchanged.
3186
+ echo "open"; return
3187
+ fi
2839
3188
  echo "merged"
2840
3189
  }
2841
3190
 
@@ -2920,8 +3269,17 @@ if [ "$next_only" != 1 ] && [ "$as_json" != 1 ]; then
2920
3269
  fi
2921
3270
 
2922
3271
  n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred=0
3272
+ # Two BRANCH counters beside the wave ones above. `n_blocked` counts waves an
3273
+ # earlier wave holds; these count branches their `waits:` annotation holds, and
3274
+ # the footer keeps the two words apart for that reason.
3275
+ n_waiting=0 n_prereq_missing=0
2923
3276
  claimable=()
2924
3277
  plan_files=()
3278
+ # `--why-nothing`'s input: one `verdict<TAB>name:state|...` line per slice, in
3279
+ # plan order. Accumulated in the SAME loop that renders the branches, so the
3280
+ # outlook reads the verdicts a caller's `--next` just acted on rather than a
3281
+ # second derivation that could disagree with them.
3282
+ outlook_lines=""
2925
3283
 
2926
3284
  plan_idx=-1
2927
3285
  for plan in "${plans[@]}"; do
@@ -3038,24 +3396,65 @@ for plan in "${plans[@]}"; do
3038
3396
  # only safe because of that same rule: it is "-" when absent, never "". The
3039
3397
  # tabs inside it are replaced with spaces by the shim above, for the same
3040
3398
  # reason. It cannot go last; `claim` already is.
3399
+ #
3400
+ # `waits` — the branch a slice waits on — is a MIDDLE column under the same
3401
+ # rule, and it is a branch name so it carries no tab of its own. It sits
3402
+ # between `why` and the wave name, which moved the wave name to field 7: the
3403
+ # `awk` that reads it below was updated with this line and the two must move
3404
+ # together.
3041
3405
  states=""
3042
- while IFS=$'\t' read -r idx br deferred why wname claim; do
3406
+ while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3043
3407
  [ -n "$br" ] || continue
3408
+ # "-" is the absent marker the shim writes, for the tab-collapse reason
3409
+ # above. Normalized here so everything downstream tests emptiness.
3410
+ [ "$waits" = "-" ] && waits=""
3044
3411
  if [ "$deferred" = "true" ]; then st="deferred"; else st=$(branch_state "$br"); fi
3045
- states+="$idx $br $st $deferred $why $wname $claim"$'\n'
3412
+ # THE PREREQUISITE, AFTER THE BRANCH'S OWN STATE AND ONLY OVER TWO OF ITS
3413
+ # WORDS. `deferred` outranks it — somebody gave the branch up, which is a
3414
+ # decision, while waiting is a measurement — and so does any state that
3415
+ # means work exists: `wip`, `claimed` and `merged` all say the branch was
3416
+ # started, and overriding `merged` would stop its wave settling FOREVER,
3417
+ # which is the blocked-on-success failure this feature is built to avoid.
3418
+ #
3419
+ # So the override lands exactly where the defect was: a branch that reads
3420
+ # as unstarted, which is the population `--next` hands out.
3421
+ if [ -n "$waits" ]; then
3422
+ case "$st" in
3423
+ open|unknown)
3424
+ waits_st=$(waits_state "$waits")
3425
+ [ -n "$waits_st" ] && st="$waits_st" ;;
3426
+ esac
3427
+ fi
3428
+ # "-" GOES BACK IN, for the reason it was there in the first place: this
3429
+ # record is re-read by two more `read` loops below, and an EMPTY middle
3430
+ # column collapses its tab into its neighbour's and shifts every later
3431
+ # field left. `$claim` is the only field allowed to be last and optional.
3432
+ states+="$idx $br $st $deferred $why ${waits:--} $wname $claim"$'\n'
3046
3433
  done <<< "$wave_lines"
3047
3434
 
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.
3435
+ # Pass 2a: what each wave HOLDS how many of its non-deferred branches have
3436
+ # not settled. A reading, and the whole of what this script contributes to the
3437
+ # verdict: which branches count as settled depends on `--loose` and on a host
3438
+ # round trip, both of which are adaptation. What a wave therefore IS is the
3439
+ # domain's, asked once for the whole plan in pass 2b.
3050
3440
  wave_ids=$(printf '%s' "$states" | cut -f1 | sort -un)
3051
- prior_ok=1
3441
+ wave_readings=""
3442
+ # ONE LINE PER SLICE, in the order `wave_ids` yields them — the same shape as
3443
+ # `wave_readings` above and `wave_verdicts` below, walked by the same index.
3444
+ # NOT an associative array: macOS ships bash 3.2, which has none, and
3445
+ # `test/reconcile/mergequeue.test.mjs` refuses one.
3446
+ wave_degraded_list=""
3052
3447
  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
3448
  outstanding=0
3056
3449
  _loose_degraded_branches=""
3057
- while IFS=$'\t' read -r idx br st deferred why nm claim; do
3450
+ wave_states=""
3451
+ while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
3058
3452
  [ "$idx" = "$wid" ] || continue
3453
+ # EVERY branch, including the deferred ones, and in the order the render
3454
+ # loop below will walk them — the claimable flags come back positionally,
3455
+ # so the two walks must agree. `outstanding` skips deferred branches; this
3456
+ # does not, because a deferred branch still occupies a position.
3457
+ wave_states+="${wave_states:+|}$st"
3059
3458
  [ "$st" = "deferred" ] && continue
3060
3459
  # strict (default): only a merged branch is settled.
3061
3460
  # loose: pushed work counts too — buys throughput, pays in rebase risk.
@@ -3075,43 +3474,42 @@ for plan in "${plans[@]}"; do
3075
3474
  *) outstanding=$((outstanding + 1)) ;;
3076
3475
  esac
3077
3476
  done <<< "$states"
3477
+ wave_readings+="$outstanding $plan_phase $wave_states"$'\n'
3478
+ wave_degraded_list+="$_loose_degraded_branches"$'\n'
3479
+ done
3078
3480
 
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
3481
+ # Pass 2b: THE DECISION, and it is not made here.
3482
+ #
3483
+ # `sliceVerdict` lives in `@plot-pm/domain` and this script asks it. The four
3484
+ # words and the order they are decided in `complete` outranking everything,
3485
+ # approval being part of `eligible`, `unapproved` kept apart from `blocked`
3486
+ # because they resolve differently are all one implementation now, shared
3487
+ # with every other component that has to agree about what may be started.
3488
+ #
3489
+ # ONE CALL PER PLAN, not per wave. The verdicts of a plan's waves are a FOLD:
3490
+ # each depends on whether every wave before it is complete, so the whole
3491
+ # ordered list is the unit the rule takes. That also keeps the cost at one
3492
+ # process per plan on a path the board polls every five seconds.
3493
+ #
3494
+ # A MISSING OR SILENT ARTIFACT REFUSES. There is no shell fallback: a second
3495
+ # implementation kept "just in case" is the duplication this adoption removes,
3496
+ # and it would be the copy nobody tests. `plot-deliver.sh` fails the same way
3497
+ # for the same reason.
3498
+ wave_verdicts=$(printf '%s' "$wave_readings" \
3499
+ | node "$script_dir/board/plot-verdicts.mjs" 2>/dev/null) \
3500
+ || { echo "error: cannot read slice verdicts — run 'pnpm build:board'." >&2; exit 2; }
3501
+ [ "$(printf '%s\n' "$wave_verdicts" | grep -c .)" = "$(printf '%s' "$wave_readings" | grep -c .)" ] \
3502
+ || { echo "error: slice verdicts did not answer for every slice of $plan_base." >&2; exit 2; }
3503
+
3504
+ verdict_i=0
3505
+ for wid in $wave_ids; do
3506
+ verdict_i=$((verdict_i + 1))
3507
+ IFS=$'\t' read -r verdict wave_claimable \
3508
+ <<< "$(printf '%s\n' "$wave_verdicts" | sed -n "${verdict_i}p")"
3509
+ branch_i=0
3510
+ _loose_degraded_branches=$(printf '%s' "$wave_degraded_list" | sed -n "${verdict_i}p")
3511
+ wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $7; exit}')
3512
+ [ "$wname" = "-" ] && wname=""
3115
3513
 
3116
3514
  [ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
3117
3515
  # A degradation that says nothing is indistinguishable from a bug.
@@ -3124,12 +3522,29 @@ for plan in "${plans[@]}"; do
3124
3522
  echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
3125
3523
  fi
3126
3524
  json_branches=""
3127
- while IFS=$'\t' read -r idx br st deferred why nm claim; do
3525
+ # The outlook's reading of this slice, built alongside the render. EVERY
3526
+ # branch including the deferred ones, in the plan's order — the rule needs
3527
+ # `deferred` to tell a branch that will never move from one that has not
3528
+ # moved yet.
3529
+ outlook_branches=""
3530
+ while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
3128
3531
  [ "$idx" = "$wid" ] || continue
3532
+ outlook_branches+="${outlook_branches:+|}$br:$st"
3129
3533
  [ "$claim" = "-" ] && claim=""
3130
3534
  [ "$why" = "-" ] && why=""
3535
+ [ "$waits" = "-" ] && waits=""
3131
3536
  n_branches=$((n_branches + 1))
3132
3537
  case "$st" in
3538
+ # WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
3539
+ # later without saying what would have to happen first, which is the
3540
+ # whole of what this state adds over `open`.
3541
+ waiting) n_waiting=$((n_waiting + 1))
3542
+ note="waiting on $waits" ;;
3543
+ # A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
3544
+ # and answered, because that is what separates this from `waiting`: a
3545
+ # host that could not be asked holds the branch at `waiting` instead.
3546
+ blocked) n_prereq_missing=$((n_prereq_missing + 1))
3547
+ note="blocked — no PR found for $waits" ;;
3133
3548
  # The REASON, where the plan recorded one. A bare `deferred` beside a
3134
3549
  # branch with no commits reads as two unrelated facts when the first is
3135
3550
  # the reason for the second, and the sentence that says so was already
@@ -3138,9 +3553,22 @@ for plan in "${plans[@]}"; do
3138
3553
  claimed) n_claimed=$((n_claimed + 1)); note="claimed${claim:+ ($claim)}" ;;
3139
3554
  merged) note="merged" ;;
3140
3555
  wip) note="in progress" ;;
3556
+ # THE HOST COULD NOT BE ASKED, so nothing is claimed about the PR. The
3557
+ # sentence says which question went unanswered rather than naming a
3558
+ # state, because a reader chasing `open` looks for work that was never
3559
+ # started — the reaper's `unlanded work` made exactly that mistake about
3560
+ # a merged branch, in a claim about CONTENT.
3561
+ unknown) note="unknown — PR could not be read ($HOST_VERDICT host)" ;;
3141
3562
  *) note="open" ;;
3142
3563
  esac
3143
- if [ "$verdict" = "eligible" ] && [ "$st" = "open" ]; then
3564
+ # WHETHER A WORKER MAY TAKE THIS BRANCH the domain's answer, read
3565
+ # positionally from the flags the same call returned. It was
3566
+ # `[ "$verdict" = "eligible" ] && [ "$st" = "open" ]` here, a second
3567
+ # statement of `isClaimable` sitting one loop away from the verdict it
3568
+ # depends on. `--next` acts on this immediately by pushing a ref, so the
3569
+ # two must not be free to disagree.
3570
+ branch_i=$((branch_i + 1))
3571
+ if [ "${wave_claimable:$((branch_i - 1)):1}" = "1" ]; then
3144
3572
  n_eligible=$((n_eligible + 1))
3145
3573
  claimable+=("$br")
3146
3574
  fi
@@ -3154,6 +3582,16 @@ for plan in "${plans[@]}"; do
3154
3582
  # branch is not deferred, and "" where it is deferred with nothing
3155
3583
  # recorded — the flag says which of those two a reader is looking at.
3156
3584
  json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
3585
+ # WHAT THIS BRANCH WAITS ON, straight from the plan's `waits:`
3586
+ # annotation. "" where the branch declares nothing, which is the answer
3587
+ # every branch gave before this field existed.
3588
+ #
3589
+ # THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
3590
+ # says. A branch whose prerequisite has MERGED reports `waits_on` with
3591
+ # its ordinary state — the declaration is still a fact about the plan,
3592
+ # and a reader who sees a cleared dependency learns why the slice is
3593
+ # now startable. Consumers test `state`, never the presence of this.
3594
+ json_branches+=",\"waits_on\":\"$(json_str "$waits")\""
3157
3595
  json_branches+=",\"claimed\":\"$(json_str "$claim")\""
3158
3596
  # What this machine knows and the refs do not. Absent everywhere else:
3159
3597
  # `local_dirty:false` and `local_worktree:""` are what a branch checked
@@ -3338,8 +3776,13 @@ for plan in "${plans[@]}"; do
3338
3776
  json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
3339
3777
  fi
3340
3778
 
3779
+ outlook_lines+="$verdict $outlook_branches"$'\n'
3780
+
3341
3781
  n_waves=$((n_waves + 1))
3342
- [ "$verdict" = "complete" ] || prior_ok=0
3782
+ # `prior_ok` used to be carried here — the ordering half of the verdict,
3783
+ # threaded through the render loop. It moved with the rule: `sliceVerdicts`
3784
+ # folds the plan's waves in order, so nothing downstream has to remember
3785
+ # what the wave before it decided.
3343
3786
  [ "$verdict" = "blocked" ] && n_blocked=$((n_blocked + 1))
3344
3787
  done
3345
3788
  if [ "$as_json" = 1 ]; then
@@ -3362,7 +3805,7 @@ for plan in "${plans[@]}"; do
3362
3805
  # THE STREAM'S POINT: this plan is fully derived, so a consumer can render
3363
3806
  # it now rather than when the eighty-fourth branch resolves. Emitted as one
3364
3807
  # 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.
3808
+ # tagged so the terminal `reading` line cannot be mistaken for another plan.
3366
3809
  #
3367
3810
  # Flushed by `printf` on a line of its own: a consumer reading this stream
3368
3811
  # is reading it BECAUSE the whole document takes 18 s, so buffering the
@@ -3374,6 +3817,37 @@ for plan in "${plans[@]}"; do
3374
3817
  [ "$quiet" = 1 ] || echo
3375
3818
  done
3376
3819
 
3820
+ # --why-nothing: WHICH nothing, for a caller `--next` has just gone silent on.
3821
+ #
3822
+ # IT ANSWERS BEFORE `--next` DOES, and that ordering is the flag's whole
3823
+ # contract. `--why-nothing` sets `next_only` so it walks the same population,
3824
+ # but it must not fall into the block below: that block exits 1 on an empty
3825
+ # `claimable[]`, and a worker reading exit 1 from the flag it asked for a WORD
3826
+ # from learns nothing it did not already know. Measured 2026-09-03 on this
3827
+ # estate: `--why-nothing <slug>` printed nothing and exited 1 for every real
3828
+ # plan, because #642 shipped the parse, the accumulator and the rule and never
3829
+ # wired the emitter on this path. Only the empty-estate arm at ":2827" ever
3830
+ # answered.
3831
+ #
3832
+ # `available` IS REPORTED RATHER THAN HIDDEN. The rule reports it when a
3833
+ # claimable branch exists, which means this scan and the caller's `--next`
3834
+ # disagree — a real state (a branch landed in the seconds between the two
3835
+ # calls) and one a waiting caller should act on rather than sleep through.
3836
+ #
3837
+ # EXIT 0 EITHER WAY, INCLUDING WHEN THE RULE CANNOT BE ASKED. A silent or
3838
+ # missing artifact answers `none`, the same conservative default ":2827" takes:
3839
+ # `none` ends a wait, and a wait that cannot end is the one failure this flag
3840
+ # exists to prevent. That is the opposite of every other caller of
3841
+ # `plot-verdicts.mjs`, which refuses on a missing artifact — those callers
3842
+ # decide what may be STARTED and a wrong answer claims a branch; this one
3843
+ # decides whether a worker sleeps, and the wrong answer is the sleep.
3844
+ if [ "$why_nothing" = 1 ]; then
3845
+ printf '%s' "$outlook_lines" \
3846
+ | node "$script_dir/board/plot-verdicts.mjs" outlook 2>/dev/null \
3847
+ || echo "none"
3848
+ exit 0
3849
+ fi
3850
+
3377
3851
  # --next: name ONE branch a worker may claim, or stay silent with exit 1.
3378
3852
  # "Nothing to start" is a normal state, not a failure — the exit code is what
3379
3853
  # distinguishes it from a name, so callers can branch on it without parsing.
@@ -3393,7 +3867,7 @@ fi
3393
3867
  # pulse re-derives everything from git.
3394
3868
  if [ "$log_pulse" = 1 ]; then
3395
3869
  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 -->"
3870
+ 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
3871
  for pf in ${plan_files[@]+"${plan_files[@]}"}; do
3398
3872
  real=$(cd "$(dirname "$pf")" && readlink "$(basename "$pf")" 2>/dev/null || true)
3399
3873
  target=$([ -n "$real" ] && echo "$(dirname "$pf")/$real" || echo "$pf")
@@ -3417,11 +3891,11 @@ fi
3417
3891
  if [ "$as_json" = 1 ]; then
3418
3892
  # --stream wraps the SAME document in one tagged line rather than emitting a
3419
3893
  # 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
3894
+ # a consumer that has seen `plan` lines and no `reading` line has a PARTIAL
3421
3895
  # answer and must say so — which is the whole distinction this mode adds, and
3422
3896
  # the reason the end is marked rather than inferred from the pipe closing.
3423
3897
  # A killed scan closes the pipe too.
3424
- [ "$stream" = 1 ] && printf '{"kind":"pulse","pulse":'
3898
+ [ "$stream" = 1 ] && printf '{"kind":"reading","reading":'
3425
3899
  # `read_ref` is the ref this document was derived from; `local_head` is the
3426
3900
  # checkout it was derived ON. A consumer needs both to tell "the board is
3427
3901
  # current" from "the board is current about an old world".
@@ -3444,8 +3918,17 @@ if [ "$as_json" = 1 ]; then
3444
3918
  "$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans"
3445
3919
  printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
3446
3920
  "$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"
3921
+ # `host` is the EVIDENCE field beside merge_detect, and it is the one that
3922
+ # says whether merge_detect can be believed. Rendered for the machine here
3923
+ # and in the footer for a human; the board reads this rather than parsing
3924
+ # the prose, the rule every other field follows.
3925
+ # `waiting` and `prereq_missing` count BRANCHES, where `blocked` above counts
3926
+ # WAVES. Two vocabularies share the word `blocked` and the footer must not:
3927
+ # a consumer adding the three would double-count nothing, because no branch
3928
+ # is in both and no wave is in either.
3929
+ printf '"eligible":%d,"blocked":%d,"deferred":%d,"waiting":%d,"prereq_missing":%d,"merge_detect":"%s","host":"%s"}}' \
3930
+ "$n_eligible" "$n_blocked" "$n_deferred" "$n_waiting" "$n_prereq_missing" \
3931
+ "$MERGE_DETECT" "$HOST_VERDICT"
3449
3932
  [ "$stream" = 1 ] && printf '}'
3450
3933
  printf '\n'
3451
3934
  exit 0
@@ -3459,6 +3942,35 @@ if [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
3459
3942
  echo " note: merge scan hit its limit of $MERGE_SCAN_LIMIT — older merges were not"
3460
3943
  echo " examined; a branch merged before that point may still read as open."
3461
3944
  fi
3945
+ # A HOST THAT COULD NOT BE ASKED SAYS SO, and says what to do about it.
3946
+ #
3947
+ # `pr-list` is ONE GraphQL call in place of ~186 REST calls — a deliberate and
3948
+ # good trade whose consequence is that throttling takes out EVERY PR answer at
3949
+ # once rather than degrading row by row. So the whole fleet reads unmerged,
3950
+ # every wave stays blocked, and the board shows a busy estate with nothing
3951
+ # eligible: indistinguishable from work genuinely in flight, which is why this
3952
+ # has to be stated rather than left for a reader to infer from a quiet report.
3953
+ #
3954
+ # THE THREE WORDS GET DIFFERENT ADVICE because they need different responses.
3955
+ # A spent quota returns at the reset, minutes away; a secondary limit clears in
3956
+ # seconds and is fixed by running fewer calls at once; an outage clears when
3957
+ # somebody looks at it.
3958
+ if [ "$HOST_VERDICT" = throttled ]; then
3959
+ echo " note: the git host's rate limit was spent, so no PR could be read. Every"
3960
+ echo " branch below reads from local evidence alone — a merged branch whose"
3961
+ echo " ref was deleted reads 'unknown', never 'open', and none was offered"
3962
+ echo " to --next. The budget refills on a clock; re-run in a few minutes."
3963
+ elif [ "$HOST_VERDICT" = secondary ]; then
3964
+ echo " note: the git host refused a burst, not a spent budget, so no PR could be"
3965
+ echo " read. Every branch below reads from local evidence alone and none"
3966
+ echo " was offered to --next. This clears in seconds — re-run shortly, and"
3967
+ echo " run fewer scans at once rather than waiting for a reset."
3968
+ elif [ "$HOST_VERDICT" = failed ]; then
3969
+ echo " note: the git host could not be reached, so no PR could be read. Every"
3970
+ echo " branch below reads from local evidence alone, and a branch whose"
3971
+ echo " PR is unknown reads 'unknown' rather than 'open'. This is not a"
3972
+ echo " rate limit — waiting will not clear it; check the host and auth."
3973
+ fi
3462
3974
  # A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
3463
3975
  # hour-old refs read exactly like a scan of current ones — the same
3464
3976
  # over-confidence, one layer up, that this plan fixes in the plan list.
@@ -3493,4 +4005,4 @@ if [ "$PLAN_SOURCE" != "ref" ]; then
3493
4005
  echo " checkout instead, so the list is only as current as your last pull."
3494
4006
  fi
3495
4007
  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"
4008
+ 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"