@plot-pm/board 0.14.4 → 0.16.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.
@@ -16,6 +16,14 @@
16
16
  # --list-eligible print EVERY claimable branch, one per line (exit 1 if none).
17
17
  # For callers that need the count rather than one item — a dry
18
18
  # run changes nothing, so its answer cannot go stale.
19
+ # WHEN THERE ARE NONE IT SAYS WHY, ON STDERR: whether every
20
+ # candidate in an eligible wave is taken, or no eligible wave held
21
+ # a startable branch at all. Two silences a reader cannot
22
+ # otherwise tell apart (#994). Stdout stays a bare branch list
23
+ # because `plot-dispatch.sh` pipes it through `sort -u` and
24
+ # dispatches every line, and the exit code stays 1 either way, so
25
+ # no caller's gate moves. An estate with NO plans exits earlier
26
+ # and says nothing — there is no candidate set to describe.
19
27
  # --loose a prior wave counts as satisfied when its branches carry PUSHED
20
28
  # work, not only merged work. Buys throughput, pays in rebase
21
29
  # risk — the plan requires a stated reason for using it. Default
@@ -88,12 +96,21 @@
88
96
  # evidence of a typo either.
89
97
  # Output: per-plan wave report on stdout, terminated by a machine-countable
90
98
  # summary line:
91
- # summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 waiting=1 prereq_missing=0 merge_detect=pr-merge host=ok main=main
99
+ # summary: plans=1 waves=3 branches=5 claimed=1 claimable=2 eligible=2 blocked=1 deferred=1 waiting=1 prereq_missing=0 merge_detect=pr-merge host=ok main=main
92
100
  # `host` is one of ok, partial, throttled, secondary, failed, unasked —
93
101
  # `partial` means some of the host's states answered and some did not,
94
102
  # so the PR readings below are incomplete rather than absent.
95
103
  # `blocked` counts WAVES an earlier wave holds; `waiting` and
96
104
  # `prereq_missing` count BRANCHES their `waits:` annotation holds.
105
+ # `claimable` counts BRANCHES a worker may take now, and it is the key
106
+ # the offer paths agree with — `--next` names one of them and
107
+ # `--list-eligible` names all of them. `eligible=` carries the SAME
108
+ # number beside it and is kept for consumers that read it; it was the
109
+ # only name this count had until 2026-09-25, sitting between two wave
110
+ # counts while counting branches, so a wave printed `eligible` in the
111
+ # body while the footer read `eligible=0` and both were right about
112
+ # different questions (#994). A reader wanting the WAVE answer counts
113
+ # the body's verdicts; no footer key reports it.
97
114
  # merge_detect names how merged-and-deleted branches were detected:
98
115
  # pr-merge (exhaustive), truncated (capped walk), none (no conforming
99
116
  # merge commits — a squash/rebase repo, where `open` says nothing about
@@ -116,13 +133,21 @@
116
133
  # lifecycle state, verbatim from plot-plan-meta.sh, and the half of a
117
134
  # row's phase git cannot answer. Which column a row reads is composed
118
135
  # from it AND the branch state one layer up; this script decides nothing.
119
- # The plan set also includes plans delivered inside a rolling 24 h
120
- # window (see "the last day of finished work"), so work does not
121
- # disappear at the moment it becomes finished.
122
- # Plans are enumerated from `origin/<main>` (`git ls-tree`/`git show`),
123
- # NOT from the working tree — so the list describes one atomic commit
124
- # and does not change while rebases and worker commits rewrite the
125
- # checkout underneath a running fleet. A consequence worth stating: an
136
+ # The plan set also includes every plan at `Delivered`, until it is
137
+ # `Released` (see "the release scope"), so DONE names what the next
138
+ # release ships.
139
+ # Plans are enumerated from REFS (`git ls-tree`/`git show`), NOT from
140
+ # the working tree — so the list describes committed state and does not
141
+ # change while rebases and worker commits rewrite the checkout
142
+ # underneath a running fleet. `origin/<main>` first, then each prefixed
143
+ # branch's own tree for the plans the default branch does not carry:
144
+ # a plan created with `Impl: same branch` lives only on its work branch
145
+ # until that branch merges, and reading main alone reported its branch as
146
+ # an anonymous row with `plan: ""` while the Board tab showed the same
147
+ # plan as a Draft card (#972). A plan path is taken ONCE — the default
148
+ # branch wins, and among branches the first to carry it — because two
149
+ # branches cut from one point hold the same file and a row per branch
150
+ # would report one plan as several. A consequence worth stating: an
126
151
  # UNCOMMITTED plan is invisible, deliberately — the fleet view shows
127
152
  # what is shared, and a plan only this machine has cannot be claimed by
128
153
  # any worker. --json carries `plan_source` (ref | worktree), which
@@ -228,6 +253,8 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
228
253
  # plot-dispatch.sh so a worker has ONE state, not one per reader.
229
254
  # shellcheck source=plot-worker-state.sh
230
255
  . "$script_dir/plot-worker-state.sh"
256
+ # Every temp path this scan creates, and its only EXIT/INT/TERM traps.
257
+ . "$script_dir/plot-tmp.sh"
231
258
  cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
232
259
 
233
260
  do_fetch=1
@@ -548,14 +575,15 @@ fi
548
575
  # outlives the scan that fetched it — a stale `merged` read from a previous run
549
576
  # is exactly the fabricated verdict the failure direction above forbids.
550
577
  #
551
- # Cleanup is trapped rather than trailing: the script exits early in several
578
+ # Cleanup is registered rather than trailing: the script exits early in several
552
579
  # places (--next with nothing to start, no active plans), and a temp directory
553
- # left behind on those paths would accumulate one per poll.
580
+ # left behind on those paths would accumulate one per poll. It goes through
581
+ # `plot-tmp.sh`'s one registry: this cache had its own EXIT trap, and the
582
+ # `REF_TMP` trap below replaced it, so every scan left one `tmp.*` directory of
583
+ # ~955 files behind (measured 2026-09-30).
554
584
  HOST_STATE_CACHE=""
555
585
  if [ "$HOST_LOOKUP_OK" = 1 ]; then
556
- HOST_STATE_CACHE=$(mktemp -d 2>/dev/null) || HOST_STATE_CACHE=""
557
- [ -n "$HOST_STATE_CACHE" ] \
558
- && trap 'rm -rf "$HOST_STATE_CACHE" 2>/dev/null || true' EXIT INT TERM
586
+ plot_tmpdir HOST_STATE_CACHE fleet-host-state 2>/dev/null || HOST_STATE_CACHE=""
559
587
  fi
560
588
 
561
589
  # The cache key. Shared by the join and by `host_pr_state`, because the two
@@ -697,49 +725,11 @@ REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
697
725
  # falls back to the listing there, which is what it has always done.
698
726
  TRACKED_BRANCHES=$(printf '%s\n' "$REMOTE_REFS" | cut -f1 | grep -v '^HEAD$' | grep -v '^$' | tr '\n' ' ')
699
727
 
