@plot-pm/board 0.14.4 → 0.15.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
@@ -119,10 +136,18 @@
119
136
  # The plan set also includes plans delivered inside a rolling 24 h
120
137
  # window (see "the last day of finished work"), so work does not
121
138
  # 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
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
@@ -697,49 +722,11 @@ REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
697
722
  # falls back to the listing there, which is what it has always done.
698
723
  TRACKED_BRANCHES=$(printf '%s\n' "$REMOTE_REFS" | cut -f1 | grep -v '^HEAD$' | grep -v '^$' | tr '\n' ' ')
699
724
 
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)
725
+ # ONE `pr-list` CALL'S EXIT, READ AS A VERDICT WORD in `_plv` — a global
726
+ # rather than stdout, so the two calls in `prefill_pr_states` fork nothing to
727
+ # classify themselves. `$1` is the exit code, `$2` the call's stderr.
728
+ pr_list_verdict() {
729
+ local rc="$1" err="$2"
743
730
  # A PARTIAL ANSWER TAKES THE PARSE PATH AND STILL DEGRADES THE VERDICT, which
744
731
  # is a control-flow change rather than another `case` arm below: every other
745
732
  # non-zero rc sets a verdict and returns BEFORE `$js` is read, because there
@@ -750,9 +737,11 @@ prefill_pr_states() {
750
737
  # BOTH HALVES ARE REQUIRED. Falling through without setting the verdict would
751
738
  # report `ok` over a page missing a whole state, which is #912; returning
752
739
  # 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
740
+ if [ "$rc" -eq 0 ]; then
741
+ _plv=ok
742
+ elif [ "$rc" -eq 7 ]; then
743
+ _plv=partial
744
+ else
756
745
  # THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
757
746
  # never put" (see HOST_VERDICT above: *not a degradation, the scan was
758
747
  # never asking*), and a host that cannot be ASKED AT ALL belongs there
@@ -774,10 +763,10 @@ prefill_pr_states() {
774
763
  # always read 1. The branch was startable; the scan had stopped being
775
764
  # able to say so.
776
765
  case "$rc" in
777
- 5) HOST_VERDICT=throttled ;;
778
- 6) HOST_VERDICT=secondary ;;
779
- 4) HOST_VERDICT=unasked ;;
780
- *) case "$host_err" in
766
+ 5) _plv=throttled ;;
767
+ 6) _plv=secondary ;;
768
+ 4) _plv=unasked ;;
769
+ *) case "$err" in
781
770
  # THE WORDING IS MEASURED, NOT GUESSED. An earlier version of this
782
771
  # list matched auth/login/credential and MISSED the message CI
783
772
  # actually emits:
@@ -794,7 +783,7 @@ prefill_pr_states() {
794
783
  # a real failure and stays `failed`, because widening this to a
795
784
  # catch-all would turn every host outage into "nobody asked".
796
785
  *TOKEN*|*token*|*auth*|*Auth*|*AUTH*|*login*|*Login*|*credential*|*Credential*|*"not logged"*)
797
- HOST_VERDICT=unasked ;;
786
+ _plv=unasked ;;
798
787
  # NO REMOTE IS A CONFIGURATION, NOT A FAULT — the same reading as a
799
788
  # missing token one line up, reached by the same route: `plot-host.sh`
800
789
  # exits 3 for both, because both are "the op cannot proceed", and
@@ -813,19 +802,123 @@ prefill_pr_states() {
813
802
  # claimable — the right refusal about the wrong thing. There is no
814
803
  # merge state to withhold on where there is no remote to hold it.
815
804
  *"no git remotes"*|*"no remote"*)
816
- HOST_VERDICT=unasked ;;
817
- *) HOST_VERDICT=failed ;;
805
+ _plv=unasked ;;
806
+ *) _plv=failed ;;
818
807
  esac ;;
819
808
  esac
820
- return 0
821
809
  fi
