@plot-pm/board 0.16.2 → 0.17.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.
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: fleet pulse — deterministic extractor for wave/claim state.
3
- # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
3
+ # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [--slice-names] [<slug>]
4
+ # --slice-names print every slice a `waits:` name may wait on, one per line,
5
+ # and exit: the estate's non-deferred slices of non-terminal plans,
6
+ # whatever slug is given. No fetch, no host call.
4
7
  # --no-fetch skip `git fetch`
5
8
  # --offline same (no fetch) — used for cheap, ambient pulses.
6
9
  # The fetch also PRUNES remote-tracking refs, so skipping it
@@ -76,11 +79,12 @@
76
79
  # Branch states — the word each BRANCH carries, distinct from the wave verdicts
77
80
  # above. `open`, `wip`, `merged`, `claimed`, `deferred` and `unknown` are read
78
81
  # from git and the host. Two more are read from the plan's `waits:` annotation:
79
- # waiting the branch names a prerequisite branch that has not merged. A
82
+ # waiting the branch names a prerequisite branch that has not merged —
83
+ # including a slice some plan names that nobody has started. A
80
84
  # wait with an end: it clears when that branch lands, and the
81
85
  # fleet payload carries `waits_on` so a reader sees on WHAT.
82
86
  # blocked the branch names a prerequisite the host has never seen a PR
83
- # for — a typo, or a branch nobody created. A defect in the plan
87
+ # for and no plan names as a slice — a typo. A defect in the plan
84
88
  # estate, not progress, which is why it is a separate word: the
85
89
  # first resolves by waiting, the second by editing the plan.
86
90
  # THE SAME WORD AS THE WAVE VERDICT, IN A DIFFERENT VOCABULARY.
@@ -261,6 +265,7 @@ do_fetch=1
261
265
  next_only=0
262
266
  list_all=0
263
267
  why_nothing=0
268
+ slice_names=0
264
269
  loose=0
265
270
  log_pulse=0
266
271
  as_json=0
@@ -295,6 +300,7 @@ while [ $# -gt 0 ]; do
295
300
  # about the SAME plans `--next` was silent over — a terminal plan admitted
296
301
  # here would answer `not-yet` about work somebody decided was not needed.
297
302
  --why-nothing) next_only=1; why_nothing=1 ;;
303
+ --slice-names) slice_names=1; do_fetch=0 ;;
298
304
  # `--json` ASSEMBLES BUT DOES NOT RECORD, and the two flags differ here for
299
305
  # a reason. `--stream` is what the BOARD spawns (`fleet.ts:2694`) and
300
306
  # `--log-pulse` is what `/plot-pulse` passes: both produce a pulse somebody
@@ -471,8 +477,13 @@ fi
471
477
  # seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
472
478
  # nothing but the silent misses described above.
473
479
  MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
480
+ # `%H %s` RATHER THAN `%s`, because the age rule needs the merge COMMIT and not
481
+ # only its sentence: a subject proves a branch for a plan only when its merge is
482
+ # not contained in the commit that added the plan file, and that is an ancestry
483
+ # question about this hash. One walk still, and the hash costs nothing — the
484
+ # same `git log`, one more format placeholder.
474
485
  MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
475
- --max-count="$MERGE_SCAN_LIMIT" --pretty=%s </dev/null 2>/dev/null || true)
486
+ --max-count="$MERGE_SCAN_LIMIT" --pretty='%H %s' </dev/null 2>/dev/null || true)
476
487
  MERGE_SCAN_TRUNCATED=0
477
488
  if [ -n "$MERGE_SUBJECTS" ] \
478
489
  && [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
@@ -489,11 +500,22 @@ fi
489
500
  # capped walk detected, but not exhaustively.
490
501
  # none — the default branch carries no conforming merge commits at all
491
502
  # (a squash/rebase repo), so `open` says nothing about merging.
492
- if printf '%s\n' "$MERGE_SUBJECTS" | grep -qE '^Merge pull request #[0-9]+ from [^/]+/.+$'; then
493
- MERGE_DETECT=$([ "$MERGE_SCAN_TRUNCATED" = 1 ] && echo truncated || echo pr-merge)
494
- else
495
- MERGE_DETECT=none
496
- fi
503
+ # unaskable — the rule could not be asked at all, so NO branch got a subject
504
+ # reading and every refless branch went to the host. A missing or
505
+ # silent bundle is the case; it is not the same answer as `none`,
506
+ # which is a measurement of this estate's history.
507
+ #
508
+ # THE WORD IS THE BUNDLE'S, and this is why no regex decides it here. The regex
509
+ # that stood on this line read GitHub's form only, so a Bitbucket estate — whose
510
+ # every merge carries `Merged in <branch> (pull request #N)` — reported
511
+ # `merge_detect=none` and said its own 1723 proofs did not exist. The forms are
512
+ # data in the host adapter now, and a second copy in shell is exactly what
513
+ # `a-merge-subject-proves-a-landing-the-host-cannot` removes.
514
+ #
515
+ # SET AFTER THE PLANS ARE PARSED, because the bundle is asked then — the plan
516
+ # branches and their adding commits are known only then. Until that point the
517
+ # word is `unaskable`, which is the honest answer for a scan that has not asked.
518
+ MERGE_DETECT=unaskable
497
519
 
498
520
  # ---------------------------------------------------------------------------
499
521
  # Squash merges: the case where no local evidence survives at all
@@ -648,7 +670,32 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
648
670
  # them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
649
671
  # already performs on Bitbucket, so the join and the per-branch lookup cannot
650
672
  # disagree about the same branch.
651
- PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
673
+ # GENEROUS MEANS ABOVE THE REPOSITORY'S PR COUNT, and 1000 stopped being that.
674
+ # It was set when this repo held 221 PRs. Measured 2026-10-02 it holds 1064, so
675
+ # `--state all --limit 1000` returned exactly 1000 rows — a page AT its limit,
676
+ # which proves only "at least 1000" and can never license completeness. The
677
+ # marker was withheld, and the 26 branches the join could not name each cost one
678
+ # `pr-state` call at 3.8 s: 54-61% of the scan's wall time (#1017).
679
+ #
680
+ # THE NUMBER IS A BUDGET, NOT AN ASSUMPTION, and that distinction is what makes
681
+ # raising it a fix rather than a deferral. A page short of this limit is now read
682
+ # as PROOF of completeness — `plot-host.sh` states it and `prefill_pr_states`
683
+ # reads the sentence — so the limit's only job is to be reachable. A repository
684
+ # that outgrows it gets a capped page, the adapter says `possibly truncated`,
685
+ # the marker is withheld, and the scan degrades to the per-branch asking it did
686
+ # before: slower, and still correct. The failure is loud in the adapter's own
687
+ # stderr and costs cost, never an answer.
688
+ #
689
+ # COST SCALES WITH ROWS RETURNED, NOT ROWS REQUESTED, so the headroom is nearly
690
+ # free. Measured 2026-10-02 through `plot-host.sh pr-list --state all`:
691
+ # `--limit 1000` → 1000 rows in 7.0 s; `--limit 3000` → 1065 rows in 9.2 s;
692
+ # `--limit 5000` → the same 1065 rows in 11.1 s. GitHub pages internally and
693
+ # stops at what exists, so asking for 3000 of 1064 fetches 1064. Two seconds
694
+ # more on one call against ~100 s of per-branch calls removed.
695
+ #
696
+ # `PLOT_PR_LIST_LIMIT` still overrides it, and lowering it is how a repository
697
+ # with a tighter quota trades the join back for per-branch asking.
698
+ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-3000}"
652
699
 
653
700
  # WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
654
701
  # computed and threw away until 2026-08-30.
@@ -823,9 +870,129 @@ pr_list_verdict_rank() {
823
870
  esac
824
871
  }
825
872
 