700
- prefill_pr_states() {
701
- [ "$HOST_LOOKUP_OK" = 1 ] || return 0
702
- [ -n "$HOST_STATE_CACHE" ] || return 0
703
- local js br st key rc
704
- # Exit code first: non-zero is a transport failure and its stdout is not an
705
- # answer. A failed list leaves the cache EMPTY, so every branch falls through
706
- # to the unanswerable `-` rather than to a fabricated "no PR".
707
- #
708
- # `--rich` adds `checks` and `draft` to the response — the fields `--loose`
709
- # needs to verify that a prior wave's PR is actually green and ready.
710
- # Requested unconditionally because the cost is zero on GitHub (same GraphQL
711
- # call) and bounded on Bitbucket (N extra calls only when CI: jenkins is
712
- # configured), and the parsing below already skips fields the response does
713
- # not contain. The BEHAVIOUR change is in `pr_ready`, which now reads the
714
- # check rollup from the cache rather than making a per-branch host call.
715
- #
716
- # THE CODE IS KEPT, not just tested. This guard was always correct — a failed
717
- # list prefills nothing — and until 2026-08-30 it never fired, because
718
- # `pr-list` swallowed its own failure and exited 0 with empty stdout. Now
719
- # that it can fail, WHICH failure it was is a fact worth carrying: exit 5 is
720
- # a rate limit and exit 3 is anything else, and the summary reports the
721
- # difference rather than degrading silently.
722
- # STDOUT TO A FILE so stderr can be captured separately — see the verdict
723
- # below. It lives in `HOST_STATE_CACHE`, which already has an EXIT trap, so
724
- # this adds no second cleanup path. When mktemp -d failed the cache is "",
725
- # and /dev/null keeps the call working with the text simply unavailable.
726
- host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
727
- host_list_out="${host_list_out:-/dev/null}"
728
- # THE BRANCHES THIS SCAN TRACKS, HANDED TO THE HOST (#333). The adapter uses
729
- # them only where it can — the Bitbucket arm sweeps its REST endpoint once per
730
- # branch per state — and ignores them everywhere else, so the GitHub arm makes
731
- # the single call it always made. Passing them unconditionally keeps one call
732
- # shape here rather than a backend test this script has no business making.
733
- #
734
- # AN EMPTY SET PASSES NOTHING and the adapter lists as before. See
735
- # `TRACKED_BRANCHES`: a completeness claim over an empty set would license
736
- # `NONE` for branches nobody asked about.
737
- _branch_args=()
738
- for _tb in $TRACKED_BRANCHES; do _branch_args+=(--branch "$_tb"); done
739
- host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
740
- ${_branch_args[@]+"${_branch_args[@]}"} \
741
- </dev/null 2>&1 >"$host_list_out"); rc=$?
742
- js=$(cat "$host_list_out" 2>/dev/null)
728
+ # ONE `pr-list` CALL'S EXIT, READ AS A VERDICT WORD in `_plv` — a global
729
+ # rather than stdout, so the two calls in `prefill_pr_states` fork nothing to
730
+ # classify themselves. `$1` is the exit code, `$2` the call's stderr.
731
+ pr_list_verdict() {
732
+ local rc="$1" err="$2"
743
733
  # A PARTIAL ANSWER TAKES THE PARSE PATH AND STILL DEGRADES THE VERDICT, which
744
734
  # is a control-flow change rather than another `case` arm below: every other
745
735
  # non-zero rc sets a verdict and returns BEFORE `$js` is read, because there
@@ -750,9 +740,11 @@ prefill_pr_states() {
750
740
  # BOTH HALVES ARE REQUIRED. Falling through without setting the verdict would
751
741
  # report `ok` over a page missing a whole state, which is #912; returning
752
742
  # early would throw away rows the host did answer with.
753
- if [ "$rc" -eq 7 ]; then
754
- HOST_VERDICT=partial
755
- elif [ "$rc" -ne 0 ]; then
743
+ if [ "$rc" -eq 0 ]; then
744
+ _plv=ok
745
+ elif [ "$rc" -eq 7 ]; then
746
+ _plv=partial
747
+ else
756
748
  # THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
757
749
  # never put" (see HOST_VERDICT above: *not a degradation, the scan was
758
750
  # never asking*), and a host that cannot be ASKED AT ALL belongs there
@@ -774,10 +766,10 @@ prefill_pr_states() {
774
766
  # always read 1. The branch was startable; the scan had stopped being
775
767
  # able to say so.
776
768
  case "$rc" in
777
- 5) HOST_VERDICT=throttled ;;
778
- 6) HOST_VERDICT=secondary ;;
779
- 4) HOST_VERDICT=unasked ;;
780
- *) case "$host_err" in
769
+ 5) _plv=throttled ;;
770
+ 6) _plv=secondary ;;
771
+ 4) _plv=unasked ;;
772
+ *) case "$err" in
781
773
  # THE WORDING IS MEASURED, NOT GUESSED. An earlier version of this
782
774
  # list matched auth/login/credential and MISSED the message CI
783
775
  # actually emits:
@@ -794,7 +786,7 @@ prefill_pr_states() {
794
786
  # a real failure and stays `failed`, because widening this to a
795
787
  # catch-all would turn every host outage into "nobody asked".
796
788
  *TOKEN*|*token*|*auth*|*Auth*|*AUTH*|*login*|*Login*|*credential*|*Credential*|*"not logged"*)
797
- HOST_VERDICT=unasked ;;
789
+ _plv=unasked ;;
798
790
  # NO REMOTE IS A CONFIGURATION, NOT A FAULT — the same reading as a
799
791
  # missing token one line up, reached by the same route: `plot-host.sh`
800
792
  # exits 3 for both, because both are "the op cannot proceed", and
@@ -813,19 +805,123 @@ prefill_pr_states() {
813
805
  # claimable — the right refusal about the wrong thing. There is no
814
806
  # merge state to withhold on where there is no remote to hold it.
815
807
  *"no git remotes"*|*"no remote"*)
816
- HOST_VERDICT=unasked ;;
817
- *) HOST_VERDICT=failed ;;
808
+ _plv=unasked ;;
809
+ *) _plv=failed ;;
818
810
  esac ;;
819
811
  esac
820
- return 0
821
812
  fi