822
- # The list arrived. An empty one arrived too — that is the whole distinction.
810
+ }
811
+
812
+ # WHICH OF TWO VERDICTS IS WORSE. Every word that returns before a payload is
813
+ # read outranks both words that parse one, so a failed call can never be
814
+ # reported as `ok` or `partial` beside a call that answered.
815
+ # The rank is set in `_plr`, for the same no-fork reason as `_plv`.
816
+ pr_list_verdict_rank() {
817
+ case "$1" in
818
+ ok) _plr=0 ;; partial) _plr=1 ;; unasked) _plr=2 ;;
819
+ secondary) _plr=3 ;; throttled) _plr=4 ;; *) _plr=5 ;;
820
+ esac
821
+ }
822
+
823
+ prefill_pr_states() {
824
+ [ "$HOST_LOOKUP_OK" = 1 ] || return 0
825
+ [ -n "$HOST_STATE_CACHE" ] || return 0
826
+ local js br st key rc
827
+ # Exit code first: non-zero is a transport failure and its stdout is not an
828
+ # answer. A failed list leaves the cache EMPTY, so every branch falls through
829
+ # to the unanswerable `-` rather than to a fabricated "no PR".
830
+ #
831
+ # `--rich` adds `checks` and `draft` to the response — the fields `--loose`
832
+ # needs to verify that a prior wave's PR is actually green and ready. The
833
+ # BEHAVIOUR change is in `pr_ready`, which reads the check rollup from the
834
+ # cache rather than making a per-branch host call.
835
+ #
836
+ # THE ROLLUP IS ASKED OF OPEN PRS ONLY. On GitHub it is `statusCheckRollup`,
837
+ # and its cost scales with the rows it is asked for. Measured 2026-09-25 on
838
+ # this repo through `plot-host.sh`: 957 PRs (920 merged, 34 closed, 3 open),
839
+ # and the one `--state all --rich` call took ~37 s of a ~55 s scan. A merged
840
+ # or closed PR's checks cannot change, and `pr_ready` reads them only for an
841
+ # open one. So TWO calls: `--state open --rich` for the PRs whose checks can
842
+ # still move, and `--state all` without `--rich` for every PR's state.
823
843
  #
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
844
+ # THE CODE IS KEPT, not just tested. This guard was always correct — a failed
845
+ # list prefills nothing — and until 2026-08-30 it never fired, because
846
+ # `pr-list` swallowed its own failure and exited 0 with empty stdout. Now
847
+ # that it can fail, WHICH failure it was is a fact worth carrying: exit 5 is
848
+ # a rate limit and exit 3 is anything else, and the summary reports the
849
+ # difference rather than degrading silently.
850
+ # STDOUT TO A FILE so stderr can be captured separately — see the verdict
851
+ # below. It lives in `HOST_STATE_CACHE`, which already has an EXIT trap, so
852
+ # this adds no second cleanup path. When mktemp -d failed the cache is "",
853
+ # and /dev/null keeps the call working with the text simply unavailable.
854
+ local open_list_out host_list_out open_err rc_open
855
+ open_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list-open.json}"
856
+ open_list_out="${open_list_out:-/dev/null}"
857
+ host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
858
+ host_list_out="${host_list_out:-/dev/null}"
859
+ # THE BRANCHES THIS SCAN TRACKS, HANDED TO THE HOST (#333). The adapter uses
860
+ # them only where it can — the Bitbucket arm sweeps its REST endpoint once per
861
+ # branch per state — and ignores them everywhere else, so the GitHub arm makes
862
+ # the single call it always made. Passing them unconditionally keeps one call
863
+ # shape here rather than a backend test this script has no business making.
864
+ #
865
+ # AN EMPTY SET PASSES NOTHING and the adapter lists as before. See
866
+ # `TRACKED_BRANCHES`: a completeness claim over an empty set would license
867
+ # `NONE` for branches nobody asked about.
868
+ _branch_args=()
869
+ for _tb in $TRACKED_BRANCHES; do _branch_args+=(--branch "$_tb"); done
870
+ # BOTH CALLS TAKE `--limit` AND THE BRANCHES. Without `--limit` the host
871
+ # returns 30 (see `PR_LIST_LIMIT`). The branches make the Bitbucket arm sweep
872
+ # the open state exactly rather than list a fixed 50 of it: an open PR
873
+ # missing from the rich payload has NO row at all once the plain payload's
874
+ # OPEN rows are dropped below, and a missing row reads as "no PR".
875
+ #
876
+ # OPEN FIRST, THEN ALL. A PR that merges between the two calls then carries
877
+ # a rich OPEN row and a plain MERGED one, and the rank keeps OPEN — stale by
878
+ # seconds, and the next scan corrects it.
879
+ open_err=$("$script_dir/plot-host.sh" pr-list --state open --limit "$PR_LIST_LIMIT" --rich \
880
+ ${_branch_args[@]+"${_branch_args[@]}"} \
881
+ </dev/null 2>&1 >"$open_list_out"); rc_open=$?
882
+ # THE VERDICT IS THE WORSE OF THE TWO, never the last one. A rich call
883
+ # throttled while the plain one answers leaves `checks` absent for every open
884
+ # PR, so `--loose` degrades to strict — and a footer reading `host=ok` would
885
+ # give no reason for it.
886
+ #
887
+ # A FAILED CALL PREFILLS NOTHING, whichever of the two failed. That is the
888
+ # single call's behaviour carried over: a verdict other than `ok` or
889
+ # `partial` sets the verdict and returns before any payload is read. A
890
+ # failed `open` call therefore decides the outcome alone, and the `all` call
891
+ # is not made: its answer could not change the verdict's direction, and on a
892
+ # throttled host it would spend quota on a payload nothing reads.
893
+ pr_list_verdict "$rc_open" "$open_err"; _v_open=$_plv
894
+ case "$_v_open" in ok|partial) ;; *) HOST_VERDICT=$_v_open; return 0 ;; esac
895
+ host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" \
896
+ ${_branch_args[@]+"${_branch_args[@]}"} \
897
+ </dev/null 2>&1 >"$host_list_out"); rc=$?
898
+ pr_list_verdict "$rc" "$host_err"; _v_all=$_plv
899
+ pr_list_verdict_rank "$_v_open"; _r_open=$_plr
900
+ pr_list_verdict_rank "$_v_all"
901
+ if [ "$_r_open" -gt "$_plr" ]; then HOST_VERDICT=$_v_open; else HOST_VERDICT=$_v_all; fi
902
+ case "$HOST_VERDICT" in ok|partial) ;; *) return 0 ;; esac
903
+ # THE MERGE, AND THE TRAP IT AVOIDS. An open PR is in BOTH payloads — rich
904
+ # from the `open` call, plain from the `all` call — and both rows rank 1 in
905
+ # the dedup below. With the sort key exhausted, `sort` compares whole lines,
906
+ # and the plain row's `-` sentinel (0x2D) sorts before any letter: the plain
907
+ # row wins every time and the rollup is lost for 100% of open PRs.
908
+ #
909
+ # So the `all` payload's OPEN rows are dropped before the concatenation, and
910
+ # each branch contributes one OPEN row at most. Those rows are unused: the
911
+ # `open` call already answered for every open PR, with its rollup.
912
+ #
913
+ # THE COMPLETENESS COUNT IS TAKEN FROM THE `all` PAYLOAD BEFORE THE FILTER.
914
+ # `.list-complete` compares a row count against `PR_LIST_LIMIT`, and a count
915
+ # taken after the filter can fall below the limit for a list that reached it
916
+ # — a truncated list then reads as whole, and a cache miss derives `NONE` for
917
+ # a branch that has a PR. The `open` payload is counted too, since it is a
918
+ # page of its own.
919
+ _pr_rows=$(grep -c '"state":"[A-Z]*","head":"' "$host_list_out" 2>/dev/null) || _pr_rows=0
920
+ _pr_open_rows=$(grep -c '"state":"[A-Z]*","head":"' "$open_list_out" 2>/dev/null) || _pr_open_rows=0
921
+ js=$(cat "$open_list_out" 2>/dev/null; grep -v '"state":"OPEN","head":"' "$host_list_out" 2>/dev/null)
829
922
  # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