873
+ # The listing a previous pulse made, handed in rather than spent again.
874
+ #
875
+ # THE BOARD DECIDES, NOT THIS SCRIPT. `listingSpend` in the domain answers
876
+ # whether the account can afford a listing now, and the board — the only
877
+ # long-lived process here — holds the previous one and hands it back. This scan
878
+ # is spawned fresh per pulse and can span none, which is `PLOT_TERMINAL_CACHE`'s
879
+ # reason (`fleet.ts:3380`) applied to the listing rather than to merge facts.
880
+ #
881
+ # Measured 2026-10-02 on the Bitbucket workspace `quatico`: this scan spent 2949
882
+ # of one account's 3150 calls in an hour, 93.6%, because the open listing sweeps
883
+ # one REST request per tracked branch per state and the pulse is 5 s. Cutting the
884
+ # number of runs does not fix that; cutting the listings a run makes does.
885
+ #
886
+ # A CARRIED LISTING IS A FULL ANSWER OR IT IS NOT USED. It carries the same
887
+ # per-branch lines `prefill_pr_states` would have written plus the markers that
888
+ # license `NONE`, so every reader below is unchanged and none of them can tell a
889
+ # carried listing from a fetched one. That is deliberate: the AGE is the board's
890
+ # to report, because the board is what knows how old its own listing is.
891
+ # An empty value means nothing was carried and the listing is spent as before.
892
+ PLOT_PR_LISTING="${PLOT_PR_LISTING:-}"
893
+
894
+ # Fill the cache from a carried listing. Prints nothing; returns 1 when there was
895
+ # nothing to carry, so the caller spends the listing instead.
896
+ #
897
+ # ABSENT IS NOT EMPTY. A carried listing with no rows would license `NONE` for
898
+ # every branch — the 2026-08-27 failure that refused four fully-merged plans —
899
+ # so a value carrying no arrival marker is refused here and the host is asked.
900
+ carried_listing() {
901
+ [ -n "$PLOT_PR_LISTING" ] || return 1
902
+ local key st chk dft arrived=0 complete=0
903
+ while IFS=" " read -r key st chk dft; do
904
+ case "$key" in
905
+ '') continue ;;
906
+ # The markers travel as lines rather than as files, because the board holds
907
+ # one string and not a directory.
908
+ .list-arrived) arrived=1; continue ;;
909
+ .list-complete) complete=1; continue ;;
910
+ # Every other dotted key is a note for the board and not a branch. Skipped
911
+ # rather than written: `git check-ref-format` rejects a branch name starting
912
+ # with a dot and the key encoding maps only `_` and `/`, so no branch key can
913
+ # begin with one and a dotted key is never a missed answer.
914
+ .*) continue ;;
915
+ esac
916
+ [ -n "$st" ] || continue
917
+ # The `-` sentinel back to empty, exactly as the host payload's plain rows are
918
+ # translated: an empty `checks` reads as `unknown` in `pr_ready` and degrades
919
+ # `--loose` to strict, which is the safe direction.
920
+ [ "$chk" = "-" ] && chk=""
921
+ [ "$dft" = "-" ] && dft=""
922
+ printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$key" 2>/dev/null || true
923
+ done <<EOF
924
+ $PLOT_PR_LISTING
925
+ EOF
926
+ # A listing that arrived with no rows is a real answer — a repository with no
927
+ # PRs — but it is indistinguishable here from a value truncated in transit, and
928
+ # the direction that errs costs a fabricated `NONE`. The MARKER carries the
929
+ # claim, so arrival decides and the row count does not.
930
+ [ "$arrived" = 1 ] || return 1
931
+ printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
932
+ [ "$complete" = 1 ] && { printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true; }
933
+ # `ok`, AND A NEW WORD HERE WOULD STOP THE FLEET. `reachFrom`
934
+ # (`entry/branch-state.ts:113`) maps any word it does not know to `failed`, so
935
+ # an invented `reused` would read as *the host could not be reached*: every
936
+ # branch with no ref would answer `unknown`, `--next` offers only `open`, and
937
+ # nothing would be handed out while the listing was being reused.
938
+ #
939
+ # `ok` IS ALSO THE TRUE ANSWER TO THE QUESTION THIS WORD ASKS. The verdict says
940
+ # how much evidence the scan holds about a branch, not how old the evidence is:
941
+ # a carried listing arrived and was whole, so it licenses exactly what a fetched
942
+ # one licenses. The AGE is the board's to report, because the board is what
943
+ # knows when it listed — this scan is spawned fresh and cannot know.
944
+ HOST_VERDICT=ok
945
+ return 0
946
+ }
947
+
948
+ # Report the listing this run fetched, for the next pulse to carry.
949
+ #
950
+ # ON STDERR AND TAGGED, exactly as the terminal map is reported: stdout is the
951
+ # scan's document and a reader of it must not have to know this exists. The board
952
+ # reads the tag; every other caller discards it with the scan's ordinary prose.
953
+ #
954
+ # REPORTED ONLY WHERE THE LISTING ARRIVED. A failed or throttled call writes no
955
+ # `.list-arrived`, and carrying its empty cache forward would turn one refusal
956
+ # into a listing the next pulse trusts.
957
+ report_listing() {
958
+ [ -n "$HOST_STATE_CACHE" ] || return 0
959
+ [ -f "$HOST_STATE_CACHE/.list-arrived" ] || return 0
960
+ printf 'listing: %s\t%s\t%s\t%s\n' .list-arrived 1 - - >&2
961
+ [ -f "$HOST_STATE_CACHE/.list-complete" ] \
962
+ && printf 'listing: %s\t%s\t%s\t%s\n' .list-complete 1 - - >&2
963
+ # WHAT THE LISTING COST, MEASURED RATHER THAN MODELLED. The Bitbucket arm sweeps
964
+ # one request per tracked branch per state, and this scan is the only thing that
965
+ # knows how many branches it tracked. A board predicting the number from the
966
+ # plans would under-count: a plan names a subset of the remote refs, and the
967
+ # sweep asks about every one of them.
968
+ printf 'listing: %s\t%s\t%s\t%s\n' .branches \
969
+ "$(printf '%s' "$TRACKED_BRANCHES" | wc -w | tr -d ' ')" - - >&2
970
+ # One line per branch the listing answered for, in the cache's own key encoding
971
+ # and its own `STATE<TAB>checks<TAB>draft` shape, so what is carried back is what
972
+ # this run wrote and the carry path parses no JSON.
973
+ local f key rec _st _chk _dft
974
+ for f in "$HOST_STATE_CACHE"/*; do
975
+ [ -f "$f" ] || continue
976
+ key=${f##*/}
977
+ case "$key" in .*) continue ;; esac
978
+ rec=$(cat "$f" 2>/dev/null) || continue
979
+ IFS=" " read -r _st _chk _dft <<EOF
980
+ $rec
981
+ EOF
982
+ [ -n "$_st" ] || continue
983
+ # Empty fields are refilled with `-` so every field stays occupied: TAB is an
984
+ # IFS whitespace character, and `read` would otherwise slide the branch key
985
+ # along by one. The carry path translates them back, which is the same `-`
986
+ # sentinel the host payload's plain rows already use.
987
+ printf 'listing: %s\t%s\t%s\t%s\n' "$key" "$_st" "${_chk:--}" "${_dft:--}" >&2
988
+ done
989
+ }
990
+
826
991
  prefill_pr_states() {
827
992
  [ "$HOST_LOOKUP_OK" = 1 ] || return 0
828
993
  [ -n "$HOST_STATE_CACHE" ] || return 0
994
+ # ASKED BEFORE THE HOST, and the only reason this function can cost nothing.
995
+ carried_listing && return 0
829
996
  local js br st key rc
830
997
  # Exit code first: non-zero is a transport failure and its stdout is not an
831
998
  # answer. A failed list leaves the cache EMPTY, so every branch falls through
@@ -1070,19 +1237,44 @@ EOF
1070
1237
  # were dropped above, so an `open` answer that is short — exit 7, or a sweep
1071
1238
  # that did not state its completeness — leaves open PRs with no row at all,
1072
1239
  # and completeness would turn those misses into `NONE`.
1240
+ # A STATED CLAIM OUTRANKS A RE-DERIVED ONE, and that is why this reads two
1241
+ # sentences before it counts anything. Either sentence is the adapter saying
1242
+ # the page was whole — `sweep complete` because every tracked branch was asked
1243
+ # and answered, `page complete` because the page came back short of a limit the
1244
+ # host honours. The row count below can only ever GUESS at the second, and on
1245
+ # this repository it guessed wrong: measured 2026-10-02, `--state all --limit
1246
+ # 1000` returned exactly 1000 rows of 1064 PRs, so `_pr_rows < PR_LIST_LIMIT`
1247
+ # was false, the marker was withheld, and 26 branches each cost one `pr-state`
1248
+ # call at 3.8 s — 54-61% of the scan's wall time across slice 1's five runs
1249
+ # (#1017). The adapter knew the page was capped and said so; nothing read it.
1250
+ #
1251
+ # BOTH PAGES MUST CLAIM IT, as the header above requires: the `all` payload's
1252
+ # OPEN rows were dropped, so a short `open` page is what keeps an open PR from
1253
+ # having no row at all, and completeness would turn those misses into `NONE`.
1254
+ # The two claims may arrive by different routes — a sweep for one state and a
1255
+ # short page for the other — and either pair licenses the marker, because each
1256
+ # sentence is the same assertion about its own page.
1257
+ #
1258
+ # THE ROW COUNT STAYS AS THE FALLBACK and is reached only when neither page
1259
+ # stated anything. An adapter that makes no claim is the case it was written
1260
+ # for, and it is still the weaker evidence: a count equal to the limit is
1261
+ # evidence of AT LEAST that many PRs, never of exactly that many. Withholding
1262
+ # the marker costs calls and never correctness, which is why every failure path
1263
+ # here does exactly that.
1073
1264
  [ "$_v_open" = ok ] || return 0
1074
- case "$host_err" in
1075
- *"pr-list sweep complete"*)
1076
- case "$open_err" in
1077
- *"pr-list sweep complete"*)
1078
- printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
1079
- esac ;;
1080
- *)
1081
- if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
1082
- && [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
1083
- printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
1084
- fi ;;
1085
- esac
1265
+ _list_whole() { # $1=the stderr text of one pr-list call
1266
+ case "$1" in
1267
+ *"pr-list sweep complete"*|*"pr-list state="*" page complete "*) return 0 ;;
1268
+ esac
1269
+ return 1
1270
+ }
1271
+ if _list_whole "$host_err" && _list_whole "$open_err"; then
1272
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
1273
+ elif [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
1274
+ && [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
1275
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
1276
+ fi
1277
+ report_listing
1086
1278
  }
1087
1279
  prefill_pr_states
1088
1280
 
@@ -1349,10 +1541,7 @@ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
1349
1541
  # backend, a host returning 503 all afternoon — must not manufacture the state
1350
1542
  # that tells a reader to stop looking. It falls through to the local signals,
1351
1543
  # so a branch with work on the floor reads `stalled`: go and look. That is the
1352
- # safe direction for an answer nobody could verify.
1353
- reached_review() { # $1=branch → 0 when an open or merged PR exists
1354
- case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
1355
- }
1544
+ # safe direction for an answer nobody could verify. `worker_of` asks it.
1356
1545
 
1357
1546
  # ---------------------------------------------------------------------------
1358
1547
  # A SLICE THAT WAITS ON ANOTHER PLAN'S BRANCH
@@ -1385,9 +1574,9 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
1385
1574
  # the branch it was cut from, which is why `plot-pr-merged.sh` reads PRs and not
1386
1575
  # refs, and why this reads the same source.
1387
1576
  #
1388
- # WHAT THE HOST SAID, AND NOT WHAT IT MEANS. This function answered
1577
+ # WHAT THE HOST SAID, AND NOT WHAT IT MEANS. The prerequisite reading answered
1389
1578
  # `waiting` / `blocked` / `""` until the derivation moved: the three answers and
1390
- # the reason `NONE` is the only one that means `blocked` are `waitVerdict` in
1579
+ # why only `NONE` on a name outside `SLICE_NAMES` means `blocked` are `waitVerdict` in
1391
1580
  # `packages/domain/src/rules/branch-state.ts`, with a test per case. What stays
1392
1581
  # here is the READING and the cost argument above it, which is a fact about
1393
1582
  # this script's host budget rather than about what a wait means.
@@ -1396,9 +1585,8 @@ reached_review() { # $1=branch → 0 when an open or merged PR exists
1396
1585
  # may legitimately omit: its plan may be delivered and its ref gone. The bound
1397
1586
  # is the same one PR #216 set — ABSENT branches, not all branches — and the
1398
1587
  # cache above keeps it at one call per run.
1399
- waits_pr_state() { # $1=prerequisite branch → OPEN|MERGED|CLOSED|NONE|-
1400
- host_pr_state "$1" --ask
1401
- }
1588
+ # `host_pr_state "$name" --ask` → OPEN|MERGED|CLOSED|NONE|-, called once per
1589
+ # prerequisite name in the refill below.
1402
1590
 
1403
1591
  # Modification time of a path, in epoch seconds, following symlinks — or "" when
1404
1592
  # it cannot be read.
@@ -1807,7 +1995,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
1807
1995
  # questions this block answers — *is anyone editing* and *when did the work
1808
1996
  # last change* — then read one list, which is what stopped them drifting
1809
1997
  # apart the last time.
1810
- if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
1998
+ if [ -n "$(plot_worker_dirty_filter "$wt_status" "$wt_path")" ]; then wt_dirty=true; else wt_dirty=false; fi
1811
1999
  elif [ "$wt_locked" = true ]; then
1812
2000
  # Status could not answer, but the lock says WHY, and that is an answer
1813
2001
  # rather than the absence of one: a write is in progress in this worktree at
@@ -1843,7 +2031,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
1843
2031
  # row after the first path with a tab in it. An integer cannot.
1844
2032
  wt_changed=""
1845
2033
  if [ -n "${wt_status:-}" ]; then
1846
- wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
2034
+ wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status" "$wt_path")
1847
2035
  if [ -n "$wt_dirty_paths" ]; then
1848
2036
  wt_mtime_args=()
1849
2037
  wt_n=0
@@ -1954,10 +2142,10 @@ worker_of() { # $1=branch → "state\tpid\texit"
1954
2142
  #
1955
2143
  # `$st` IS NOT THIS FACT. It answers a ref/ancestry question — a branch under
1956
2144
  # review reads `wip` — and `merged` there can come from a merge subject with
1957
- # no PR behind it at all. `reached_review` asks the one question that
2145
+ # no PR behind it at all. The PR state below is the one question that
1958
2146
  # outranks the local signals: has this work left the worker's hands?
1959
2147
  local pr_fact=""
1960
- reached_review "$br" && pr_fact="pr"
2148
+ case "$(host_pr_state "$br")" in OPEN|MERGED) pr_fact="pr" ;; esac
1961
2149
  plot_worker_state "$wt" "$pr_fact"
1962
2150
  }
1963
2151
 
@@ -2170,19 +2358,42 @@ changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
2170
2358
  | head -n "$CHANGED_PATHS_LIMIT"
2171
2359
  }