822
- # The list arrived. An empty one arrived too — that is the whole distinction.
813
+ }
814
+
815
+ # WHICH OF TWO VERDICTS IS WORSE. Every word that returns before a payload is
816
+ # read outranks both words that parse one, so a failed call can never be
817
+ # reported as `ok` or `partial` beside a call that answered.
818
+ # The rank is set in `_plr`, for the same no-fork reason as `_plv`.
819
+ pr_list_verdict_rank() {
820
+ case "$1" in
821
+ ok) _plr=0 ;; partial) _plr=1 ;; unasked) _plr=2 ;;
822
+ secondary) _plr=3 ;; throttled) _plr=4 ;; *) _plr=5 ;;
823
+ esac
824
+ }
825
+
826
+ prefill_pr_states() {
827
+ [ "$HOST_LOOKUP_OK" = 1 ] || return 0
828
+ [ -n "$HOST_STATE_CACHE" ] || return 0
829
+ local js br st key rc
830
+ # Exit code first: non-zero is a transport failure and its stdout is not an
831
+ # answer. A failed list leaves the cache EMPTY, so every branch falls through
832
+ # to the unanswerable `-` rather than to a fabricated "no PR".
833
+ #
834
+ # `--rich` adds `checks` and `draft` to the response — the fields `--loose`
835
+ # needs to verify that a prior wave's PR is actually green and ready. The
836
+ # BEHAVIOUR change is in `pr_ready`, which reads the check rollup from the
837
+ # cache rather than making a per-branch host call.
838
+ #
839
+ # THE ROLLUP IS ASKED OF OPEN PRS ONLY. On GitHub it is `statusCheckRollup`,
840
+ # and its cost scales with the rows it is asked for. Measured 2026-09-25 on
841
+ # this repo through `plot-host.sh`: 957 PRs (920 merged, 34 closed, 3 open),
842
+ # and the one `--state all --rich` call took ~37 s of a ~55 s scan. A merged
843
+ # or closed PR's checks cannot change, and `pr_ready` reads them only for an
844
+ # open one. So TWO calls: `--state open --rich` for the PRs whose checks can
845
+ # still move, and `--state all` without `--rich` for every PR's state.
823
846
  #
824
- # A PARTIAL VERDICT IS NOT OVERWRITTEN HERE. Exit 7 reaches this line
825
- # deliberately, because its rows must be parsed; an unguarded `ok` would
826
- # undo the one thing that distinguishes an incomplete page from a whole one
827
- # and report #912 as a healthy reading.
828
- [ "$HOST_VERDICT" = partial ] || HOST_VERDICT=ok
847
+ # THE CODE IS KEPT, not just tested. This guard was always correct — a failed
848
+ # list prefills nothing — and until 2026-08-30 it never fired, because
849
+ # `pr-list` swallowed its own failure and exited 0 with empty stdout. Now
850
+ # that it can fail, WHICH failure it was is a fact worth carrying: exit 5 is
851
+ # a rate limit and exit 3 is anything else, and the summary reports the
852
+ # difference rather than degrading silently.
853
+ # STDOUT TO A FILE so stderr can be captured separately — see the verdict
854
+ # below. It lives in `HOST_STATE_CACHE`, which already has an EXIT trap, so
855
+ # this adds no second cleanup path. When mktemp -d failed the cache is "",
856
+ # and /dev/null keeps the call working with the text simply unavailable.
857
+ local open_list_out host_list_out open_err rc_open
858
+ open_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list-open.json}"
859
+ open_list_out="${open_list_out:-/dev/null}"
860
+ host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
861
+ host_list_out="${host_list_out:-/dev/null}"
862
+ # THE BRANCHES THIS SCAN TRACKS, HANDED TO THE HOST (#333). The adapter uses
863
+ # them only where it can — the Bitbucket arm sweeps its REST endpoint once per
864
+ # branch per state — and ignores them everywhere else, so the GitHub arm makes
865
+ # the single call it always made. Passing them unconditionally keeps one call
866
+ # shape here rather than a backend test this script has no business making.
867
+ #
868
+ # AN EMPTY SET PASSES NOTHING and the adapter lists as before. See
869
+ # `TRACKED_BRANCHES`: a completeness claim over an empty set would license
870
+ # `NONE` for branches nobody asked about.
871
+ _branch_args=()
872
+ for _tb in $TRACKED_BRANCHES; do _branch_args+=(--branch "$_tb"); done
873
+ # BOTH CALLS TAKE `--limit` AND THE BRANCHES. Without `--limit` the host
874
+ # returns 30 (see `PR_LIST_LIMIT`). The branches make the Bitbucket arm sweep
875
+ # the open state exactly rather than list a fixed 50 of it: an open PR
876
+ # missing from the rich payload has NO row at all once the plain payload's
877
+ # OPEN rows are dropped below, and a missing row reads as "no PR".
878
+ #
879
+ # OPEN FIRST, THEN ALL. A PR that merges between the two calls then carries
880
+ # a rich OPEN row and a plain MERGED one, and the rank keeps OPEN — stale by
881
+ # seconds, and the next scan corrects it.
882
+ open_err=$("$script_dir/plot-host.sh" pr-list --state open --limit "$PR_LIST_LIMIT" --rich \
883
+ ${_branch_args[@]+"${_branch_args[@]}"} \
884
+ </dev/null 2>&1 >"$open_list_out"); rc_open=$?
885
+ # THE VERDICT IS THE WORSE OF THE TWO, never the last one. A rich call
886
+ # throttled while the plain one answers leaves `checks` absent for every open
887
+ # PR, so `--loose` degrades to strict — and a footer reading `host=ok` would
888
+ # give no reason for it.
889
+ #
890
+ # A FAILED CALL PREFILLS NOTHING, whichever of the two failed. That is the
891
+ # single call's behaviour carried over: a verdict other than `ok` or
892
+ # `partial` sets the verdict and returns before any payload is read. A
893
+ # failed `open` call therefore decides the outcome alone, and the `all` call
894
+ # is not made: its answer could not change the verdict's direction, and on a
895
+ # throttled host it would spend quota on a payload nothing reads.
896
+ pr_list_verdict "$rc_open" "$open_err"; _v_open=$_plv
897
+ case "$_v_open" in ok|partial) ;; *) HOST_VERDICT=$_v_open; return 0 ;; esac
898
+ host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" \
899
+ ${_branch_args[@]+"${_branch_args[@]}"} \
900
+ </dev/null 2>&1 >"$host_list_out"); rc=$?
901
+ pr_list_verdict "$rc" "$host_err"; _v_all=$_plv
902
+ pr_list_verdict_rank "$_v_open"; _r_open=$_plr
903
+ pr_list_verdict_rank "$_v_all"
904
+ if [ "$_r_open" -gt "$_plr" ]; then HOST_VERDICT=$_v_open; else HOST_VERDICT=$_v_all; fi
905
+ case "$HOST_VERDICT" in ok|partial) ;; *) return 0 ;; esac
906
+ # THE MERGE, AND THE TRAP IT AVOIDS. An open PR is in BOTH payloads — rich
907
+ # from the `open` call, plain from the `all` call — and both rows rank 1 in
908
+ # the dedup below. With the sort key exhausted, `sort` compares whole lines,
909
+ # and the plain row's `-` sentinel (0x2D) sorts before any letter: the plain
910
+ # row wins every time and the rollup is lost for 100% of open PRs.
911
+ #
912
+ # So the `all` payload's OPEN rows are dropped before the concatenation, and
913
+ # each branch contributes one OPEN row at most. Those rows are unused: the
914
+ # `open` call already answered for every open PR, with its rollup.
915
+ #
916
+ # THE COMPLETENESS COUNT IS TAKEN FROM THE `all` PAYLOAD BEFORE THE FILTER.
917
+ # `.list-complete` compares a row count against `PR_LIST_LIMIT`, and a count
918
+ # taken after the filter can fall below the limit for a list that reached it
919
+ # — a truncated list then reads as whole, and a cache miss derives `NONE` for
920
+ # a branch that has a PR. The `open` payload is counted too, since it is a
921
+ # page of its own.
922
+ _pr_rows=$(grep -c '"state":"[A-Z]*","head":"' "$host_list_out" 2>/dev/null) || _pr_rows=0
923
+ _pr_open_rows=$(grep -c '"state":"[A-Z]*","head":"' "$open_list_out" 2>/dev/null) || _pr_open_rows=0
924
+ js=$(cat "$open_list_out" 2>/dev/null; grep -v '"state":"OPEN","head":"' "$host_list_out" 2>/dev/null)
829
925
  # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
830
926
  # that is a correctness-of-cost property rather than a style preference:
831
927
  # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
@@ -860,12 +956,8 @@ prefill_pr_states() {
860
956
  # response, which is now only an error case) reads as empty and `pr_ready`
861
957
  # treats it as `unknown`, which degrades to strict — the safer direction.
862
958
  local last="" chk dft
863
- # Rows parsed, for the completeness test below. Counted here because this is
864
- # the one place every row passes through.
865
- _pr_rows=0
866
959
  while IFS=" " read -r st chk dft br; do
867
960
  [ -n "$br" ] && [ -n "$st" ] || continue
868
- _pr_rows=$((_pr_rows + 1))
869
961
  [ "$br" = "$last" ] && continue
870
962
  last="$br"
871
963
  # A PLAIN row (no `--rich` fields) carries `-` in the checks/draft slots so
@@ -930,10 +1022,12 @@ EOF
930
1022
  # records on Bitbucket, where `bb pr list` is silently partial past 50 PRs per
931
1023
  # state.
932
1024
  #
933
- # Counted from the rows actually parsed rather than from the raw payload, so a
934
- # malformed line that the `sed` skipped cannot inflate the count into a false
1025
+ # Counted from the rows carrying the fields the `sed` anchors on, so a
1026
+ # malformed line that the parse skips cannot inflate the count into a false
935
1027
  # claim of completeness. Fewer rows than the limit means the host had no more
936
- # to give; equal to it means it may have.
1028
+ # to give; equal to it means it may have. BOTH PAGES must be short: the
1029
+ # `open` page is a listing of its own, and a truncated one drops open PRs
1030
+ # the filtered `all` page no longer carries.
937
1031
  #
938
1032
  # AN EMPTY LIST IS NOT A COMPLETE ONE, and the test that caught this is the
939
1033
  # reason it is written down. A host that exits 0 while printing nothing —
@@ -971,11 +1065,21 @@ EOF
971
1065
  # unjoined branch and still answers correctly — the per-branch N+1 that #216
972
1066
  # removed. That is why withholding the marker is always the safe direction and
973
1067
  # is what every failure path here does.
1068
+ #
1069
+ # THE `open` CALL MUST BE WHOLE FOR EITHER CLAIM. The `all` call's OPEN rows
1070
+ # were dropped above, so an `open` answer that is short — exit 7, or a sweep
1071
+ # that did not state its completeness — leaves open PRs with no row at all,
1072
+ # and completeness would turn those misses into `NONE`.
1073
+ [ "$_v_open" = ok ] || return 0
974
1074
  case "$host_err" in
975
1075
  *"pr-list sweep complete"*)
