@plot-pm/board 0.16.2 → 0.16.3

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.
@@ -471,8 +471,13 @@ fi
471
471
  # seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
472
472
  # nothing but the silent misses described above.
473
473
  MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
474
+ # `%H %s` RATHER THAN `%s`, because the age rule needs the merge COMMIT and not
475
+ # only its sentence: a subject proves a branch for a plan only when its merge is
476
+ # not contained in the commit that added the plan file, and that is an ancestry
477
+ # question about this hash. One walk still, and the hash costs nothing — the
478
+ # same `git log`, one more format placeholder.
474
479
  MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
475
- --max-count="$MERGE_SCAN_LIMIT" --pretty=%s </dev/null 2>/dev/null || true)
480
+ --max-count="$MERGE_SCAN_LIMIT" --pretty='%H %s' </dev/null 2>/dev/null || true)
476
481
  MERGE_SCAN_TRUNCATED=0
477
482
  if [ -n "$MERGE_SUBJECTS" ] \
478
483
  && [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
@@ -489,11 +494,22 @@ fi
489
494
  # capped walk detected, but not exhaustively.
490
495
  # none — the default branch carries no conforming merge commits at all
491
496
  # (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
497
+ # unaskable — the rule could not be asked at all, so NO branch got a subject
498
+ # reading and every refless branch went to the host. A missing or
499
+ # silent bundle is the case; it is not the same answer as `none`,
500
+ # which is a measurement of this estate's history.
501
+ #
502
+ # THE WORD IS THE BUNDLE'S, and this is why no regex decides it here. The regex
503
+ # that stood on this line read GitHub's form only, so a Bitbucket estate — whose
504
+ # every merge carries `Merged in <branch> (pull request #N)` — reported
505
+ # `merge_detect=none` and said its own 1723 proofs did not exist. The forms are
506
+ # data in the host adapter now, and a second copy in shell is exactly what
507
+ # `a-merge-subject-proves-a-landing-the-host-cannot` removes.
508
+ #
509
+ # SET AFTER THE PLANS ARE PARSED, because the bundle is asked then — the plan
510
+ # branches and their adding commits are known only then. Until that point the
511
+ # word is `unaskable`, which is the honest answer for a scan that has not asked.
512
+ MERGE_DETECT=unaskable
497
513
 
498
514
  # ---------------------------------------------------------------------------
499
515
  # Squash merges: the case where no local evidence survives at all
@@ -648,7 +664,32 @@ cache_key() { # $1=branch → a filename that is injective in the branch name
648
664
  # them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
649
665
  # already performs on Bitbucket, so the join and the per-branch lookup cannot
650
666
  # disagree about the same branch.
651
- PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
667
+ # GENEROUS MEANS ABOVE THE REPOSITORY'S PR COUNT, and 1000 stopped being that.
668
+ # It was set when this repo held 221 PRs. Measured 2026-10-02 it holds 1064, so
669
+ # `--state all --limit 1000` returned exactly 1000 rows — a page AT its limit,
670
+ # which proves only "at least 1000" and can never license completeness. The
671
+ # marker was withheld, and the 26 branches the join could not name each cost one
672
+ # `pr-state` call at 3.8 s: 54-61% of the scan's wall time (#1017).
673
+ #
674
+ # THE NUMBER IS A BUDGET, NOT AN ASSUMPTION, and that distinction is what makes
675
+ # raising it a fix rather than a deferral. A page short of this limit is now read
676
+ # as PROOF of completeness — `plot-host.sh` states it and `prefill_pr_states`
677
+ # reads the sentence — so the limit's only job is to be reachable. A repository
678
+ # that outgrows it gets a capped page, the adapter says `possibly truncated`,
679
+ # the marker is withheld, and the scan degrades to the per-branch asking it did
680
+ # before: slower, and still correct. The failure is loud in the adapter's own
681
+ # stderr and costs cost, never an answer.
682
+ #
683
+ # COST SCALES WITH ROWS RETURNED, NOT ROWS REQUESTED, so the headroom is nearly
684
+ # free. Measured 2026-10-02 through `plot-host.sh pr-list --state all`:
685
+ # `--limit 1000` → 1000 rows in 7.0 s; `--limit 3000` → 1065 rows in 9.2 s;
686
+ # `--limit 5000` → the same 1065 rows in 11.1 s. GitHub pages internally and
687
+ # stops at what exists, so asking for 3000 of 1064 fetches 1064. Two seconds
688
+ # more on one call against ~100 s of per-branch calls removed.
689
+ #
690
+ # `PLOT_PR_LIST_LIMIT` still overrides it, and lowering it is how a repository
691
+ # with a tighter quota trades the join back for per-branch asking.
692
+ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-3000}"
652
693
 
653
694
  # WHETHER THE HOST ANSWERED, as a fact of its own — the thing this scan
654
695
  # computed and threw away until 2026-08-30.
@@ -823,9 +864,129 @@ pr_list_verdict_rank() {
823
864
  esac
824
865
  }
825
866
 
867
+ # The listing a previous pulse made, handed in rather than spent again.
868
+ #
869
+ # THE BOARD DECIDES, NOT THIS SCRIPT. `listingSpend` in the domain answers
870
+ # whether the account can afford a listing now, and the board — the only
871
+ # long-lived process here — holds the previous one and hands it back. This scan
872
+ # is spawned fresh per pulse and can span none, which is `PLOT_TERMINAL_CACHE`'s
873
+ # reason (`fleet.ts:3380`) applied to the listing rather than to merge facts.
874
+ #
875
+ # Measured 2026-10-02 on the Bitbucket workspace `quatico`: this scan spent 2949
876
+ # of one account's 3150 calls in an hour, 93.6%, because the open listing sweeps
877
+ # one REST request per tracked branch per state and the pulse is 5 s. Cutting the
878
+ # number of runs does not fix that; cutting the listings a run makes does.
879
+ #
880
+ # A CARRIED LISTING IS A FULL ANSWER OR IT IS NOT USED. It carries the same
881
+ # per-branch lines `prefill_pr_states` would have written plus the markers that
882
+ # license `NONE`, so every reader below is unchanged and none of them can tell a
883
+ # carried listing from a fetched one. That is deliberate: the AGE is the board's
884
+ # to report, because the board is what knows how old its own listing is.
885
+ # An empty value means nothing was carried and the listing is spent as before.
886
+ PLOT_PR_LISTING="${PLOT_PR_LISTING:-}"
887
+
888
+ # Fill the cache from a carried listing. Prints nothing; returns 1 when there was
889
+ # nothing to carry, so the caller spends the listing instead.
890
+ #
891
+ # ABSENT IS NOT EMPTY. A carried listing with no rows would license `NONE` for
892
+ # every branch — the 2026-08-27 failure that refused four fully-merged plans —
893
+ # so a value carrying no arrival marker is refused here and the host is asked.
894
+ carried_listing() {
895
+ [ -n "$PLOT_PR_LISTING" ] || return 1
896
+ local key st chk dft arrived=0 complete=0
897
+ while IFS=" " read -r key st chk dft; do
898
+ case "$key" in
899
+ '') continue ;;
900
+ # The markers travel as lines rather than as files, because the board holds
901
+ # one string and not a directory.
902
+ .list-arrived) arrived=1; continue ;;
903
+ .list-complete) complete=1; continue ;;
904
+ # Every other dotted key is a note for the board and not a branch. Skipped
905
+ # rather than written: `git check-ref-format` rejects a branch name starting
906
+ # with a dot and the key encoding maps only `_` and `/`, so no branch key can
907
+ # begin with one and a dotted key is never a missed answer.
908
+ .*) continue ;;
909
+ esac
910
+ [ -n "$st" ] || continue
911
+ # The `-` sentinel back to empty, exactly as the host payload's plain rows are
912
+ # translated: an empty `checks` reads as `unknown` in `pr_ready` and degrades
913
+ # `--loose` to strict, which is the safe direction.
914
+ [ "$chk" = "-" ] && chk=""
915
+ [ "$dft" = "-" ] && dft=""
916
+ printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$key" 2>/dev/null || true
917
+ done <<EOF
918
+ $PLOT_PR_LISTING
919
+ EOF
920
+ # A listing that arrived with no rows is a real answer — a repository with no
921
+ # PRs — but it is indistinguishable here from a value truncated in transit, and
922
+ # the direction that errs costs a fabricated `NONE`. The MARKER carries the
923
+ # claim, so arrival decides and the row count does not.
924
+ [ "$arrived" = 1 ] || return 1
925
+ printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
926
+ [ "$complete" = 1 ] && { printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true; }
927
+ # `ok`, AND A NEW WORD HERE WOULD STOP THE FLEET. `reachFrom`
928
+ # (`entry/branch-state.ts:113`) maps any word it does not know to `failed`, so
929
+ # an invented `reused` would read as *the host could not be reached*: every
930
+ # branch with no ref would answer `unknown`, `--next` offers only `open`, and
931
+ # nothing would be handed out while the listing was being reused.
932
+ #
933
+ # `ok` IS ALSO THE TRUE ANSWER TO THE QUESTION THIS WORD ASKS. The verdict says
934
+ # how much evidence the scan holds about a branch, not how old the evidence is:
935
+ # a carried listing arrived and was whole, so it licenses exactly what a fetched
936
+ # one licenses. The AGE is the board's to report, because the board is what
937
+ # knows when it listed — this scan is spawned fresh and cannot know.
938
+ HOST_VERDICT=ok
939
+ return 0
940
+ }
941
+
942
+ # Report the listing this run fetched, for the next pulse to carry.
943
+ #
944
+ # ON STDERR AND TAGGED, exactly as the terminal map is reported: stdout is the
945
+ # scan's document and a reader of it must not have to know this exists. The board
946
+ # reads the tag; every other caller discards it with the scan's ordinary prose.
947
+ #
948
+ # REPORTED ONLY WHERE THE LISTING ARRIVED. A failed or throttled call writes no
949
+ # `.list-arrived`, and carrying its empty cache forward would turn one refusal
950
+ # into a listing the next pulse trusts.
951
+ report_listing() {
952
+ [ -n "$HOST_STATE_CACHE" ] || return 0
953
+ [ -f "$HOST_STATE_CACHE/.list-arrived" ] || return 0
954
+ printf 'listing: %s\t%s\t%s\t%s\n' .list-arrived 1 - - >&2
955
+ [ -f "$HOST_STATE_CACHE/.list-complete" ] \
956
+ && printf 'listing: %s\t%s\t%s\t%s\n' .list-complete 1 - - >&2
957
+ # WHAT THE LISTING COST, MEASURED RATHER THAN MODELLED. The Bitbucket arm sweeps
958
+ # one request per tracked branch per state, and this scan is the only thing that
959
+ # knows how many branches it tracked. A board predicting the number from the
960
+ # plans would under-count: a plan names a subset of the remote refs, and the
961
+ # sweep asks about every one of them.
962
+ printf 'listing: %s\t%s\t%s\t%s\n' .branches \
963
+ "$(printf '%s' "$TRACKED_BRANCHES" | wc -w | tr -d ' ')" - - >&2
964
+ # One line per branch the listing answered for, in the cache's own key encoding
965
+ # and its own `STATE<TAB>checks<TAB>draft` shape, so what is carried back is what
966
+ # this run wrote and the carry path parses no JSON.
967
+ local f key rec _st _chk _dft
968
+ for f in "$HOST_STATE_CACHE"/*; do
969
+ [ -f "$f" ] || continue
970
+ key=${f##*/}
971
+ case "$key" in .*) continue ;; esac
972
+ rec=$(cat "$f" 2>/dev/null) || continue
973
+ IFS=" " read -r _st _chk _dft <<EOF
974
+ $rec
975
+ EOF
976
+ [ -n "$_st" ] || continue
977
+ # Empty fields are refilled with `-` so every field stays occupied: TAB is an
978
+ # IFS whitespace character, and `read` would otherwise slide the branch key
979
+ # along by one. The carry path translates them back, which is the same `-`
980
+ # sentinel the host payload's plain rows already use.
981
+ printf 'listing: %s\t%s\t%s\t%s\n' "$key" "$_st" "${_chk:--}" "${_dft:--}" >&2
982
+ done
983
+ }
984
+
826
985
  prefill_pr_states() {
827
986
  [ "$HOST_LOOKUP_OK" = 1 ] || return 0
828
987
  [ -n "$HOST_STATE_CACHE" ] || return 0
988
+ # ASKED BEFORE THE HOST, and the only reason this function can cost nothing.
989
+ carried_listing && return 0
829
990
  local js br st key rc
830
991
  # Exit code first: non-zero is a transport failure and its stdout is not an
831
992
  # answer. A failed list leaves the cache EMPTY, so every branch falls through
@@ -1070,19 +1231,44 @@ EOF
1070
1231
  # were dropped above, so an `open` answer that is short — exit 7, or a sweep
1071
1232
  # that did not state its completeness — leaves open PRs with no row at all,
1072
1233
  # and completeness would turn those misses into `NONE`.
1234
+ # A STATED CLAIM OUTRANKS A RE-DERIVED ONE, and that is why this reads two
1235
+ # sentences before it counts anything. Either sentence is the adapter saying
1236
+ # the page was whole — `sweep complete` because every tracked branch was asked
1237
+ # and answered, `page complete` because the page came back short of a limit the
1238
+ # host honours. The row count below can only ever GUESS at the second, and on
1239
+ # this repository it guessed wrong: measured 2026-10-02, `--state all --limit
1240
+ # 1000` returned exactly 1000 rows of 1064 PRs, so `_pr_rows < PR_LIST_LIMIT`
1241
+ # was false, the marker was withheld, and 26 branches each cost one `pr-state`
1242
+ # call at 3.8 s — 54-61% of the scan's wall time across slice 1's five runs
1243
+ # (#1017). The adapter knew the page was capped and said so; nothing read it.
1244
+ #
1245
+ # BOTH PAGES MUST CLAIM IT, as the header above requires: the `all` payload's
1246
+ # OPEN rows were dropped, so a short `open` page is what keeps an open PR from
1247
+ # having no row at all, and completeness would turn those misses into `NONE`.
1248
+ # The two claims may arrive by different routes — a sweep for one state and a
1249
+ # short page for the other — and either pair licenses the marker, because each
1250
+ # sentence is the same assertion about its own page.
1251
+ #
1252
+ # THE ROW COUNT STAYS AS THE FALLBACK and is reached only when neither page
1253
+ # stated anything. An adapter that makes no claim is the case it was written
1254
+ # for, and it is still the weaker evidence: a count equal to the limit is
1255
+ # evidence of AT LEAST that many PRs, never of exactly that many. Withholding
1256
+ # the marker costs calls and never correctness, which is why every failure path
1257
+ # here does exactly that.
1073
1258
  [ "$_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
1259
+ _list_whole() { # $1=the stderr text of one pr-list call
1260
+ case "$1" in
1261
+ *"pr-list sweep complete"*|*"pr-list state="*" page complete "*) return 0 ;;
1262
+ esac
1263
+ return 1
1264
+ }
1265
+ if _list_whole "$host_err" && _list_whole "$open_err"; then
1266
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
1267
+ elif [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
1268
+ && [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
1269
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
1270
+ fi
1271
+ report_listing
1086
1272
  }
1087
1273
  prefill_pr_states
1088
1274
 
@@ -1807,7 +1993,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
1807
1993
  # questions this block answers — *is anyone editing* and *when did the work
1808
1994
  # last change* — then read one list, which is what stopped them drifting
1809
1995
  # apart the last time.
1810
- if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
1996
+ if [ -n "$(plot_worker_dirty_filter "$wt_status" "$wt_path")" ]; then wt_dirty=true; else wt_dirty=false; fi
1811
1997
  elif [ "$wt_locked" = true ]; then
1812
1998
  # Status could not answer, but the lock says WHY, and that is an answer
1813
1999
  # rather than the absence of one: a write is in progress in this worktree at
@@ -1843,7 +2029,7 @@ while IFS=$'\t' read -r wt_branch wt_path; do
1843
2029
  # row after the first path with a tab in it. An integer cannot.
1844
2030
  wt_changed=""
1845
2031
  if [ -n "${wt_status:-}" ]; then
1846
- wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
2032
+ wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status" "$wt_path")
1847
2033
  if [ -n "$wt_dirty_paths" ]; then
1848
2034
  wt_mtime_args=()
1849
2035
  wt_n=0
@@ -2170,19 +2356,42 @@ changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
2170
2356
  | head -n "$CHANGED_PATHS_LIMIT"
2171
2357
  }
2172
2358
 
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')\$"
2359
+ # Did this branch land on the default branch, for THIS plan? A lookup in what
2360
+ # the rule already proved — positive evidence only, and absence keeps today's
2361
+ # answer.
2362
+ #
2363
+ # A LOOKUP, NOT A MATCH. The matching is `mergedBySubject`'s, asked once per
2364
+ # scan through `plot-merge-subject.mjs`; this reads the answer. The regex that
2365
+ # stood here read GitHub's form only and was one of three copies of it on the
2366
+ # estate — and being a regex it carried its own fault: a branch name may
2367
+ # legally hold `+`, `(` or `.`, and unescaped each one changes what the pattern
2368
+ # means, so `feature/v.1` matched `feature/vX1` while `bug/a+b` failed to match
2369
+ # its own subject. The rule matches the branch as literal text and cannot have
2370
+ # that fault at all.
2371
+ #
2372
+ # KEYED BY PLAN AND BRANCH, which the regex could not be. A later plan may
2373
+ # reuse a merged branch name — a reopened ticket does it, and one measured
2374
+ # estate holds 87 reused names — so one plan's proof must never settle
2375
+ # another's. `$SUBJECT_PROVEN` holds `<plan>\t<branch>` lines and this tests the
2376
+ # pair.
2377
+ merged_by_subject() { # $1=plan-base $2=branch → 0 when the rule proved the pair
2378
+ case "$SUBJECT_PROVEN" in
2379
+ *$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
2380
+ esac
2381
+ return 1
2382
+ }
2383
+
2384
+ # Was a subject naming this branch REFUSED because its merge predates the plan?
2385
+ #
2386
+ # A SEPARATE QUESTION FROM THE ONE ABOVE, and the footer and the branch JSON
2387
+ # both report it: a slice with no subject and a slice whose subject was refused
2388
+ # for age both read `unknown` under a refused host, and only the second has an
2389
+ # explanation a reader can act on.
2390
+ subject_predates_plan() { # $1=plan-base $2=branch → 0 when a subject was refused
2391
+ case "$SUBJECT_IGNORED" in
2392
+ *$'\n'"$1"$'\t'"$2"$'\n'*) return 0 ;;
2393
+ esac
2394
+ return 1
2186
2395
  }