830
923
  # that is a correctness-of-cost property rather than a style preference:
831
924
  # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
@@ -860,12 +953,8 @@ prefill_pr_states() {
860
953
  # response, which is now only an error case) reads as empty and `pr_ready`
861
954
  # treats it as `unknown`, which degrades to strict — the safer direction.
862
955
  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
956
  while IFS=" " read -r st chk dft br; do
867
957
  [ -n "$br" ] && [ -n "$st" ] || continue
868
- _pr_rows=$((_pr_rows + 1))
869
958
  [ "$br" = "$last" ] && continue
870
959
  last="$br"
871
960
  # A PLAIN row (no `--rich` fields) carries `-` in the checks/draft slots so
@@ -930,10 +1019,12 @@ EOF
930
1019
  # records on Bitbucket, where `bb pr list` is silently partial past 50 PRs per
931
1020
  # state.
932
1021
  #
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
1022
+ # Counted from the rows carrying the fields the `sed` anchors on, so a
1023
+ # malformed line that the parse skips cannot inflate the count into a false
935
1024
  # claim of completeness. Fewer rows than the limit means the host had no more
936
- # to give; equal to it means it may have.
1025
+ # to give; equal to it means it may have. BOTH PAGES must be short: the
1026
+ # `open` page is a listing of its own, and a truncated one drops open PRs
1027
+ # the filtered `all` page no longer carries.
937
1028
  #
938
1029
  # AN EMPTY LIST IS NOT A COMPLETE ONE, and the test that caught this is the
939
1030
  # reason it is written down. A host that exits 0 while printing nothing —
@@ -971,11 +1062,21 @@ EOF
971
1062
  # unjoined branch and still answers correctly — the per-branch N+1 that #216
972
1063
  # removed. That is why withholding the marker is always the safe direction and
973
1064
  # is what every failure path here does.
1065
+ #
1066
+ # THE `open` CALL MUST BE WHOLE FOR EITHER CLAIM. The `all` call's OPEN rows
1067
+ # were dropped above, so an `open` answer that is short — exit 7, or a sweep
1068
+ # that did not state its completeness — leaves open PRs with no row at all,
1069
+ # and completeness would turn those misses into `NONE`.
1070
+ [ "$_v_open" = ok ] || return 0
974
1071
  case "$host_err" in
975
1072
  *"pr-list sweep complete"*)