976
- printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
1076
+ case "$open_err" in
1077
+ *"pr-list sweep complete"*)
1078
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
1079
+ esac ;;
977
1080
  *)
978
- if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
1081
+ if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
1082
+ && [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
979
1083
  printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
980
1084
  fi ;;
981
1085
  esac
@@ -2144,37 +2248,15 @@ pr_ready() {
2144
2248
  }
2145
2249
 
2146
2250
  # ---------------------------------------------------------------------------
2147
- # Recently delivered plans: the last day of finished work
2251
+ # Delivered plans: the release scope
2148
2252
  # ---------------------------------------------------------------------------
2149
2253
  #
2150
- # The pulse read `active/` only, so a plan left the view the INSTANT it was
2151
- # delivered — taking every branch with it. Measured on this repo: five plans
2152
- # delivered in one day named eight branches between them, and DONE showed one,
2153
- # because delivery and merge are minutes apart and only whichever branch
2154
- # happened to sit in the gap survived. A group that is full by accident is
2155
- # worse than one that is empty by rule.
2156
- #
2157
- # A ROLLING 24 HOURS, not the calendar day. Literally "delivered today" is
2158
- # easier to explain and wrong at exactly the wrong moment: a plan delivered at
2159
- # 23:50 vanishes ten minutes later, mid-session, while the branches it names
2160
- # are still on screen. 24 is also the one freshness bound this repo already
2161
- # uses (`Claim stale after`), so it is one unit to learn rather than two.
2162
- #
2163
- # THE WINDOW FILTERS BEFORE THE PARSE. Measured: ~57 ms per plan through
2164
- # plot-plan-meta.sh against a scan that already runs 500–1050 ms, so parsing
2165
- # fourteen delivered plans to discard thirteen would roughly double the pulse —
2166
- # and that cost grows with the archive, which only ever gets larger, while the
2167
- # answer stays the size of a day's work. So the cheap signal comes first (the
2168
- # delivered symlink's own mtime) and only the candidates it admits are parsed.
2169
- #
2170
- # The pre-filter may OVER-ADMIT AND PAY A PARSE; it may never exclude. A
2171
- # checkout can freshen an old file, so the `Delivered:` record keeps the last
2172
- # word — but nothing mtime rules out could have been delivered inside the
2173
- # window. On a fresh clone or a CI worktree every file shares one checkout
2174
- # timestamp and ALL of them are admitted: correct, merely slower, once. Reaching
2175
- # for `git log` per plan to avoid that would spend a git call to save a parse.
2176
- DELIVERED_WINDOW_HOURS=$(cfg "Claim stale after" "24")
2177
- case "$DELIVERED_WINDOW_HOURS" in (*[!0-9]*|'') DELIVERED_WINDOW_HOURS=24 ;; esac
2254
+ # A plan at `Delivered` stays in the pulse until it is `Released`, whatever its
2255
+ # age. DONE holds the release scope (`done-holds-what-is-still-yours`): every
2256
+ # plan whose work has landed and whose version has not shipped. A 24-hour window
2257
+ # on the `Delivered:` record bounded this until 2026-10-01, when 25 plans awaited
2258
+ # 2.22.0 and DONE showed 10. Every plan file is parsed in one pass anyway, so the
2259
+ # window bought no parse time; it only hid work the next release ships.
2178
2260
 
2179
2261
 
2180
2262
  # ---------------------------------------------------------------------------
@@ -2340,52 +2422,7 @@ changed_ago_of() { # $1=branch → "<seconds since>\t<epoch of>" for the newest
2340
2422
  if [ "$newest" -gt "$now" ]; then printf '0\t%s' "$newest"; else printf '%s\t%s' "$((now - newest))" "$newest"; fi
2341
2423
  }
2342
2424
 
2343
- # A plan whose delivered symlink was touched inside the window. `find -newermt`
2344
- # is not portable to every BSD find in the wild, so the cutoff is computed and
2345
- # compared with `stat` — one stat per file, no parse.
2346
- delivered_candidates() {
2347
- local cutoff now link mtime
2348
- now=$(date +%s)
2349
- cutoff=$((now - DELIVERED_WINDOW_HOURS * 3600))
2350
- for link in "$DELIVERED_DIR"*.md; do
2351
- [ -e "$link" ] || continue
2352
- # `stat` follows the symlink, which is what we want: the TARGET is the plan,
2353
- # and a plan edited after delivery must still admit. An unreadable time
2354
- # ADMITS rather than excludes — the pre-filter may only over-admit, and the
2355
- # `Delivered:` record has the last word either way.
2356
- mtime=$(file_mtime "$link") || { printf '%s\n' "$link"; continue; }
2357
- [ "$mtime" -ge "$cutoff" ] && printf '%s\n' "$link"
2358
- done
2359
- }
2360
2425
 
2361
- # Does this plan's `Delivered:` record fall inside the window? The RECORD
2362
- # decides — mtime only chose who got asked.
2363
- #
2364
- # "No date, no row." A delivered plan with an empty record does not appear at
2365
- # all: no date means no membership in any window, the same rule the waiting age
2366
- # already follows. Showing it always would create the one row that can never
2367
- # age out of DONE, and the missing record is a bookkeeping fault
2368
- # plot-reconcile-scan.sh exists to report — a view that quietly compensates
2369
- # for it makes the fault harder to see.
2370
- #
2371
- # A BARE DATE IS ANCHORED AT THE END OF ITS DAY, not at midnight, and this is
2372
- # the one detail that makes "rolling, not the calendar day" true rather than
2373
- # merely stated. Every `Delivered:` record in this repo is a bare date, which
2374
- # names no time — so anchoring at 00:00 measures from up to a day BEFORE the
2375
- # delivery, and the window collapses back into exactly the calendar boundary
2376
- # the rolling window exists to avoid: a plan delivered at 23:50 would be an
2377
- # hour from expiry the moment it was written, and gone ten minutes later
2378
- # mid-session while the branches it names are still on screen.
2379
- #
2380
- # Anchoring at 23:59:59 over-admits by at most the length of the delivery day.
2381
- # That is the same direction the mtime pre-filter is allowed to err in, and for
2382
- # the same reason: showing a finished plan slightly too long costs a row, while
2383
- # dropping one mid-session costs the reader the work they were looking at. A
2384
- # record that DOES carry a time is honoured exactly, so the imprecision belongs
2385
- # to the record rather than to the rule.
2386
- # The rule itself now runs inside the ONE estate parse below (`in_window`),
2387
- # because asking it per plan meant an interpreter per plan. What it decides is
2388
- # unchanged; only the number of processes that decide it is.
2389
2426
 
2390
2427
  # ---------------------------------------------------------------------------
2391
2428
  # Plan enumeration: from the REF, not from the tree
@@ -2476,15 +2513,15 @@ ref_ls() { # $1=dir → newline-separated paths under it in origin/$MAIN
2476
2513
  # `ref_plan_file` is called as `$(ref_plan_file ...)`, which runs it in a
2477
2514
  # SUBSHELL. A lazy `[ -z "$REF_TMP" ] && REF_TMP=$(mktemp -d)` inside it
2478
2515
  # assigns in the child and the parent never sees it — so every call made a
2479
- # fresh directory, the parent's variable stayed empty, and the EXIT trap
2480
- # cleaned nothing. Measured while writing this: three plans, three temp dirs,
2516
+ # fresh directory, the parent's variable stayed empty, and the exit cleanup
2517
+ # removed nothing. Measured while writing this: three plans, three temp dirs,
2481
2518
  # none removed. The lifetime is owned out here, where the trap can see it.
2482
2519
  REF_TMP=""
2483
2520
  if [ "$PLAN_SOURCE" = "ref" ]; then
2484
- REF_TMP=$(mktemp -d "${TMPDIR:-/tmp}/plot-fleet-ref.XXXXXX") || REF_TMP=""
2485
2521
  # The scan is read-only and short-lived, and the board polls it every 5 s —
2486
- # a directory that outlives the run would accumulate one per poll.
2487
- [ -n "$REF_TMP" ] && trap 'rm -rf "$REF_TMP"' EXIT INT TERM
2522
+ # a directory that outlives the run would accumulate one per poll, so the
2523
+ # helper removes it at exit.
2524
+ plot_tmpdir REF_TMP fleet-ref || REF_TMP=""
2488
2525
  # No temp dir means no way to hand the parser a file, so the ref path cannot
2489
2526
  # work. Falling back to the checkout is the honest answer, and it announces
2490
2527
  # itself through `plan_source` exactly like an unreadable ref.
@@ -2625,6 +2662,68 @@ ref_plan_file() { # $1=path in ref → temp file path, or "" when unreadable
2625
2662
  printf '%s' "$out"
2626
2663
  }
2627
2664
 
2665
+ # ---------------------------------------------------------------------------
2666
+ # A branch's own plans
2667
+ # ---------------------------------------------------------------------------
2668
+ #
2669
+ # A plan created with `Impl: same branch` lives ONLY on its work branch until
2670
+ # that branch merges. The enumeration above reads `origin/$MAIN` and finds
2671
+ # nothing, so the branch carrying the plan reached the pulse as an anonymous row
2672
+ # with `plan: ""` while `/api/board` showed the same plan as a Draft card.
2673
+ # Measured on Plot 2.20.0 (#972): two tabs of one board disagreeing about
2674
+ # whether a plan exists, which is worse than either answer alone.
2675
+ #
2676
+ # `board.ts:802-831` already solved this and the RULE is what transfers, not the
2677
+ # call: the scan keeps its own reading (Manifesto Principle 3) and the two stay
2678
+ # separate implementations. `readBranchPlans` is the reference.
2679
+ #
2680
+ # A NARROW READER RATHER THAN A REF ARGUMENT ON THE FOUR HELPERS ABOVE, and the
2681
+ # reason is `$REF_TMP`'s namespace. `ref_plan_file` caches by `basename "$p"` in
2682
+ # one flat directory, deliberately, so the filename a report prints is the
2683
+ # plan's own. Widening that across refs would let `origin/A:docs/plans/x.md` and
2684
+ # `origin/B:docs/plans/x.md` overwrite each other SILENTLY — the dedup's concern
2685
+ # ("one plan reported as several") inverted into two plans reported as one,
2686
+ # carrying whichever content was written last. A per-branch subdirectory makes
2687
+ # the collision unrepresentable instead of unlikely. The batch materialiser at
2688
+ # `:2543` is also built around one ref by construction: it keys a single
2689
+ # `cat-file --batch` stream by basename off one `PLAN_MODES`.
2690
+ #
2691
+ # REGULAR BLOBS ONLY, never mode 120000. A symlink blob holds its TARGET PATH as
2692
+ # content, so parsing one hands plot-plan-meta.sh a line of text where a plan
2693
+ # should be, and `$ACTIVE_DIR`'s links would double-count every indexed plan.
2694
+ # `$PLAN_DIR` alone is listed, with modes — which is also why the ref-space
2695
+ # symlink walk in `ref_plan_file` is deliberately NOT ported here: filtering the
2696
+ # links out first means there is no link left to follow.
2697
+ branch_plan_paths() { # $1=branch → regular .md blob paths under $PLAN_DIR
2698
+ # An UNREADABLE REF CONTRIBUTES NOTHING, silently — an empty set, exactly as
2699
+ # `planPathsInTree` returns. It never produces a blank or guessed plan, and
2700
+ # this is asked once per branch where a fetch may legitimately have missed one.
2701
+ git ls-tree -z "origin/$1" -- "$PLAN_DIR" </dev/null 2>/dev/null \
2702
+ | tr '\0' '\n' \
2703
+ | awk -F'\t' '$1 ~ /^100644 |^100755 / && $2 ~ /\.md$/ { print $2 }' || true
2704
+ }
2705
+
2706
+ # One branch plan blob, materialized where plot-plan-meta.sh can parse it.
2707
+ #
2708
+ # NAMED UNDER A PER-BRANCH DIRECTORY for the collision reason above. The branch
2709
+ # name is sanitized because it carries `/` by convention (`bug/…`), which would
2710
+ # otherwise name a directory that does not exist.
2711
+ branch_plan_file() { # $1=branch, $2=path in that branch → temp file, or ""
2712
+ local branch="$1" p="$2" content dir out
2713
+ [ -n "$REF_TMP" ] || return 1
2714
+ content=$(git show "origin/$branch:$p" </dev/null 2>/dev/null) || return 1
2715
+ # AN EMPTY BLOB IS SKIPPED RATHER THAN PARSED (`board.ts:817-822`): a plan
2716
+ # file with no bytes parses to no phase, and a row with no phase belongs in no
2717
+ # column. The emptiness test sits beside the failure test rather than
2718
+ # replacing it, because the two are different faults.
2719
+ [ -n "$content" ] || return 1
2720
+ dir="$REF_TMP/branch/$(printf '%s' "$branch" | tr '/' '_')"
2721
+ mkdir -p "$dir" 2>/dev/null || return 1
2722
+ out="$dir/$(basename "$p")"
2723
+ printf '%s\n' "$content" > "$out" 2>/dev/null || return 1
2724
+ printf '%s' "$out"
2725
+ }
2726
+
2628
2727
  # Resolve which plans to report on.
2629
2728
  #
2630
2729
  # TWO PARALLEL ARRAYS, because a plan now has two paths that must not be
@@ -2727,7 +2826,7 @@ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2727
2826
  #
2728
2827
  # `record` types, one per line, all tab-separated and all prefixed by the plan
2729
2828
  # file they describe:
2730
- # P <file> <phase> <delivered_in_window> one per parsed file
2829
+ # P <file> <phase> one per parsed file
2731
2830
  # W <file> <wave-idx> <branch> <deferred> <why> <wave-name> <claim>
2732
2831
  #
2733
2832
  # NO ASSOCIATIVE ARRAYS. `/bin/bash` on macOS is 3.2 and this script uses no
@@ -2745,7 +2844,6 @@ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2745
2844
  # gave when it failed.
2746
2845
  plan_meta_files=()
2747
2846
  plan_meta_phases=()
2748
- plan_meta_inwindow=()
2749
2847
  plan_meta_waves=()
2750
2848
 
2751
2849
  # Parses every plan file given, filling the four arrays above. Called ONCE.
@@ -2756,33 +2854,6 @@ parse_plan_estate() { # $@=files to parse
2756
2854
  | python3 -c '
2757
2855
  import json, re, sys, time
2758
2856
 
2759
- window = float(sys.argv[1]) * 3600
2760
- now = time.time()
2761
-
2762
- def in_window(raw):
2763
- """The `Delivered:` record against the rolling window — the same rule the
2764
- per-plan test applied, moved into the one pass. A record whose date does
2765
- not parse is dropped rather than coerced: Date-style leniency would turn a
2766
- typo into a confident answer. A record with no time anchors at 23:59:59,
2767
- so a plan delivered at 23:50 is not an hour from expiry the moment it is
2768
- written. A FUTURE record is INSIDE (negative age), because hiding a live
2769
- plan for a mistyped year costs more than showing one."""
2770
- raw = (raw or "").strip()
2771
- if not raw:
2772
- return False
2773
- m = re.match(r"(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?", raw)
2774
- if not m:
2775
- return False
2776
- y, mo, dy, hh, mi = m.groups()
2777
- timed = hh is not None
2778
- try:
2779
- at = time.mktime((int(y), int(mo), int(dy),
2780
- int(hh) if timed else 23, int(mi) if timed else 59,
2781
- 0 if timed else 59, 0, 0, -1))
2782
- except (ValueError, OverflowError):
2783
- return False
2784
- return (now - at) <= window
2785
-
2786
2857
  def clean(s):
2787
2858
  return str(s).replace("\t", " ").replace("\n", " ")
2788
2859
 
@@ -2800,8 +2871,7 @@ for line in sys.stdin:
2800
2871
  f = d.get("file")
2801
2872
  if not f:
2802
2873
  continue
2803
- print("\t".join(["P", clean(f), clean(d.get("phase", "")),
2804
- "1" if in_window(d.get("delivered_raw")) else "0"]))
2874
+ print("\t".join(["P", clean(f), clean(d.get("phase", ""))]))
2805
2875
  for i, w in enumerate(d.get("waves", []) or []):
2806
2876
  name = w.get("name")
2807
2877
  for b in w.get("branches", []) or []:
@@ -2828,7 +2898,7 @@ for line in sys.stdin:
2828
2898
  (b.get("deferred_reason") or "-"),
2829
2899
  (b.get("waits_on") or "-"),
2830
2900
  name or "-", b.get("claimed") or "-"]))