2172
2360
 
2173
- # Did this branch land on the default branch? Positive evidence only — absence
2174
- # keeps today's answer.
2175
- #
2176
- # The branch name is INTERPOLATED INTO AN ERE, so every metacharacter it may
2177
- # legally contain is escaped first. Git allows `+`, `(`, `)`, `?`, `{`, `}` and
2178
- # `.` in ref names, and unescaped each one changes what the pattern means —
2179
- # `feature/v.1` would match `feature/vX1`, and `bug/a+b` would fail to match
2180
- # its OWN merge subject. Both directions are wrong, and the second is the
2181
- # quieter one: a branch that silently never matches simply keeps reading
2182
- # `open`, which is this plan's own bug wearing a different hat.
2183
- merged_by_subject() { # $1=branch → 0 when a conforming merge names it
2184
- printf '%s\n' "$MERGE_SUBJECTS" \
2185
- | grep -qE "^Merge pull request #[0-9]+ from [^/]+/$(printf '%s' "$1" | sed 's/[][\.*^$+?(){}|\/]/\\&/g')\$"
2361
+ # Did this branch land on the default branch, for THIS plan? A lookup in what
2362
+ # the rule already proved — positive evidence only, and absence keeps today's
2363
+ # answer.
2364
+ #
2365
+ # A LOOKUP, NOT A MATCH. The matching is `mergedBySubject`'s, asked once per
2366
+ # scan through `plot-merge-subject.mjs`; this reads the answer. The regex that
2367
+ # stood here read GitHub's form only and was one of three copies of it on the
2368
+ # estate — and being a regex it carried its own fault: a branch name may
2369
+ # legally hold `+`, `(` or `.`, and unescaped each one changes what the pattern
2370
+ # means, so `feature/v.1` matched `feature/vX1` while `bug/a+b` failed to match
2371
+ # its own subject. The rule matches the branch as literal text and cannot have
2372
+ # that fault at all.
2373
+ #
2374
+ # KEYED BY PLAN AND BRANCH, which the regex could not be. A later plan may
2375
+ # reuse a merged branch name — a reopened ticket does it, and one measured
2376
+ # estate holds 87 reused names — so one plan's proof must never settle
2377
+ # another's. `$SUBJECT_PROVEN` holds `<plan>\t<branch>` lines and this tests the
2378
+ # pair.
2379
+ merged_by_subject() { # $1=plan-base $2=branch → 0 when the rule proved the pair
2380
+ case "$SUBJECT_PROVEN" in
2381
+ *$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
2382
+ esac
2383
+ return 1
2384
+ }
2385
+
2386
+ # Was a subject naming this branch REFUSED because its merge predates the plan?
2387
+ #
2388
+ # A SEPARATE QUESTION FROM THE ONE ABOVE, and the footer and the branch JSON
2389
+ # both report it: a slice with no subject and a slice whose subject was refused
2390
+ # for age both read `unknown` under a refused host, and only the second has an
2391
+ # explanation a reader can act on.
2392
+ subject_predates_plan() { # $1=plan-base $2=branch → 0 when a subject was refused
2393
+ case "$SUBJECT_IGNORED" in
2394
+ *$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
2395
+ esac
2396
+ return 1
2186
2397
  }
2187
2398
 
2188
2399
  # Is this branch's PR ready to merge — open, not draft, AND checks green?
@@ -2784,13 +2995,7 @@ add_ref_plan() { # $1=path in ref
2784
2995
  # `$PLAN_DIR` by default, and their symlinks resolve to files already
2785
2996
  # enumerated — counting both would double every plan. `git ls-tree` without
2786
2997
  # `-r` lists one level, and the worktree glob `"$PLAN_DIR"*.md` does not
2787
- # descend either.
2788
- is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2789
- case "$1" in
2790
- ""|NONE) return 1 ;;
2791
- *) return 0 ;;
2792
- esac
2793
- }
2998
+ # descend either. `add_plan_by_phase` below applies the rule.
2794
2999
 
2795
3000
  # ---------------------------------------------------------------------------
2796
3001
  # ONE PARSE FOR THE WHOLE ESTATE
@@ -2847,6 +3052,36 @@ plan_meta_phases=()
2847
3052
  plan_meta_types=()
2848
3053
  plan_meta_waves=()
2849
3054
 
3055
+ # WHERE THE LAST LOOKUP LANDED, so the next one starts there instead of at 0.
3056
+ #
3057
+ # THE CALLERS ASK IN THE ORDER THE ESTATE WAS PARSED. `parse_plan_estate` is
3058
+ # handed `cand_reads` and the candidate loop at `:3415` then walks the SAME
3059
+ # array in the SAME order, so consecutive asks are consecutive entries and a
3060
+ # search that resumes finds its answer in one step. The scan's other two
3061
+ # callers walk `plans`, which is built from that same enumeration.
3062
+ #
3063
+ # IT IS A CURSOR AND NOT A CACHE. Nothing is remembered about any key; the
3064
+ # lookup still compares strings and still searches the whole array before it
3065
+ # answers "not parsed". Resuming changes WHERE the search starts, never what it
3066
+ # finds — a wrap brings it back to every entry it skipped.
3067
+ #
3068
+ # NO ASSOCIATIVE ARRAY, for the reason the header above gives: `/bin/bash` on
3069
+ # macOS is 3.2. A keyed map is the obvious index and `declare -A` would narrow
3070
+ # where Plot runs. Measured 2026-10-02 on this estate, the bash-3.2 string
3071
+ # idioms are all WORSE than the walk at this size: a `case` over a
3072
+ # newline-delimited index answers membership in 1 ms but `${s#*"$k"$'\t'}`
3073
+ # takes 2.9-22.8 s per call to recover the value, because a leading `*` makes
3074
+ # bash re-test the pattern at every offset of a 52 KB string. The cursor needs
3075
+ # no second structure at all.
3076
+ plan_meta_cursor=0
3077
+
3078
+ # Whether any path was parsed twice. A cursor that starts mid-array would return
3079
+ # the LATER copy of a duplicated path, and the walk this replaces always
3080
+ # returned the first — so a duplicate turns the cursor off and the walk runs
3081
+ # from 0, exactly as it did before. Computed ONCE per parse, three forks, 177 ms
3082
+ # at 416 entries; a per-lookup check would cost the walk it is meant to avoid.
3083
+ plan_meta_dup=0
3084
+
2850
3085
  # Parses every plan file given, filling the four arrays above. Called ONCE.