976
- printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
1073
+ case "$open_err" in
1074
+ *"pr-list sweep complete"*)
1075
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
1076
+ esac ;;
977
1077
  *)
978
- if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
1078
+ if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] \
1079
+ && [ "$_pr_open_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
979
1080
  printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
980
1081
  fi ;;
981
1082
  esac
@@ -2625,6 +2726,68 @@ ref_plan_file() { # $1=path in ref → temp file path, or "" when unreadable
2625
2726
  printf '%s' "$out"
2626
2727
  }
2627
2728
 
2729
+ # ---------------------------------------------------------------------------
2730
+ # A branch's own plans
2731
+ # ---------------------------------------------------------------------------
2732
+ #
2733
+ # A plan created with `Impl: same branch` lives ONLY on its work branch until
2734
+ # that branch merges. The enumeration above reads `origin/$MAIN` and finds
2735
+ # nothing, so the branch carrying the plan reached the pulse as an anonymous row
2736
+ # with `plan: ""` while `/api/board` showed the same plan as a Draft card.
2737
+ # Measured on Plot 2.20.0 (#972): two tabs of one board disagreeing about
2738
+ # whether a plan exists, which is worse than either answer alone.
2739
+ #
2740
+ # `board.ts:802-831` already solved this and the RULE is what transfers, not the
2741
+ # call: the scan keeps its own reading (Manifesto Principle 3) and the two stay
2742
+ # separate implementations. `readBranchPlans` is the reference.
2743
+ #
2744
+ # A NARROW READER RATHER THAN A REF ARGUMENT ON THE FOUR HELPERS ABOVE, and the
2745
+ # reason is `$REF_TMP`'s namespace. `ref_plan_file` caches by `basename "$p"` in
2746
+ # one flat directory, deliberately, so the filename a report prints is the
2747
+ # plan's own. Widening that across refs would let `origin/A:docs/plans/x.md` and
2748
+ # `origin/B:docs/plans/x.md` overwrite each other SILENTLY — the dedup's concern
2749
+ # ("one plan reported as several") inverted into two plans reported as one,
2750
+ # carrying whichever content was written last. A per-branch subdirectory makes
2751
+ # the collision unrepresentable instead of unlikely. The batch materialiser at
2752
+ # `:2543` is also built around one ref by construction: it keys a single
2753
+ # `cat-file --batch` stream by basename off one `PLAN_MODES`.
2754
+ #
2755
+ # REGULAR BLOBS ONLY, never mode 120000. A symlink blob holds its TARGET PATH as
2756
+ # content, so parsing one hands plot-plan-meta.sh a line of text where a plan
2757
+ # should be, and `$ACTIVE_DIR`'s links would double-count every indexed plan.
2758
+ # `$PLAN_DIR` alone is listed, with modes — which is also why the ref-space
2759
+ # symlink walk in `ref_plan_file` is deliberately NOT ported here: filtering the
2760
+ # links out first means there is no link left to follow.
2761
+ branch_plan_paths() { # $1=branch → regular .md blob paths under $PLAN_DIR
2762
+ # An UNREADABLE REF CONTRIBUTES NOTHING, silently — an empty set, exactly as
2763
+ # `planPathsInTree` returns. It never produces a blank or guessed plan, and
2764
+ # this is asked once per branch where a fetch may legitimately have missed one.
2765
+ git ls-tree -z "origin/$1" -- "$PLAN_DIR" </dev/null 2>/dev/null \
2766
+ | tr '\0' '\n' \
2767
+ | awk -F'\t' '$1 ~ /^100644 |^100755 / && $2 ~ /\.md$/ { print $2 }' || true
2768
+ }
2769
+
2770
+ # One branch plan blob, materialized where plot-plan-meta.sh can parse it.
2771
+ #
2772
+ # NAMED UNDER A PER-BRANCH DIRECTORY for the collision reason above. The branch
2773
+ # name is sanitized because it carries `/` by convention (`bug/…`), which would
2774
+ # otherwise name a directory that does not exist.
2775
+ branch_plan_file() { # $1=branch, $2=path in that branch → temp file, or ""
2776
+ local branch="$1" p="$2" content dir out
2777
+ [ -n "$REF_TMP" ] || return 1
2778
+ content=$(git show "origin/$branch:$p" </dev/null 2>/dev/null) || return 1
2779
+ # AN EMPTY BLOB IS SKIPPED RATHER THAN PARSED (`board.ts:817-822`): a plan
2780
+ # file with no bytes parses to no phase, and a row with no phase belongs in no
2781
+ # column. The emptiness test sits beside the failure test rather than
2782
+ # replacing it, because the two are different faults.
2783
+ [ -n "$content" ] || return 1
2784
+ dir="$REF_TMP/branch/$(printf '%s' "$branch" | tr '/' '_')"
2785
+ mkdir -p "$dir" 2>/dev/null || return 1
2786
+ out="$dir/$(basename "$p")"
2787
+ printf '%s\n' "$content" > "$out" 2>/dev/null || return 1
2788
+ printf '%s' "$out"
2789
+ }
2790
+
2628
2791
  # Resolve which plans to report on.
2629
2792
  #
2630
2793
  # TWO PARALLEL ARRAYS, because a plan now has two paths that must not be
@@ -3013,6 +3176,56 @@ else
3013
3176
  cand_ids+=("$plan_path")
3014
3177
  cand_reads+=("$plan_blob")
3015
3178
  done <<< "$(ref_ls "$PLAN_DIR")"
3179
+
3180
+ # THEN EACH PREFIXED BRANCH'S OWN TREE, for the plans `origin/$MAIN` does
3181
+ # not carry. Appended to the SAME candidate arrays, before the one
3182
+ # `parse_plan_estate` call below: a second call would build a second
3183
+ # `plan_meta_files` index and the lookup at the row loop keys on the file
3184
+ # path, so the branch plans would parse and then be unfindable.
3185
+ #
3186
+ # From here the existing pipeline carries the plan unchanged — it names its
3187
+ # branch in `## Slices`, the wave walk finds it, and the branch stops
3188
+ # reaching the report through the plan-less loop in `fleet.ts`.
3189
+ #
3190
+ # THE DEDUP IS THE BOARD'S, COPIED RATHER THAN RE-DERIVED. `on_default` is
3191
+ # every plan path the default branch carries; `seen_branch_plans` is every
3192
+ # path already taken from an earlier branch. Two branches cut from one point
3193
+ # carry the SAME plan file, and without the second test one plan reports as
3194
+ # several — a regression the board measured and fixed, and the reason its
3195
+ # comment exists.
3196
+ #
3197
+ # WHICH BRANCHES: `REMOTE_REFS`, already read once above, filtered by
3198
+ # `PREFIX_RE` — the same population the board calls a prefixed branch. No
3199
+ # second `for-each-ref`. The narrowing to PR-less branches the slice line
3200
+ # offered was WITHDRAWN by the plan's Design section: it was a fallback
3201
+ # against a cost that does not exist. Measured 2026-09-24, one `ls-tree`
3202
+ # over the plan directory is ~0.00 s and the whole addition 0.24 s, 0.4% of
3203
+ # a scan whose wall time is 95% waiting.
3204
+ on_default=$'\n'"$(ref_ls "$PLAN_DIR")"$'\n'
3205
+ seen_branch_plans=$'\n'
3206
+ while IFS=$'\t' read -r branch_name _branch_sha; do
3207
+ [ -n "$branch_name" ] || continue
3208
+ [ "$branch_name" = "HEAD" ] && continue
3209
+ [ "$branch_name" = "$MAIN" ] && continue
3210
+ printf '%s' "$branch_name" | grep -Eq "^($PREFIX_RE)/" || continue
3211
+ while IFS= read -r bp; do
3212
+ [ -n "$bp" ] || continue
3213
+ case "$on_default" in *$'\n'"$bp"$'\n'*) continue ;; esac
3214
+ case "$seen_branch_plans" in *$'\n'"$bp"$'\n'*) continue ;; esac
3215
+ plan_blob=$(branch_plan_file "$branch_name" "$bp") || continue
3216
+ [ -n "$plan_blob" ] || continue
3217
+ # MARKED SEEN ONLY ONCE IT IS TAKEN, matching `board.ts:823`: a blob
3218
+ # that could not be read has not been reported, so a later branch
3219
+ # carrying a readable copy of the same path must still get its turn.
3220
+ seen_branch_plans="${seen_branch_plans}${bp}"$'\n'
3221
+ # THE IDENTITY STAYS THE RELATIVE PATH. The row loop parses
3222
+ # `plan_reads[i]` and never re-reads by the identity in `plans[i]`, so
3223
+ # the `docs/plans/…md` path is a usable id — and the dedup above is what
3224
+ # guarantees it cannot collide with a default-branch plan's.
3225
+ cand_ids+=("$bp")
3226
+ cand_reads+=("$plan_blob")
3227
+ done <<< "$(branch_plan_paths "$branch_name")"
3228
+ done <<< "$REMOTE_REFS"
3016
3229
  else