2831
- ' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
2901
+ ' 2>/dev/null) || records=""
2832
2902
 
2833
2903
  local kind file rest
2834
2904
  while IFS=$'\t' read -r kind file rest; do
@@ -2836,9 +2906,8 @@ for line in sys.stdin:
2836
2906
  case "$kind" in
2837
2907
  P)
2838
2908
  plan_meta_files+=("$file")
2839
- # `rest` is "<phase>\t<inwindow>"; both are single tokens with no tabs.
2840
- plan_meta_phases+=("${rest%% *}")
2841
- plan_meta_inwindow+=("${rest##* }")
2909
+ # `rest` is "<phase>", a single token with no tabs.
2910
+ plan_meta_phases+=("$rest")
2842
2911
  plan_meta_waves+=("")
2843
2912
  ;;
2844
2913
  W)
@@ -2962,8 +3031,8 @@ else
2962
3031
  # was buying less than it appeared to: it keyed off the `$DELIVERED_DIR`
2963
3032
  # symlink's mtime, and a fresh checkout stamps every symlink at once — 56 of
2964
3033
  # 56 delivered links admitted here, so the parse it was meant to avoid was
2965
- # already being paid in full. `delivered_in_window` (the `Delivered:` record)
2966
- # was always the filter that actually decided, and the pre-filter's own
3034
+ # already being paid in full. The `Delivered:` record's window was the filter
3035
+ # that actually decided until 2026-10-01, when the phase alone took over, and the pre-filter's own
2967
3036
  # contract was that it may only ever OVER-admit. Removing it takes that