2187
2396
 
2188
2397
  # Is this branch's PR ready to merge — open, not draft, AND checks green?
@@ -2847,6 +3056,36 @@ plan_meta_phases=()
2847
3056
  plan_meta_types=()
2848
3057
  plan_meta_waves=()
2849
3058
 
3059
+ # WHERE THE LAST LOOKUP LANDED, so the next one starts there instead of at 0.
3060
+ #
3061
+ # THE CALLERS ASK IN THE ORDER THE ESTATE WAS PARSED. `parse_plan_estate` is
3062
+ # handed `cand_reads` and the candidate loop at `:3415` then walks the SAME
3063
+ # array in the SAME order, so consecutive asks are consecutive entries and a
3064
+ # search that resumes finds its answer in one step. The scan's other two
3065
+ # callers walk `plans`, which is built from that same enumeration.
3066
+ #
3067
+ # IT IS A CURSOR AND NOT A CACHE. Nothing is remembered about any key; the
3068
+ # lookup still compares strings and still searches the whole array before it
3069
+ # answers "not parsed". Resuming changes WHERE the search starts, never what it
3070
+ # finds — a wrap brings it back to every entry it skipped.
3071
+ #
3072
+ # NO ASSOCIATIVE ARRAY, for the reason the header above gives: `/bin/bash` on
3073
+ # macOS is 3.2. A keyed map is the obvious index and `declare -A` would narrow
3074
+ # where Plot runs. Measured 2026-10-02 on this estate, the bash-3.2 string
3075
+ # idioms are all WORSE than the walk at this size: a `case` over a
3076
+ # newline-delimited index answers membership in 1 ms but `${s#*"$k"$'\t'}`
3077
+ # takes 2.9-22.8 s per call to recover the value, because a leading `*` makes
3078
+ # bash re-test the pattern at every offset of a 52 KB string. The cursor needs
3079
+ # no second structure at all.
3080
+ plan_meta_cursor=0
3081
+
3082
+ # Whether any path was parsed twice. A cursor that starts mid-array would return
3083
+ # the LATER copy of a duplicated path, and the walk this replaces always
3084
+ # returned the first — so a duplicate turns the cursor off and the walk runs
3085
+ # from 0, exactly as it did before. Computed ONCE per parse, three forks, 177 ms
3086
+ # at 416 entries; a per-lookup check would cost the walk it is meant to avoid.
3087
+ plan_meta_dup=0
3088
+
2850
3089
  # Parses every plan file given, filling the four arrays above. Called ONCE.