2851
3086
  parse_plan_estate() { # $@=files to parse
2852
3087
  [ $# -gt 0 ] || return 0
@@ -2889,15 +3124,23 @@ for line in sys.stdin:
2889
3124
  # run of tabs collapses to one separator and only the LAST field
2890
3125
  # may be optional. "-" stands in for empty everywhere, so no run
2891
3126
  # can form.
2892
- # THE PREREQUISITE, from the `waits_on` key the parser emits —
2893
- # never re-parsed from the annotation. The key is ABSENT on a branch that
2894
- # declares nothing (`plot-plan-meta.sh` promises "a branch name or
2895
- # nothing — never a blank string"), and "-" stands in here for the
2896
- # same tab-collapse reason every other middle column does.
3127
+ # THE PREREQUISITES, from the `waits_on` key the parser emits —
3128
+ # never re-parsed from the annotation. The key is a LIST, ABSENT on
3129
+ # a branch that declares nothing (`plot-plan-meta.sh` promises "a
3130
+ # list of one or more names or no key at all — never []"), joined
3131
+ # with commas into this one column: `entry/branch-state.ts` reads
3132
+ # field 9 as a comma-separated name list, the shape it already
3133
+ # expects for the parallel PR-state list in field 10. "-" stands in
3134
+ # for absence here for the same tab-collapse reason every other
3135
+ # middle column does. A plan parsed by an OLD parser still emits a
3136
+ # bare string for this key, which ",".join would shred into
3137
+ # letters -- isinstance guards it, so that shape still passes
3138
+ # through as one name.
3139
+ waits_on = b.get("waits_on")
2897
3140
  print("\t".join(clean(x) for x in [
2898
3141
  "W", f, str(i), ref, str(b.get("deferred")).lower(),
2899
3142
  (b.get("deferred_reason") or "-"),
2900
- (b.get("waits_on") or "-"),
3143
+ (",".join(waits_on) if isinstance(waits_on, list) else waits_on) or "-",
2901
3144
  name or "-", b.get("claimed") or "-"]))
2902
3145
  ' 2>/dev/null) || records=""
2903
3146
 
@@ -2926,27 +3169,106 @@ for line in sys.stdin:
2926
3169
  ;;
2927
3170
  esac
2928
3171
  done <<< "$records"
3172
+
3173
+ # ONCE PER PARSE, over the whole array — a second `parse_plan_estate` call
3174
+ # EXTENDS the arrays, so a path it adds may duplicate one the first call
3175
+ # stored and the question has to be re-asked of everything.
3176
+ #
3177
+ # A duplicate is not expected: the enumeration lists each file once. It is
3178
+ # possible — a slug reachable both through the active index and through the
3179
+ # plan directory resolves to one file by two paths — and the walk's answer
3180
+ # for it was the FIRST index, which `:3598` and `:4149` then use to read
3181
+ # `plan_meta_waves`. Guessing here would hand a caller another plan's waves.
3182
+ plan_meta_dup=0
3183
+ if [ ${#plan_meta_files[@]} -gt 1 ]; then
3184
+ if printf '%s\n' "${plan_meta_files[@]}" | sort | uniq -d | grep -q .; then
3185
+ plan_meta_dup=1
3186
+ fi
3187
+ fi
3188
+ plan_meta_cursor=0
2929
3189
  }
2930
3190
 
2931
3191
  # The index of a parsed file in the arrays above, or "" when it was not parsed
2932
- # (an unreadable file, or one the helper could not decode). Linear, over an
2933
- # array the size of the plan directory — the estate is parsed once, so this
2934
- # replaces a SUBPROCESS per lookup with a string compare per lookup.
2935
- plan_meta_index_of() { # $1=file → index on stdout, or ""
2936
- local i
2937
- for i in "${!plan_meta_files[@]}"; do
2938
- if [ "${plan_meta_files[$i]}" = "$1" ]; then printf '%s' "$i"; return 0; fi
3192
+ # (an unreadable file, or one the helper could not decode).
3193
+ #
3194
+ # THE SEARCH RESUMES WHERE THE LAST ONE STOPPED. It was a walk from 0 on every
3195
+ # call, and `add_plan_by_phase` asks it once per CANDIDATE file under the plan
3196
+ # directory — 416 here — so the cost was quadratic in the estate. Measured
3197
+ # 2026-10-02 under `--offline` on this estate: 1248 calls walking 260,208
3198
+ # entries between them, 208.5 per call, which is half the array and exactly
3199
+ # what a from-0 walk costs when the answers are spread through it. Slice 1's
3200
+ # `PS4` trace put the line at 294.4 s over ~345,000 gaps, the largest single
3201
+ # shell cost in the scan.
3202
+ #
3203
+ # **1245 of those 1248 calls were HITS**, not misses: in ref mode the estate
3204
+ # parse covers every candidate, so almost every ask finds its file. That is why
3205
+ # the hit path is what had to get cheaper, and why a membership test alone
3206
+ # would not have helped.
3207
+ #
3208
+ # ONE STEP PER ASK IN THE COMMON CASE. The callers ask in parse order, so the
3209
+ # entry after the last answer is usually the next answer. Measured over all 416
3210
+ # candidates in order: 0.31 s against 5.17 s for the from-0 walk, 16.5x.
3211
+ #
3212
+ # IT STILL SEARCHES EVERYTHING BEFORE ANSWERING "NO". The loop runs for the
3213
+ # array's whole length and wraps, so an ask out of order costs what it always
3214
+ # did and an unparsed file is still reported absent. Only the STARTING POINT
3215
+ # changed.
3216
+ #
3217
+ # ABSENT IS NOT FALSE. A file that was not parsed yields "", which every caller
3218
+ # already reads as "not a plan" (`:3547`, `:4096`). It must never be `0` — that
3219
+ # is the FIRST plan's index — and the function keeps exiting 0 on a miss, since
3220
+ # the callers test the string and not the status.
3221
+ #
3222
+ # THE KEY IS THE STORED STRING, compared with `[ = ]`. A `P` row's file passed
3223
+ # through `clean()`, which turns tabs and newlines into spaces, and
3224
+ # `plan_meta_files` holds that cleaned form; a caller's raw path that the clean
3225
+ # would have changed was never findable and stays unfindable. The compare is
3226
+ # literal, so a path holding `[`, `*` or `?` matches itself and never a glob.
3227
+ # THE SEARCH ITSELF, assigning to `plan_meta_index_reply`. Run it in the shell
3228
+ # whose cursor should advance.
3229
+ plan_meta_index_reply=""
3230
+ plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
3231
+ plan_meta_index_reply=""
3232
+ local n=${#plan_meta_files[@]}
3233
+ [ "$n" -gt 0 ] || return 0
3234
+ local i k=0
3235
+ # A DUPLICATED PATH TURNS THE CURSOR OFF. The walk returned the lowest index
3236
+ # that equalled the file; resuming mid-array could return a later copy, so
3237
+ # this falls back to the from-0 walk and keeps the old answer.
3238
+ if [ "$plan_meta_dup" = 1 ]; then
3239
+ for i in "${!plan_meta_files[@]}"; do
3240
+ if [ "${plan_meta_files[$i]}" = "$1" ]; then plan_meta_index_reply="$i"; return 0; fi
3241
+ done
3242
+ return 0
3243
+ fi
3244
+ i=$plan_meta_cursor
3245
+ while [ "$k" -lt "$n" ]; do
3246
+ if [ "${plan_meta_files[$i]}" = "$1" ]; then
3247
+ # The NEXT entry, because the next ask is usually the next candidate.
3248
+ plan_meta_cursor=$(( (i + 1) % n ))
3249
+ plan_meta_index_reply="$i"
3250
+ return 0
3251
+ fi
3252
+ i=$(( (i + 1) % n ))
3253
+ k=$((k + 1))
2939
3254
  done
2940
- printf ''
2941
3255
  }
2942
3256
 
2943
- # The phase a file declares, or "" when it is not a plan. Read from the single
2944
- # estate parse above rather than spawned per file.
2945
- plan_phase_of() { # $1=file to parse → normalized phase on stdout
3257
+ # The phase a file declares, or "" when it is not a plan, ASSIGNED to
3258
+ # `plan_phase_reply`. Read from the single estate parse above rather than
3259
+ # spawned per file.
3260
+ #
3261
+ # NO STDOUT FORM. A subshell's write to `plan_meta_cursor` is discarded when it
3262
+ # exits, so a `$(…)` reader would restart the search from 0 on every call. Every
3263
+ # caller of this and of `plan_meta_index_into` runs in the scan's own shell.
3264
+ plan_phase_reply=""
3265
+ plan_phase_into() { # $1=file to parse → sets plan_phase_reply
3266
+ plan_phase_reply=""
2946
3267
  local i
2947
- i=$(plan_meta_index_of "$1")
2948
- [ -n "$i" ] || { printf ''; return 0; }
2949
- printf '%s' "${plan_meta_phases[$i]}"
3268
+ plan_meta_index_into "$1"
3269
+ i="$plan_meta_index_reply"
3270
+ [ -n "$i" ] || return 0
3271
+ plan_phase_reply="${plan_meta_phases[$i]}"
2950
3272
  }
2951
3273
 
2952
3274
  # A terminal phase belongs to the delivered group: the plan is finished, and it
@@ -2963,6 +3285,80 @@ is_terminal_phase() { # $1=normalized phase → 0 when finished
2963
3285
  esac
2964
3286
  }
2965
3287
 
3288
+ # EVERY PLAN FILE OF THE ESTATE, into `cand_ids` (identity) and `cand_reads`
3289
+ # (the file to parse): the plan directory of `origin/$MAIN`, then each prefixed
3290
+ # branch's plans the default branch does not carry. A named slug reads one plan
3291
+ # and still enumerates the estate, because `SLICE_NAMES` is the estate's set.
3292
+ enumerate_estate() {
3293
+ cand_ids=()
3294
+ cand_reads=()
3295
+ if [ "$PLAN_SOURCE" = "ref" ]; then
3296
+ while IFS= read -r plan_path; do
3297
+ [ -n "$plan_path" ] || continue
3298
+ plan_blob=$(ref_plan_file "$plan_path") || continue
3299
+ [ -n "$plan_blob" ] || continue
3300
+ cand_ids+=("$plan_path")
3301
+ cand_reads+=("$plan_blob")
3302
+ done <<< "$(ref_ls "$PLAN_DIR")"
3303
+
3304
+ # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3305
+ # not carry. Appended to the SAME candidate arrays, before the one
3306
+ # `parse_plan_estate` call below: a second call would build a second
3307
+ # `plan_meta_files` index and the lookup at the row loop keys on the file
3308
+ # path, so the branch plans would parse and then be unfindable.
3309
+ #
3310
+ # From here the existing pipeline carries the plan unchanged — it names its
3311
+ # branch in `## Slices`, the wave walk finds it, and the branch stops
3312
+ # reaching the report through the plan-less loop in `fleet.ts`.
3313
+ #
3314
+ # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3315
+ # every plan path the default branch carries; `seen_branch_plans` is every
3316
+ # path already taken from an earlier branch. Two branches cut from one point
3317
+ # carry the SAME plan file, and without the second test one plan reports as
3318
+ # several — a regression the board measured and fixed, and the reason its
3319
+ # comment exists.
3320
+ #
3321
+ # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3322
+ # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3323
+ # second `for-each-ref`. The narrowing to PR-less branches the slice line
3324
+ # offered was WITHDRAWN by the plan's Design section: it was a fallback
3325
+ # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3326
+ # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3327
+ # a scan whose wall time is 95% waiting.
3328
+ on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3329
+ seen_branch_plans=$'\n'
3330
+ while IFS=$'\t' read -r branch_name _branch_sha; do
3331
+ [ -n "$branch_name" ] || continue
3332
+ [ "$branch_name" = "HEAD" ] && continue
3333
+ [ "$branch_name" = "$MAIN" ] && continue
3334
+ printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3335
+ while IFS= read -r bp; do
3336
+ [ -n "$bp" ] || continue
3337
+ case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3338
+ case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3339
+ plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3340
+ [ -n "$plan_blob" ] || continue
3341
+ # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3342
+ # that could not be read has not been reported, so a later branch
3343
+ # carrying a readable copy of the same path must still get its turn.
3344
+ seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3345
+ # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3346
+ # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3347
+ # the `docs/plans/…md` path is a usable id — and the dedup above is what
3348
+ # guarantees it cannot collide with a default-branch plan's.
3349
+ cand_ids+=("$bp")
3350
+ cand_reads+=("$plan_blob")
3351
+ done <<< "$(branch_plan_paths "$branch_name")"
3352
+ done <<< "$REMOTE_REFS"
3353
+ else
3354
+ for plan_path in "$PLAN_DIR"*.md; do
3355
+ [ -e "$plan_path" ] || continue
3356
+ cand_ids+=("$plan_path")
3357
+ cand_reads+=("$plan_path")
3358
+ done
3359
+ fi
3360
+ }
3361
+
2966
3362
  if [ -n "$slug" ]; then
2967
3363
  # A NAMED SLUG IS NOT A LIST, so it keeps its own resolution: the caller
2968
3364
  # already said which plan it means, and the phase rule would only be able to
@@ -2997,7 +3393,18 @@ if [ -n "$slug" ]; then
2997
3393
  # because that is where every later question reads its answer from.
2998
3394
  if [ ${#plans[@]} -gt 0 ]; then
2999
3395
  parse_plan_estate "${plan_reads[0]}"
3000
- plan_phases+=("$(plan_phase_of "${plan_reads[0]}")")
3396
+ plan_phase_into "${plan_reads[0]}"
3397
+ plan_phases+=("$plan_phase_reply")
3398
+ fi
3399
+ # THE WHOLE ESTATE TOO, when a `waits:` name will be looked up in
3400
+ # `SLICE_NAMES` below: a prerequisite that is a slice of ANOTHER plan must
3401
+ # read the same here as on a full scan (#1305). The plan loop reads only
3402
+ # `plans`, so the extra plans change no row. Asked only when needed, because
3403
+ # it is the cost: measured 2026-10-06 on this estate (ref mode, `--offline`),
3404
+ # a slug scan took 1.6 s alone and 5.2 s with the estate enumerated.
3405
+ if [ "$slice_names" = 1 ] || printf '%s' ${plan_meta_waves[@]+"${plan_meta_waves[@]}"} | awk -F'\t' '$5 != "-" { w = 1 } END { exit !w }'; then
3406
+ enumerate_estate
3407
+ [ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
3001
3408
  fi
3002
3409
  else
3003
3410
  # ---------------------------------------------------------------------------
@@ -3058,8 +3465,13 @@ else
3058
3465
  # result, so no path below asks the format contract the same question twice.
3059
3466
  add_plan_by_phase() { # $1=identity path, $2=file to parse
3060
3467
  local id="$1" src="$2" ph
3061
- ph=$(plan_phase_of "$src")
3062
- is_plan_phase "$ph" || return 0
3468
+ # NO COMMAND SUBSTITUTION. This runs once per candidate file — 416 on this
3469
+ # estate — and `$(…)` would both fork a subshell per call and discard the
3470
+ # lookup cursor's advance, which is the whole saving.
3471
+ plan_phase_into "$src"
3472
+ ph="$plan_phase_reply"
3473
+ # NOT A PLAN: no phase parsed — see "What makes a file a plan" above.
3474
+ case "$ph" in ""|NONE) return 0 ;; esac
3063
3475
  if is_terminal_phase "$ph"; then
3064
3476
  [ "$next_only" = 1 ] && return 0
3065
3477
  terminal_plans+=("$id")
@@ -3077,73 +3489,7 @@ else
3077
3489
  # ref mode that means every blob is materialized first: the phase decides the
3078
3490
  # group, so the file must exist before it can be asked, and it must be asked
3079
3491
  # together with all the others rather than one at a time.
3080
- cand_ids=()
3081
- cand_reads=()
3082
- if [ "$PLAN_SOURCE" = "ref" ]; then
3083
- while IFS= read -r plan_path; do
3084
- [ -n "$plan_path" ] || continue
3085
- plan_blob=$(ref_plan_file "$plan_path") || continue
3086
- [ -n "$plan_blob" ] || continue
3087
- cand_ids+=("$plan_path")
3088
- cand_reads+=("$plan_blob")
3089
- done <<< "$(ref_ls "$PLAN_DIR")"
3090
-
3091
- # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3092
- # not carry. Appended to the SAME candidate arrays, before the one
3093
- # `parse_plan_estate` call below: a second call would build a second
3094
- # `plan_meta_files` index and the lookup at the row loop keys on the file
3095
- # path, so the branch plans would parse and then be unfindable.
3096
- #
3097
- # From here the existing pipeline carries the plan unchanged — it names its
3098
- # branch in `## Slices`, the wave walk finds it, and the branch stops
3099
- # reaching the report through the plan-less loop in `fleet.ts`.
3100
- #
3101
- # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3102
- # every plan path the default branch carries; `seen_branch_plans` is every
3103
- # path already taken from an earlier branch. Two branches cut from one point
3104
- # carry the SAME plan file, and without the second test one plan reports as
3105
- # several — a regression the board measured and fixed, and the reason its
3106
- # comment exists.
3107
- #
3108
- # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3109
- # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3110
- # second `for-each-ref`. The narrowing to PR-less branches the slice line
3111
- # offered was WITHDRAWN by the plan's Design section: it was a fallback
3112
- # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3113
- # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3114
- # a scan whose wall time is 95% waiting.
3115
- on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3116
- seen_branch_plans=$'\n'
3117
- while IFS=$'\t' read -r branch_name _branch_sha; do
3118
- [ -n "$branch_name" ] || continue
3119
- [ "$branch_name" = "HEAD" ] && continue
3120
- [ "$branch_name" = "$MAIN" ] && continue
3121
- printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3122
- while IFS= read -r bp; do
3123
- [ -n "$bp" ] || continue
3124
- case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3125
- case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3126
- plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3127
- [ -n "$plan_blob" ] || continue
3128
- # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3129
- # that could not be read has not been reported, so a later branch
3130
- # carrying a readable copy of the same path must still get its turn.
3131
- seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3132
- # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3133
- # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3134
- # the `docs/plans/…md` path is a usable id — and the dedup above is what
3135
- # guarantees it cannot collide with a default-branch plan's.
3136
- cand_ids+=("$bp")
3137
- cand_reads+=("$plan_blob")
3138
- done <<< "$(branch_plan_paths "$branch_name")"
3139
- done <<< "$REMOTE_REFS"
3140
- else
3141
- for plan_path in "$PLAN_DIR"*.md; do
3142
- [ -e "$plan_path" ] || continue
3143
- cand_ids+=("$plan_path")
3144
- cand_reads+=("$plan_path")
3145
- done
3146
- fi
3492
+ enumerate_estate
3147
3493
 
3148
3494
  # ONE INVOCATION FOR THE WHOLE ESTATE. Everything below reads its result.
3149
3495
  [ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
@@ -3159,6 +3505,19 @@ else
3159
3505
  done
3160
3506
  fi
3161
3507
 
3508
+ # EVERY SLICE SOMEBODY MAY STILL START, newline-framed for a `case` lookup: the
3509
+ # non-deferred slices (wave field 3) of every parsed plan whose phase is not
3510
+ # terminal. Every mode parses the whole estate, so a slug run, `--next` and the
3511
+ # full scan hold one set, and `plot-dispatch.sh` asks `--slice-names` for it
3512
+ # rather than reading its own plan. A `waits:` name in the set with no pull
3513
+ # request reads `waiting`; any other name with none reads `blocked` (#1305).
3514
+ # `namedSlices` in `rules/branch-state.ts` states the same rule, and
3515
+ # `branch-state.corpus.test.ts` holds the two to one answer.
3516
+ live_waves=()
3517
+ for _si in "${!plan_meta_files[@]}"; do is_terminal_phase "${plan_meta_phases[$_si]}" || live_waves+=("${plan_meta_waves[$_si]}"); done
3518
+ SLICE_NAMES=$'\n'"$(printf '%s' ${live_waves[@]+"${live_waves[@]}"} | awk -F'\t' '$3 != "true" && !seen[$2]++ { print $2 }')"$'\n'
3519
+ [ "$slice_names" = 1 ] && { printf '%s' "$SLICE_NAMES" | sed '/^$/d'; exit 0; }
3520
+
3162
3521
  if [ ${#plans[@]} -eq 0 ]; then
3163
3522
  # --next/--list-eligible must stay silent and exit 1: "nothing to start" is
3164
3523
  # the same answer whether the plans are all claimed or there are no plans at
@@ -3195,6 +3554,209 @@ if [ ${#plans[@]} -eq 0 ]; then
3195
3554
  fi
3196
3555
  fi
3197
3556
 
3557
+ # ---------------------------------------------------------------------------
3558
+ # The merge subjects, asked of the rule once the plans are known
3559
+ # ---------------------------------------------------------------------------
3560
+ #
3561
+ # WHY HERE AND NOT WITH THE WALK ABOVE. The rule is asked per plan, with that
3562
+ # plan's own refless branches and the commit that added its file — and neither
3563
+ # is known until the estate is parsed. The walk itself still happens once, up
3564
+ # at `MERGE_SUBJECTS`; this is where its lines are USED.
3565
+ #
3566
+ # THREE READINGS PER SCAN, NONE PER PLAN. The per-plan lookup this replaces
3567
+ # cost 6 to 37 ms per plan, 5.23 s for 140 plans here; the batched walk below
3568
+ # costs 0.10 s for 392 plan files, 0.02 s on the estate that reported #1139.
3569
+ #
3570
+ # 1. one `git log --diff-filter=AR --name-status` for every plan file's
3571
+ # adding commit (here)
3572
+ # 2. the merges walk, already run above with `%H %s`
3573
+ # 3. one `git merge-base --is-ancestor` per MATCHED PAIR, which is a few calls
3574
+ # per scan rather than one per plan
3575
+ #
3576
+ # THE BUNDLE IS ASKED TWICE, with reading 3 between the calls: the age rule
3577
+ # needs an ancestry answer and the domain may not run git, so the first call
3578
+ # names the pairs needing a test and the second applies the rule to the
3579
+ # answers. The decision stays in the bundle; this shell only reads git.
3580
+ SUBJECT_PROVEN=$'\n'
3581
+ SUBJECT_IGNORED=$'\n'
3582
+ SUBJECT_PREDATES_PLAN=0
3583
+
3584
+ # The commit that first added each plan file, as `<path>\t<sha>` lines.
3585
+ #
3586
+ # `--diff-filter=AR` AND THE RENAME FOLLOW. With git's default rename
3587
+ # detection a renamed dated file is listed `R` and never `A`, so an `A`-only
3588
+ # walk leaves the current path out of the answer and the plan gets no subjects
3589
+ # — measured on `origin/main`, 4 of 392 dated plans have no `A` entry for their
3590
+ # current path, all renamed while Draft. Each `R` is followed back to the old
3591
+ # path's `A` in the SAME call, at no extra cost, so a retitled plan keeps the
3592
+ # commit that first added it.
3593
+ #
3594
+ # ON `origin/<main>`, NEVER `HEAD`. This runs in desks and on feature branches,
3595
+ # where `HEAD` holds another history — and the merges walk reads the same ref,
3596
+ # so a mismatch would compare an age from one history with a merge from
3597
+ # another.
3598
+ #
3599
+ # THE DATED FILE, NEVER THE SYMLINK under `active/` or `delivered/`. A delivery
3600
+ # moves the symlink as a git rename, so the symlink's adding commit is the
3601
+ # delivery commit: measured on the estate that reported #1139, one delivered
3602
+ # plan keeps 4 of 4 subjects through its target and 0 of 4 through its symlink,
3603
+ # because one bulk move re-added 13 symlinks at once.
3604
+ plan_adding_commits() { # → <path>\t<sha> per plan file
3605
+ git log "origin/$MAIN" --diff-filter=AR --name-status --format=@%H \
3606
+ -- "$PLAN_DIR" </dev/null 2>/dev/null \
3607
+ | awk '
3608
+ /^@/ { commit = substr($0, 2); next }
3609
+ # An A line gives a path and the commit that added it. The walk runs
3610
+ # newest-first, so the LAST A seen for a path is the oldest — which is the
3611
+ # first add, and the age the rule wants.
3612
+ /^A\t/ { add[$2] = commit; next }
3613
+ # An R line maps an old path (field 2) to a new one (field 3). Recorded as
3614
+ # a chain and resolved after the walk: a rename may be seen before the add
3615
+ # of the path it renames from.
3616
+ /^R/ { from[$3] = $2; next }
3617
+ END {
3618
+ for (path in add) resolved[path] = add[path]
3619
+ # Follow each chain to a path with an A line. Bounded by the chain
3620
+ # length, and a cycle cannot form — a rename always names an earlier
3621
+ # path, and `seen` stops one anyway.
3622
+ for (path in from) {
3623
+ cur = path
3624
+ delete seen
3625
+ while (cur in from && !(cur in seen)) { seen[cur] = 1; cur = from[cur] }
3626
+ if (cur in add) resolved[path] = add[cur]
3627
+ # A chain ending in no A line inside this walk leaves the path
3628
+ # unresolved, so its plan gets no subjects and its branches go to the
3629
+ # host — stated in the plan as the limit it is.
3630
+ }
3631
+ for (path in resolved) printf "%s\t%s\n", path, resolved[path]
3632
+ }'
3633
+ }
3634
+
3635
+ # What the rule was asked, assembled once and reused for both calls.
3636
+ subject_readings() {
3637
+ printf '@backend %s\n' "$HOST_BACKEND"
3638
+ printf '@origin %s\n' "$ORIGIN_URL"
3639
+ printf '@merges\n'
3640
+ [ -n "$MERGE_SUBJECTS" ] && printf '%s\n' "$MERGE_SUBJECTS"
3641
+ printf '%s' "$SUBJECT_PLAN_SECTIONS"
3642
+ }
3643
+
3644
+ ask_merge_subject() { # $1=verb; stdin=readings → the rule's answer
3645
+ node "$script_dir/board/plot-merge-subject.mjs" "$1" 2>/dev/null
3646
+ }
3647
+
3648
+ # AN EMPTY WALK IS A MEASUREMENT, AND IT ANSWERS `none`.
3649
+ #
3650
+ # A squash or rebase estate leaves no merge commit at all, so `$MERGE_SUBJECTS`
3651
+ # is empty — and that is the walk having run and found nothing, which is exactly
3652
+ # what `none` says. `unaskable` must mean only *the rule could not be asked*, or
3653
+ # the footer stops telling a reader the two apart; the asking side of that
3654
+ # distinction is this script's and the found/not-found side is the bundle's.
3655
+ #
3656
+ # Measured by CI on the first run of this slice: the guard below read
3657
+ # `[ -n "$MERGE_SUBJECTS" ]` and a fixture with no merges reported
3658
+ # `merge_detect=unaskable`, which claims the question was never put about an
3659
+ # estate the walk had just examined.
3660
+ if [ -z "$MERGE_SUBJECTS" ]; then
3661
+ MERGE_DETECT=none
3662
+ fi
3663
+
3664
+ # Only where there is something for the rule to match. `--offline` and `--no-pr`
3665
+ # do not gate this: the walks are LOCAL, they cost no host call, and a subject is
3666
+ # the one proof an offline scan can still have.
3667
+ if [ ${#plans[@]} -gt 0 ] && [ -n "$MERGE_SUBJECTS" ]; then
3668
+ # The origin URL, read once. The rule parses it — this script never does, so
3669
+ # the owner is read one way by the scan and the supervisor alike.
3670
+ ORIGIN_URL=$(git config --get remote.origin.url 2>/dev/null || echo "")
3671
+ HOST_BACKEND=$("$script_dir/plot-host.sh" backend 2>/dev/null || echo "")
3672
+
3673
+ SUBJECT_ADDING=$'\n'"$(plan_adding_commits)"$'\n'
3674
+
3675
+ # One section per plan: its dated file, its adding commit, and its refless
3676
+ # branches. A branch WITH a ref is never offered — the ref check stays in
3677
+ # front, and a recreated branch carrying new work must not be settled by the
3678
+ # first attempt's subject.
3679
+ SUBJECT_PLAN_SECTIONS=""
3680
+ for _sp_i in "${!plans[@]}"; do
3681
+ _sp_plan="${plans[$_sp_i]}"
3682
+ _sp_base=$(basename "$(readlink "$_sp_plan" 2>/dev/null || echo "$_sp_plan")")
3683
+ _sp_path="$PLAN_DIR$_sp_base"
3684
+ _sp_added=""
3685
+ case "$SUBJECT_ADDING" in
3686
+ *$'\n'"$_sp_path"$'\t'*)
3687
+ _sp_added=$(printf '%s' "$SUBJECT_ADDING" \
3688
+ | awk -F'\t' -v p="$_sp_path" '$1 == p { print $2; exit }') ;;
3689
+ esac
3690
+ # KEYED ON THE FILE THAT WAS PARSED, which is what `plan_meta_files` holds
3691
+ # — `$plan_reads[i]`, the same key the row loop uses at pass 1a. In ref mode
3692
+ # that is a materialized blob under a temp path and not `$PLAN_DIR` at all,
3693
+ # so a reconstructed path finds nothing.
3694
+ plan_meta_index_into "${plan_reads[$_sp_i]}"
3695
+ _sp_meta_i=$plan_meta_index_reply
3696
+ [ -n "$_sp_meta_i" ] || continue
3697
+ _sp_branches=""
3698
+ while IFS=$'\t' read -r _sp_idx _sp_br _sp_rest; do
3699
+ [ -n "$_sp_br" ] || continue
3700
+ remote_ref_exists "$_sp_br" && continue
3701
+ _sp_branches+="$_sp_br"$'\n'
3702
+ done <<< "${plan_meta_waves[$_sp_meta_i]}"
3703
+ [ -n "$_sp_branches" ] || continue
3704
+ # THE BASENAME IS THE KEY, because that is what the row loop holds as
3705
+ # `$plan_base` and what the lookup tests. The adding-commit walk keys on
3706
+ # the full path, which is what git reports — the two are joined here, once,
3707
+ # rather than at every lookup.
3708
+ SUBJECT_PLAN_SECTIONS+="@plan $_sp_base"$'\n'
3709
+ SUBJECT_PLAN_SECTIONS+="@added ${_sp_added:--}"$'\n'
3710
+ SUBJECT_PLAN_SECTIONS+="$_sp_branches"
3711
+ done
3712
+
3713
+ if [ -n "$SUBJECT_PLAN_SECTIONS" ]; then
3714
+ # CALL 1: which pairs need an ancestry test.
3715
+ _sp_pairs=$(subject_readings | ask_merge_subject pairs || echo "")
3716
+ # THE READING, one per matched pair. The age rule in
3717
+ # plot-merge-subject.mjs is what decides what it means.
3718
+ _sp_answers=""
3719
+ while IFS=$'\t' read -r _sp_pplan _sp_pbr _sp_merge _sp_added2; do
3720
+ [ -n "$_sp_merge" ] || continue
3721
+ # A wrong "contained" sends the branch to the host; a wrong "not
3722
+ # contained" is the reused-name case this narrows.
3723
+ #
3724
+ # plot-ancestry: evidence — handed to `proofOf` in
3725
+ # plot-merge-subject.mjs, which decides and
3726
+ # reads `unknown` as proving nothing.
3727
+ if git merge-base --is-ancestor "$_sp_merge" "$_sp_added2" </dev/null 2>/dev/null; then
3728
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 yes"$'\n'
3729
+ elif git cat-file -e "$_sp_merge^{commit}" </dev/null 2>/dev/null \
3730
+ && git cat-file -e "$_sp_added2^{commit}" </dev/null 2>/dev/null; then
3731
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 no"$'\n'
3732
+ else
3733
+ # A commit this checkout cannot read answers neither way, and the rule
3734
+ # reads `unknown` as proving nothing.
3735
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 unknown"$'\n'
3736
+ fi
3737
+ done <<< "$_sp_pairs"
3738
+
3739
+ # CALL 2: the proof, given the answers.
3740
+ while IFS=$'\t' read -r _sp_word _sp_a _sp_b _sp_c; do
3741
+ case "$_sp_word" in
3742
+ proven) SUBJECT_PROVEN+="$_sp_a"$'\t'"$_sp_b"$'\n' ;;
3743
+ ignored)
3744
+ SUBJECT_IGNORED+="$_sp_a"$'\t'"$_sp_b"$'\n'
3745
+ SUBJECT_PREDATES_PLAN=$((SUBJECT_PREDATES_PLAN + 1)) ;;
3746
+ detect)
3747
+ # `truncated` is THIS script's reading and outranks `pr-merge`: the
3748
+ # bundle cannot see the cap, and a capped walk detected but did not
3749
+ # examine exhaustively.
3750
+ if [ "$_sp_a" = "pr-merge" ] && [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
3751
+ MERGE_DETECT=truncated
3752
+ else
3753
+ MERGE_DETECT="$_sp_a"
3754
+ fi ;;
3755
+ esac
3756
+ done <<< "$(printf '%s\n%s' "$(subject_readings)" "$_sp_answers" | ask_merge_subject proven || echo "")"
3757
+ fi
3758
+ fi
3759
+
3198
3760
  # A branch is merged when its remote ref is an ancestor of origin/<main>, or —
3199
3761
  # once the ref is gone — when the default branch carries a conforming PR-merge
3200
3762
  # commit naming it (see "the evidence that survives the ref" above). An absent
@@ -3316,8 +3878,9 @@ EOF
3316
3878
  MAIN_TIP=$(remote_ref_oid "$MAIN")
3317
3879
  [ -n "$MAIN_TIP" ] || MAIN_TIP="-"
3318
3880
 
3319
- branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3320
- local br="$1" _bs_deferred="$2" _bs_subject=false _bs_ahead=0 _bs_real=0 _bs_tip
3881
+ branch_readings() { # $1=branch $2=deferred $3=plan-base → readings
3882
+ local br="$1" _bs_deferred="$2" _bs_plan="${3:-}" _bs_subject=false
3883
+ local _bs_ahead=0 _bs_real=0 _bs_tip
3321
3884
  local _bs_main="$MAIN_TIP"
3322
3885
  # THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
3323
3886
  #
@@ -3350,7 +3913,17 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3350
3913
  # exists — squash merges, a hand-rewritten subject, a branch genuinely
3351
3914
  # never started — today's `open` stands. The evidence may only move a branch
3352
3915
  # from `open` to `merged`, and only when it is positive.
3353
- merged_by_subject "$br" && _bs_subject=true
3916
+ # THE PAIR, not the branch alone. `$_bs_plan` is what stops one plan's
3917
+ # proof settling another plan's reused name — the case round 1 executed,
3918
+ # where an unstarted reused name read `merged`, its slice read complete,
3919
+ # the next slice opened, and the reused slice was never offered.
3920
+ merged_by_subject "$_bs_plan" "$br" && _bs_subject=true
3921
+ # A subject the age rule refused is reported apart from one that never
3922
+ # existed: both read `unknown` under a refused host, and only this one has
3923
+ # an explanation.
3924
+ _bs_predates=false
3925
+ [ "$_bs_subject" = false ] && subject_predates_plan "$_bs_plan" "$br" \
3926
+ && _bs_predates=true
3354
3927
  # No merge commit names it — which is the ordinary case under a squash
3355
3928
  # merge, not an exotic one. The local walk is now out of evidence, so the
3356
3929
  # host is asked. It may only ever move this branch from `open` to `merged`:
@@ -3375,7 +3948,20 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3375
3948
  # what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
3376
3949
  # cannot. `|| true` because a not-merged answer is an ordinary reading and
3377
3950
  # `set -e` must not read it as a failure.
3378
- merged_by_host "$br" || true
3951
+ #
3952
+ # NOT ASKED FOR A BRANCH THE SUBJECT ALREADY PROVED. For the wave gate the
3953
+ # subject IS enough — settling a slice and opening the next is reversible
3954
+ # work — and asking anyway is the spend #1139 reports: a host refusing
3955
+ # every question, asked again on a 5-second pulse about a branch already
3956
+ # proven.
3957
+ #
3958
+ # THE ONE EXCEPTION IS A DELIVERY CANDIDATE, and it is asked in PASS 1e of
3959
+ # the plan loop rather than here. Candidacy needs every branch's DECIDED
3960
+ # state, which exists only after the rule has answered for the whole plan,
3961
+ # and this function runs per branch in a subshell before that.
3962
+ if [ "$_bs_subject" = false ]; then
3963
+ merged_by_host "$br" || true
3964
+ fi
3379
3965
  # `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
3380
3966
  # With no ref, the host is the only remaining source, so when it could not
3381
3967
  # be asked that claim was never earned — and the branch measured on
@@ -3528,7 +4114,7 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3528
4114
  # There is no shell fallback: a second implementation kept "just in case" is
3529
4115
  # the duplication this adoption removes, and it would be the copy nobody tests.
3530
4116
  # `plot-deliver.sh` fails the same way for the same reason.
3531
- ask_branch_states() { # stdin=readings → one `state<TAB>needs` line per branch
4117
+ ask_branch_states() { # stdin=readings → one `state<TAB>needs<TAB>own` line per branch
3532
4118
  node "$script_dir/board/plot-branch-state.mjs" 2>/dev/null \
3533
4119
  || { echo "error: cannot read branch states — run 'pnpm build:board'." >&2; exit 2; }
3534
4120
  }
@@ -3646,7 +4232,8 @@ for plan in "${plans[@]}"; do
3646
4232
  # The estate was parsed ONCE, before this loop. A plan absent from that
3647
4233
  # result could not be read at all, which is the same answer the per-plan
3648
4234
  # parse gave by failing — so it is skipped here exactly as it was then.
3649
- meta_i=$(plan_meta_index_of "$plan_read")
4235
+ plan_meta_index_into "$plan_read"
4236
+ meta_i=$plan_meta_index_reply
3650
4237
  [ -n "$meta_i" ] || continue
3651
4238
 
3652
4239
  # The plan's own phase, carried onto the pulse so a consumer can derive a row
@@ -3758,13 +4345,20 @@ for plan in "${plans[@]}"; do
3758
4345
  # not evidence in either direction. The two were one marker until CI ran the
3759
4346
  # corpus with no token, where every prerequisite answers `-` and every waiting
3760
4347
  # branch read `open`. Reading it
3761
- # costs a host round trip (`waits_pr_state` passes `--ask`, because a delivered
4348
+ # costs a host round trip (`host_pr_state … --ask`, because a delivered
3762
4349
  # prerequisite's ref is gone and only its PR outlives it), and the scan spends
3763
4350
  # that only where the answer could change the branch's state. Which states
3764
4351
  # those are IS the precedence, so the rule reports it rather than this loop
3765
4352
  # deciding it — see pass 1c.
3766
4353
  readings=""
3767
4354
  order=""
4355
+ # WHICH BRANCHES THE HOST ITSELF CALLED MERGED, recorded here because
4356
+ # `_merged_by_host_state` is a single variable that pass 1a overwrites per
4357
+ # branch — by the time the JSON emitter runs it holds the LAST branch's
4358
+ # answer. The `evidence` field needs to know, per branch, whether the host
4359
+ # confirmed the landing, so the answer is kept while it is still the right
4360
+ # one.
4361
+ host_merged_branches=$'\n'
3768
4362
  # THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
3769
4363
  # written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
3770
4364
  # main as `open` only when it is true; a capped list may omit a merged PR.
@@ -3775,7 +4369,15 @@ for plan in "${plans[@]}"; do
3775
4369
  # "-" is the absent marker the shim writes, for the tab-collapse reason
3776
4370
  # above. Normalized here so everything downstream tests emptiness.
3777
4371
  [ "$waits" = "-" ] && waits=""
3778
- readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
4372
+ readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete ?"$'\n'
4373
+ # Read from the readings line just built rather than from the variable: the
4374
+ # subshell `branch_readings` runs in cannot export it back.
4375
+ # FIELD 6 is the host's own word, in both arms of `branch_readings`:
4376
+ # `$_merged_by_host_state` for a refless branch and `host_pr_state` for one
4377
+ # with a ref. Field 9 is `waits`, appended by this loop.
4378
+ case "$(printf '%s' "$readings" | tail -n1 | cut -f6)" in
4379
+ MERGED) host_merged_branches+="$br"$'\n' ;;
4380
+ esac
3779
4381
  order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
3780
4382
  done <<< "$wave_lines"
3781
4383
 
@@ -3821,7 +4423,7 @@ for plan in "${plans[@]}"; do
3821
4423
  while IFS= read -r rd_line; do
3822
4424
  [ -n "$rd_line" ] || continue
3823
4425
  answer_i=$((answer_i + 1))
3824
- IFS=$'\t' read -r _st needs \
4426
+ IFS=$'\t' read -r _st needs _ \
3825
4427
  <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
3826
4428
  waits_br=$(printf '%s' "$rd_line" | cut -f9)
3827
4429
  if [ "$needs" = "1" ] && [ "$waits_br" != "-" ]; then
@@ -3830,8 +4432,23 @@ for plan in "${plans[@]}"; do
3830
4432
  # list may legitimately omit: its plan may be delivered and its ref gone.
3831
4433
  # `host_pr_state`'s run cache keeps this at one call per prerequisite per
3832
4434
  # run, never one per pass.
3833
- # Field 10 is replaced; field 11 is carried.
3834
- refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br") $(printf '%s' "$rd_line" | cut -f11)"$'\n'
4435
+ #
4436
+ # FIELD 9 IS A LIST, comma-joined, so field 10 answers each prerequisite
4437
+ # IN THE SAME ORDER — one `host_pr_state … --ask` call per name, joined the same
4438
+ # way. `entry/branch-state.ts` reads the two columns as parallel lists of
4439
+ # equal length and throws otherwise, so a single answer for several names
4440
+ # would desync them. Field 11 is carried, unreplaced. Field 12 says, in
4441
+ # the same order, whether `SLICE_NAMES` holds each name.
4442
+ #
4443
+ # A NAMED SLICE WITH NO REF IS STILL ASKED. Its ref may be gone because it
4444
+ # merged and `plot-release-refs.sh` reaped it, and only the host's MERGED
4445
+ # clears the wait; skipping the call would hold the branch forever.
4446
+ waits_state="" waits_named=""
4447
+ for _wn in ${waits_br//,/ }; do
4448
+ waits_state+="${waits_state:+,}$(host_pr_state "$_wn" --ask)"
4449
+ case "$SLICE_NAMES" in *$'\n'"$_wn"$'\n'*) waits_named+="${waits_named:+,}true" ;; *) waits_named+="${waits_named:+,}false" ;; esac
4450
+ done
4451
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11) $waits_named"$'\n'
3835
4452
  else
3836
4453
  refill+="$rd_line"$'\n'
3837
4454
  fi
@@ -3849,15 +4466,56 @@ for plan in "${plans[@]}"; do
3849
4466
  # record is re-read by two more `read` loops below, and an EMPTY middle
3850
4467
  # column collapses its tab into its neighbour's and shifts every later
3851
4468
  # field left. `$claim` is the only field allowed to be last and optional.
4469
+ # `$own` is the branch's state before its prerequisite, the third answer column.
3852
4470
  states=""
3853
4471
  answer_i=0
3854
4472
  while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3855
4473
  [ -n "$br" ] || continue
3856
4474
  answer_i=$((answer_i + 1))
3857
- st=$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p" | cut -f1)
3858
- states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
4475
+ IFS=$'\t' read -r st _ own <<< "$(printf '%s\n' "$branch_answers" | sed -n "${answer_i}p")"
4476
+ states+="$idx $br $st $deferred $why $waits $wname ${own:-$st} $claim"$'\n'
3859
4477
  done <<< "$order"
3860
4478
 
4479
+ # PASS 1e: A DELIVERY CANDIDATE ASKS THE HOST about its subject-proven branches.
4480
+ #
4481
+ # A candidate is an Approved plan whose every non-deferred branch reads
4482
+ # `merged`. Its next step is a delivery, and `allSlicesConfirmed` refuses a
4483
+ # delivery while any branch carries `evidence: subject`. So for a candidate,
4484
+ # and only under `HOST_VERDICT` `ok` or `partial`, `merged_by_host` is asked
4485
+ # about each branch the subject alone proved. A refused host is not asked:
4486
+ # that spend is the condition #1139 reports, and the branch keeps
4487
+ # `evidence: subject`.
4488
+ #
4489
+ # IN THE MAIN SHELL, not in `branch_readings`. That function runs per branch
4490
+ # in a subshell before any state is decided, so it cannot know whether the
4491
+ # plan is a candidate. Measured 2026-10-02: its `$4` was never passed, 32 of
4492
+ # 39 merged branches on this estate read `evidence: subject`, and auto-deliver
4493
+ # held every plan whose branch refs were deleted at merge.
4494
+ #
4495
+ # The terminal cache bounds the cost: a MERGED answer is kept per tip of
4496
+ # `origin/<main>`, so a candidate is asked once per tip, not once per pulse.
4497
+ if [ "$plan_phase" = approved ]; then
4498
+ case "$HOST_VERDICT" in
4499
+ ok|partial)
4500
+ cand=true
4501
+ cand_proven=""
4502
+ while IFS=$'\t' read -r _ c_br c_st c_deferred _; do
4503
+ [ -n "$c_br" ] || continue
4504
+ if [ "$c_deferred" = true ]; then continue; fi
4505
+ [ "$c_st" = merged ] || { cand=false; break; }
4506
+ case "$host_merged_branches" in *$'\n'"$c_br"$'\n'*) continue ;; esac
4507
+ if merged_by_subject "$plan_base" "$c_br"; then cand_proven+="$c_br"$'\n'; fi
4508
+ done <<< "$states"
4509
+ if [ "$cand" = true ]; then
4510
+ while IFS= read -r c_br; do
4511
+ [ -n "$c_br" ] || continue
4512
+ if merged_by_host "$c_br"; then host_merged_branches+="$c_br"$'\n'; fi
4513
+ done <<< "$cand_proven"
4514
+ fi
4515
+ ;;
4516
+ esac
4517
+ fi
4518
+
3861
4519
  # Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
3862
4520
  # not settled. A reading, and the whole of what this script contributes to the
3863
4521
  # verdict: which branches count as settled depends on `--loose` and on a host
@@ -3874,7 +4532,7 @@ for plan in "${plans[@]}"; do
3874
4532
  outstanding=0
3875
4533
  _loose_degraded_branches=""
3876
4534
  wave_states=""
3877
- while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
4535
+ while IFS=$'\t' read -r idx br st deferred why waits nm _ claim; do
3878
4536
  [ "$idx" = "$wid" ] || continue
3879
4537
  # EVERY branch, including the deferred ones, and in the order the render
3880
4538
  # loop below will walk them — the claimable flags come back positionally,
@@ -3973,24 +4631,30 @@ for plan in "${plans[@]}"; do
3973
4631
  # `deferred` to tell a branch that will never move from one that has not
3974
4632
  # moved yet.
3975
4633
  outlook_branches=""
3976
- while IFS=$'\t' read -r idx br st deferred why waits nm claim; do
4634
+ while IFS=$'\t' read -r idx br st deferred why waits nm own claim; do
3977
4635
  [ "$idx" = "$wid" ] || continue
3978
4636
  outlook_branches+="${outlook_branches:+|}$br:$st"
3979
4637
  [ "$claim" = "-" ] && claim=""
3980
4638
  [ "$why" = "-" ] && why=""
3981
4639
  [ "$waits" = "-" ] && waits=""
4640
+ # THE NOTE NAMES EVERY PREREQUISITE, not just one: `$waits` is comma-joined
4641
+ # with no space (the shim's separator), and the sentence reads "a, b" —
4642
+ # with one name both forms are byte-identical. The scan has the VERDICT
4643
+ # and not the per-prerequisite answer here, so `blocked`'s sentence names
4644
+ # every prerequisite rather than which one lacks a PR.
4645
+ waits_prose="${waits//,/, }"
3982
4646
  n_branches=$((n_branches + 1))
3983
4647
  case "$st" in
3984
4648
  # WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
3985
4649
  # later without saying what would have to happen first, which is the
3986
4650
  # whole of what this state adds over `open`.
3987
4651
  waiting) n_waiting=$((n_waiting + 1))
3988
- note="waiting on $waits" ;;
3989
- # A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
3990
- # and answered, because that is what separates this from `waiting`: a
3991
- # host that could not be asked holds the branch at `waiting` instead.
4652
+ note="waiting on $waits_prose" ;;
4653
+ # A PREREQUISITE NOBODY DECLARED: no plan names it as a slice, and the
4654
+ # host answered that no PR exists for it. A host that could not be
4655
+ # asked, or a named slice nobody has started, reads `waiting` instead.
3992
4656
  blocked) n_prereq_missing=$((n_prereq_missing + 1))
3993
- note="blocked — no PR found for $waits" ;;
4657
+ note="blocked — no PR found for $waits_prose" ;;
3994
4658
  # The REASON, where the plan recorded one. A bare `deferred` beside a
3995
4659
  # branch with no commits reads as two unrelated facts when the first is
3996
4660
  # the reason for the second, and the sentence that says so was already
@@ -4032,21 +4696,49 @@ for plan in "${plans[@]}"; do
4032
4696
  # The INTERNAL state ($st), never the prose label ($note): the board
4033
4697
  # must not parse a string that exists for humans to read.
4034
4698
  json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
4035
- json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
4699
+ json_branches+=",\"state\":\"$st\",\"own_state\":\"$own\",\"deferred\":$deferred"
4700
+ # WHAT PROVED THE LANDING, where the proof is weaker than the host's.
4701
+ #
4702
+ # EMITTED ONLY WHERE THE BRANCH READS `merged` AND THE HOST HAS NOT
4703
+ # CONFIRMED IT. `merged_by_host` leaves its own word in
4704
+ # `_merged_by_host_state`, and the scan does not even ask for a proven
4705
+ # branch that is not a delivery candidate — so the absence of a host
4706
+ # `MERGED` is what the field reports. The branch loses the field the
4707
+ # moment the host answers merged.
4708
+ #
4709
+ # A QUALIFIER, NOT A STATE. The state stays `merged`, the wave
4710
+ # arithmetic is unmoved and the next slice still opens; what reads this
4711
+ # is `allSlicesConfirmed`, in front of a delivery, which is the one
4712
+ # decision here that cannot be undone.
4713
+ if [ "$st" = merged ] && merged_by_subject "$plan_base" "$br" \
4714
+ && [ "${host_merged_branches#*$'\n'"$br"$'\n'}" = "$host_merged_branches" ]; then
4715
+ json_branches+=",\"evidence\":\"subject\""
4716
+ fi
4717
+ # WHY A SUBJECT NAMING THIS BRANCH PROVED NOTHING. A reused name whose
4718
+ # merge predates the plan: the slice reads as it would with no subject
4719
+ # at all, and this is what tells a reader the two cases apart.
4720
+ if subject_predates_plan "$plan_base" "$br"; then
4721
+ json_branches+=",\"subjectIgnored\":\"predates-plan\""
4722
+ fi
4036
4723
  # WHY it was deferred, straight from the plan's annotation. "" where the
4037
4724
  # branch is not deferred, and "" where it is deferred with nothing
4038
4725
  # recorded — the flag says which of those two a reader is looking at.
4039
4726
  json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
4040
4727
  # WHAT THIS BRANCH WAITS ON, straight from the plan's `waits:`
4041
- # annotation. "" where the branch declares nothing, which is the answer
4042
- # every branch gave before this field existed.
4728
+ # annotation. `[]` where the branch declares nothing — `FleetBranchSchema`
4729
+ # carries the key on every branch since #1253, so absence is an empty
4730
+ # list here rather than a missing key, unlike the parser's own
4731
+ # `waits_on` which omits the key entirely.
4043
4732
  #
4044
4733
  # THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
4045
4734
  # says. A branch whose prerequisite has MERGED reports `waits_on` with
4046
4735
  # its ordinary state — the declaration is still a fact about the plan,
4047
4736
  # and a reader who sees a cleared dependency learns why the slice is
4048
4737
  # now startable. Consumers test `state`, never the presence of this.
4049
- json_branches+=",\"waits_on\":\"$(json_str "$waits")\""
4738
+ #
4739
+ # `$waits` IS COMMA-JOINED NAMES, never PR states: one name per line
4740
+ # for `json_array`, which escapes each.
4741
+ json_branches+=",\"waits_on\":$(json_array "${waits//,/$'\n'}")"
4050
4742
  json_branches+=",\"claimed\":\"$(json_str "$claim")\""
4051
4743
  # What this machine knows and the refs do not. Absent everywhere else:
4052
4744
  # `local_dirty:false` and `local_worktree:""` are what a branch checked
@@ -4674,4 +5366,9 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
4674
5366
  fi
4675
5367
  write_bridge
4676
5368
  echo "Pulse complete. This report is derived — nothing was changed."
4677
- echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed claimable=$n_eligible 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"
5369
+ # `subject_predates_plan` NAMES A SUBJECT THE AGE RULE REFUSED, and it is its
5370
+ # own counter rather than a silence. A slice whose subject was refused reads
5371
+ # `unknown` under a refused host, exactly as a slice with no subject does — and
5372
+ # only this one has an explanation an operator can act on, which is that a
5373
+ # later plan reused a merged branch name.
5374
+ echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed claimable=$n_eligible eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing merge_detect=$MERGE_DETECT subject_predates_plan=$SUBJECT_PREDATES_PLAN host=$HOST_VERDICT main=$MAIN"