2968
3037
  # contract to its limit — strictly more correct, and on this repo not even
2969
3038
  # more expensive.
@@ -3013,6 +3082,56 @@ else
3013
3082
  cand_ids+=("$plan_path")
3014
3083
  cand_reads+=("$plan_blob")
3015
3084
  done <<< "$(ref_ls "$PLAN_DIR")"
3085
+
3086
+ # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3087
+ # not carry. Appended to the SAME candidate arrays, before the one
3088
+ # `parse_plan_estate` call below: a second call would build a second
3089
+ # `plan_meta_files` index and the lookup at the row loop keys on the file
3090
+ # path, so the branch plans would parse and then be unfindable.
3091
+ #
3092
+ # From here the existing pipeline carries the plan unchanged — it names its
3093
+ # branch in `## Slices`, the wave walk finds it, and the branch stops
3094
+ # reaching the report through the plan-less loop in `fleet.ts`.
3095
+ #
3096
+ # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3097
+ # every plan path the default branch carries; `seen_branch_plans` is every
3098
+ # path already taken from an earlier branch. Two branches cut from one point
3099
+ # carry the SAME plan file, and without the second test one plan reports as
3100
+ # several — a regression the board measured and fixed, and the reason its
3101
+ # comment exists.
3102
+ #
3103
+ # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3104
+ # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3105
+ # second `for-each-ref`. The narrowing to PR-less branches the slice line
3106
+ # offered was WITHDRAWN by the plan's Design section: it was a fallback
3107
+ # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3108
+ # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3109
+ # a scan whose wall time is 95% waiting.
3110
+ on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3111
+ seen_branch_plans=$'\n'
3112
+ while IFS=$'\t' read -r branch_name _branch_sha; do
3113
+ [ -n "$branch_name" ] || continue
3114
+ [ "$branch_name" = "HEAD" ] && continue
3115
+ [ "$branch_name" = "$MAIN" ] && continue
3116
+ printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3117
+ while IFS= read -r bp; do
3118
+ [ -n "$bp" ] || continue
3119
+ case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3120
+ case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3121
+ plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3122
+ [ -n "$plan_blob" ] || continue
3123
+ # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3124
+ # that could not be read has not been reported, so a later branch
3125
+ # carrying a readable copy of the same path must still get its turn.
3126
+ seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3127
+ # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3128
+ # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3129
+ # the `docs/plans/…md` path is a usable id — and the dedup above is what
3130
+ # guarantees it cannot collide with a default-branch plan's.
3131
+ cand_ids+=("$bp")
3132
+ cand_reads+=("$plan_blob")
3133
+ done <<< "$(branch_plan_paths "$branch_name")"
3134
+ done <<< "$REMOTE_REFS"
3016
3135
  else
3017
3136
  for plan_path in "$PLAN_DIR"*.md; do
3018
3137
  [ -e "$plan_path" ] || continue