3017
3230
  for plan_path in "$PLAN_DIR"*.md; do
3018
3231
  [ -e "$plan_path" ] || continue
@@ -3066,7 +3279,7 @@ if [ ${#plans[@]} -eq 0 ]; then
3066
3279
  # the scan globbed it; pointing a reader at the index would now send them to
3067
3280
  # look for the cause of an empty list in a directory nothing consults.
3068
3281
  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"
3282
+ 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
3283
  exit 0
3071
3284
  fi
3072
3285
  fi
@@ -3494,6 +3707,12 @@ n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred
3494
3707
  # the footer keeps the two words apart for that reason.
3495
3708
  n_waiting=0 n_prereq_missing=0
3496
3709
  claimable=()
3710
+ # HOW MANY BRANCHES AN ELIGIBLE WAVE HOLDS THAT SOMEBODY IS ON — the fact that
3711
+ # separates "this estate has no work" from "every candidate here is taken", and
3712
+ # the only reason `--list-eligible` can say which of the two its silence means.
3713
+ # Counted across eligible waves only: a taken branch in a blocked wave was never
3714
+ # a candidate, so reporting it would name work the offer path never considered.
3715
+ n_taken_in_eligible=0
3497
3716
  plan_files=()
3498
3717
  # `--why-nothing`'s input: one `verdict<TAB>name:state|...` line per slice, in
3499
3718
  # plan order. Accumulated in the SAME loop that renders the branches, so the
@@ -3807,16 +4026,36 @@ for plan in "${plans[@]}"; do
3807
4026
  wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $7; exit}')
3808
4027
  [ "$wname" = "-" ] && wname=""
3809
4028
 
3810
- [ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
4029
+ # THE HEADER IS HELD, NOT PRINTED — it carries a suffix that only the
4030
+ # branch loop below can decide. `eligible` answers *are this wave's
4031
+ # prerequisites met?* while every offer path answers *can a branch here be
4032
+ # claimed now?*, and a wave whose branches are all taken satisfies the first
4033
+ # and not the second. Reported 2026-09-25 (#994) from a Bitbucket estate:
4034
+ # one wave `eligible`, eleven branches, both offer paths silent. The two
4035
+ # computations are correct and the word was carrying both answers.
4036
+ #
4037
+ # The line still prints BEFORE its branches — only the decision is delayed,
4038
+ # not the reading order — so the branch lines are buffered and flushed with
4039
+ # it. There is exactly one `echo` in that loop, which is why buffering it
4040
+ # costs less than threading the wave header past the six counters,
4041
+ # `json_branches` and `outlook_branches` the loop also builds.
4042
+ wave_header=" ${wname:-(unnamed)} — $verdict"
3811
4043
  # A degradation that says nothing is indistinguishable from a bug.
3812
4044
  # When --loose falls back to strict because the rollup cannot be had,
3813
4045
  # say so — an operator who passed the flag and sees strict behaviour
3814
4046
  # has no way to tell "the rollup said not-green" from "the rollup
3815
4047
  # could not be had". Both are correct refusals; only one is about
3816
4048
  # their PR.
4049
+ wave_body=""
3817
4050
  if [ "$quiet" != 1 ] && [ "$loose" = 1 ] && [ -n "$_loose_degraded_branches" ]; then
3818
- echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
4051
+ wave_body+=" (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"$'\n'
3819
4052
  fi
4053
+ # HOW MANY BRANCHES OF THIS WAVE ARE TAKEN, and how many claimable. Counted
4054
+ # here rather than derived from the estate-wide `n_eligible`, which spans
4055
+ # plans. `unknown` is in neither: the host could not be asked, so the branch
4056
+ # is not taken and not free, and a wave holding one must never be reported
4057
+ # as somebody else's work.
4058
+ wave_taken=0 wave_free=0
3820
4059
  json_branches=""
3821
4060
  # The outlook's reading of this slice, built alongside the render. EVERY
3822
4061
  # branch including the deferred ones, in the plan's order — the rule needs
@@ -3867,8 +4106,17 @@ for plan in "${plans[@]}"; do
3867
4106
  if [ "${wave_claimable:$((branch_i - 1)):1}" = "1" ]; then
3868
4107
  n_eligible=$((n_eligible + 1))
3869
4108
  claimable+=("$br")
4109
+ wave_free=$((wave_free + 1))
3870
4110
  fi
3871
- [ "$quiet" = 1 ] || echo " $br — $note"
4111
+ # TAKEN MEANS SOMEBODY HAS IT — claimed, or carrying pushed work. Read
4112
+ # from the branch's own state rather than from the claimable flag above,
4113
+ # because that flag is 0 for six different reasons and only these two mean
4114
+ # a person is on it. `unknown` is excluded by naming the two states rather
4115
+ # than by negating the flag.
4116
+ case "$st" in
4117
+ claimed|wip) wave_taken=$((wave_taken + 1)) ;;
4118
+ esac
4119
+ wave_body+=" $br — $note"$'\n'
3872
4120
  if [ "$build_doc" = 1 ]; then
3873
4121
  # The INTERNAL state ($st), never the prose label ($note): the board
3874
4122
  # must not parse a string that exists for humans to read.
@@ -4067,6 +4315,45 @@ for plan in "${plans[@]}"; do
4067
4315
  fi
4068
4316
  done <<< "$states"
4069
4317
 
4318
+ # WHICH QUESTION THE WORD ANSWERED, said in the estate's existing
4319
+ # vocabulary. `someone-is-on-it` is `StartabilityVerdictSchema`'s word
4320
+ # (`packages/board/src/contract/schema.ts`) for exactly these two branch
4321
+ # states, so the wave line and its branch lines now agree rather than
4322
+ # offering a reader two answers to compare.
4323
+ #
4324
+ # A PROSE SUFFIX, NOT A SIXTH VERDICT. `$verdict` is untouched and reaches
4325
+ # `--json`, `--stream` and the outlook byte-identical: `FleetWaveSchema`'s
4326
+ # verdict is a strict enum, so a parsed pulse cannot carry a new word and
4327
+ # widening it is a separate change with its own consumers.
4328
+ #
4329
+ # THE RULE, STATED: a wave line carries BOTH answers — the verdict, then who
4330
+ # has it — and the suffix appears exactly when the wave is `eligible`, holds
4331
+ # no free branch, and holds at least one branch that is `claimed` or `wip`.
4332
+ # A wave with a free branch keeps bare `eligible`, which is what the offer
4333
+ # paths will confirm; a wave whose only non-free branches are `unknown`
4334
+ # keeps it too, because the host could not be asked and nobody has claimed
4335
+ # anything.
4336
+ #
4337
+ # `wip` COUNTS AS TAKEN, which the plan settles three times: *"a branch
4338
+ # counts as taken when it is claimed or in progress"*. A one-branch wave
4339
+ # whose branch is `wip` therefore reads `eligible — someone-is-on-it`, and
4340
+ # two tests in `fleet.test.mjs` asserted `/ — eligible$/` on exactly that
4341
+ # shape. Their subject is the VERDICT — *"has not settled its wave"*,
4342
+ # *"the wave must stay open"* — so the anchor moved to the verdict rather
4343
+ # than the suffix being suppressed; the `$` was free before a suffix existed
4344
+ # and asserted a second thing neither test meant. The other five sites
4345
+ # carrying that anchor hold a free branch and are untouched.
4346
+ if [ "$verdict" = "eligible" ] && [ "$wave_free" -eq 0 ] && [ "$wave_taken" -gt 0 ]; then
4347
+ wave_header+=" — someone-is-on-it"
4348
+ fi
4349
+ if [ "$verdict" = "eligible" ]; then
4350
+ n_taken_in_eligible=$((n_taken_in_eligible + wave_taken))
4351
+ fi
4352
+ if [ "$quiet" != 1 ]; then
4353
+ echo "$wave_header"
4354
+ [ -n "$wave_body" ] && printf '%s' "$wave_body"
4355
+ fi
4356
+
4070
4357
  if [ "$build_doc" = 1 ]; then
4071
4358
  json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
4072
4359
  json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
@@ -4148,6 +4435,27 @@ fi
4148
4435
  # "Nothing to start" is a normal state, not a failure — the exit code is what
4149
4436
  # distinguishes it from a name, so callers can branch on it without parsing.
4150
4437
  if [ "$next_only" = 1 ]; then
4438
+ # WHY THE LIST IS EMPTY, for `--list-eligible` only, and ON STDERR. An
4439
+ # operator reading the body's `eligible` and then getting silence here cannot
4440
+ # tell a claimed-out estate from an empty one, and #994 is that reading: one
4441
+ # wave eligible, eleven branches, nothing offered. The exit code still carries
4442
+ # the answer — 1 either way — so no caller's gate moves.
4443
+ #
4444
+ # STDERR BECAUSE STDOUT IS A TARGET LIST. `plot-dispatch.sh:3475` pipes this
4445
+ # stdout through `sort -u` and dispatches every line, so a sentence there
4446
+ # becomes a branch name it tries to claim. That caller already discards
4447
+ # stderr (`2>/dev/null`), so the sentence reaches a person and no machine.
4448
+ #
4449
+ # `--next` IS UNTOUCHED: its exit-1 contract is shipped, documented and
4450
+ # tested twice, and it names ONE branch for a worker that has nothing to read
4451
+ # a sentence with.
4452
+ if [ ${#claimable[@]} -eq 0 ] && [ "$list_all" = 1 ]; then
4453
+ if [ "$n_taken_in_eligible" -gt 0 ]; then
4454
+ echo "nothing claimable: $n_taken_in_eligible branch(es) in eligible waves are taken." >&2
4455
+ else
4456
+ echo "nothing claimable: no eligible wave holds a startable branch." >&2
4457
+ fi
4458
+ fi
4151
4459
  [ ${#claimable[@]} -gt 0 ] || exit 1
4152
4460
  if [ "$list_all" = 1 ]; then
4153
4461
  printf '%s\n' "${claimable[@]}"
@@ -4455,4 +4763,4 @@ if [ "$build_doc" = 1 ] && [ "$record" = 1 ] && [ -n "$reading_doc" ]; then
4455
4763
  fi
4456
4764
  write_bridge
4457
4765
  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"
4766
+ 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"