2851
3090
  parse_plan_estate() { # $@=files to parse
2852
3091
  [ $# -gt 0 ] || return 0
@@ -2889,15 +3128,23 @@ for line in sys.stdin:
2889
3128
  # run of tabs collapses to one separator and only the LAST field
2890
3129
  # may be optional. "-" stands in for empty everywhere, so no run
2891
3130
  # 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.
3131
+ # THE PREREQUISITES, from the `waits_on` key the parser emits —
3132
+ # never re-parsed from the annotation. The key is a LIST, ABSENT on
3133
+ # a branch that declares nothing (`plot-plan-meta.sh` promises "a
3134
+ # list of one or more names or no key at all — never []"), joined
3135
+ # with commas into this one column: `entry/branch-state.ts` reads
3136
+ # field 9 as a comma-separated name list, the shape it already
3137
+ # expects for the parallel PR-state list in field 10. "-" stands in
3138
+ # for absence here for the same tab-collapse reason every other
3139
+ # middle column does. A plan parsed by an OLD parser still emits a
3140
+ # bare string for this key, which ",".join would shred into
3141
+ # letters -- isinstance guards it, so that shape still passes
3142
+ # through as one name.
3143
+ waits_on = b.get("waits_on")
2897
3144
  print("\t".join(clean(x) for x in [
2898
3145
  "W", f, str(i), ref, str(b.get("deferred")).lower(),
2899
3146
  (b.get("deferred_reason") or "-"),
2900
- (b.get("waits_on") or "-"),
3147
+ (",".join(waits_on) if isinstance(waits_on, list) else waits_on) or "-",
2901
3148
  name or "-", b.get("claimed") or "-"]))
2902
3149
  ' 2>/dev/null) || records=""
2903
3150
 
@@ -2926,18 +3173,99 @@ for line in sys.stdin:
2926
3173
  ;;
2927
3174
  esac
2928
3175
  done <<< "$records"
3176
+
3177
+ # ONCE PER PARSE, over the whole array — a second `parse_plan_estate` call
3178
+ # EXTENDS the arrays, so a path it adds may duplicate one the first call
3179
+ # stored and the question has to be re-asked of everything.
3180
+ #
3181
+ # A duplicate is not expected: the enumeration lists each file once. It is
3182
+ # possible — a slug reachable both through the active index and through the
3183
+ # plan directory resolves to one file by two paths — and the walk's answer
3184
+ # for it was the FIRST index, which `:3598` and `:4149` then use to read
3185
+ # `plan_meta_waves`. Guessing here would hand a caller another plan's waves.
3186
+ plan_meta_dup=0
3187
+ if [ ${#plan_meta_files[@]} -gt 1 ]; then
3188
+ if printf '%s\n' "${plan_meta_files[@]}" | sort | uniq -d | grep -q .; then
3189
+ plan_meta_dup=1
3190
+ fi
3191
+ fi
3192
+ plan_meta_cursor=0
2929
3193
  }
2930
3194
 
2931
3195
  # 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
3196
+ # (an unreadable file, or one the helper could not decode).
3197
+ #
3198
+ # THE SEARCH RESUMES WHERE THE LAST ONE STOPPED. It was a walk from 0 on every
3199
+ # call, and `add_plan_by_phase` asks it once per CANDIDATE file under the plan
3200
+ # directory — 416 here — so the cost was quadratic in the estate. Measured
3201
+ # 2026-10-02 under `--offline` on this estate: 1248 calls walking 260,208
3202
+ # entries between them, 208.5 per call, which is half the array and exactly
3203
+ # what a from-0 walk costs when the answers are spread through it. Slice 1's
3204
+ # `PS4` trace put the line at 294.4 s over ~345,000 gaps, the largest single
3205
+ # shell cost in the scan.
3206
+ #
3207
+ # **1245 of those 1248 calls were HITS**, not misses: in ref mode the estate
3208
+ # parse covers every candidate, so almost every ask finds its file. That is why
3209
+ # the hit path is what had to get cheaper, and why a membership test alone
3210
+ # would not have helped.
3211
+ #
3212
+ # ONE STEP PER ASK IN THE COMMON CASE. The callers ask in parse order, so the
3213
+ # entry after the last answer is usually the next answer. Measured over all 416
3214
+ # candidates in order: 0.31 s against 5.17 s for the from-0 walk, 16.5x.
3215
+ #
3216
+ # IT STILL SEARCHES EVERYTHING BEFORE ANSWERING "NO". The loop runs for the
3217
+ # array's whole length and wraps, so an ask out of order costs what it always
3218
+ # did and an unparsed file is still reported absent. Only the STARTING POINT
3219
+ # changed.
3220
+ #
3221
+ # ABSENT IS NOT FALSE. A file that was not parsed yields "", which every caller
3222
+ # already reads as "not a plan" (`:3547`, `:4096`). It must never be `0` — that
3223
+ # is the FIRST plan's index — and the function keeps exiting 0 on a miss, since
3224
+ # the callers test the string and not the status.
3225
+ #
3226
+ # THE KEY IS THE STORED STRING, compared with `[ = ]`. A `P` row's file passed
3227
+ # through `clean()`, which turns tabs and newlines into spaces, and
3228
+ # `plan_meta_files` holds that cleaned form; a caller's raw path that the clean
3229
+ # would have changed was never findable and stays unfindable. The compare is
3230
+ # literal, so a path holding `[`, `*` or `?` matches itself and never a glob.
3231
+ # THE SEARCH ITSELF, assigning to `plan_meta_index_reply`. Run it in the shell
3232
+ # whose cursor should advance; `plan_meta_index_of` below is the stdout wrapper
3233
+ # for callers that are already inside a `$(…)`.
3234
+ plan_meta_index_reply=""
3235
+ plan_meta_index_into() { # $1=file → sets plan_meta_index_reply
3236
+ plan_meta_index_reply=""
3237
+ local n=${#plan_meta_files[@]}
3238
+ [ "$n" -gt 0 ] || return 0
3239
+ local i k=0
3240
+ # A DUPLICATED PATH TURNS THE CURSOR OFF. The walk returned the lowest index
3241
+ # that equalled the file; resuming mid-array could return a later copy, so
3242
+ # this falls back to the from-0 walk and keeps the old answer.
3243
+ if [ "$plan_meta_dup" = 1 ]; then
3244
+ for i in "${!plan_meta_files[@]}"; do
3245
+ if [ "${plan_meta_files[$i]}" = "$1" ]; then plan_meta_index_reply="$i"; return 0; fi
3246
+ done
3247
+ return 0
3248
+ fi
3249
+ i=$plan_meta_cursor
3250
+ while [ "$k" -lt "$n" ]; do
3251
+ if [ "${plan_meta_files[$i]}" = "$1" ]; then
3252
+ # The NEXT entry, because the next ask is usually the next candidate.
3253
+ plan_meta_cursor=$(( (i + 1) % n ))
3254
+ plan_meta_index_reply="$i"
3255
+ return 0
3256
+ fi
3257
+ i=$(( (i + 1) % n ))
3258
+ k=$((k + 1))
2939
3259
  done
2940
- printf ''
3260
+ }
3261
+
3262
+ # The stdout form, for the two callers that read it inside a command
3263
+ # substitution. The cursor it advances belongs to that subshell and dies with
3264
+ # it, which costs those callers nothing: both ask once per live plan, and the
3265
+ # weight was never there.
3266
+ plan_meta_index_of() { # $1=file → index on stdout, or ""
3267
+ plan_meta_index_into "$1"
3268
+ printf '%s' "$plan_meta_index_reply"
2941
3269
  }
2942
3270
 
2943
3271
  # The phase a file declares, or "" when it is not a plan. Read from the single
@@ -2949,6 +3277,29 @@ plan_phase_of() { # $1=file to parse → normalized phase on stdout
2949
3277
  printf '%s' "${plan_meta_phases[$i]}"
2950
3278
  }
2951
3279
 
3280
+ # The same answer, ASSIGNED to `plan_phase_reply` instead of printed.
3281
+ #
3282
+ # THE CURSOR ONLY ADVANCES IN THE PARENT SHELL. `plan_phase_of` is read as
3283
+ # `$(plan_phase_of …)` and `plan_meta_index_of` as `$(plan_meta_index_of …)`,
3284
+ # and a subshell's write to `plan_meta_cursor` is discarded when it exits — so
3285
+ # the resumable search would restart from 0 on every call and the quadratic
3286
+ # shape would survive the fix. This form runs in the caller's own shell, which
3287
+ # is where `add_plan_by_phase` runs and where the 416 asks come from.
3288
+ #
3289
+ # A SECOND FUNCTION RATHER THAN A CHANGED CONTRACT. `plan_phase_of` keeps its
3290
+ # stdout form for the slug path at `:3308`, which asks once and whose answer is
3291
+ # interpolated into an array append. Rewriting that caller to read a global
3292
+ # would trade a one-off subshell for a less obvious assignment.
3293
+ plan_phase_reply=""
3294
+ plan_phase_into() { # $1=file to parse → sets plan_phase_reply
3295
+ plan_phase_reply=""
3296
+ local i
3297
+ plan_meta_index_into "$1"
3298
+ i="$plan_meta_index_reply"
3299
+ [ -n "$i" ] || return 0
3300
+ plan_phase_reply="${plan_meta_phases[$i]}"
3301
+ }
3302
+
2952
3303
  # A terminal phase belongs to the delivered group: the plan is finished, and it
2953
3304
  # appears only while its `Delivered:` record is inside the rolling window.
2954
3305
  #
@@ -3058,7 +3409,11 @@ else
3058
3409
  # result, so no path below asks the format contract the same question twice.
3059
3410
  add_plan_by_phase() { # $1=identity path, $2=file to parse
3060
3411
  local id="$1" src="$2" ph
3061
- ph=$(plan_phase_of "$src")
3412
+ # NO COMMAND SUBSTITUTION. This runs once per candidate file — 416 on this
3413
+ # estate — and `$(…)` would both fork a subshell per call and discard the
3414
+ # lookup cursor's advance, which is the whole saving.
3415
+ plan_phase_into "$src"
3416
+ ph="$plan_phase_reply"
3062
3417
  is_plan_phase "$ph" || return 0
3063
3418
  if is_terminal_phase "$ph"; then
3064
3419
  [ "$next_only" = 1 ] && return 0
@@ -3195,6 +3550,208 @@ if [ ${#plans[@]} -eq 0 ]; then
3195
3550
  fi
3196
3551
  fi
3197
3552
 
3553
+ # ---------------------------------------------------------------------------
3554
+ # The merge subjects, asked of the rule once the plans are known
3555
+ # ---------------------------------------------------------------------------
3556
+ #
3557
+ # WHY HERE AND NOT WITH THE WALK ABOVE. The rule is asked per plan, with that
3558
+ # plan's own refless branches and the commit that added its file — and neither
3559
+ # is known until the estate is parsed. The walk itself still happens once, up
3560
+ # at `MERGE_SUBJECTS`; this is where its lines are USED.
3561
+ #
3562
+ # THREE READINGS PER SCAN, NONE PER PLAN. The per-plan lookup this replaces
3563
+ # cost 6 to 37 ms per plan, 5.23 s for 140 plans here; the batched walk below
3564
+ # costs 0.10 s for 392 plan files, 0.02 s on the estate that reported #1139.
3565
+ #
3566
+ # 1. one `git log --diff-filter=AR --name-status` for every plan file's
3567
+ # adding commit (here)
3568
+ # 2. the merges walk, already run above with `%H %s`
3569
+ # 3. one `git merge-base --is-ancestor` per MATCHED PAIR, which is a few calls
3570
+ # per scan rather than one per plan
3571
+ #
3572
+ # THE BUNDLE IS ASKED TWICE, with reading 3 between the calls: the age rule
3573
+ # needs an ancestry answer and the domain may not run git, so the first call
3574
+ # names the pairs needing a test and the second applies the rule to the
3575
+ # answers. The decision stays in the bundle; this shell only reads git.
3576
+ SUBJECT_PROVEN=$'\n'
3577
+ SUBJECT_IGNORED=$'\n'
3578
+ SUBJECT_PREDATES_PLAN=0
3579
+
3580
+ # The commit that first added each plan file, as `<path>\t<sha>` lines.
3581
+ #
3582
+ # `--diff-filter=AR` AND THE RENAME FOLLOW. With git's default rename
3583
+ # detection a renamed dated file is listed `R` and never `A`, so an `A`-only
3584
+ # walk leaves the current path out of the answer and the plan gets no subjects
3585
+ # — measured on `origin/main`, 4 of 392 dated plans have no `A` entry for their
3586
+ # current path, all renamed while Draft. Each `R` is followed back to the old
3587
+ # path's `A` in the SAME call, at no extra cost, so a retitled plan keeps the
3588
+ # commit that first added it.
3589
+ #
3590
+ # ON `origin/<main>`, NEVER `HEAD`. This runs in desks and on feature branches,
3591
+ # where `HEAD` holds another history — and the merges walk reads the same ref,
3592
+ # so a mismatch would compare an age from one history with a merge from
3593
+ # another.
3594
+ #
3595
+ # THE DATED FILE, NEVER THE SYMLINK under `active/` or `delivered/`. A delivery
3596
+ # moves the symlink as a git rename, so the symlink's adding commit is the
3597
+ # delivery commit: measured on the estate that reported #1139, one delivered
3598
+ # plan keeps 4 of 4 subjects through its target and 0 of 4 through its symlink,
3599
+ # because one bulk move re-added 13 symlinks at once.
3600
+ plan_adding_commits() { # → <path>\t<sha> per plan file
3601
+ git log "origin/$MAIN" --diff-filter=AR --name-status --format=@%H \
3602
+ -- "$PLAN_DIR" </dev/null 2>/dev/null \
3603
+ | awk '
3604
+ /^@/ { commit = substr($0, 2); next }
3605
+ # An A line gives a path and the commit that added it. The walk runs
3606
+ # newest-first, so the LAST A seen for a path is the oldest — which is the
3607
+ # first add, and the age the rule wants.
3608
+ /^A\t/ { add[$2] = commit; next }
3609
+ # An R line maps an old path (field 2) to a new one (field 3). Recorded as
3610
+ # a chain and resolved after the walk: a rename may be seen before the add
3611
+ # of the path it renames from.
3612
+ /^R/ { from[$3] = $2; next }
3613
+ END {
3614
+ for (path in add) resolved[path] = add[path]
3615
+ # Follow each chain to a path with an A line. Bounded by the chain
3616
+ # length, and a cycle cannot form — a rename always names an earlier
3617
+ # path, and `seen` stops one anyway.
3618
+ for (path in from) {
3619
+ cur = path
3620
+ delete seen
3621
+ while (cur in from && !(cur in seen)) { seen[cur] = 1; cur = from[cur] }
3622
+ if (cur in add) resolved[path] = add[cur]
3623
+ # A chain ending in no A line inside this walk leaves the path
3624
+ # unresolved, so its plan gets no subjects and its branches go to the
3625
+ # host — stated in the plan as the limit it is.
3626
+ }
3627
+ for (path in resolved) printf "%s\t%s\n", path, resolved[path]
3628
+ }'
3629
+ }
3630
+
3631
+ # What the rule was asked, assembled once and reused for both calls.
3632
+ subject_readings() {
3633
+ printf '@backend %s\n' "$HOST_BACKEND"
3634
+ printf '@origin %s\n' "$ORIGIN_URL"
3635
+ printf '@merges\n'
3636
+ [ -n "$MERGE_SUBJECTS" ] && printf '%s\n' "$MERGE_SUBJECTS"
3637
+ printf '%s' "$SUBJECT_PLAN_SECTIONS"
3638
+ }
3639
+
3640
+ ask_merge_subject() { # $1=verb; stdin=readings → the rule's answer
3641
+ node "$script_dir/board/plot-merge-subject.mjs" "$1" 2>/dev/null
3642
+ }
3643
+
3644
+ # AN EMPTY WALK IS A MEASUREMENT, AND IT ANSWERS `none`.
3645
+ #
3646
+ # A squash or rebase estate leaves no merge commit at all, so `$MERGE_SUBJECTS`
3647
+ # is empty — and that is the walk having run and found nothing, which is exactly
3648
+ # what `none` says. `unaskable` must mean only *the rule could not be asked*, or
3649
+ # the footer stops telling a reader the two apart; the asking side of that
3650
+ # distinction is this script's and the found/not-found side is the bundle's.
3651
+ #
3652
+ # Measured by CI on the first run of this slice: the guard below read
3653
+ # `[ -n "$MERGE_SUBJECTS" ]` and a fixture with no merges reported
3654
+ # `merge_detect=unaskable`, which claims the question was never put about an
3655
+ # estate the walk had just examined.
3656
+ if [ -z "$MERGE_SUBJECTS" ]; then
3657
+ MERGE_DETECT=none
3658
+ fi
3659
+
3660
+ # Only where there is something for the rule to match. `--offline` and `--no-pr`
3661
+ # do not gate this: the walks are LOCAL, they cost no host call, and a subject is
3662
+ # the one proof an offline scan can still have.
3663
+ if [ ${#plans[@]} -gt 0 ] && [ -n "$MERGE_SUBJECTS" ]; then
3664
+ # The origin URL, read once. The rule parses it — this script never does, so
3665
+ # the owner is read one way by the scan and the supervisor alike.
3666
+ ORIGIN_URL=$(git config --get remote.origin.url 2>/dev/null || echo "")
3667
+ HOST_BACKEND=$("$script_dir/plot-host.sh" backend 2>/dev/null || echo "")
3668
+
3669
+ SUBJECT_ADDING=$'\n'"$(plan_adding_commits)"$'\n'
3670
+
3671
+ # One section per plan: its dated file, its adding commit, and its refless
3672
+ # branches. A branch WITH a ref is never offered — the ref check stays in
3673
+ # front, and a recreated branch carrying new work must not be settled by the
3674
+ # first attempt's subject.
3675
+ SUBJECT_PLAN_SECTIONS=""
3676
+ for _sp_i in "${!plans[@]}"; do
3677
+ _sp_plan="${plans[$_sp_i]}"
3678
+ _sp_base=$(basename "$(readlink "$_sp_plan" 2>/dev/null || echo "$_sp_plan")")
3679
+ _sp_path="$PLAN_DIR$_sp_base"
3680
+ _sp_added=""
3681
+ case "$SUBJECT_ADDING" in
3682
+ *$'\n'"$_sp_path"$'\t'*)
3683
+ _sp_added=$(printf '%s' "$SUBJECT_ADDING" \
3684
+ | awk -F'\t' -v p="$_sp_path" '$1 == p { print $2; exit }') ;;
3685
+ esac
3686
+ # KEYED ON THE FILE THAT WAS PARSED, which is what `plan_meta_files` holds
3687
+ # — `$plan_reads[i]`, the same key the row loop uses at pass 1a. In ref mode
3688
+ # that is a materialized blob under a temp path and not `$PLAN_DIR` at all,
3689
+ # so a reconstructed path finds nothing.
3690
+ _sp_meta_i=$(plan_meta_index_of "${plan_reads[$_sp_i]}" 2>/dev/null || echo "")
3691
+ [ -n "$_sp_meta_i" ] || continue
3692
+ _sp_branches=""
3693
+ while IFS=$'\t' read -r _sp_idx _sp_br _sp_rest; do
3694
+ [ -n "$_sp_br" ] || continue
3695
+ remote_ref_exists "$_sp_br" && continue
3696
+ _sp_branches+="$_sp_br"$'\n'
3697
+ done <<< "${plan_meta_waves[$_sp_meta_i]}"
3698
+ [ -n "$_sp_branches" ] || continue
3699
+ # THE BASENAME IS THE KEY, because that is what the row loop holds as
3700
+ # `$plan_base` and what the lookup tests. The adding-commit walk keys on
3701
+ # the full path, which is what git reports — the two are joined here, once,
3702
+ # rather than at every lookup.
3703
+ SUBJECT_PLAN_SECTIONS+="@plan $_sp_base"$'\n'
3704
+ SUBJECT_PLAN_SECTIONS+="@added ${_sp_added:--}"$'\n'
3705
+ SUBJECT_PLAN_SECTIONS+="$_sp_branches"
3706
+ done
3707
+
3708
+ if [ -n "$SUBJECT_PLAN_SECTIONS" ]; then
3709
+ # CALL 1: which pairs need an ancestry test.
3710
+ _sp_pairs=$(subject_readings | ask_merge_subject pairs || echo "")
3711
+ # THE READING, one per matched pair. The age rule in
3712
+ # plot-merge-subject.mjs is what decides what it means.
3713
+ _sp_answers=""
3714
+ while IFS=$'\t' read -r _sp_pplan _sp_pbr _sp_merge _sp_added2; do
3715
+ [ -n "$_sp_merge" ] || continue
3716
+ # A wrong "contained" sends the branch to the host; a wrong "not
3717
+ # contained" is the reused-name case this narrows.
3718
+ #
3719
+ # plot-ancestry: evidence — handed to `proofOf` in
3720
+ # plot-merge-subject.mjs, which decides and
3721
+ # reads `unknown` as proving nothing.
3722
+ if git merge-base --is-ancestor "$_sp_merge" "$_sp_added2" </dev/null 2>/dev/null; then
3723
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 yes"$'\n'
3724
+ elif git cat-file -e "$_sp_merge^{commit}" </dev/null 2>/dev/null \
3725
+ && git cat-file -e "$_sp_added2^{commit}" </dev/null 2>/dev/null; then
3726
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 no"$'\n'
3727
+ else
3728
+ # A commit this checkout cannot read answers neither way, and the rule
3729
+ # reads `unknown` as proving nothing.
3730
+ _sp_answers+="@ancestry $_sp_merge $_sp_added2 unknown"$'\n'
3731
+ fi
3732
+ done <<< "$_sp_pairs"
3733
+
3734
+ # CALL 2: the proof, given the answers.
3735
+ while IFS=$'\t' read -r _sp_word _sp_a _sp_b _sp_c; do
3736
+ case "$_sp_word" in
3737
+ proven) SUBJECT_PROVEN+="$_sp_a"$'\t'"$_sp_b"$'\n' ;;
3738
+ ignored)
3739
+ SUBJECT_IGNORED+="$_sp_a"$'\t'"$_sp_b"$'\n'
3740
+ SUBJECT_PREDATES_PLAN=$((SUBJECT_PREDATES_PLAN + 1)) ;;
3741
+ detect)
3742
+ # `truncated` is THIS script's reading and outranks `pr-merge`: the
3743
+ # bundle cannot see the cap, and a capped walk detected but did not
3744
+ # examine exhaustively.
3745
+ if [ "$_sp_a" = "pr-merge" ] && [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
3746
+ MERGE_DETECT=truncated
3747
+ else
3748
+ MERGE_DETECT="$_sp_a"
3749
+ fi ;;
3750
+ esac
3751
+ done <<< "$(printf '%s\n%s' "$(subject_readings)" "$_sp_answers" | ask_merge_subject proven || echo "")"
3752
+ fi
3753
+ fi
3754
+
3198
3755
  # A branch is merged when its remote ref is an ancestor of origin/<main>, or —
3199
3756
  # once the ref is gone — when the default branch carries a conforming PR-merge
3200
3757
  # commit naming it (see "the evidence that survives the ref" above). An absent
@@ -3316,8 +3873,9 @@ EOF
3316
3873
  MAIN_TIP=$(remote_ref_oid "$MAIN")
3317
3874
  [ -n "$MAIN_TIP" ] || MAIN_TIP="-"
3318
3875
 
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
3876
+ branch_readings() { # $1=branch $2=deferred $3=plan-base → readings
3877
+ local br="$1" _bs_deferred="$2" _bs_plan="${3:-}" _bs_subject=false
3878
+ local _bs_ahead=0 _bs_real=0 _bs_tip
3321
3879
  local _bs_main="$MAIN_TIP"
3322
3880
  # THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
3323
3881
  #
@@ -3350,7 +3908,17 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3350
3908
  # exists — squash merges, a hand-rewritten subject, a branch genuinely
3351
3909
  # never started — today's `open` stands. The evidence may only move a branch
3352
3910
  # from `open` to `merged`, and only when it is positive.
3353
- merged_by_subject "$br" && _bs_subject=true
3911
+ # THE PAIR, not the branch alone. `$_bs_plan` is what stops one plan's
3912
+ # proof settling another plan's reused name — the case round 1 executed,
3913
+ # where an unstarted reused name read `merged`, its slice read complete,
3914
+ # the next slice opened, and the reused slice was never offered.
3915
+ merged_by_subject "$_bs_plan" "$br" && _bs_subject=true
3916
+ # A subject the age rule refused is reported apart from one that never
3917
+ # existed: both read `unknown` under a refused host, and only this one has
3918
+ # an explanation.
3919
+ _bs_predates=false
3920
+ [ "$_bs_subject" = false ] && subject_predates_plan "$_bs_plan" "$br" \
3921
+ && _bs_predates=true
3354
3922
  # No merge commit names it — which is the ordinary case under a squash
3355
3923
  # merge, not an exotic one. The local walk is now out of evidence, so the
3356
3924
  # host is asked. It may only ever move this branch from `open` to `merged`:
@@ -3375,7 +3943,20 @@ branch_readings() { # $1=branch $2=deferred → eight tab-separated readings
3375
3943
  # what travels: the rule tells `CLOSED` from `NONE` from `-`, and a boolean
3376
3944
  # cannot. `|| true` because a not-merged answer is an ordinary reading and
3377
3945
  # `set -e` must not read it as a failure.
3378
- merged_by_host "$br" || true
3946
+ #
3947
+ # NOT ASKED FOR A BRANCH THE SUBJECT ALREADY PROVED. For the wave gate the
3948
+ # subject IS enough — settling a slice and opening the next is reversible
3949
+ # work — and asking anyway is the spend #1139 reports: a host refusing
3950
+ # every question, asked again on a 5-second pulse about a branch already
3951
+ # proven.
3952
+ #
3953
+ # THE ONE EXCEPTION IS A DELIVERY CANDIDATE, and it is asked in PASS 1e of
3954
+ # the plan loop rather than here. Candidacy needs every branch's DECIDED
3955
+ # state, which exists only after the rule has answered for the whole plan,
3956
+ # and this function runs per branch in a subshell before that.
3957
+ if [ "$_bs_subject" = false ]; then
3958
+ merged_by_host "$br" || true
3959
+ fi
3379
3960
  # `open` IS A CLAIM ABOUT A PR: that one was looked for and none was found.
3380
3961
  # With no ref, the host is the only remaining source, so when it could not
3381
3962
  # be asked that claim was never earned — and the branch measured on
@@ -3765,6 +4346,13 @@ for plan in "${plans[@]}"; do
3765
4346
  # deciding it — see pass 1c.
3766
4347
  readings=""
3767
4348
  order=""
4349
+ # WHICH BRANCHES THE HOST ITSELF CALLED MERGED, recorded here because
4350
+ # `_merged_by_host_state` is a single variable that pass 1a overwrites per
4351
+ # branch — by the time the JSON emitter runs it holds the LAST branch's
4352
+ # answer. The `evidence` field needs to know, per branch, whether the host
4353
+ # confirmed the landing, so the answer is kept while it is still the right
4354
+ # one.
4355
+ host_merged_branches=$'\n'
3768
4356
  # THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
3769
4357
  # written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
3770
4358
  # main as `open` only when it is true; a capped list may omit a merged PR.
@@ -3775,7 +4363,15 @@ for plan in "${plans[@]}"; do
3775
4363
  # "-" is the absent marker the shim writes, for the tab-collapse reason
3776
4364
  # above. Normalized here so everything downstream tests emptiness.
3777
4365
  [ "$waits" = "-" ] && waits=""
3778
- readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
4366
+ readings+="$(branch_readings "$br" "$deferred" "$plan_base") ${waits:--} ? $list_complete"$'\n'
4367
+ # Read from the readings line just built rather than from the variable: the
4368
+ # subshell `branch_readings` runs in cannot export it back.
4369
+ # FIELD 6 is the host's own word, in both arms of `branch_readings`:
4370
+ # `$_merged_by_host_state` for a refless branch and `host_pr_state` for one
4371
+ # with a ref. Field 9 is `waits`, appended by this loop.
4372
+ case "$(printf '%s' "$readings" | tail -n1 | cut -f6)" in
4373
+ MERGED) host_merged_branches+="$br"$'\n' ;;
4374
+ esac
3779
4375
  order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
3780
4376
  done <<< "$wave_lines"
3781
4377
 
@@ -3830,8 +4426,17 @@ for plan in "${plans[@]}"; do
3830
4426
  # list may legitimately omit: its plan may be delivered and its ref gone.
3831
4427
  # `host_pr_state`'s run cache keeps this at one call per prerequisite per
3832
4428
  # 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'
4429
+ #
4430
+ # FIELD 9 IS A LIST, comma-joined, so field 10 answers each prerequisite
4431
+ # IN THE SAME ORDER — one `waits_pr_state` call per name, joined the same
4432
+ # way. `entry/branch-state.ts` reads the two columns as parallel lists of
4433
+ # equal length and throws otherwise, so a single answer for several names
4434
+ # would desync them. Field 11 is carried, unreplaced.
4435
+ waits_state=""
4436
+ for _wn in ${waits_br//,/ }; do
4437
+ waits_state+="${waits_state:+,}$(waits_pr_state "$_wn")"
4438
+ done
4439
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $waits_state $(printf '%s' "$rd_line" | cut -f11)"$'\n'
3835
4440
  else
3836
4441
  refill+="$rd_line"$'\n'
3837
4442
  fi
@@ -3858,6 +4463,46 @@ for plan in "${plans[@]}"; do
3858
4463
  states+="$idx $br $st $deferred $why $waits $wname $claim"$'\n'
3859
4464
  done <<< "$order"
3860
4465
 
4466
+ # PASS 1e: A DELIVERY CANDIDATE ASKS THE HOST about its subject-proven branches.
4467
+ #
4468
+ # A candidate is an Approved plan whose every non-deferred branch reads
4469
+ # `merged`. Its next step is a delivery, and `allSlicesConfirmed` refuses a
4470
+ # delivery while any branch carries `evidence: subject`. So for a candidate,
4471
+ # and only under `HOST_VERDICT` `ok` or `partial`, `merged_by_host` is asked
4472
+ # about each branch the subject alone proved. A refused host is not asked:
4473
+ # that spend is the condition #1139 reports, and the branch keeps
4474
+ # `evidence: subject`.
4475
+ #
4476
+ # IN THE MAIN SHELL, not in `branch_readings`. That function runs per branch
4477
+ # in a subshell before any state is decided, so it cannot know whether the
4478
+ # plan is a candidate. Measured 2026-10-02: its `$4` was never passed, 32 of
4479
+ # 39 merged branches on this estate read `evidence: subject`, and auto-deliver
4480
+ # held every plan whose branch refs were deleted at merge.
4481
+ #
4482
+ # The terminal cache bounds the cost: a MERGED answer is kept per tip of
4483
+ # `origin/<main>`, so a candidate is asked once per tip, not once per pulse.
4484
+ if [ "$plan_phase" = approved ]; then
4485
+ case "$HOST_VERDICT" in
4486
+ ok|partial)
4487
+ cand=true
4488
+ cand_proven=""
4489
+ while IFS=$'\t' read -r _ c_br c_st c_deferred _; do
4490
+ [ -n "$c_br" ] || continue
4491
+ if [ "$c_deferred" = true ]; then continue; fi
4492
+ [ "$c_st" = merged ] || { cand=false; break; }
4493
+ case "$host_merged_branches" in *$'\n'"$c_br"$'\n'*) continue ;; esac
4494
+ if merged_by_subject "$plan_base" "$c_br"; then cand_proven+="$c_br"$'\n'; fi
4495
+ done <<< "$states"
4496
+ if [ "$cand" = true ]; then
4497
+ while IFS= read -r c_br; do
4498
+ [ -n "$c_br" ] || continue
4499
+ if merged_by_host "$c_br"; then host_merged_branches+="$c_br"$'\n'; fi
4500
+ done <<< "$cand_proven"
4501
+ fi
4502
+ ;;
4503
+ esac
4504
+ fi
4505
+
3861
4506
  # Pass 2a: what each wave HOLDS — how many of its non-deferred branches have
3862
4507
  # not settled. A reading, and the whole of what this script contributes to the
3863
4508
  # verdict: which branches count as settled depends on `--loose` and on a host
@@ -3979,18 +4624,24 @@ for plan in "${plans[@]}"; do
3979
4624
  [ "$claim" = "-" ] && claim=""
3980
4625
  [ "$why" = "-" ] && why=""
3981
4626
  [ "$waits" = "-" ] && waits=""
4627
+ # THE NOTE NAMES EVERY PREREQUISITE, not just one: `$waits` is comma-joined
4628
+ # with no space (the shim's separator), and the sentence reads "a, b" —
4629
+ # with one name both forms are byte-identical. The scan has the VERDICT
4630
+ # and not the per-prerequisite answer here, so `blocked`'s sentence names
4631
+ # every prerequisite rather than which one lacks a PR.
4632
+ waits_prose="${waits//,/, }"
3982
4633
  n_branches=$((n_branches + 1))
3983
4634
  case "$st" in
3984
4635
  # WHAT IT WAITS ON, NAMED. A bare `waiting` tells a reader to come back
3985
4636
  # later without saying what would have to happen first, which is the
3986
4637
  # whole of what this state adds over `open`.
3987
4638
  waiting) n_waiting=$((n_waiting + 1))
3988
- note="waiting on $waits" ;;
4639
+ note="waiting on $waits_prose" ;;
3989
4640
  # A PREREQUISITE NOBODY DECLARED. The sentence says the host was asked
3990
4641
  # and answered, because that is what separates this from `waiting`: a
3991
4642
  # host that could not be asked holds the branch at `waiting` instead.
3992
4643
  blocked) n_prereq_missing=$((n_prereq_missing + 1))
3993
- note="blocked — no PR found for $waits" ;;
4644
+ note="blocked — no PR found for $waits_prose" ;;
3994
4645
  # The REASON, where the plan recorded one. A bare `deferred` beside a
3995
4646
  # branch with no commits reads as two unrelated facts when the first is
3996
4647
  # the reason for the second, and the sentence that says so was already
@@ -4033,20 +4684,48 @@ for plan in "${plans[@]}"; do
4033
4684
  # must not parse a string that exists for humans to read.
4034
4685
  json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
4035
4686
  json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
4687
+ # WHAT PROVED THE LANDING, where the proof is weaker than the host's.
4688
+ #
4689
+ # EMITTED ONLY WHERE THE BRANCH READS `merged` AND THE HOST HAS NOT
4690
+ # CONFIRMED IT. `merged_by_host` leaves its own word in
4691
+ # `_merged_by_host_state`, and the scan does not even ask for a proven
4692
+ # branch that is not a delivery candidate — so the absence of a host
4693
+ # `MERGED` is what the field reports. The branch loses the field the
4694
+ # moment the host answers merged.
4695
+ #
4696
+ # A QUALIFIER, NOT A STATE. The state stays `merged`, the wave
4697
+ # arithmetic is unmoved and the next slice still opens; what reads this
4698
+ # is `allSlicesConfirmed`, in front of a delivery, which is the one
4699
+ # decision here that cannot be undone.
4700
+ if [ "$st" = merged ] && merged_by_subject "$plan_base" "$br" \
4701
+ && [ "${host_merged_branches#*$'\n'"$br"$'\n'}" = "$host_merged_branches" ]; then
4702
+ json_branches+=",\"evidence\":\"subject\""
4703
+ fi
4704
+ # WHY A SUBJECT NAMING THIS BRANCH PROVED NOTHING. A reused name whose
4705
+ # merge predates the plan: the slice reads as it would with no subject
4706
+ # at all, and this is what tells a reader the two cases apart.
4707
+ if subject_predates_plan "$plan_base" "$br"; then
4708
+ json_branches+=",\"subjectIgnored\":\"predates-plan\""
4709
+ fi
4036
4710
  # WHY it was deferred, straight from the plan's annotation. "" where the
4037
4711
  # branch is not deferred, and "" where it is deferred with nothing
4038
4712
  # recorded — the flag says which of those two a reader is looking at.
4039
4713
  json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
4040
4714
  # 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.
4715
+ # annotation. `[]` where the branch declares nothing — `FleetBranchSchema`
4716
+ # carries the key on every branch since #1253, so absence is an empty
4717
+ # list here rather than a missing key, unlike the parser's own
4718
+ # `waits_on` which omits the key entirely.
4043
4719
  #
4044
4720
  # THE ANNOTATION, NOT THE VERDICT, and it is emitted whatever `state`
4045
4721
  # says. A branch whose prerequisite has MERGED reports `waits_on` with
4046
4722
  # its ordinary state — the declaration is still a fact about the plan,
4047
4723
  # and a reader who sees a cleared dependency learns why the slice is
4048
4724
  # now startable. Consumers test `state`, never the presence of this.
4049
- json_branches+=",\"waits_on\":\"$(json_str "$waits")\""
4725
+ #
4726
+ # `$waits` IS COMMA-JOINED NAMES, never PR states: one name per line
4727
+ # for `json_array`, which escapes each.
4728
+ json_branches+=",\"waits_on\":$(json_array "${waits//,/$'\n'}")"
4050
4729
  json_branches+=",\"claimed\":\"$(json_str "$claim")\""
4051
4730
  # What this machine knows and the refs do not. Absent everywhere else:
4052
4731
  # `local_dirty:false` and `local_worktree:""` are what a branch checked
@@ -4674,4 +5353,9 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
4674
5353
  fi
4675
5354
  write_bridge
4676
5355
  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"
5356
+ # `subject_predates_plan` NAMES A SUBJECT THE AGE RULE REFUSED, and it is its
5357
+ # own counter rather than a silence. A slice whose subject was refused reads
5358
+ # `unknown` under a refused host, exactly as a slice with no subject does — and
5359
+ # only this one has an explanation an operator can act on, which is that a
5360
+ # later plan reused a merged branch name.
5361
+ 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"