@@ -3066,7 +3185,7 @@ if [ ${#plans[@]} -eq 0 ]; then
3066
3185
  # the scan globbed it; pointing a reader at the index would now send them to
3067
3186
  # look for the cause of an empty list in a directory nothing consults.
3068
3187
  echo "No plans found in ${PLAN_DIR}."
3069
- echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 waiting=0 prereq_missing=0 main=$MAIN"
3188
+ echo "summary: plans=0 waves=0 branches=0 claimed=0 claimable=0 eligible=0 blocked=0 deferred=0 waiting=0 prereq_missing=0 main=$MAIN"
3070
3189
  exit 0
3071
3190
  fi
3072
3191
  fi
@@ -3161,7 +3280,7 @@ EOF
3161
3280
  echo "$total $n"
3162
3281
  }
3163
3282
 
3164
- # WHAT WAS READ OF ONE BRANCH — ten tab-separated fields, and no decision.
3283
+ # WHAT WAS READ OF ONE BRANCH — eleven tab-separated fields, and no decision.
3165
3284
  #
3166
3285
  # `branch_state()` UNTIL THIS SLICE, and every line of git archaeology below is
3167
3286
  # its own, unchanged. What went is the `if` chain that merged these readings
@@ -3178,10 +3297,11 @@ EOF
3178
3297
  # run of tabs collapses into one separator under `read`, so no field is ever
3179
3298
  # empty. Nothing here is optional, so nothing can shift.
3180
3299
  #
3181
- # EIGHT FIELDS, NOT TEN. The two the plan states — the prerequisite's name and
3182
- # what the host said about it — are appended by the caller, because reading the
3183
- # second costs a host round trip and the scan spends it only where it could
3300
+ # EIGHT FIELDS, NOT ELEVEN. The two the plan states — the prerequisite's name
3301
+ # and what the host said about it — are appended by the caller, because reading
3302
+ # the second costs a host round trip and the scan spends it only where it could
3184
3303
  # change the answer. The rule reports which states those are; see the caller.
3304
+ # The caller appends the PR list's completeness last, once per run.
3185
3305
  #
3186
3306
  # THE DEFAULT BRANCH'S TIP IS READ ONCE PER RUN, not once per branch. It does
3187
3307
  # not move while the scan runs — every fact below is derived from the ref batch
@@ -3494,6 +3614,12 @@ n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred
3494
3614
  # the footer keeps the two words apart for that reason.
3495
3615
  n_waiting=0 n_prereq_missing=0
3496
3616
  claimable=()
3617
+ # HOW MANY BRANCHES AN ELIGIBLE WAVE HOLDS THAT SOMEBODY IS ON — the fact that
3618
+ # separates "this estate has no work" from "every candidate here is taken", and
3619
+ # the only reason `--list-eligible` can say which of the two its silence means.
3620
+ # Counted across eligible waves only: a taken branch in a blocked wave was never
3621
+ # a candidate, so reporting it would name work the offer path never considered.
3622
+ n_taken_in_eligible=0
3497
3623
  plan_files=()
3498
3624
  # `--why-nothing`'s input: one `verdict<TAB>name:state|...` line per slice, in
3499
3625
  # plan order. Accumulated in the SAME loop that renders the branches, so the
@@ -3524,26 +3650,14 @@ for plan in "${plans[@]}"; do
3524
3650
  # is a judgment that belongs one layer up (Manifesto Principle 3).
3525
3651
  plan_phase="${plan_meta_phases[$meta_i]}"
3526
3652
 
3527
- # The delivered window, applied to the plans the PHASE put in the terminal
3528
- # group. Enumeration grouped them; the `Delivered:` RECORD decides which of
3529
- # them still appears.
3530
- #
3531
- # THE TEST IS THE PHASE, not the path. It read `case "$plan" in "$DELIVERED_DIR"*)`
3532
- # — the directory the link sat in — and that made "which group is this plan
3533
- # in" a fact about a symlink while "what phase is it" was a fact about the
3534
- # file. The old comment here noted that an active plan carrying
3535
- # `Phase: Delivered` was drift the window must not hide; under the phase rule
3536
- # that drift cannot be constructed, because there is no second place for the
3537
- # answer to live. One source, so nothing to disagree.
3538
- #
3539
- # Two exits, and both matter:
3540
- # * the record's date has aged out of the window — ordinary expiry;
3541
- # * there is NO record — "no date, no row". `reconcile-scan-accuracy.md` is
3542
- # the live example; showing it would create the one row that can never
3543
- # age out of DONE.
3544
- # Both leave before a single git call is spent on the plan's branches.
3545
- if is_terminal_phase "$plan_phase"; then
3546
- [ "${plan_meta_inwindow[$meta_i]}" = "1" ] || continue
3653
+ # THE RELEASE SCOPE, decided by the PHASE alone. A terminal plan stays in the
3654
+ # pulse only while it is `delivered`: its work has landed and its version has
3655
+ # not shipped, which is what DONE holds. `released`, `rejected` and
3656
+ # `superseded` leave, whatever their age, and leave before a single git call
3657
+ # is spent on their branches. No date bounds it, so a plan delivered weeks
3658
+ # before a release still names the release's contents.
3659
+ if is_terminal_phase "$plan_phase" && [ "$plan_phase" != "delivered" ]; then
3660
+ continue
3547
3661
  fi
3548
3662
 
3549
3663
  n_plans=$((n_plans + 1))
@@ -3641,12 +3755,17 @@ for plan in "${plans[@]}"; do
3641
3755
  # deciding it — see pass 1c.
3642
3756
  readings=""
3643
3757
  order=""
3758
+ # THE ELEVENTH FIELD: whether the PR list held every PR (`.list-complete`,
3759
+ # written by `prefill_pr_states`). The rule reads a `NONE` for a ref behind
3760
+ # main as `open` only when it is true; a capped list may omit a merged PR.
3761
+ list_complete=false
3762
+ [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-complete" ] && list_complete=true
3644
3763
  while IFS=$'\t' read -r idx br deferred why waits wname claim; do
3645
3764
  [ -n "$br" ] || continue
3646
3765
  # "-" is the absent marker the shim writes, for the tab-collapse reason
3647
3766
  # above. Normalized here so everything downstream tests emptiness.
3648
3767
  [ "$waits" = "-" ] && waits=""
3649
- readings+="$(branch_readings "$br" "$deferred") ${waits:--} ?"$'\n'
3768
+ readings+="$(branch_readings "$br" "$deferred") ${waits:--} ? $list_complete"$'\n'
3650
3769
  order+="$idx $br $deferred $why ${waits:--} $wname $claim"$'\n'
3651
3770
  done <<< "$wave_lines"
3652
3771
 
@@ -3701,7 +3820,8 @@ for plan in "${plans[@]}"; do
3701
3820
  # list may legitimately omit: its plan may be delivered and its ref gone.
3702
3821
  # `host_pr_state`'s run cache keeps this at one call per prerequisite per
3703
3822
  # run, never one per pass.
3704
- refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br")"$'\n'
3823
+ # Field 10 is replaced; field 11 is carried.
3824
+ refill+="$(printf '%s' "$rd_line" | cut -f1-9) $(waits_pr_state "$waits_br") $(printf '%s' "$rd_line" | cut -f11)"$'\n'
3705
3825
  else
3706
3826
  refill+="$rd_line"$'\n'
3707
3827
  fi
@@ -3807,16 +3927,36 @@ for plan in "${plans[@]}"; do
3807
3927
  wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $7; exit}')
3808
3928
  [ "$wname" = "-" ] && wname=""
3809
3929
 
3810
- [ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
3930
+ # THE HEADER IS HELD, NOT PRINTED — it carries a suffix that only the
3931
+ # branch loop below can decide. `eligible` answers *are this wave's
3932
+ # prerequisites met?* while every offer path answers *can a branch here be
3933
+ # claimed now?*, and a wave whose branches are all taken satisfies the first
3934
+ # and not the second. Reported 2026-09-25 (#994) from a Bitbucket estate:
3935
+ # one wave `eligible`, eleven branches, both offer paths silent. The two
3936
+ # computations are correct and the word was carrying both answers.
3937
+ #
3938
+ # The line still prints BEFORE its branches — only the decision is delayed,
3939
+ # not the reading order — so the branch lines are buffered and flushed with
3940
+ # it. There is exactly one `echo` in that loop, which is why buffering it
3941
+ # costs less than threading the wave header past the six counters,
3942
+ # `json_branches` and `outlook_branches` the loop also builds.
3943
+ wave_header=" ${wname:-(unnamed)} — $verdict"
3811
3944
  # A degradation that says nothing is indistinguishable from a bug.
3812
3945
  # When --loose falls back to strict because the rollup cannot be had,
3813
3946
  # say so — an operator who passed the flag and sees strict behaviour
3814
3947
  # has no way to tell "the rollup said not-green" from "the rollup
3815
3948
  # could not be had". Both are correct refusals; only one is about
3816
3949
  # their PR.
3950
+ wave_body=""
3817
3951
  if [ "$quiet" != 1 ] && [ "$loose" = 1 ] && [ -n "$_loose_degraded_branches" ]; then
3818
- echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
3952
+ wave_body+=" (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"$'\n'
3819
3953
  fi
3954
+ # HOW MANY BRANCHES OF THIS WAVE ARE TAKEN, and how many claimable. Counted
3955
+ # here rather than derived from the estate-wide `n_eligible`, which spans
3956
+ # plans. `unknown` is in neither: the host could not be asked, so the branch
3957
+ # is not taken and not free, and a wave holding one must never be reported
3958
+ # as somebody else's work.
3959
+ wave_taken=0 wave_free=0
3820
3960
  json_branches=""
3821
3961
  # The outlook's reading of this slice, built alongside the render. EVERY
3822
3962
  # branch including the deferred ones, in the plan's order — the rule needs
@@ -3867,8 +4007,17 @@ for plan in "${plans[@]}"; do
3867
4007
  if [ "${wave_claimable:$((branch_i - 1)):1}" = "1" ]; then
3868
4008
  n_eligible=$((n_eligible + 1))
3869
4009
  claimable+=("$br")
4010
+ wave_free=$((wave_free + 1))
3870
4011
  fi
3871
- [ "$quiet" = 1 ] || echo " $br — $note"
4012
+ # TAKEN MEANS SOMEBODY HAS IT — claimed, or carrying pushed work. Read
4013
+ # from the branch's own state rather than from the claimable flag above,
4014
+ # because that flag is 0 for six different reasons and only these two mean
4015
+ # a person is on it. `unknown` is excluded by naming the two states rather
4016
+ # than by negating the flag.
4017
+ case "$st" in
4018
+ claimed|wip) wave_taken=$((wave_taken + 1)) ;;
4019
+ esac
4020
+ wave_body+=" $br — $note"$'\n'
3872
4021
  if [ "$build_doc" = 1 ]; then
3873
4022
  # The INTERNAL state ($st), never the prose label ($note): the board
3874
4023
  # must not parse a string that exists for humans to read.
@@ -4067,6 +4216,45 @@ for plan in "${plans[@]}"; do
4067
4216
  fi
4068
4217
  done <<< "$states"
4069
4218
 
4219
+ # WHICH QUESTION THE WORD ANSWERED, said in the estate's existing
4220
+ # vocabulary. `someone-is-on-it` is `StartabilityVerdictSchema`'s word
4221
+ # (`packages/board/src/contract/schema.ts`) for exactly these two branch
4222
+ # states, so the wave line and its branch lines now agree rather than
4223
+ # offering a reader two answers to compare.
4224
+ #
4225
+ # A PROSE SUFFIX, NOT A SIXTH VERDICT. `$verdict` is untouched and reaches
4226
+ # `--json`, `--stream` and the outlook byte-identical: `FleetWaveSchema`'s
4227
+ # verdict is a strict enum, so a parsed pulse cannot carry a new word and
4228
+ # widening it is a separate change with its own consumers.
4229
+ #
4230
+ # THE RULE, STATED: a wave line carries BOTH answers — the verdict, then who
4231
+ # has it — and the suffix appears exactly when the wave is `eligible`, holds
4232
+ # no free branch, and holds at least one branch that is `claimed` or `wip`.
4233
+ # A wave with a free branch keeps bare `eligible`, which is what the offer
4234
+ # paths will confirm; a wave whose only non-free branches are `unknown`
4235
+ # keeps it too, because the host could not be asked and nobody has claimed
4236
+ # anything.
4237
+ #
4238
+ # `wip` COUNTS AS TAKEN, which the plan settles three times: *"a branch
4239
+ # counts as taken when it is claimed or in progress"*. A one-branch wave
4240
+ # whose branch is `wip` therefore reads `eligible — someone-is-on-it`, and
4241
+ # two tests in `fleet.test.mjs` asserted `/ — eligible$/` on exactly that
4242
+ # shape. Their subject is the VERDICT — *"has not settled its wave"*,
4243
+ # *"the wave must stay open"* — so the anchor moved to the verdict rather
4244
+ # than the suffix being suppressed; the `$` was free before a suffix existed
4245
+ # and asserted a second thing neither test meant. The other five sites
4246
+ # carrying that anchor hold a free branch and are untouched.
4247
+ if [ "$verdict" = "eligible" ] && [ "$wave_free" -eq 0 ] && [ "$wave_taken" -gt 0 ]; then
4248
+ wave_header+=" — someone-is-on-it"
4249
+ fi
4250
+ if [ "$verdict" = "eligible" ]; then
4251
+ n_taken_in_eligible=$((n_taken_in_eligible + wave_taken))
4252
+ fi
4253
+ if [ "$quiet" != 1 ]; then
4254
+ echo "$wave_header"
4255
+ [ -n "$wave_body" ] && printf '%s' "$wave_body"
4256
+ fi
4257
+
4070
4258
  if [ "$build_doc" = 1 ]; then
4071
4259
  json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
4072
4260
  json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
@@ -4148,6 +4336,27 @@ fi
4148
4336
  # "Nothing to start" is a normal state, not a failure — the exit code is what
4149
4337
  # distinguishes it from a name, so callers can branch on it without parsing.
4150
4338
  if [ "$next_only" = 1 ]; then
4339
+ # WHY THE LIST IS EMPTY, for `--list-eligible` only, and ON STDERR. An
4340
+ # operator reading the body's `eligible` and then getting silence here cannot
4341
+ # tell a claimed-out estate from an empty one, and #994 is that reading: one
4342
+ # wave eligible, eleven branches, nothing offered. The exit code still carries
4343
+ # the answer — 1 either way — so no caller's gate moves.
4344
+ #
4345
+ # STDERR BECAUSE STDOUT IS A TARGET LIST. `plot-dispatch.sh:3475` pipes this
4346
+ # stdout through `sort -u` and dispatches every line, so a sentence there
4347
+ # becomes a branch name it tries to claim. That caller already discards
4348
+ # stderr (`2>/dev/null`), so the sentence reaches a person and no machine.
4349
+ #
4350
+ # `--next` IS UNTOUCHED: its exit-1 contract is shipped, documented and
4351
+ # tested twice, and it names ONE branch for a worker that has nothing to read
4352
+ # a sentence with.
4353
+ if [ ${#claimable[@]} -eq 0 ] && [ "$list_all" = 1 ]; then
4354
+ if [ "$n_taken_in_eligible" -gt 0 ]; then
4355
+ echo "nothing claimable: $n_taken_in_eligible branch(es) in eligible waves are taken." >&2
4356
+ else
4357
+ echo "nothing claimable: no eligible wave holds a startable branch." >&2
4358
+ fi
4359
+ fi
4151
4360
  [ ${#claimable[@]} -gt 0 ] || exit 1
4152
4361
  if [ "$list_all" = 1 ]; then
4153
4362
  printf '%s\n' "${claimable[@]}"
@@ -4455,4 +4664,4 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
4455
4664
  fi
4456
4665
  write_bridge
4457
4666
  echo "Pulse complete. This report is derived — nothing was changed."
4458
- echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred waiting=$n_waiting prereq_missing=$n_prereq_missing merge_detect=$MERGE_DETECT host=$HOST_VERDICT main=$MAIN"
4667
+ 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"