@plot-pm/board 0.9.0 → 0.9.1

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.
@@ -0,0 +1,3496 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: fleet pulse — deterministic extractor for wave/claim state.
3
+ # Usage: plot-fleet-scan.sh [--no-fetch] [--offline] [--next] [<slug>]
4
+ # --no-fetch skip `git fetch`
5
+ # --offline same (no network) — used for cheap, ambient pulses.
6
+ # The fetch also PRUNES remote-tracking refs, so skipping it
7
+ # keeps whatever stale refs this checkout holds: a branch merged
8
+ # and deleted upstream may read `wip` rather than `merged`, and
9
+ # its wave may read blocked. That is the honest answer for a scan
10
+ # that asked nothing, and the footer says so rather than leaving
11
+ # it to be discovered.
12
+ # --list-eligible print EVERY claimable branch, one per line (exit 1 if none).
13
+ # For callers that need the count rather than one item — a dry
14
+ # run changes nothing, so its answer cannot go stale.
15
+ # --loose a prior wave counts as satisfied when its branches carry PUSHED
16
+ # work, not only merged work. Buys throughput, pays in rebase
17
+ # risk — the plan requires a stated reason for using it. Default
18
+ # is strict (merged only).
19
+ # --log-pulse append one pulse line to each reported plan's ## Notes, clean
20
+ # pulses included — without a record of quiet pulses an idle fleet
21
+ # and a dead fleet look identical. The ONLY thing this script ever
22
+ # writes, and it is a log, not state.
23
+ # --next print ONE claimable branch name and exit 0; print nothing and
24
+ # exit 1 when there is none. Used by /plot-implement to pick work
25
+ # without re-deriving eligibility. "Nothing to start" is a normal
26
+ # state — the exit code, not stderr, is what says so.
27
+ # --stream --json, emitted as it resolves rather than as one document at
28
+ # the end. One `{"kind":"plan","plan":{...}}` line per plan the
29
+ # moment that plan is fully derived, then one
30
+ # `{"kind":"pulse","pulse":{...}}` line carrying the SAME
31
+ # document --json prints. A consumer that has seen plan lines and
32
+ # no pulse line holds a PARTIAL answer — the scan takes 18 s on
33
+ # 84 branches and a board that renders nothing for that long
34
+ # looks broken. The terminal line is what says the scan finished;
35
+ # a closed pipe does not, because a killed scan closes it too.
36
+ # <slug> limit the report to one plan (default: all active plans)
37
+ # Wave verdicts — the word each wave carries, and what a reader may do with it:
38
+ # complete every non-deferred branch of the wave has merged
39
+ # eligible A DISPATCH WOULD TAKE THIS: prior waves landed AND the plan is
40
+ # approved. The word a reader acts on, and the only one that
41
+ # promises the dispatcher agrees.
42
+ # blocked an earlier wave has not landed — resolves by merging work
43
+ # unapproved the plan is not approved, so nothing here may be dispatched —
44
+ # resolves by a person approving the plan, not by any merge.
45
+ # Kept apart from `blocked` because the reader's next action
46
+ # differs, and a terminal-phase plan lands here too (it is not
47
+ # approved); the board routes those to DONE by phase before the
48
+ # verdict is read.
49
+ # Output: per-plan wave report on stdout, terminated by a machine-countable
50
+ # summary line:
51
+ # summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 merge_detect=pr-merge main=main
52
+ # merge_detect names how merged-and-deleted branches were detected:
53
+ # pr-merge (exhaustive), truncated (capped walk), none (no conforming
54
+ # merge commits — a squash/rebase repo, where `open` says nothing about
55
+ # merging).
56
+ # Consumers that only need counts (the /plot-fleet pulse log, the
57
+ # board) read that one line and never re-count the body.
58
+ # --json additionally carries, per PLAN, `phase` — the plan's own
59
+ # lifecycle state, verbatim from plot-plan-meta.sh, and the half of a
60
+ # row's phase git cannot answer. Which column a row reads is composed
61
+ # from it AND the branch state one layer up; this script decides nothing.
62
+ # The plan set also includes plans delivered inside a rolling 24 h
63
+ # window (see "the last day of finished work"), so work does not
64
+ # disappear at the moment it becomes finished.
65
+ # Plans are enumerated from `origin/<main>` (`git ls-tree`/`git show`),
66
+ # NOT from the working tree — so the list describes one atomic commit
67
+ # and does not change while rebases and worker commits rewrite the
68
+ # checkout underneath a running fleet. A consequence worth stating: an
69
+ # UNCOMMITTED plan is invisible, deliberately — the fleet view shows
70
+ # what is shared, and a plan only this machine has cannot be claimed by
71
+ # any worker. --json carries `plan_source` (ref | worktree), which
72
+ # reads `worktree` only when `origin/<main>` cannot be resolved at all.
73
+ # --json also carries `fetch_failed` and `fetch_error`: a failed fetch
74
+ # means these refs are older than the report implies, and that fact is
75
+ # now reported rather than discarded.
76
+ # --json additionally carries, per branch, what THIS MACHINE knows and
77
+ # the refs do not: `local_dirty` (a local worktree has uncommitted
78
+ # changes), `local_locked` (a local worktree holds `.git/index.lock` —
79
+ # a write is in progress THIS INSTANT), `local_worktree` (where it is
80
+ # checked out here) and `local_ahead` (commits on the local branch the
81
+ # remote does not have). All four are absent-shaped — false, false, ""
82
+ # and 0 — wherever this machine holds nothing, so a branch living on
83
+ # another machine answers exactly as it did before.
84
+ # --json additionally carries `changed_at` (the epoch second of the
85
+ # newest evidence of work — what a CHANGE detector must watch, since it
86
+ # moves only when something happens) and `changed_ago_seconds`: how long since this
87
+ # branch last CHANGED, from any source — the newest of its last commit,
88
+ # the newest mtime on the floor (editor leftovers excluded) and the
89
+ # worker log. The other local signals are STATE and cannot separate a
90
+ # branch that finished from one that was abandoned; both read
91
+ # `ahead=0 dirty=false`. This is `null` — never 0 — wherever this
92
+ # machine has no worktree, and branches with none cost nothing to skip.
93
+ # IT IS A MEASUREMENT AND NOT A VERDICT: no threshold, no `stalled`.
94
+ # A worker inside a serial test suite writes nothing for minutes while
95
+ # its child processes work, and this will correctly report it quiet —
96
+ # so "quiet" must not be rendered as "stuck". Worker verdicts belong to
97
+ # `plot-worker-state.sh`; the threshold belongs to the reader.
98
+ # --json additionally carries, per branch, WHICH FILES would collide
99
+ # merging it into the default branch: `conflicts` (paths, from
100
+ # `git merge-tree --write-tree`, which computes entirely in memory),
101
+ # `conflicts_known` (whether the question was asked at all) and
102
+ # `changed_paths` (what the branch touches, capped and reported as
103
+ # evidence). `conflicts_known` is what keeps an empty list from meaning
104
+ # two things — a branch that merges cleanly and one nobody could ask.
105
+ # The SET is reported and never judged: which sets mean what is a
106
+ # decision one layer up.
107
+ # --json also carries, per branch, `worker` — whether anything is
108
+ # actually RUNNING on it: running|finished|failed|ended|none|elsewhere,
109
+ # with `worker_pid` and `worker_exit` beside it. A claim says a
110
+ # dispatcher took the branch; this says whether a worker was ever
111
+ # started. `none` means UNKNOWN (no pid was recorded here), never
112
+ # "nobody"; `elsewhere` means this machine has no worktree and so
113
+ # cannot answer at all. `worker_activity` rides beside them ONLY where
114
+ # `worker` is `running`: `working` when the worker's child is burning
115
+ # CPU, `idle` when its clock is frozen, "" everywhere else. A secondary
116
+ # cue, not a sixth state — `running` is honest and coarse, and this says
117
+ # which kind of running it is without the enum growing a member.
118
+ # Designed for small-model consumption: mechanical enumeration, no judgment.
119
+ #
120
+ # STATELESS AND READ-ONLY. This is the whole design (Manifesto Principle 1):
121
+ # there is no fleet database. Every fact printed here is re-derived from git
122
+ # refs and plan files on each run, so a killed dispatcher, a dead worker, or a
123
+ # crashed pulse costs nothing — the next pulse re-derives the truth. Nothing
124
+ # here creates a branch, pushes a ref, or starts a worker.
125
+ #
126
+ # ONE exception to "writes nothing": --log-pulse appends a pulse line to each
127
+ # reported plan (see below). That is a LOG, not state — deleting the whole log
128
+ # changes no behaviour, because the next run re-derives everything. The flag
129
+ # defaults OFF precisely so internal callers (plot-implement, plot-dispatch,
130
+ # which invoke --next) can never amend a plan as a side effect of asking what
131
+ # to work on; /plot-fleet, the human-facing command, passes it every run.
132
+ #
133
+ # Wave eligibility (the one rule this script encodes):
134
+ # A wave is ELIGIBLE when every non-deferred branch in every PRIOR wave is
135
+ # merged into the main branch. Prior waves outstanding → BLOCKED. All of a
136
+ # wave's own non-deferred branches merged → COMPLETE.
137
+ # Deferred branches never count as outstanding work — that is what the
138
+ # `<!-- deferred: -->` annotation is for.
139
+ #
140
+ # Claim state comes from git, not from the plan file. A branch whose only
141
+ # commits beyond main are `plot: claim ...` markers is a CLAIM: a dispatcher
142
+ # pushed it to take the work. The marker commit is what makes the claim
143
+ # exclusive — a branch merely pointing at main does not diverge from it, so a
144
+ # second push would succeed and both sides would think they held it.
145
+ # The plan's `<!-- claimed: -->` annotation is a reflection for humans and the
146
+ # board; where the two disagree, git wins. The one exception is the reaper in
147
+ # plot-reconcile-scan.sh, which reads the annotation to tell a deliberately
148
+ # abandoned claim from a dead worker.
149
+ set -uo pipefail
150
+
151
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
152
+
153
+ # The shared worker classifier. Sourced by both this script and
154
+ # plot-dispatch.sh so a worker has ONE state, not one per reader.
155
+ # shellcheck source=plot-worker-state.sh
156
+ . "$script_dir/plot-worker-state.sh"
157
+ cfg() { "$script_dir/plot-config.sh" get "$1" "${2:-}"; }
158
+
159
+ do_fetch=1
160
+ next_only=0
161
+ list_all=0
162
+ loose=0
163
+ log_pulse=0
164
+ as_json=0
165
+ stream=0
166
+ slug=""
167
+ while [ $# -gt 0 ]; do
168
+ case "$1" in
169
+ --no-fetch|--offline) do_fetch=0 ;;
170
+ --loose) loose=1 ;;
171
+ --log-pulse) log_pulse=1 ;;
172
+ --next) next_only=1 ;;
173
+ --list-eligible) next_only=1; list_all=1 ;;
174
+ --json) as_json=1 ;;
175
+ --stream) as_json=1; stream=1 ;;
176
+ -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
177
+ *) slug="$1" ;;
178
+ esac
179
+ shift
180
+ done
181
+
182
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
183
+
184
+ PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
185
+ ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
186
+ DELIVERED_DIR=$(cfg "Delivered index" "docs/plans/delivered/")
187
+ PREFIX_RE=$(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
188
+ | tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' -)
189
+ [ -n "$PREFIX_RE" ] || PREFIX_RE="idea|feature|bug|docs|infra"
190
+
191
+ MAIN=$(cfg "Main branch")
192
+ if [ -z "$MAIN" ]; then
193
+ MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
194
+ fi
195
+ [ -n "$MAIN" ] || MAIN="main"
196
+
197
+ # A FAILED FETCH IS A FACT, not a shrug. The old line was
198
+ # `git fetch ... 2>/dev/null` with its status discarded: a GitHub 503, a
199
+ # concurrent worker holding the ref lock, an offline laptop — every one of
200
+ # them produced a scan indistinguishable from a healthy one, reporting refs
201
+ # that were older than the banner claimed. That is the same failure this whole
202
+ # plan is about, one layer up: the report was more confident than its evidence.
203
+ #
204
+ # The scan STILL RUNS on a failed fetch, and that is deliberate. `origin/$MAIN`
205
+ # from an hour ago is a real answer about a real commit; refusing to report it
206
+ # would trade a slightly stale board for no board at all, exactly when the
207
+ # operator is most likely to be watching something go wrong. What changes is
208
+ # that the staleness is CARRIED — `fetch_failed` in `--json`, a line in the
209
+ # prose report — so a consumer can mark the view instead of trusting it blindly.
210
+ #
211
+ # --offline/--no-fetch is NOT a failure. The operator asked for local refs and
212
+ # got them; there is nothing to report but the fact that no fetch was tried.
213
+ #
214
+ # THE FETCH PRUNES WHAT IT FETCHES. `git fetch` does not remove
215
+ # remote-tracking refs for branches deleted upstream; only `--prune` does. A
216
+ # branch merged with --delete-branch therefore leaves `refs/remotes/origin/<br>`
217
+ # behind on every machine that ever fetched it, and it survives until somebody
218
+ # prunes for unrelated reasons. That leftover is not noise: branch_state()
219
+ # picks its arm on the ref's PRESENCE, so a stale ref routes the branch into
220
+ # the ancestry path — which a squash merge breaks by construction — and the
221
+ # host lookup that would have answered `merged` is never reached. Measured
222
+ # 2026-08-18: a wave could not be dispatched at all until an operator happened
223
+ # to run `git fetch --prune` by hand.
224
+ #
225
+ # THE EXPLICIT REFSPEC IS REQUIRED, and this is the part that is easy to get
226
+ # wrong: `git fetch --prune origin "$MAIN"` prunes NOTHING outside $MAIN.
227
+ # Naming a refspec scopes the prune to that refspec's destination namespace, so
228
+ # the narrow fetch this scan makes would prune only `refs/remotes/origin/$MAIN`
229
+ # — a no-op for exactly the branches this exists to clear. Restating the
230
+ # default heads refspec widens the prune back to the whole mirror while the
231
+ # narrow one keeps the intent legible: fetch $MAIN, and while the connection is
232
+ # open, make the local mirror match the remote.
233
+ #
234
+ # NOTHING HERE DEPENDS ON A STALE REF SURVIVING. The case to fear is a branch
235
+ # deleted upstream while a local worktree still holds work: `local_ahead_of()`
236
+ # reads `refs/remotes/origin/<br>..refs/heads/<br>`, so pruning removes its left
237
+ # side. It already answers 0 on a missing ref by exit code rather than by
238
+ # emptiness ("not observed → not reported"), which is the same answer it gives
239
+ # for every branch living on another machine — so the count degrades to absent,
240
+ # never to a wrong number. `local_dirty`, `local_locked` and `local_worktree`
241
+ # read the worktree, not the mirror, so uncommitted work stays visible either
242
+ # way. Conflict prediction is gated to wip|claimed and a pruned branch is
243
+ # neither. The local `refs/heads/<br>` is untouched: --prune removes only
244
+ # remote-tracking refs, so no local work is destroyed or hidden by this.
245
+ #
246
+ # ONE CONNECTION, NOT TWO. The prune rides the fetch already being made — no
247
+ # extra round trip on a scan the board polls every five seconds.
248
+ FETCH_FAILED=0
249
+ FETCH_ERROR=""
250
+ if [ "$do_fetch" = 1 ]; then
251
+ if ! FETCH_ERROR=$(git fetch -q --prune origin "$MAIN" \
252
+ "+refs/heads/*:refs/remotes/origin/*" 2>&1); then
253
+ FETCH_FAILED=1
254
+ # Collapsed to one line: git's multi-line advice is for a human at a
255
+ # terminal, and this string travels through JSON into a board cell.
256
+ FETCH_ERROR=$(printf '%s' "$FETCH_ERROR" | tr '\n' ' ' | sed 's/ */ /g; s/^ *//; s/ *$//')
257
+ [ -n "$FETCH_ERROR" ] || FETCH_ERROR="git fetch failed with no message"
258
+ else
259
+ FETCH_ERROR=""
260
+ fi
261
+ fi
262
+
263
+ # --loose promises "the prior wave's PRs are green and ready", which needs the
264
+ # git host. An earlier version accepted ANY pushed commit — strictly weaker
265
+ # than promised, and dangerous: red CI or a draft PR would open the next wave,
266
+ # so it built on a seam that was not merely unlanded but possibly broken.
267
+ #
268
+ # Readiness must be VERIFIED, never assumed. Without a host CLI, --loose
269
+ # degrades to strict and says so: an unverifiable claim of readiness is not
270
+ # readiness.
271
+ loose_verifiable=0
272
+ if [ "$loose" = 1 ]; then
273
+ if [ "$do_fetch" = 1 ] && "$script_dir/plot-host.sh" backend >/dev/null 2>&1 \
274
+ && [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
275
+ loose_verifiable=1
276
+ fi
277
+ fi
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # Merged-and-deleted branches: the evidence that survives the ref
281
+ # ---------------------------------------------------------------------------
282
+ #
283
+ # A branch merged by PR usually has its ref deleted at merge, and nothing local
284
+ # survives it — `git reflog show origin/<br>` fails outright and for-each-ref
285
+ # finds nothing. What DOES survive is the merge commit on the default branch.
286
+ #
287
+ # Asking the host for merged PRs (as plot-reconcile-scan.sh does) is not
288
+ # available here: this scan is git-only on its default path, which is exactly
289
+ # why the board can poll it every 5 s. One board already costs 80 GraphQL
290
+ # calls/hour; a metered scan on a 5-second timer would dwarf that.
291
+ #
292
+ # The candidate set is what is REACHABLE from the default branch, matched by an
293
+ # ANCHORED subject:
294
+ #
295
+ # ^Merge pull request #<n> from <owner>/<branch>$
296
+ #
297
+ # The anchoring is the whole mechanism. A name-only grep reads a BACKWARD merge
298
+ # — `Merge remote-tracking branch 'origin/main' into <branch>` — as evidence
299
+ # that <branch> landed, when it means the opposite: main was pulled INTO the
300
+ # branch. That inversion reports unfinished work as finished and opens the next
301
+ # wave on an unlanded seam, which is strictly worse than the bug this fixes. A
302
+ # backward merge opens with a different sentence, so it cannot match.
303
+ #
304
+ # TWO STRUCTURAL FILTERS WERE MEASURED AND REMOVED. Do not reintroduce either;
305
+ # see docs/plans/2026-08-16-fleet-sees-merged-branches.md for the numbers.
306
+ # * A FIRST-PARENT filter looked convincing at "119 merges → 109 on the
307
+ # chain". Measured against the right baseline — the anchored pattern, not
308
+ # raw merges — it scores 108 to 108: it catches NOTHING extra, because
309
+ # backward merges cannot match the anchored pattern anyway. And it breaks
310
+ # GitFlow: a feature merged into `develop`, where `develop` later merges to
311
+ # the default branch, is not on the first-parent chain and would read
312
+ # `open` while its work is an ancestor.
313
+ # * A SECOND-PARENT counter-check does not discriminate: PR merges and
314
+ # backward merges both have a distinct second-parent tip.
315
+ #
316
+ # Reachability does not over-report either: a PR merged into a long-lived
317
+ # branch that was then abandoned is not reachable from the default branch at
318
+ # all. Reachability is itself an ancestry claim, so it cannot see work that
319
+ # never arrived.
320
+ #
321
+ # The walk is bundled — ONE `git log` per run, not one per branch. branch_state
322
+ # runs per branch and the board polls every 5 s, so the naive shape is
323
+ # O(history × branches) where O(history + branches) is available (measured:
324
+ # 197 ms vs 79 ms on a 2000-merge fixture). Same bundling rule
325
+ # plot-reconcile-scan.sh applies to PR lists, with local data.
326
+ #
327
+ # The cap guards against a pathological history rather than buying time — the
328
+ # walk is local and nearly free (cap 500: 7.7 ms, no cap: 11.8 ms at 2000
329
+ # merges). It is therefore set high, and SATURATION IS REPORTED. A blind cap
330
+ # re-creates this very bug: at 300 against 2000 merges an early merge is not
331
+ # found and reads `open`, hitting precisely the long-hanging plans most likely
332
+ # to suffer it.
333
+ #
334
+ # PLOT_MERGE_SCAN_LIMIT exists so the test suite can force saturation against a
335
+ # small fixture — a cap of 2000 is otherwise unreachable in a test. It is a
336
+ # seam, not a knob: nothing in Plot sets it, and lowering it in real use buys
337
+ # nothing but the silent misses described above.
338
+ MERGE_SCAN_LIMIT=${PLOT_MERGE_SCAN_LIMIT:-2000}
339
+ MERGE_SUBJECTS=$(git log "origin/$MAIN" --merges \
340
+ --max-count="$MERGE_SCAN_LIMIT" --pretty=%s </dev/null 2>/dev/null || true)
341
+ MERGE_SCAN_TRUNCATED=0
342
+ if [ -n "$MERGE_SUBJECTS" ] \
343
+ && [ "$(printf '%s\n' "$MERGE_SUBJECTS" | grep -c .)" -ge "$MERGE_SCAN_LIMIT" ]; then
344
+ MERGE_SCAN_TRUNCATED=1
345
+ fi
346
+
347
+ # merge_detect names the detection source in the footer, the way
348
+ # plot-reconcile-scan.sh names pr_source. `open` must stop meaning both "never
349
+ # started" and "I could not tell" — that ambiguity is the defect this fix
350
+ # exists to remove, and it would otherwise reappear one level up.
351
+ # pr-merge — conforming merge commits were found and examined exhaustively
352
+ # truncated — the walk hit its cap; a branch merged before that point may
353
+ # still read `open`. Its own value, not folded into pr-merge: a
354
+ # capped walk detected, but not exhaustively.
355
+ # none — the default branch carries no conforming merge commits at all
356
+ # (a squash/rebase repo), so `open` says nothing about merging.
357
+ if printf '%s\n' "$MERGE_SUBJECTS" | grep -qE '^Merge pull request #[0-9]+ from [^/]+/.+$'; then
358
+ MERGE_DETECT=$([ "$MERGE_SCAN_TRUNCATED" = 1 ] && echo truncated || echo pr-merge)
359
+ else
360
+ MERGE_DETECT=none
361
+ fi
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Squash merges: the case where no local evidence survives at all
365
+ # ---------------------------------------------------------------------------
366
+ #
367
+ # The merge walk above finds a branch whose PR produced a MERGE COMMIT. A
368
+ # squash merge produces none: measured on the merge of PR #209, the commit on
369
+ # the default branch has ONE parent and a subject naming `(#209)` — the PR
370
+ # number, never the branch. So the exhaustive walk has nothing to match, and a
371
+ # branch squash-merged and deleted reads `open`: the same word used for work
372
+ # nobody has started. Its wave never completes, and the next wave stays blocked
373
+ # forever.
374
+ #
375
+ # The data is not missing — it is simply not local. `pr-state` answers in one
376
+ # call, and a branch with NO REF is exactly where that call is worth making:
377
+ # nothing local is left to read, so the host is the only remaining source, and
378
+ # the cost follows the count of ABSENT branches rather than all of them.
379
+ #
380
+ # THE FAILURE DIRECTION IS THE POINT. `plot-host.sh` already separates a lookup
381
+ # miss (exit 0, state NONE) from a transport failure (non-zero) — the
382
+ # distinction it grew on 2026-08-17, when GitHub returned 503 all afternoon and
383
+ # every branch read as having no PR. Both arms land on today's `open` here, and
384
+ # ONLY an explicit "MERGED" may move a branch off it. An unreachable host must
385
+ # never manufacture a `merged`, because `merged` settles a wave and opens the
386
+ # next one on work that may not have landed.
387
+ #
388
+ # GATED ONCE PER RUN, not per branch, and it honours --offline: that flag
389
+ # promises no network, and a scan that promised no network and then called the
390
+ # host would be lying in the direction of a slow ambient pulse. Without a
391
+ # backend — or offline — the lookup is simply never attempted and every branch
392
+ # answers exactly as it did before.
393
+ HOST_LOOKUP_OK=0
394
+ if [ "$do_fetch" = 1 ] \
395
+ && [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ]; then
396
+ HOST_LOOKUP_OK=1
397
+ fi
398
+
399
+ # ONE ANSWER PER BRANCH PER RUN, cached on disk rather than in a variable.
400
+ #
401
+ # `branch_state` is called as `$(branch_state "$br")` — a SUBSHELL — so a
402
+ # variable it assigned would be discarded the moment the substitution closed,
403
+ # and every branch would pay the call again. The board polls this scan every
404
+ # 5 s; a plan whose branches were all squash-merged would otherwise spend one
405
+ # host call per branch, per poll, forever.
406
+ #
407
+ # A directory is the cache because it survives the subshell without restructuring
408
+ # the caller, and it is created per run and removed on exit, so no answer ever
409
+ # outlives the scan that fetched it — a stale `merged` read from a previous run
410
+ # is exactly the fabricated verdict the failure direction above forbids.
411
+ #
412
+ # Cleanup is trapped rather than trailing: the script exits early in several
413
+ # places (--next with nothing to start, no active plans), and a temp directory
414
+ # left behind on those paths would accumulate one per poll.
415
+ HOST_STATE_CACHE=""
416
+ if [ "$HOST_LOOKUP_OK" = 1 ]; then
417
+ HOST_STATE_CACHE=$(mktemp -d 2>/dev/null) || HOST_STATE_CACHE=""
418
+ [ -n "$HOST_STATE_CACHE" ] \
419
+ && trap 'rm -rf "$HOST_STATE_CACHE" 2>/dev/null || true' EXIT INT TERM
420
+ fi
421
+
422
+ # The cache key. Shared by the join and by `host_pr_state`, because the two
423
+ # must agree on it EXACTLY: a prefill written under one spelling and read under
424
+ # another is a cache that silently never hits, which restores the per-branch
425
+ # cost this whole change removes — and does it invisibly, since every answer is
426
+ # still correct.
427
+ #
428
+ # The branch name contains slashes and a flat file per branch needs them gone,
429
+ # but the mapping must be INJECTIVE. `tr '/' '_'` is not: `feature/a_b` and
430
+ # `feature_a/b` are both legal refs and collapse to one key, and a collision
431
+ # here serves one branch's verdict to another — which, when the verdict is
432
+ # `merged`, settles a wave on a branch nobody looked at. Encoding `_` first
433
+ # makes the substitution reversible, so distinct refs stay distinct.
434
+ cache_key() { # $1=branch → a filename that is injective in the branch name
435
+ printf '%s' "$1" | sed 's/_/__/g; s|/|_|g'
436
+ }
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # ONE LIST, JOINED LOCALLY — not one lookup per branch
440
+ # ---------------------------------------------------------------------------
441
+ #
442
+ # THE MEASUREMENT THAT FORCED THIS (issues #228 and #226, both 2026-08-18):
443
+ #
444
+ # bitbucket/ekzweb 14 branches → 39 `bb` calls, scan unfinished at 110 s
445
+ # this repo, GitHub 84 branches × 438 ms → 34 s observed
446
+ # one pr-list (all) → 1107 ms
447
+ #
448
+ # The board's `run()` helper times out at 30 s (`fleet.ts:260`), so the scan had
449
+ # begun exceeding it on GitHub too: the board served a pulse 644 s old while
450
+ # reporting `Command failed`, and the reason was invisible to the operator.
451
+ #
452
+ # The scan needs `branch → state` for a KNOWN SET of branches. That is a JOIN
453
+ # over one response, not N lookups — and the ratio worsens with every branch
454
+ # added, which is what makes it a shape problem rather than a tuning one.
455
+ #
456
+ # WHAT THE JOIN MAY NOT DO, and why it is written the way it is:
457
+ #
458
+ # * AN EMPTY JOIN IS NOT A FAILED JOIN. `plot-host.sh` separates a lookup
459
+ # miss (exit 0, state NONE) from a transport failure (non-zero) — the
460
+ # distinction it grew on 2026-08-17, when GitHub returned 503 all afternoon
461
+ # and every branch read as having no PR. A join over a response that never
462
+ # arrived is that same trap in a new shape, so the list's EXIT CODE is
463
+ # checked before its payload is read, and a failed list prefills NOTHING.
464
+ # Branches then answer `-` (unanswerable) exactly as they did during an
465
+ # outage before this existed — never a confident "no PR".
466
+ #
467
+ # * THE PAGE LIMIT IS NOT OPTIONAL. Measured on this repo 2026-08-18: 221 PRs
468
+ # exist and `pr-list --state all` returns 30 without `--limit`, because that
469
+ # is the host CLI's default. Joining against the newest 30 would silently
470
+ # lose 191 PRs — every older merged branch reading as "no PR", which is the
471
+ # fabricated verdict this scan refuses everywhere else. The limit is asked
472
+ # for explicitly and generously; one call for all 221 measured at 1.8 s.
473
+ #
474
+ # * THE VOCABULARY IS THE HOST ADAPTER'S, UNCHANGED. `pr-list` already emits
475
+ # OPEN/MERGED/CLOSED per PR and already folds Bitbucket's DECLINED into
476
+ # CLOSED, the same three-way vocabulary `pr-state` produces. Nothing is
477
+ # translated here.
478
+ #
479
+ # ONE PR PER BRANCH, and the ordering decides which. A branch can carry several
480
+ # PRs over its life — opened, closed, reopened — and `pr-list` returns all of
481
+ # them. OPEN outranks MERGED outranks CLOSED, matching the walk `pr-state`
482
+ # already performs on Bitbucket, so the join and the per-branch lookup cannot
483
+ # disagree about the same branch.
484
+ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
485
+ prefill_pr_states() {
486
+ [ "$HOST_LOOKUP_OK" = 1 ] || return 0
487
+ [ -n "$HOST_STATE_CACHE" ] || return 0
488
+ local js br st key
489
+ # Exit code first: non-zero is a transport failure and its stdout is not an
490
+ # answer. A failed list leaves the cache EMPTY, so every branch falls through
491
+ # to the unanswerable `-` rather than to a fabricated "no PR".
492
+ #
493
+ # `--rich` adds `checks` and `draft` to the response — the fields `--loose`
494
+ # needs to verify that a prior wave's PR is actually green and ready.
495
+ # Requested unconditionally because the cost is zero on GitHub (same GraphQL
496
+ # call) and bounded on Bitbucket (N extra calls only when CI: jenkins is
497
+ # configured), and the parsing below already skips fields the response does
498
+ # not contain. The BEHAVIOUR change is in `pr_ready`, which now reads the
499
+ # check rollup from the cache rather than making a per-branch host call.
500
+ js=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
501
+ </dev/null 2>/dev/null) || return 0
502
+ # `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
503
+ # that is a correctness-of-cost property rather than a style preference:
504
+ # measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
505
+ # 442 forks — took 46 s, which is WORSE than the 34 s of host calls this
506
+ # change exists to remove. Trading N network round trips for N process forks
507
+ # is not a fix. One `sed` over the whole stream emits `branch<TAB>state`, and
508
+ # the loop below forks nothing at all.
509
+ #
510
+ # The fields are matched in their emitted order (`state` precedes `head`) and
511
+ # anchored to `","head":"` so a PR TITLE containing the word `state` cannot be
512
+ # mistaken for the field — titles are free text and this repo has several
513
+ # that name their own fields.
514
+ #
515
+ # `cache_key`'s substitution is inlined here for the same fork reason. It must
516
+ # stay IDENTICAL to that function; a divergence is a cache that silently never
517
+ # hits.
518
+ #
519
+ # STATE FIRST, BRANCH LAST. A branch name may contain almost anything; the
520
+ # state is a single bare word. Emitting `STATE<TAB>branch` lets the key
521
+ # encoding be one anchored substitution on the tail of the line, with no
522
+ # reconstruction of the separator afterwards.
523
+ # THE ROWS ARRIVE ALREADY RANKED, so this loop needs no memory of what it has
524
+ # seen and forks nothing to find out. `sort` below puts the winning row for
525
+ # each branch first; `last=` skips the rest of that branch's rows with a
526
+ # string compare. Reading the rank back from the file with `$(cat …)` would
527
+ # be a fork PER DUPLICATE ROW, which is what this loop was rewritten to avoid.
528
+ #
529
+ # The `--rich` response includes `checks` and `draft` — the fields `pr_ready`
530
+ # needs to verify the rollup. STORED AS `STATE<TAB>checks<TAB>draft`, so the
531
+ # cache file stays a single line of tab-separated fields and the read path
532
+ # can split on tabs without allocating anything. A missing field (non-rich
533
+ # response, which is now only an error case) reads as empty and `pr_ready`
534
+ # treats it as `unknown`, which degrades to strict — the safer direction.
535
+ local last="" chk dft
536
+ # Rows parsed, for the completeness test below. Counted here because this is
537
+ # the one place every row passes through.
538
+ _pr_rows=0
539
+ while IFS=" " read -r st chk dft br; do
540
+ [ -n "$br" ] && [ -n "$st" ] || continue
541
+ _pr_rows=$((_pr_rows + 1))
542
+ [ "$br" = "$last" ] && continue
543
+ last="$br"
544
+ # A PLAIN row (no `--rich` fields) carries `-` in the checks/draft slots so
545
+ # the tab-separated line has no EMPTY field: TAB is an IFS whitespace char,
546
+ # so `read` would collapse `A<TAB><TAB><TAB>B` and slide the branch into
547
+ # `chk`. The `-` sentinel keeps every field occupied; it is translated back
548
+ # to empty here so the cache stores exactly what a rich-less response means:
549
+ # STATE known, rollup unknown.
550
+ [ "$chk" = "-" ] && chk=""
551
+ [ "$dft" = "-" ] && dft=""
552
+ # Cache format: STATE<TAB>checks<TAB>draft — one line per branch.
553
+ # `checks` is one of: green, failing, pending, none, unknown (or empty when
554
+ # the response was not `--rich`); `draft` is true or false (or empty).
555
+ printf '%s\t%s\t%s' "$st" "$chk" "$dft" > "$HOST_STATE_CACHE/$br" 2>/dev/null || true
556
+ done <<EOF
557
+ $(printf '%s\n' "$js" \
558
+ | sed -n -e 's/.*"state":"\([A-Z]*\)","head":"\([^"]*\)","draft":\([a-z]*\),"checks":"\([a-z]*\)".*/\1 \4 \3 \2/p' \
559
+ -e 't' \
560
+ -e 's/.*"state":"\([A-Z]*\)","head":"\([^"]*\)".*/\1 - - \2/p' \
561
+ | sed 's/_/__/g; s|/|_|g; s|^\([A-Z]* [a-z-]* [a-z-]*\)_|\1 |' \
562
+ | sed 's/^\(OPEN [^ ]* [^ ]*\) /1 \1 /; s/^\(MERGED [^ ]* [^ ]*\) /2 \1 /; s/^\([A-Z]\)/3 \1/' \
563
+ | sort -t"$(printf '\t')" -k5,5 -k1,1 | cut -f2,3,4,5)
564
+ EOF
565
+ # The pipeline above, read left to right:
566
+ # 1. pull `STATE<TAB>checks<TAB>draft<TAB>branch` out of each JSON line,
567
+ # anchored on the fields' emitted adjacency so a free-text TITLE cannot
568
+ # impersonate them. TWO patterns tried in order: the RICH line carries
569
+ # `draft` and `checks` (the `--loose` rollup); a PLAIN line — a `pr-list`
570
+ # reply without `--rich`, or a stub that emits only the fields it needs —
571
+ # matches the second pattern and fills the checks/draft slots with `-`.
572
+ # `t` after the first branches past the second only when it matched, so a
573
+ # rich line is never re-parsed as plain. The `-` becomes empty in the
574
+ # loop, where an empty `checks` reads as `unknown` in `pr_ready` and
575
+ # degrades `--loose` to strict — the safe direction — while the STATE the
576
+ # row always carries keeps `host_pr_state`'s join (the N+1 fix) answering
577
+ # for every branch the list names, rich or not;
578
+ # 2. encode the branch into the injective cache key (`cache_key`, inlined);
579
+ # 3. prefix a RANK digit — OPEN 1, MERGED 2, everything else 3;
580
+ # 4. sort by branch (field 5), then rank (field 1), so each branch's winning
581
+ # row comes first;
582
+ # 5. drop the rank column again, leaving `STATE<TAB>checks<TAB>draft<TAB>branch`.
583
+ # The loop then keeps the first row per branch and skips the rest.
584
+ # THE LIST ARRIVED. Recorded as a fact of its own, because "the cache has no
585
+ # entry for this branch" means two different things and only this flag tells
586
+ # them apart: with the list in hand it is real evidence of no PR, without it
587
+ # the question was never answered. `host_pr_state` reads this to decide
588
+ # between NONE and `-`.
589
+ #
590
+ # The marker shares the cache directory with the per-branch files and CANNOT
591
+ # collide with one: `git check-ref-format` rejects a branch whose name starts
592
+ # with a dot, and the key encoding maps only `_` and `/`, so no branch key can
593
+ # begin with one either. The separation is git's rule, not a lucky prefix.
594
+ printf '1' > "$HOST_STATE_CACHE/.list-arrived" 2>/dev/null || true
595
+ # THE LIST WAS WHOLE — a stronger claim than `.list-arrived`, and the only one
596
+ # that licenses deriving `NONE` from a cache miss.
597
+ #
598
+ # `.list-arrived` says the host answered. This says the answer was not
599
+ # truncated, which arrival alone cannot establish: a list returned AT the
600
+ # limit is evidence of *at least* that many PRs, never of exactly that many.
601
+ # Deriving absence from a partial list would report a real PR as having none —
602
+ # strictly worse than the per-branch cost being removed, and the failure #333
603
+ # records on Bitbucket, where `bb pr list` is silently partial past 50 PRs per
604
+ # state.
605
+ #
606
+ # Counted from the rows actually parsed rather than from the raw payload, so a
607
+ # malformed line that the `sed` skipped cannot inflate the count into a false
608
+ # claim of completeness. Fewer rows than the limit means the host had no more
609
+ # to give; equal to it means it may have.
610
+ #
611
+ # AN EMPTY LIST IS NOT A COMPLETE ONE, and the test that caught this is the
612
+ # reason it is written down. A host that exits 0 while printing nothing —
613
+ # a stub, a backend that cannot list, a payload whose shape changed — parses
614
+ # to zero rows, and zero is `< PR_LIST_LIMIT`, so a completeness test on the
615
+ # limit ALONE reads a silent failure as "this repo has no PRs" and derives
616
+ # `NONE` for every branch. That is the outage-renders-a-fleet-unstarted
617
+ # failure this whole guard exists to prevent, reached from the other side.
618
+ #
619
+ # A repository genuinely holding zero PRs loses nothing by being asked: it has
620
+ # no branches with PRs for the join to serve either, so the cost is zero calls
621
+ # in both readings.
622
+ if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
623
+ printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
624
+ fi
625
+ }
626
+ prefill_pr_states
627
+
628
+ # The cache stores the STATE WORD, not a yes/no, so a future reader can tell
629
+ # "asked, answered CLOSED" from "asked, could not reach the host". `-` is the
630
+ # unanswerable marker, and it is cached too: a host that is down stays down for
631
+ # the length of a scan, and re-asking once per branch would multiply an outage
632
+ # by the branch count.
633
+ #
634
+ # The host's PR state word for a branch, cached once per branch per run.
635
+ #
636
+ # SPLIT OUT OF `merged_by_host` so a SECOND question can reuse the SAME cached
637
+ # reply. `worker_of` needs to know whether an OPEN PR exists — the fact that
638
+ # outranks everything in the worker classification — and `merged` does not
639
+ # answer it: a branch under review reads `wip` by ancestry and MERGED by
640
+ # neither. Asking the host again would double the per-branch cost this cache
641
+ # exists to avoid, on a scan the board polls every 5 s.
642
+ #
643
+ # Returns the STATE WORD, or `-` when the question could not be answered —
644
+ # never a yes/no, so each caller applies its own test and a reader can still
645
+ # tell "asked, answered CLOSED" from "asked, could not reach the host".
646
+ # THE PER-BRANCH LOOKUP IS NOW OPT-IN, and that is the whole saving.
647
+ #
648
+ # `--ask` asks the host about this ONE branch when the join cannot answer. Only
649
+ # the no-ref arm passes it (PR #216), and that is bounded by ABSENT branches
650
+ # rather than by all of them — a branch with no ref may genuinely be missing
651
+ # from a repo-wide list if its PR was never opened, so the list's silence about
652
+ # it is not evidence.
653
+ #
654
+ # Without `--ask` an unjoined branch answers from the list alone: NONE when the
655
+ # list arrived (real evidence — the repo has no PR for it) and `-` when it did
656
+ # not (the question was never answered). Collapsing those two is the 2026-08-17
657
+ # failure in a new shape and is what the `.list-arrived` marker prevents.
658
+ host_pr_state() { # $1=branch [--ask] → OPEN|MERGED|CLOSED|NONE|-
659
+ [ "$HOST_LOOKUP_OK" = 1 ] || { printf '%s' '-'; return; }
660
+ local br="$1" ask="${2:-}" st js cache="" cached
661
+ [ -n "$HOST_STATE_CACHE" ] && cache="$HOST_STATE_CACHE/$(cache_key "$br")"
662
+ if [ -n "$cache" ] && [ -f "$cache" ]; then
663
+ # Cache format is now `STATE<TAB>checks<TAB>draft`. Return only STATE
664
+ # for backward compatibility — callers wanting the full record use
665
+ # `host_pr_readiness`.
666
+ cached=$(cat "$cache" 2>/dev/null)
667
+ printf '%s' "${cached%% *}"
668
+ return
669
+ fi
670
+ # THE ARRIVED LIST ANSWERS FOR THE BRANCHES IT OMITS, and it is tested BEFORE
671
+ # the host call rather than after it. `pr-list` returns every PR in the repo,
672
+ # so once it has arrived COMPLETE, a branch missing from the cache has no PR:
673
+ # absence is the answer, not the absence of one. Asking the host can only
674
+ # confirm what the list already said, at one round trip per branch.
675
+ #
676
+ # MEASURED, 2026-08-23: 28 of 29 host calls in a scan were for branches with
677
+ # no ref and no PR — branches an approved plan NAMES and nobody has started.
678
+ # Each cost a round trip to re-learn `NONE`, every scan, forever. One board
679
+ # spent ~3,600 calls/hour that way and emptied a 5000/hour budget in ~78
680
+ # minutes; four boards were running.
681
+ #
682
+ # COMPLETENESS IS THE LICENCE, and it is checked rather than assumed. A list
683
+ # returned AT the limit may be truncated — a count equal to the limit is
684
+ # evidence of *at least* that many PRs, never of exactly that many — and
685
+ # deriving `NONE` from a partial list would report a real PR as absent, which
686
+ # is worse than the cost this removes. `bb pr list` is silently partial past
687
+ # 50 PRs per state (#333), which is that failure already observed on one host.
688
+ # `.list-complete` is written only when the list is known whole; without it
689
+ # this arm does not fire and the host is asked exactly as before.
690
+ #
691
+ # `PLOT_SCAN_ASK_ALWAYS=1` restores the old behaviour on the NEXT SCAN with no
692
+ # rebuild — the scan is a script the board re-executes every cycle. It may
693
+ # only ever restore asking, never suppress it, so setting it can cost budget
694
+ # but cannot produce a wrong answer.
695
+ if [ "$ask" = "--ask" ] && [ "${PLOT_SCAN_ASK_ALWAYS:-0}" != 1 ] \
696
+ && [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-complete" ]; then
697
+ # Not in a complete list = no PR. Cached so a second caller for the same
698
+ # branch reads the file rather than re-deriving.
699
+ [ -n "$cache" ] && printf '%s' 'NONE' > "$cache" 2>/dev/null
700
+ printf '%s' 'NONE'
701
+ return
702
+ fi
703
+ if [ "$ask" = "--ask" ]; then
704
+ # Exit code first: non-zero is a transport failure and its stdout is not an
705
+ # answer. Only then is the payload read.
706
+ if js=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
707
+ st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
708
+ [ -n "$st" ] || st='-'
709
+ else
710
+ st='-'
711
+ fi
712
+ # Cached even when it is `-`: a host that is down stays down for the length
713
+ # of a scan, and re-asking once per branch would multiply an outage by the
714
+ # branch count.
715
+ [ -n "$cache" ] && printf '%s' "$st" > "$cache" 2>/dev/null
716
+ printf '%s' "$st"
717
+ return
718
+ fi
719
+ # Not asked, not joined. The list's own arrival decides which silence this is.
720
+ if [ -n "$HOST_STATE_CACHE" ] && [ -f "$HOST_STATE_CACHE/.list-arrived" ]; then
721
+ printf '%s' 'NONE'
722
+ else
723
+ printf '%s' '-'
724
+ fi
725
+ }
726
+
727
+ # ---------------------------------------------------------------------------
728
+ # A TERMINAL BRANCH IS ASKED ONCE — the cache that spans pulses
729
+ # ---------------------------------------------------------------------------
730
+ #
731
+ # THE MEASUREMENT, taken on this repo 2026-08-19 after the join (#232) landed:
732
+ # 26 of 54 branches are terminal — merged or deferred — and a terminal fact
733
+ # cannot change. A merged branch stays merged. The board pulses every 5 s, so
734
+ # the scan re-derived those 26 answers at full price, forever.
735
+ #
736
+ # WHAT THE JOIN LEFT FOR THIS TO FIX. Measured in a sandbox before writing it:
737
+ #
738
+ # merged, ref KEPT 3 branches → 1 pr-list 9 → 1 pr-list
739
+ # squash-merged, DELETED 3 branches → 3 pr-state 9 → 9 pr-state
740
+ #
741
+ # After #232 the ONLY per-branch host cost left is the no-ref `--ask` arm that
742
+ # PR #216 put there — and that arm IS the terminal population: a branch whose
743
+ # ref is gone and whose merge already landed. So the cache lands exactly there
744
+ # and nowhere else. A live branch cannot be cached even by accident, because a
745
+ # live branch has a ref and never reaches the call.
746
+ #
747
+ # THE CACHE IS A DERIVATION, NOT A RECORD. That distinction is the whole design
748
+ # and Manifesto Principle 1 rests on it: nothing is remembered that git cannot
749
+ # re-establish. GIT IS CONSULTED ON EVERY PASS; only the host round trip is
750
+ # skipped. The asymmetry is the point — git is local and cheap, the host is
751
+ # remote and metered — and a cache that also skipped git would be a record of
752
+ # the past rather than a derivation of the present.
753
+ #
754
+ # So an entry carries THE EVIDENCE THAT MADE THE BRANCH TERMINAL, and every
755
+ # pass asks git whether that evidence still holds:
756
+ #
757
+ # branch <TAB> state <TAB> plan-oid <TAB> main-oid
758
+ #
759
+ # * THE REF REAPPEARED → not served, and not even reached. A branch name is
760
+ # reusable: merge `bug/flaky`, delete it, push it again for a second
761
+ # attempt. Serving the first attempt's `merged` would settle a wave and
762
+ # open the next one on work that has not landed. Checked live rather than
763
+ # stored, because a ref's absence is the precondition of the call itself.
764
+ # * THE PLAN WAS EDITED → its branches' answers are discarded. A plan is an
765
+ # INPUT to the derivation, not just a list of names: `deferred:`
766
+ # annotations, wave membership and the plan's phase all decide what an
767
+ # answer means. Content-addressed by blob hash, so an edit is caught
768
+ # without trusting a timestamp.
769
+ # * MAIN MOVED → the merge evidence is re-derived. `merged_by_subject` walks
770
+ # main's history, so a new tip is a new question.
771
+ #
772
+ # IT NEVER TOUCHES DISK AND NEVER OUTLIVES A PROCESS. The scan is spawned fresh
773
+ # every pulse, so it cannot hold the map itself; it RECEIVES the cache in the
774
+ # environment and REPORTS what it learned on stderr, leaving stdout
775
+ # byte-identical. The board — the only long-lived process in the system — holds
776
+ # the map in memory and dies with it. A file would be a second source of truth
777
+ # about a repo whose only source of truth is git, and a restart must re-derive
778
+ # everything.
779
+ # Defaulted rather than assumed: `set -u` is on, and an unset variable here
780
+ # would abort the scan for every caller that does not know the cache exists —
781
+ # which is every caller but the board.
782
+ PLOT_TERMINAL_CACHE="${PLOT_TERMINAL_CACHE:-}"
783
+ TERMINAL_LEARNED=""
784
+ # The tip the merge evidence is derived against, resolved once per run.
785
+ TERMINAL_MAIN_OID=$(git rev-parse "refs/remotes/origin/$MAIN" 2>/dev/null || echo "-")
786
+ # The plan whose branches are currently being walked, as a blob hash. Set by the
787
+ # plan loop; empty until then, and an entry with no plan identity is never
788
+ # served — an answer we cannot attribute to a plan revision is not evidence.
789
+ TERMINAL_PLAN_OID=""
790
+
791
+ # Is this branch's cached answer still good? Prints the state word when it is.
792
+ #
793
+ # THE VALIDATION IS THE FEATURE. Every arm here is a question to git, asked on
794
+ # every pass, and any disagreement discards the entry rather than repairing it.
795
+ terminal_cached() { # $1=branch → the cached state word, or nothing
796
+ [ -n "$PLOT_TERMINAL_CACHE" ] || return 1
797
+ [ -n "$TERMINAL_PLAN_OID" ] || return 1
798
+ local br="$1" cbr cst cplan cmain
799
+ while IFS=" " read -r cbr cst cplan cmain; do
800
+ [ "$cbr" = "$br" ] || continue
801
+ # The plan revision the answer was derived under, and the tip it was
802
+ # derived against. Either having moved makes it a fact about a repo that no
803
+ # longer exists.
804
+ [ "$cplan" = "$TERMINAL_PLAN_OID" ] || return 1
805
+ [ "$cmain" = "$TERMINAL_MAIN_OID" ] || return 1
806
+ printf '%s' "$cst"
807
+ return 0
808
+ done <<EOF
809
+ $PLOT_TERMINAL_CACHE
810
+ EOF
811
+ return 1
812
+ }
813
+
814
+ # Record a decided terminal answer for the next pulse to reuse.
815
+ #
816
+ # ONLY A DECIDED ANSWER IS TERMINAL. `-` means the question could not be
817
+ # answered, and caching it would freeze one bad afternoon into every later
818
+ # pulse — the 2026-08-17 outage multiplied by the life of the board rather than
819
+ # by the branch count. `MERGED` and `CLOSED` are settled; `OPEN` and `NONE` are
820
+ # not, because both can still change without anything local moving.
821
+ terminal_learn() { # $1=branch $2=state
822
+ case "$2" in MERGED|CLOSED) ;; *) return 0 ;; esac
823
+ [ -n "$TERMINAL_PLAN_OID" ] || return 0
824
+ TERMINAL_LEARNED+="$1 $2 $TERMINAL_PLAN_OID $TERMINAL_MAIN_OID"$'\n'
825
+ # Reported as it is learned rather than at exit: the scan is killed with
826
+ # SIGKILL on timeout (`fleet.ts`), and a summary written only on a clean exit
827
+ # would teach the board nothing on exactly the slow pulses that need it most.
828
+ printf 'terminal: %s\t%s\t%s\t%s\n' \
829
+ "$1" "$2" "$TERMINAL_PLAN_OID" "$TERMINAL_MAIN_OID" >&2
830
+ }
831
+
832
+ # Does the host say this branch's PR is MERGED? Anything else — OPEN, CLOSED,
833
+ # NONE, an unreachable host, a malformed reply — is NOT a yes. Unchanged in
834
+ # behaviour; only the lookup underneath it is now shared.
835
+ #
836
+ # THE ONE CALLER THAT MAY ASK PER BRANCH (PR #216). It is reached only from the
837
+ # no-ref arm of `branch_state`, for a branch the repo-wide list may legitimately
838
+ # not contain, and its cost is therefore bounded by ABSENT branches rather than
839
+ # by all of them. The join answers everything else. Do not add `--ask` to a
840
+ # caller that runs for every branch — that is the loop this change removed.
841
+ #
842
+ # THE ONE PLACE THE TERMINAL CACHE IS CONSULTED, and it is deliberate that
843
+ # there is only one. This arm is reached only for a branch with NO REF, so a
844
+ # branch that is live — in flight, claimed, or with work on the floor — never
845
+ # arrives here and therefore cannot be cached however the cache is filled. The
846
+ # invariant is structural rather than a check that could be forgotten.
847
+ merged_by_host() { # $1=branch → 0 when the host reports its PR MERGED
848
+ local st
849
+ # Git has already been consulted to get here (no ref) and `terminal_cached`
850
+ # asks it again about the plan and the tip. Only the round trip is skipped.
851
+ if st=$(terminal_cached "$1"); then
852
+ # A SERVED ENTRY RE-REPORTS ITSELF, so what the scan emits is always the
853
+ # WHOLE map the next pulse should hold rather than a delta the board would
854
+ # have to merge. Without this the second pulse serves the entry, reports
855
+ # nothing, and the third starts cold — the cache would work exactly once
856
+ # and the saving would vanish on the pulse after it.
857
+ #
858
+ # It is re-derived, not merely echoed: reaching here means git was asked
859
+ # again this pass and still agrees — no ref, same plan, same tip.
860
+ terminal_learn "$1" "$st"
861
+ [ "$st" = "MERGED" ]
862
+ return
863
+ fi
864
+ st=$(host_pr_state "$1" --ask)
865
+ terminal_learn "$1" "$st"
866
+ [ "$st" = "MERGED" ]
867
+ }
868
+
869
+ # Has this branch's work REACHED REVIEW — an open or merged PR?
870
+ #
871
+ # The fact that outranks every local signal in the worker classification: work
872
+ # under review has left the worker's hands, so leftover edits in its worktree
873
+ # are not unfinished work. OPEN and MERGED both count; CLOSED does not, because
874
+ # a closed PR is work that was rejected or withdrawn and whatever sits in the
875
+ # worktree is back on the floor.
876
+ #
877
+ # UNANSWERABLE IS NOT A YES, and the direction matters. `-` — offline, no
878
+ # backend, a host returning 503 all afternoon — must not manufacture the state
879
+ # that tells a reader to stop looking. It falls through to the local signals,
880
+ # so a branch with work on the floor reads `stalled`: go and look. That is the
881
+ # safe direction for an answer nobody could verify.
882
+ reached_review() { # $1=branch → 0 when an open or merged PR exists
883
+ case "$(host_pr_state "$1")" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
884
+ }
885
+
886
+ # Modification time of a path, in epoch seconds, following symlinks — or "" when
887
+ # it cannot be read.
888
+ #
889
+ # BSD (`stat -f %m`) and GNU (`stat -c %Y`) spell this differently, and the
890
+ # fallback CANNOT be written as `bsd || gnu`: on GNU coreutils `-f` is a valid
891
+ # flag meaning *file system status*, so it SUCCEEDS with a filesystem report
892
+ # instead of failing over. That reads as a mtime of zero and quietly excludes
893
+ # every delivered plan on Linux, which is exactly what CI caught — the answer
894
+ # was wrong in the safe-looking direction (nothing shown) rather than the loud
895
+ # one. So the OUTPUT is validated rather than the exit code: whichever form
896
+ # yields digits is the one that was understood.
897
+ #
898
+ # THE FORM IS DETECTED ONCE, and that is an efficiency fix rather than a
899
+ # correctness one — the output validation below still has the last word. Two
900
+ # costs made it worth doing here: `for m in "$(bsd)" "$(gnu)"` evaluates BOTH
901
+ # command substitutions before the loop body runs, so the second fork happens
902
+ # even when the first answered; and on Linux the first fork is the one that
903
+ # prints five lines of filesystem report to be thrown away. Measured on this
904
+ # machine: ~60 ms per fork under load. Paid once per delivered plan that was
905
+ # affordable; `changed_ago_seconds` asks per worktree, and the same waste
906
+ # compounds.
907
+ #
908
+ # `-c` IS PROBED FIRST, deliberately, the same order `plot-board-probe.sh`
909
+ # settled on: GNU is the implementation that mis-parses the other's flag, so
910
+ # asking it its own question first means the ambiguous form is never reached on
911
+ # Linux. Verified on both platforms while writing this — macOS rejects `-c`
912
+ # with an empty stdout, alpine's busybox answers `-c` and pollutes stdout for
913
+ # `-f`.
914
+ # DETECTED LAZILY, INSIDE THE FUNCTION, so `file_mtime` stays SELF-CONTAINED.
915
+ # `fleetdelivered.test.mjs` lifts this function out of the script by regex and
916
+ # runs it standalone — deliberately, since sourcing the file would execute the
917
+ # whole scan — so a form probed at top level would leave the extracted copy with
918
+ # an unset variable under `set -u`. That test says so itself: a reshaped
919
+ # function is a signal to look, not to skip. It caught this.
920
+ _STAT_FMT=""
921
+
922
+ file_mtime() { # $1=path → epoch seconds, or ""
923
+ local m
924
+ # Probed on first use and remembered. `-c` FIRST, deliberately, the same order
925
+ # `plot-board-probe.sh` settled on: GNU is the implementation that mis-parses
926
+ # the other's flag, so asking it its own question first means the ambiguous
927
+ # form is never reached on Linux. Verified on both platforms while writing
928
+ # this — macOS rejects `-c` with an empty stdout, alpine's busybox answers
929
+ # `-c` and pollutes stdout for `-f`.
930
+ if [ -z "${_STAT_FMT:-}" ]; then
931
+ if [ -n "$(stat -c %Y "$1" 2>/dev/null)" ]; then
932
+ _STAT_FMT="gnu"
933
+ elif [ -n "$(stat -f %m "$1" 2>/dev/null)" ]; then
934
+ _STAT_FMT="bsd"
935
+ else
936
+ _STAT_FMT="none"
937
+ fi
938
+ fi
939
+ case "$_STAT_FMT" in
940
+ gnu) m=$(stat -c %Y "$1" 2>/dev/null) ;;
941
+ bsd) m=$(stat -f %m "$1" 2>/dev/null) ;;
942
+ # No `stat` at all, or one that speaks neither dialect — or a first path
943
+ # that could not be read, which would misprobe. Fall back to trying both and
944
+ # validating the output, which is what this function did before the
945
+ # detection existed. Slower, still correct, and it re-probes next call.
946
+ *)
947
+ _STAT_FMT=""
948
+ for m in "$(stat -c %Y "$1" 2>/dev/null)" "$(stat -f %m "$1" 2>/dev/null)"; do
949
+ case "$m" in
950
+ ''|*[!0-9]*) ;;
951
+ *) printf '%s' "$m"; return 0 ;;
952
+ esac
953
+ done
954
+ return 1 ;;
955
+ esac
956
+ # The OUTPUT is validated, never the exit code — kept from the original for
957
+ # the reason it was written: a form that "succeeds" while answering a
958
+ # different question must not reach the arithmetic that consumes this.
959
+ case "$m" in
960
+ ''|*[!0-9]*) return 1 ;;
961
+ *) printf '%s' "$m"; return 0 ;;
962
+ esac
963
+ }
964
+
965
+ # The newest mtime among MANY paths, in ONE `stat` call — epoch seconds, or ""
966
+ # when none could be read.
967
+ #
968
+ # ONE FORK FOR THE WHOLE LIST, and the measurement is why this is not a loop
969
+ # over `file_mtime`. `stat` takes many paths and prints one line each on both
970
+ # dialects (verified on macOS and alpine). Measured here: 50 sequential forks
971
+ # cost 3.1 s, one batched call over three paths cost 0.023 s. A worker mid-build
972
+ # has hundreds of untracked files, so per-file forking would have made the pulse
973
+ # slower than the work it measures — the plan budgeted "one directory stat per
974
+ # worktree" and the design it describes implies one per FILE. Batching is what
975
+ # makes those two the same number again.
976
+ #
977
+ # NO `xargs`, NO ARGUMENT-LIMIT DANCE. A path list long enough to exceed
978
+ # `ARG_MAX` (~1 MB) is not a case this needs to serve: the answer is the MAXIMUM
979
+ # mtime, so a truncated list can only under-report, and a worktree with tens of
980
+ # thousands of dirty files has an answer no operator is reading a clock for. The
981
+ # list is capped by the caller instead, which reports that it capped.
982
+ #
983
+ # UNREADABLE PATHS ARE SKIPPED, not zeroed. `stat` prints nothing for a path it
984
+ # cannot read (a file deleted between `git status` and here — a live race in a
985
+ # worktree being written to), and a zero would be the newest-looking answer's
986
+ # opposite: it drags nothing down, but a fabricated 0 elsewhere in this field
987
+ # would read as 1970. Non-digit lines are dropped for the same reason.
988
+ newest_mtime() { # $@=paths → epoch seconds, or ""
989
+ [ "$#" -gt 0 ] || return 1
990
+ local out
991
+ # Share `file_mtime`'s probe rather than repeating it: one call settles
992
+ # `_STAT_FMT` for the run, and the batched form below can then be chosen
993
+ # without a second detection. Its answer is discarded — only the side effect
994
+ # is wanted, and a path that cannot be read leaves the format unset, which the
995
+ # `*)` arm handles.
996
+ [ -n "${_STAT_FMT:-}" ] || file_mtime "$1" >/dev/null 2>&1
997
+ case "${_STAT_FMT:-}" in
998
+ gnu) out=$(stat -c %Y "$@" 2>/dev/null) ;;
999
+ bsd) out=$(stat -f %m "$@" 2>/dev/null) ;;
1000
+ *) return 1 ;;
1001
+ esac
1002
+ # `sort -n | tail -1` would fork twice more for a maximum awk already has in
1003
+ # hand. The digit test is what keeps a filesystem report — should the
1004
+ # detection ever be wrong — out of the arithmetic downstream.
1005
+ printf '%s\n' "$out" | awk '
1006
+ /^[0-9]+$/ { if ($1 > max) max = $1 }
1007
+ END { if (max != "") print max }
1008
+ '
1009
+ }
1010
+
1011
+ # THE DIRTY LIST IS CAPPED, AND THE CAP NEEDS NO SATURATION FLAG — the one
1012
+ # place in this file where that is true, so it is argued rather than assumed.
1013
+ #
1014
+ # The rule elsewhere here is that caps drop results silently unless they also
1015
+ # report saturation, and it holds because those caps report a SET: a truncated
1016
+ # conflict list reads exactly like a short one, and the reader cannot tell. This
1017
+ # cap feeds a MAXIMUM. Dropping members of a set you take the max over can only
1018
+ # move the answer EARLIER — it can never invent recency — so the failure mode is
1019
+ # "reported quieter than it is", which is the direction that makes a reader look
1020
+ # rather than the one that reassures them wrongly.
1021
+ #
1022
+ # WHICH 500, STATED PLAINLY: the first 500 in git's own order, which is
1023
+ # alphabetical by path — NOT the 500 newest. Sorting by mtime to pick the newest
1024
+ # would need every file's mtime first, which is the entire cost the cap exists
1025
+ # to bound. So on a worktree past the cap the answer is the newest among an
1026
+ # arbitrary subset, and it is reported as the same number as any other run.
1027
+ #
1028
+ # That is acceptable HERE and would not be in a set-shaped field, for the reason
1029
+ # above: it can only under-report. It is written down because a reader who
1030
+ # assumes "newest 500" would trust a capped answer more than it deserves.
1031
+ #
1032
+ # 500 is far above any hand-edited working set and far below `ARG_MAX` (~1 MB of
1033
+ # argv). A tree big enough to hit it is a build directory, not a person's work.
1034
+ CHANGED_DIRTY_CAP=500
1035
+
1036
+ # ---------------------------------------------------------------------------
1037
+ # Local worktrees: what the refs cannot see
1038
+ # ---------------------------------------------------------------------------
1039
+ #
1040
+ # An agent that has edited files and not committed has written nothing git can
1041
+ # see, so a branch someone is actively working reads as abandoned — on the one
1042
+ # machine that could have known better. This scan runs on that machine, and
1043
+ # `git worktree list --porcelain` names every worktree and its branch.
1044
+ #
1045
+ # The signal is strictly ONE-DIRECTIONAL, which is the whole reason it can be
1046
+ # added without weakening refs-as-truth: it may ADD an answer where this machine
1047
+ # knows more, never downgrade one. A machine with no worktree for a branch —
1048
+ # every detached worker, every teammate's laptop, every CI run — reports nothing
1049
+ # here, and the branch answers from refs exactly as it did before.
1050
+ #
1051
+ # TWO EMPTY-MEANS-ONE-THING GUARDS, both cheap:
1052
+ # * SKIP `prunable` ENTRIES. A worktree directory can be deleted without `git
1053
+ # worktree remove`, and the entry survives. `git status` there exits 128 and
1054
+ # prints NOTHING — so a check on emptiness reads "clean" and is right by
1055
+ # ACCIDENT. `git worktree list --porcelain` already marks such entries, so
1056
+ # running `git status` on a directory known to be gone is asking a question
1057
+ # whose answer was printed a line earlier.
1058
+ # * READ THE EXIT CODE, not the emptiness. A non-zero status is a failure to
1059
+ # observe, and a failure to observe is not evidence of cleanliness.
1060
+ #
1061
+ # NO CAP, and the measurement is the reason: 6.6 ms per worktree, so twenty cost
1062
+ # ~133 ms against a scan that already runs 500–1050 ms. A cap would be stock
1063
+ # against a problem the numbers rule out, and caps drop results silently unless
1064
+ # they also report saturation.
1065
+ #
1066
+ # A LOCK IS A THIRD ANSWER, not a failure to observe. `.git/index.lock` means
1067
+ # *an agent is writing HERE, RIGHT NOW* — precisely what the fleet view exists to
1068
+ # show — and it is the most informative state a worktree can be in. Until #167 it
1069
+ # was invisible: the branch answered from refs as though this machine had no
1070
+ # worktree for it, and the row read *claimed, no commits yet* while a commit was
1071
+ # in flight. The branch that looked least active was the one being written to.
1072
+ #
1073
+ # THE LOCK FILE IS OBSERVED DIRECTLY, and that is a correction to the plan rather
1074
+ # than a shortcut around it. The plan expected the lock to announce itself by
1075
+ # FAILING `git status`, so that reading the exit code would be enough. Measured
1076
+ # on 2026-08-17, it does not: `git status --porcelain` exits 0 under a held lock
1077
+ # in every ordinary condition — clean tree, modified file, staged change,
1078
+ # untracked file — because it needs the index lock only when it decides to WRITE
1079
+ # a refreshed index back, which it skips whenever the cached stat info already
1080
+ # answers. The failure this plan was written from was real and is racy: it
1081
+ # reproduces when the index is stale enough to force a refresh-and-write, and not
1082
+ # otherwise. Keying the signal on that exit code would report a lock on some runs
1083
+ # and not others, for the same worktree in the same state — a flaky signal is
1084
+ # worse than none, because it teaches the reader to disbelieve the row.
1085
+ #
1086
+ # So the question is asked of the filesystem, where the answer is unambiguous:
1087
+ # the lock file is either there or it is not.
1088
+ #
1089
+ # LOCKED AND MISSING STAY APART, which is what the direct check buys. They are
1090
+ # now answered by two independent observations rather than by one exit code
1091
+ # carrying two meanings — a vanished directory has no git dir to look in and
1092
+ # reports nothing at all, exactly as before.
1093
+ #
1094
+ # NEVER RETRY, NEVER WAIT. A lock held through a rebase can last seconds and the
1095
+ # next poll is 4 s away — it will find it unlocked. A scan that blocks on one
1096
+ # worktree makes the pulse late for every branch on the board, which is a worse
1097
+ # version of the defect being fixed. Reporting beats blocking.
1098
+ #
1099
+ # Read ONCE per run, not once per branch — same rule the merge walk follows, for
1100
+ # the same reason: branch_state runs per branch and the board polls every 5 s.
1101
+ # bash 3.2 (macOS) has no associative arrays, so the table is a newline-
1102
+ # separated string of `branch<TAB>path<TAB>dirty<TAB>locked` rows, looked up with
1103
+ # awk.
1104
+ worktree_rows() {
1105
+ git worktree list --porcelain </dev/null 2>/dev/null | awk '
1106
+ /^worktree / { path = substr($0, 10); br = ""; prunable = 0; next }
1107
+ /^branch / { br = substr($0, 8); sub(/^refs\/heads\//, "", br); next }
1108
+ /^prunable/ { prunable = 1; next }
1109
+ /^$/ { if (br != "" && !prunable) print br "\t" path; br = ""; path = ""; prunable = 0; next }
1110
+ END { if (br != "" && !prunable) print br "\t" path }
1111
+ '
1112
+ }
1113
+
1114
+ # Whether a worktree is mid-write, asked of the worktree's OWN git dir.
1115
+ #
1116
+ # A linked worktree does not keep its index beside the repository's: `.git` there
1117
+ # is a FILE reading `gitdir: <repo>/.git/worktrees/<name>`, and that is where its
1118
+ # `index.lock` lives. Testing `$wt/.git/index.lock` would answer for the main
1119
+ # checkout only and report every linked worktree unlocked — the population this
1120
+ # whole signal is about, since every dispatched agent works in one.
1121
+ #
1122
+ # NO GIT CALL. `git rev-parse --absolute-git-dir` would answer both shapes in one
1123
+ # line and costs 14 ms — measured, against the 6.6 ms per worktree the sweep
1124
+ # already accepts, so asking it per worktree would roughly TRIPLE the cost of the
1125
+ # local signals to learn something the filesystem already states. `.git` is a
1126
+ # directory in the main checkout and a one-line file in a linked one; reading it
1127
+ # is a stat and at most a 50-byte read.
1128
+ #
1129
+ # ABSENT IS ABSENT, here as everywhere: an unreadable or missing `.git` returns
1130
+ # "not locked" rather than guessing, and the worktree's other answers are
1131
+ # unaffected.
1132
+ worktree_locked() { # $1=worktree path → 0 when a lock is held there
1133
+ local gd=$1/.git line
1134
+ if [ -f "$gd" ]; then
1135
+ # A linked worktree: `.git` is a pointer file. Relative targets are legal, so
1136
+ # resolve against the worktree rather than the caller's cwd.
1137
+ line=$(cut -d' ' -f2- <"$gd" 2>/dev/null) || return 1
1138
+ case "$line" in
1139
+ gitdir:*) line=${line#gitdir:} ;;
1140
+ esac
1141
+ line=${line# }
1142
+ [ -n "$line" ] || return 1
1143
+ case "$line" in
1144
+ /*) gd=$line ;;
1145
+ *) gd=$1/$line ;;
1146
+ esac
1147
+ elif [ ! -d "$gd" ]; then
1148
+ return 1
1149
+ fi
1150
+ [ -e "$gd/index.lock" ]
1151
+ }
1152
+
1153
+ # ---------------------------------------------------------------------------
1154
+ # EVERY REMOTE REF, IN ONE CALL — the spawn count IS the cost
1155
+ # ---------------------------------------------------------------------------
1156
+ #
1157
+ # THE MEASUREMENT, 2026-08-20, with a wrapper counting every `git` invocation:
1158
+ # 459 spawns for one scan of 54 branches, at 56 ms of process launch each —
1159
+ # roughly 24 s before git does any work. No single operation was slow: `fetch`
1160
+ # 0 s, `pr-list` 1 s, all 54 ancestry walks 1 s together. The distribution had
1161
+ # no hotspot (68 rev-list, 68 ls-tree, 67 show, 59 show-ref) because the cost is
1162
+ # the SPAWNING: 8 per branch, 54 branches.
1163
+ #
1164
+ # `git show-ref --verify` was asked once per branch from two places, always the
1165
+ # same question — does `origin/<branch>` exist. `for-each-ref` answers it for
1166
+ # every branch at once, so 59 spawns become one.
1167
+ #
1168
+ # Spawn cost is itself a property of the ESTATE rather than of this script:
1169
+ # every git process reads the ref database and worktree list at startup.
1170
+ # Measured on this repo, 44 worktrees gave 56 ms per spawn and a 105 s scan;
1171
+ # after removing 33 finished ones, 31 ms and 63 s. Both multipliers are real,
1172
+ # which is why housekeeping alone did not reach the 30 s budget.
1173
+ #
1174
+ # WHY IT MATTERS BEYOND THIS REPO: on Bitbucket one host call was measured at
1175
+ # ~10 s against GitHub's 461 ms, so a scan that cannot finish here cannot finish
1176
+ # there at all. The board serves no rows without a completed pulse — a fresh
1177
+ # process has no previous one to fall back on — so an over-budget scan is an
1178
+ # EMPTY Agents tab, not a stale one.
1179
+ #
1180
+ # The tab-delimited-string-plus-`case` shape matches `WORKTREES` below and is
1181
+ # kept for the same reason: no associative arrays, so it must work in POSIX sh.
1182
+ # `%(objectname)` rides along FREE — the same call, one more field. It is what
1183
+ # lets the commit walk name a SHA range instead of `origin/<branch>..`, which a
1184
+ # test at fleet.test.mjs:3138 forbids: a per-branch `git log origin/<branch>` is
1185
+ # how a worker's timestamp would be read from a remote ref, and that cost lands
1186
+ # on the population that must stay free. The walk here is a subject/emptiness
1187
+ # question rather than a timestamp read, but the guard is deliberately broad and
1188
+ # loosening it to fit this change is how a guard rots.
1189
+ REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
1190
+ "refs/remotes/origin" </dev/null 2>/dev/null)
1191
+
1192
+ # Whether `origin/$1` exists, answered from the batch rather than by spawning.
1193
+ #
1194
+ # EXIT STATUS ONLY, matching what `show-ref -q --verify` returned, so every
1195
+ # caller's `if !` reads identically. An empty `REMOTE_REFS` — a repo with no
1196
+ # remote, or a `for-each-ref` that failed — makes this false for every branch,
1197
+ # exactly as a failing `show-ref` did.
1198
+ remote_ref_exists() { # $1=branch → 0 when origin/$1 is present
1199
+ case "
1200
+ $REMOTE_REFS
1201
+ " in
1202
+ *"
1203
+ $1 "*) return 0 ;;
1204
+ esac
1205
+ return 1
1206
+ }
1207
+
1208
+ # The tip SHA of `origin/$1`, from the same batch. Empty when absent.
1209
+ remote_ref_oid() { # $1=branch → oid, or ""
1210
+ printf '%s\n' "$REMOTE_REFS" | awk -F'\t' -v b="$1" '$1==b {print $2; exit}'
1211
+ }
1212
+
1213
+ # EVERY LOCAL HEAD, IN ONE CALL — the same batch shape as REMOTE_REFS, for the
1214
+ # same reason. `local_ahead_of` (below) asks `refs/remotes/origin/<br>..refs/heads/<br>`
1215
+ # per branch, and on the population that dominates a real scan — branches living
1216
+ # on somebody else's machine, with no local `refs/heads/<br>` at all — that
1217
+ # rev-list exits 128 and answers 0. Measured on this repo in --json mode: 64
1218
+ # `local_ahead_of` calls, 25 of them against branches with no local head,
1219
+ # spawning a process to re-derive a zero already knowable from this one call.
1220
+ #
1221
+ # ONLY the no-local-head case is gated. A local head that HAS NO UPSTREAM
1222
+ # (committed, never pushed) still spawns the rev-list — it is a real question
1223
+ # with a real 128-failure answer, pinned by `a MISSING upstream is detected, not
1224
+ # read as zero`, and gating it here would answer 0 without ever looking, which
1225
+ # is the exact accident that test exists to forbid.
1226
+ LOCAL_HEADS=$(git for-each-ref --format='%(refname:strip=2)' \
1227
+ "refs/heads" </dev/null 2>/dev/null)
1228
+
1229
+ # Whether `refs/heads/$1` exists locally, from the batch. An empty LOCAL_HEADS —
1230
+ # a bare repo, or a failed for-each-ref — makes this false for every branch, so
1231
+ # `local_ahead_of` falls back to spawning and its own 128-handling decides. The
1232
+ # gate can only ever SKIP a spawn it is certain would answer 0; it never invents
1233
+ # one.
1234
+ local_head_exists() { # $1=branch → 0 when refs/heads/$1 is present
1235
+ case "
1236
+ $LOCAL_HEADS
1237
+ " in
1238
+ *"
1239
+ $1
1240
+ "*) return 0 ;;
1241
+ esac
1242
+ return 1
1243
+ }
1244
+
1245
+ WORKTREES=""
1246
+ while IFS=$'\t' read -r wt_branch wt_path; do
1247
+ [ -n "$wt_branch" ] || continue
1248
+ # Asked BEFORE `git status` and independently of it. The lock is a fact about
1249
+ # the repository, not a property of whether the status call happened to
1250
+ # succeed — and it is the one fact here that can change between this scan and
1251
+ # the next poll four seconds later, so it is read as close to the truth as the
1252
+ # filesystem allows.
1253
+ if worktree_locked "$wt_path"; then wt_locked=true; else wt_locked=false; fi
1254
+ # Exit code, never emptiness: `git status` on a vanished directory prints
1255
+ # nothing and fails, and "I could not look" must not read as "clean".
1256
+ #
1257
+ # `--untracked-files=no`, and the omission was a measured defect. `local_dirty`
1258
+ # means SOMEBODY IS EDITING THIS WORK, and an untracked path is not that: it is
1259
+ # most often a tool's leavings. Measured 2026-08-22 on the project directory —
1260
+ # the one checkout that is worked in continuously and therefore accumulates
1261
+ # them — `git status --porcelain` reported two entries, `.playwright-mcp/` and
1262
+ # `.plot/agents/`, while `--untracked-files=no` reported none. The row for the
1263
+ # branch checked out there pulsed the activity mark for hours with nothing
1264
+ # being written, which is how it was found.
1265
+ #
1266
+ # The cost is stated rather than hidden: brand-new work is invisible here
1267
+ # until it is staged. That is the right trade for a signal whose whole job is
1268
+ # *someone is editing*, because the alternative is a checkout that reads dirty
1269
+ # forever from a directory nobody will ever commit — a mark that is always on
1270
+ # is a mark that says nothing.
1271
+ #
1272
+ # It also cleans `wt_changed` below, which derives the freshest mtime from
1273
+ # these same paths: a tool writing into `.playwright-mcp/` was moving the
1274
+ # *last changed* clock of work nobody had touched.
1275
+ if wt_status=$(git -C "$wt_path" status --porcelain </dev/null 2>/dev/null); then
1276
+ # DIRTY IS WHAT THE FILTER LEAVES, not what git prints.
1277
+ #
1278
+ # The first cut passed `--untracked-files=no`, which fixed the reported case
1279
+ # — a checkout reading dirty for hours over `.playwright-mcp/` — and broke
1280
+ # the one `fleet.test.mjs` states in as many words: *an untracked source
1281
+ # file IS work and must reset the clock*. A new `new-module.ts` is the most
1282
+ # interesting thing a worktree can hold.
1283
+ #
1284
+ # So the exclusion is per-PATH and lives in `plot_worker_dirty_filter`,
1285
+ # beside the editor leftovers it already drops for the same reason. Both
1286
+ # questions this block answers — *is anyone editing* and *when did the work
1287
+ # last change* — then read one list, which is what stopped them drifting
1288
+ # apart the last time.
1289
+ if [ -n "$(plot_worker_dirty_filter "$wt_status")" ]; then wt_dirty=true; else wt_dirty=false; fi
1290
+ elif [ "$wt_locked" = true ]; then
1291
+ # Status could not answer, but the lock says WHY, and that is an answer
1292
+ # rather than the absence of one: a write is in progress in this worktree at
1293
+ # this instant. Reported with its path, because the directory demonstrably
1294
+ # exists — something is writing in it.
1295
+ #
1296
+ # `dirty` stays FALSE, and that is not a claim of cleanliness. It was not
1297
+ # observed, and the two facts travel separately: `local_locked` says a write
1298
+ # is happening, `local_dirty` says someone is editing. Folding the lock into
1299
+ # dirtiness would answer a second question with the first one's word, which
1300
+ # is the one-label-two-states defect this scan keeps removing.
1301
+ wt_dirty=false
1302
+ else
1303
+ # A failure to observe with no lock to explain it: the directory is gone, or
1304
+ # unreadable, or not a worktree any more. Not reported at all — neither its
1305
+ # dirtiness (unknown) nor its path (it may not be there).
1306
+ continue
1307
+ fi
1308
+ # The newest mtime of the real work on the floor, computed HERE because this
1309
+ # is where the status output already exists — see `changed_ago_of`, the one
1310
+ # consumer, for what the number is for.
1311
+ #
1312
+ # ONE `git status` PER WORKTREE, AND THIS IS WHERE THAT IS ENFORCED. Asking
1313
+ # `plot_worker_dirty` for the file list later would run a SECOND status on the
1314
+ # same worktree, and `fleet.test.mjs` counts them ("a locked worktree must be
1315
+ # asked ONCE") precisely because a scan the board polls every 5 s cannot pay
1316
+ # twice for one answer. Measured by that test rather than by a stopwatch: a
1317
+ # timing assertion cannot tell a cheap duplicate call from no duplicate at all.
1318
+ #
1319
+ # A SCALAR IN THE TABLE, never the file list. `WORKTREES` is a tab-separated
1320
+ # string (bash 3.2 on macOS has no associative arrays), and status output
1321
+ # carries newlines and arbitrary filenames — a list field would corrupt every
1322
+ # row after the first path with a tab in it. An integer cannot.
1323
+ wt_changed=""
1324
+ if [ -n "${wt_status:-}" ]; then
1325
+ wt_dirty_paths=$(plot_worker_dirty_filter "$wt_status")
1326
+ if [ -n "$wt_dirty_paths" ]; then
1327
+ wt_mtime_args=()
1328
+ wt_n=0
1329
+ while IFS= read -r wt_f; do
1330
+ [ -n "$wt_f" ] || continue
1331
+ # `git status --porcelain` renders a rename as `old -> new`; the path
1332
+ # that EXISTS is the one after the arrow. Left alone, `stat` is handed a
1333
+ # path with no file behind it and silently under-reports.
1334
+ case "$wt_f" in *' -> '*) wt_f=${wt_f#* -> } ;; esac
1335
+ # Quoted when the path holds a character git chooses to escape. The
1336
+ # quotes are git's rendering, not part of the name.
1337
+ case "$wt_f" in '"'*'"') wt_f=${wt_f#\"}; wt_f=${wt_f%\"} ;; esac
1338
+ wt_mtime_args+=("$wt_path/$wt_f")
1339
+ wt_n=$((wt_n + 1))
1340
+ [ "$wt_n" -ge "$CHANGED_DIRTY_CAP" ] && break
1341
+ done <<< "$wt_dirty_paths"
1342
+ [ "${#wt_mtime_args[@]}" -gt 0 ] && wt_changed=$(newest_mtime "${wt_mtime_args[@]}")
1343
+ fi
1344
+ fi
1345
+ WORKTREES+="$wt_branch $wt_path $wt_dirty $wt_locked $wt_changed"$'\n'
1346
+ done <<< "$(worktree_rows)"
1347
+
1348
+ # The local worktree for a branch as `path<TAB>dirty<TAB>locked`, or empty when
1349
+ # this machine has none. Absent is ABSENT — never a false, never a path that
1350
+ # does not exist here.
1351
+ #
1352
+ # THREE FIELDS, not the table's five: the newest-dirty-mtime column beside them
1353
+ # is `changed_ago_of`'s input and no caller of this function has ever wanted it.
1354
+ # Widening the return would land a timestamp in whatever slot the callers'
1355
+ # `cut -f` happens to reach next.
1356
+ local_worktree_of() { # $1=branch → "path\tdirty\tlocked" or ""
1357
+ printf '%s' "$WORKTREES" | awk -F'\t' -v b="$1" '$1==b {print $2 "\t" $3 "\t" $4; exit}'
1358
+ }
1359
+
1360
+ # ---------------------------------------------------------------------------
1361
+ # The worker: whether anything is actually running on a claimed branch
1362
+ # ---------------------------------------------------------------------------
1363
+ #
1364
+ # A claim is a push. It says a dispatcher TOOK the branch, and nothing more —
1365
+ # on 2026-08-17 three rows sat in WORKING with a pulsing dot while nobody was
1366
+ # working on any of them. The claim was real; the worker was never started.
1367
+ #
1368
+ # `worker_state()` in plot-dispatch.sh has distinguished FIVE outcomes since it
1369
+ # was written — running / finished / failed (exit N) / ended (status unknown) /
1370
+ # no worker — and measured against the board, `grep -rn "plot-worker.pid"
1371
+ # packages/board/src` returned NOTHING. The information was already richer than
1372
+ # the row assumed and reached no screen. This reports it; it invents no new
1373
+ # liveness check.
1374
+ #
1375
+ # THE CLASSIFICATION ITSELF NOW LIVES IN plot-worker-state.sh, sourced above and
1376
+ # shared with plot-dispatch.sh. It used to live here TWICE — this file carried
1377
+ # its own copy of the pid read, the `kill -0`, and the exit-code mapping. The
1378
+ # copies agreed on five of six states and had already drifted apart on the
1379
+ # sixth. What remains here is `elsewhere` plus the tab-separated rendering the
1380
+ # JSON consumes.
1381
+ #
1382
+ # SIX VALUES, because the absence of a worktree is a THIRD kind of answer and
1383
+ # not the second one. The pid lives in the worktree (`$wt/.plot-worker.pid`),
1384
+ # so a branch claimed and started on ANOTHER machine has no path to look at:
1385
+ #
1386
+ # claim worktree pid worker the reader's next move
1387
+ # ✓ ✓ ✓ running leave it alone
1388
+ # finished review it
1389
+ # failed restart it — its exit code is carried
1390
+ # ended look in the log; the status was not kept
1391
+ # ✓ ✓ — none claimed, no KNOWN worker
1392
+ # ✓ — n/a elsewhere ask the machine that took it
1393
+ #
1394
+ # `none` and `elsewhere` differ because the ACTIONS differ — *look in this
1395
+ # checkout* versus *ask another machine* — the same split `local_dirty` and
1396
+ # `local_ahead` make between a worktree question and a ref question.
1397
+ #
1398
+ # ABSENT IS NOT FALSE, and `none` is the strongest statement it licenses:
1399
+ # **UNKNOWN, never "nobody"**. plot-dispatch writes the pid only where it
1400
+ # started the worker itself, so a hand-started worker leaves none — and
1401
+ # hand-starting is the normal case for as long as `Worker command` is unset.
1402
+ # Five agents were started that way in one session; reading a missing pid as
1403
+ # "nobody is working" would have reported every one of them dead.
1404
+ #
1405
+ # A PID OF `0` IS NEVER RUNNING. `kill -0 0` signals the whole process GROUP and
1406
+ # succeeds, so a naive liveness check reports it alive forever. It is rejected
1407
+ # here exactly as `worker_state()` rejects it, and the answer travels as a value
1408
+ # rather than being re-derived on the far side where the trap would be sprung
1409
+ # again.
1410
+ #
1411
+ # READ THE EXIT CODE, NOT THE EMPTINESS: an unreadable `.plot-worker.exit` is
1412
+ # `ended` (status unknown), never `finished`. Guessing success from an absent
1413
+ # record is the same mistake in the other direction, and `finished` is the one
1414
+ # answer that tells a reader to stop looking.
1415
+ worker_of() { # $1=branch → "state\tpid\texit"
1416
+ local br="$1" wt
1417
+ wt=$(printf '%s' "$WORKTREES" | awk -F'\t' -v b="$br" '$1==b {print $2; exit}')
1418
+ # No worktree here: this machine cannot answer the question at all. Not the
1419
+ # same as looking and finding nothing. This is the one state the shared
1420
+ # classifier does not produce — it is a question about the worktree LIST,
1421
+ # asked before there is any worktree to look inside.
1422
+ [ -n "$wt" ] || { printf 'elsewhere\t\t'; return; }
1423
+ # Everything below the worktree is the shared classifier's answer, already in
1424
+ # the tab-separated shape this function returns. plot-dispatch renders the
1425
+ # same facts as prose for `--status`.
1426
+ #
1427
+ # THE PR FACT TRAVELS AS AN ARGUMENT, computed HERE where the host is already
1428
+ # being asked and the answer is already cached. The classifier is called once
1429
+ # per branch inside this loop and must not fork a `gh` of its own — and it
1430
+ # must not break `--offline`, which promises no network. Both are properties
1431
+ # of this caller, not of the classification, so the caller supplies the fact
1432
+ # exactly as it supplies `elsewhere` above.
1433
+ #
1434
+ # `$st` IS NOT THIS FACT. It answers a ref/ancestry question — a branch under
1435
+ # review reads `wip` — and `merged` there can come from a merge subject with
1436
+ # no PR behind it at all. `reached_review` asks the one question that
1437
+ # outranks the local signals: has this work left the worker's hands?
1438
+ local pr_fact=""
1439
+ reached_review "$br" && pr_fact="pr"
1440
+ plot_worker_state "$wt" "$pr_fact"
1441
+ }
1442
+
1443
+ # ---------------------------------------------------------------------------
1444
+ # Unpushed commits: work finished on this machine that nobody else can see
1445
+ # ---------------------------------------------------------------------------
1446
+ #
1447
+ # `local_dirty` reports *someone is editing*, and committing clears it. So the
1448
+ # moment a worker finishes tidily the signal covering for it disappears, and the
1449
+ # board reads "claimed, no commits yet" for a branch holding a complete
1450
+ # implementation. Measured on 2026-08-16 on the very branch that fixed the other
1451
+ # half: 3 commits ahead, 0 dirty files, no PR.
1452
+ #
1453
+ # THIS IS A REF QUESTION, NOT A WORKTREE QUESTION, and getting that wrong was
1454
+ # this plan's own first draft. Worktrees share ONE ref database, so
1455
+ # `refs/heads/<br>` answers from here for a branch checked out in a different
1456
+ # worktree — no `git -C`, no worktree needed. Binding it to the worktree list
1457
+ # would have been *consistent* with `local_dirty` and wrong: a local branch with
1458
+ # no worktree — checked out once and moved away from, or fetched from a
1459
+ # colleague — still holds commits nobody else can see, and the worktree-shaped
1460
+ # version skips exactly those. Dirtiness belongs to a working directory;
1461
+ # aheadness belongs to the refs.
1462
+ #
1463
+ # AHEAD ONLY. `A..B` counts one direction and that is the right one: the
1464
+ # question is whether work exists here that nobody else can see. Being *behind*
1465
+ # is not an invisible state — it is sitting in the remote for anyone to read —
1466
+ # and reporting it would answer a second question with no action attached.
1467
+ #
1468
+ # READ THE EXIT CODE, NOT THE EMPTINESS. A missing upstream exits 128 printing
1469
+ # NOTHING — bit-identical to the deleted-worktree signature above — so a check
1470
+ # on emptiness reads "zero ahead" and is right only by accident, for exactly the
1471
+ # reason empty `git status` output must not read as "clean". A failure to
1472
+ # observe is not evidence of nothing to see, and 0 is what the caller renders.
1473
+ #
1474
+ # ABSENT IS NOT FALSE: a branch with no local ref exits 128 too and answers 0,
1475
+ # which is precisely the answer that changes nothing for every branch living on
1476
+ # somebody else's machine.
1477
+ #
1478
+ # NO CAP. Measured at 5.2 ms per call from the main repo (20 iterations,
1479
+ # 0.104 s), against the 6.6 ms per worktree the shipped scan already accepts.
1480
+ # Twenty branches cost ~104 ms on a scan that runs 500–1050 ms, and the count
1481
+ # follows the plans rather than the checkout. A cap would be stock against a
1482
+ # problem the numbers rule out, and caps drop results silently unless they also
1483
+ # report saturation.
1484
+ local_ahead_of() { # $1=branch → count of local commits the remote lacks, or 0
1485
+ local out
1486
+ # NO LOCAL HEAD, NO SPAWN. A branch with no `refs/heads/$1` — the common case,
1487
+ # every branch on another machine — makes the rev-list below exit 128 and
1488
+ # answer 0. That 0 is already known from the LOCAL_HEADS batch, so the process
1489
+ # is not spawned to rediscover it. This skips ONLY the absent-head case; a
1490
+ # present head with no upstream still falls through and fails 128 as before,
1491
+ # because absent-upstream is a different fact the caller's tests pin.
1492
+ local_head_exists "$1" || { printf '0'; return; }
1493
+ # Exit code, never emptiness. `|| return`-style shortcuts would swallow the
1494
+ # distinction this whole comment exists to preserve.
1495
+ if out=$(git rev-list --count \
1496
+ "refs/remotes/origin/$1..refs/heads/$1" </dev/null 2>/dev/null); then
1497
+ case "$out" in
1498
+ ''|*[!0-9]*) printf '0' ;;
1499
+ *) printf '%s' "$out" ;;
1500
+ esac
1501
+ else
1502
+ # No local ref, no upstream, or an unreadable ref database. Not observed →
1503
+ # not reported.
1504
+ printf '0'
1505
+ fi
1506
+ }
1507
+
1508
+ # ---------------------------------------------------------------------------
1509
+ # Conflict prediction: which FILES would collide, not merely whether any would
1510
+ # ---------------------------------------------------------------------------
1511
+ #
1512
+ # `plot-merge-queue.sh` has predicted conflicts since it was written, and it
1513
+ # asks a yes/no question — `would_conflict()` throws the output away and reads
1514
+ # the exit code. That is the right question for a merge ORDER and the wrong one
1515
+ # for a stuck branch: on 2026-08-17 two branches (#176, #177) conflicted in
1516
+ # exactly one file, the board artifact, whose resolution is mechanical, while a
1517
+ # third needed a person. A yes/no answer cannot tell those apart.
1518
+ #
1519
+ # So the SET is reported and nothing here judges it. Which conflict sets mean
1520
+ # what is a decision one layer up (Principle 3: scripts collect and report).
1521
+ #
1522
+ # `merge-tree --write-tree` computes the merge ENTIRELY IN MEMORY — no working
1523
+ # tree, no index, no checkout, nothing written. Its output is a tree OID on the
1524
+ # first line, then the conflicted-file info, then a blank line and the
1525
+ # human-readable messages. Non-zero exit means the merge would conflict.
1526
+ #
1527
+ # THE STAGE-TUPLE FORM IS PARSED, NOT `--name-only`. The flag would be tidier
1528
+ # and arrived in git 2.40; the tuples have been there since 2.38, which is the
1529
+ # floor `plot-merge-queue.sh` already checks and states its reasons for. Raising
1530
+ # the floor for a formatting convenience would make this scan refuse to answer
1531
+ # on a git the rest of Plot supports — and a scan that cannot answer reports an
1532
+ # EMPTY set, which reads exactly like "merges cleanly". Same shape, one line of
1533
+ # `awk`, no new requirement.
1534
+ #
1535
+ # ABSENT IS NOT CLEAN, and this is the one place that rule is hard to hold: an
1536
+ # empty list has to mean both "merges cleanly" and "could not look" unless
1537
+ # something else distinguishes them. `conflicts_known` is that something — see
1538
+ # `conflicts_of` — and a consumer that reads the list without it will read every
1539
+ # unanswerable branch as mergeable.
1540
+ #
1541
+ # Only branches with real unlanded work are asked about. A merged branch has
1542
+ # nothing left to merge, an `open` branch has no ref, and a bare claim CHANGES
1543
+ # no file — every one of them would cost a process spawn to be told the obvious,
1544
+ # on every scan. See `conflicts_known_of` for why they report `false` rather
1545
+ # than a convenient `true`.
1546
+ # CAN THE QUESTION BE PUT AT ALL? Two prerequisites, both checked ONCE per run
1547
+ # rather than per branch, and both collapsing to the same honest answer when
1548
+ # they fail: nothing was observed, so nothing is claimed.
1549
+ #
1550
+ # 1. A default branch to merge INTO. Without `origin/$MAIN` there is no
1551
+ # target, and a prediction against nothing is not a prediction.
1552
+ # 2. A git that answers this question. Before 2.38 `merge-tree` EXISTS with
1553
+ # entirely different semantics — a three-way file diff — so it does not
1554
+ # fail cleanly: it succeeds while answering something else, and every
1555
+ # branch would silently read as conflict-free. plot-merge-queue.sh refuses
1556
+ # outright on that git; refusing is not an option here (the scan answers
1557
+ # many other questions), so the capability is reported as unknown instead.
1558
+ CONFLICT_MAIN_OK=0
1559
+ git show-ref -q --verify "refs/remotes/origin/$MAIN" </dev/null 2>/dev/null \
1560
+ && CONFLICT_MAIN_OK=1
1561
+
1562
+ MERGE_TREE_OK=0
1563
+ if [ "$CONFLICT_MAIN_OK" = 1 ]; then
1564
+ git_ver=$(git --version 2>/dev/null | sed -n 's/^git version \([0-9]*\)\.\([0-9]*\).*/\1 \2/p')
1565
+ gv_major=${git_ver%% *}; gv_minor=${git_ver##* }
1566
+ if [ -n "$git_ver" ] && { [ "$gv_major" -gt 2 ] \
1567
+ || { [ "$gv_major" -eq 2 ] && [ "$gv_minor" -ge 38 ]; }; }; then
1568
+ MERGE_TREE_OK=1
1569
+ fi
1570
+ fi
1571
+
1572
+ # The files that would collide merging this branch into the default branch, one
1573
+ # per line — or nothing. Two ways of printing nothing, and they are NOT the same
1574
+ # statement, which is why `conflicts_known` travels beside the list:
1575
+ #
1576
+ # merges cleanly → known, empty
1577
+ # cannot be asked → unknown, empty
1578
+ #
1579
+ # Sorted and deduplicated: one path appears once per conflicting stage (base,
1580
+ # ours, theirs), and a set reported three times over is a set nobody can count.
1581
+ conflicts_of() { # $1=branch → conflicting paths, one per line (may be empty)
1582
+ [ "$MERGE_TREE_OK" = 1 ] || return 0
1583
+ # Exit 0 means a clean merge and there is nothing to print. Anything the
1584
+ # command could not do lands here too, which is exactly why the caller must
1585
+ # consult `conflicts_known_of` rather than the emptiness of this output.
1586
+ #
1587
+ # Each info line is `<mode> <oid> <stage>\t<path>`, and the PATH IS EVERYTHING
1588
+ # AFTER THE TAB. Splitting on whitespace and taking the last field would work
1589
+ # on every path in this repo and mangle `docs/my notes.md` into `notes.md` —
1590
+ # a wrong filename, silently, in exactly the report that decides whether a
1591
+ # conflict set is "exactly the artifact".
1592
+ git merge-tree --write-tree "origin/$MAIN" "origin/$1" </dev/null 2>/dev/null \
1593
+ | awk -F'\t' 'NR == 1 { next } # the merged tree OID
1594
+ /^$/ { exit } # the blank line ends the file info
1595
+ NF > 1 { print $2 }' \
1596
+ | sort -u
1597
+ }
1598
+
1599
+ # Whether this branch's conflict set was OBSERVED. False is not "clean" — it is
1600
+ # "not looked at", and the two must never render alike.
1601
+ conflicts_known_of() { # $1=branch $2=state → true|false
1602
+ [ "$MERGE_TREE_OK" = 1 ] || { printf 'false'; return; }
1603
+ # Only branches with real unlanded work are asked about — the same candidate
1604
+ # rule plot-merge-queue.sh applies, and for the same reason. A merged branch
1605
+ # has nothing left to merge, an `open` branch has no ref, and a bare claim is
1606
+ # an EMPTY commit: predicting its merge would spend a process to be told the
1607
+ # obvious, on every claimed branch, every five seconds.
1608
+ #
1609
+ # `false` for all of them is the honest answer rather than a convenient one:
1610
+ # nobody asked, so nothing is known. Reporting `true` about a question never
1611
+ # put is the absent-is-clean mistake pointing the other way — it would license
1612
+ # a consumer to read "no conflicts" off a branch nothing was computed for.
1613
+ case "$2" in
1614
+ wip|claimed) ;;
1615
+ *) printf 'false'; return ;;
1616
+ esac
1617
+ remote_ref_exists "$1" || { printf 'false'; return; }
1618
+ # CHANGES of its own, not merely commits of its own. A claim IS a commit —
1619
+ # the empty `plot: claim <branch>` push, which is what makes claiming
1620
+ # exclusive — so a commit count is non-zero for every claimed branch and would
1621
+ # let this gate through unchanged. What distinguishes a claim from work is
1622
+ # that it touches no file.
1623
+ #
1624
+ # The answer for a claim would be "clean" every time, so the cost is a process
1625
+ # spawn per claimed branch per five-second scan to be told the obvious. `false`
1626
+ # is also the more honest report: nobody asked, so nothing is known — and
1627
+ # `true` about a question never put would license a consumer to read "merges
1628
+ # cleanly" off a branch carrying nothing to merge.
1629
+ [ -n "$(git diff --name-only "origin/$MAIN...origin/$1" </dev/null 2>/dev/null \
1630
+ | head -n 1)" ] || { printf 'false'; return; }
1631
+ printf 'true'
1632
+ }
1633
+
1634
+ # The files this branch changes relative to the default branch, one per line.
1635
+ #
1636
+ # EVIDENCE, never a verdict. It is one of the three lines a CI failure is
1637
+ # reported with — *this branch changes only .md* — and it exists so a reader can
1638
+ # weigh a failing step against what the branch actually touched. Nothing here
1639
+ # maps steps to paths: that mapping is a table nobody maintains, and it goes
1640
+ # silently wrong the first time a workflow is restructured.
1641
+ #
1642
+ # CAPPED, and the cap is REPORTED rather than silently applied — the rule this
1643
+ # scan already follows for its merge walk. A branch touching two hundred files
1644
+ # tells the reader nothing they can hold in their head, and shipping two hundred
1645
+ # strings per row through a 5 s poll is a cost with no matching benefit.
1646
+ CHANGED_PATHS_LIMIT=${PLOT_CHANGED_PATHS_LIMIT:-40}
1647
+ changed_paths_of() { # $1=branch → changed paths, one per line (may be empty)
1648
+ git diff --name-only "origin/$MAIN...origin/$1" </dev/null 2>/dev/null \
1649
+ | head -n "$CHANGED_PATHS_LIMIT"
1650
+ }
1651
+
1652
+ # Did this branch land on the default branch? Positive evidence only — absence
1653
+ # keeps today's answer.
1654
+ #
1655
+ # The branch name is INTERPOLATED INTO AN ERE, so every metacharacter it may
1656
+ # legally contain is escaped first. Git allows `+`, `(`, `)`, `?`, `{`, `}` and
1657
+ # `.` in ref names, and unescaped each one changes what the pattern means —
1658
+ # `feature/v.1` would match `feature/vX1`, and `bug/a+b` would fail to match
1659
+ # its OWN merge subject. Both directions are wrong, and the second is the
1660
+ # quieter one: a branch that silently never matches simply keeps reading
1661
+ # `open`, which is this plan's own bug wearing a different hat.
1662
+ merged_by_subject() { # $1=branch → 0 when a conforming merge names it
1663
+ printf '%s\n' "$MERGE_SUBJECTS" \
1664
+ | grep -qE "^Merge pull request #[0-9]+ from [^/]+/$(printf '%s' "$1" | sed 's/[][\.*^$+?(){}|\/]/\\&/g')\$"
1665
+ }
1666
+
1667
+ # Is this branch's PR ready to merge — open, not draft, AND checks green?
1668
+ # `--loose` promises "the prior wave's PRs are green and ready", so unknown
1669
+ # degrades to NO rather than assuming the best.
1670
+ #
1671
+ # READS THE SHARED CACHE, never the host directly, on any path. `prefill_pr_states`
1672
+ # has already fetched every PR in the repo in one `pr-list --rich` call, so the
1673
+ # cache holds `STATE<TAB>checks<TAB>draft` per branch. `pr_ready` is a lookup in
1674
+ # that cache and issues ZERO per-branch host calls — the N+1 that #228 removed
1675
+ # survived here alone, on the one path the board does not exercise, and this is
1676
+ # where it is finally gone WITHOUT A QUALIFIER. A qualifier ("except when the
1677
+ # list was empty…") is exactly the seam a later change slips the N+1 back through.
1678
+ #
1679
+ # THE PREDICATE IS `checks = green AND draft = false`. An earlier version
1680
+ # checked only `draft`, accepting any non-draft PR regardless of its build.
1681
+ # That violated the documented promise and would open the next wave on red CI —
1682
+ # precisely the failure shape the comment says `pr_ready` exists to prevent.
1683
+ #
1684
+ # EVERYTHING THAT IS NOT `green` REFUSES, and the refusals split by whether the
1685
+ # rollup could be had at all:
1686
+ # failing / pending — the rollup answered, and the answer is "not ready".
1687
+ # An ordinary refusal about the PR.
1688
+ # unknown / none / "" — the rollup could NOT be established: the host could not
1689
+ # say (Bitbucket with no CI backend), no checks ran, or
1690
+ # the list carried no row for this branch. `--loose`
1691
+ # degrades to strict, and the caller ANNOUNCES it, because
1692
+ # a silent degradation is indistinguishable from a bug.
1693
+ # An empty `checks` — a branch the list did not name, or named without a rollup —
1694
+ # is deliberately in the SECOND group: absent is not green, and asking the host
1695
+ # per branch to fill the gap is the very N+1 this removes. Where no rollup
1696
+ # arrived, `--loose` refuses and says so; it does not fall back to the host.
1697
+ #
1698
+ # SETS `_pr_ready_degraded` to the checks value (or "no-cache"/"no-host") when
1699
+ # readiness could not be verified, so the caller can distinguish "not ready
1700
+ # because failing" from "could not be verified" and announce only the latter.
1701
+ _pr_ready_degraded=""
1702
+ pr_ready() {
1703
+ local br="$1" cache="" cached st chk dft
1704
+ _pr_ready_degraded=""
1705
+ [ "$HOST_LOOKUP_OK" = 1 ] || { _pr_ready_degraded="no-host"; return 1; }
1706
+ [ -n "$HOST_STATE_CACHE" ] && cache="$HOST_STATE_CACHE/$(cache_key "$br")"
1707
+ [ -n "$cache" ] && [ -f "$cache" ] || { _pr_ready_degraded="no-cache"; return 1; }
1708
+
1709
+ cached=$(cat "$cache" 2>/dev/null)
1710
+ # Parse `STATE<TAB>checks<TAB>draft`
1711
+ st="${cached%% *}"
1712
+ cached="${cached#* }"
1713
+ chk="${cached%% *}"
1714
+ dft="${cached#* }"
1715
+
1716
+ # Must be OPEN (not MERGED/CLOSED/NONE), green, and not a draft.
1717
+ [ "$st" = "OPEN" ] || return 1
1718
+ if [ "$chk" != "green" ]; then
1719
+ # unknown/none/absent mean the rollup could not be established — announce it.
1720
+ # failing/pending are ordinary refusals about the PR itself and stay silent.
1721
+ case "$chk" in
1722
+ unknown|none|"") _pr_ready_degraded="${chk:-no-cache}" ;;
1723
+ esac
1724
+ return 1
1725
+ fi
1726
+ [ "$dft" = "false" ] || return 1
1727
+ }
1728
+
1729
+ # ---------------------------------------------------------------------------
1730
+ # Recently delivered plans: the last day of finished work
1731
+ # ---------------------------------------------------------------------------
1732
+ #
1733
+ # The pulse read `active/` only, so a plan left the view the INSTANT it was
1734
+ # delivered — taking every branch with it. Measured on this repo: five plans
1735
+ # delivered in one day named eight branches between them, and DONE showed one,
1736
+ # because delivery and merge are minutes apart and only whichever branch
1737
+ # happened to sit in the gap survived. A group that is full by accident is
1738
+ # worse than one that is empty by rule.
1739
+ #
1740
+ # A ROLLING 24 HOURS, not the calendar day. Literally "delivered today" is
1741
+ # easier to explain and wrong at exactly the wrong moment: a plan delivered at
1742
+ # 23:50 vanishes ten minutes later, mid-session, while the branches it names
1743
+ # are still on screen. 24 is also the one freshness bound this repo already
1744
+ # uses (`Claim stale after`), so it is one unit to learn rather than two.
1745
+ #
1746
+ # THE WINDOW FILTERS BEFORE THE PARSE. Measured: ~57 ms per plan through
1747
+ # plot-plan-meta.sh against a scan that already runs 500–1050 ms, so parsing
1748
+ # fourteen delivered plans to discard thirteen would roughly double the pulse —
1749
+ # and that cost grows with the archive, which only ever gets larger, while the
1750
+ # answer stays the size of a day's work. So the cheap signal comes first (the
1751
+ # delivered symlink's own mtime) and only the candidates it admits are parsed.
1752
+ #
1753
+ # The pre-filter may OVER-ADMIT AND PAY A PARSE; it may never exclude. A
1754
+ # checkout can freshen an old file, so the `Delivered:` record keeps the last
1755
+ # word — but nothing mtime rules out could have been delivered inside the
1756
+ # window. On a fresh clone or a CI worktree every file shares one checkout
1757
+ # timestamp and ALL of them are admitted: correct, merely slower, once. Reaching
1758
+ # for `git log` per plan to avoid that would spend a git call to save a parse.
1759
+ DELIVERED_WINDOW_HOURS=$(cfg "Claim stale after" "24")
1760
+ case "$DELIVERED_WINDOW_HOURS" in (*[!0-9]*|'') DELIVERED_WINDOW_HOURS=24 ;; esac
1761
+
1762
+
1763
+ # ---------------------------------------------------------------------------
1764
+ # When did this branch last CHANGE? — a measurement, and never a verdict
1765
+ # ---------------------------------------------------------------------------
1766
+ #
1767
+ # `local_ahead` and `local_dirty` are STATE, not CHANGE, and that is the whole
1768
+ # gap this fills. Measured 2026-08-18 across four concurrent workers: the branch
1769
+ # that had just opened the session's hardest PR read `ahead=0 dirty=False`,
1770
+ # bit-identical to a branch claimed a minute earlier and abandoned. Commits were
1771
+ # pushed, so `ahead` was 0; the tree was tidy, so `dirty` was false. Two opposite
1772
+ # situations, one row.
1773
+ #
1774
+ # Runtime cannot separate them either, and that is the measurement this plan was
1775
+ # written from: of four workers, the LONGEST-RUNNING was the most productive
1776
+ # (55 min, 4 commits, PR opened) while a 27-minute one had written nothing for
1777
+ # six. An operator watching the clock would have restarted exactly the wrong one.
1778
+ #
1779
+ # THE MAXIMUM OF THREE SOURCES, because work leaves evidence in three places and
1780
+ # any one alone is silent for long stretches:
1781
+ #
1782
+ # the newest commit — silent for the whole span BETWEEN commits,
1783
+ # which is precisely the window where a worker is
1784
+ # either deep in a test suite or dead
1785
+ # the newest dirty mtime — silent for a worker that commits tidily and
1786
+ # leaves nothing on the floor
1787
+ # the worker log's mtime — the only one that moves while a build runs
1788
+ #
1789
+ # A maximum, never a sum or an average: each source is evidence that work
1790
+ # happened, and the most RECENT evidence is the answer. A source that cannot be
1791
+ # read contributes nothing rather than a zero — a fabricated 0 here reads as
1792
+ # 1970, which is the oldest possible answer and would report every branch as
1793
+ # maximally quiet the moment one input went missing.
1794
+ #
1795
+ # ==> THIS FUNCTION DRAWS NO CONCLUSION, AND THAT IS DELIBERATE. <==
1796
+ #
1797
+ # There is no threshold here, no `stalled`, no "probably stuck". "Stuck" depends
1798
+ # on what the branch is DOING: fifteen minutes of silence is alarming during an
1799
+ # edit and unremarkable during `test:board`, which takes about that long by
1800
+ # itself. The threshold belongs to the reader; the measurement belongs here
1801
+ # (Principle 3 — scripts collect and report).
1802
+ #
1803
+ # A FACT MEASURED AFTER THE PLAN WAS WRITTEN, and the reason the paragraph above
1804
+ # is a warning rather than a preference: a worker deep in a SERIAL test run
1805
+ # writes no file for minutes at a time while its CHILD PROCESSES do the work.
1806
+ # This function will report that worker as quiet, and the number will be honest
1807
+ # — nothing was written. A consumer that renders "quiet for 8 minutes" as
1808
+ # "stuck" will restart a healthy worker mid-suite and redo everything it had
1809
+ # done, which is the exact failure the plan measured and costs more than the
1810
+ # ambiguity it was replacing. The number says how long since a file moved. It
1811
+ # does not say whether anything is wrong.
1812
+ #
1813
+ # ABSENT IS ABSENT — the rule every local signal in this file follows. A branch
1814
+ # with no worktree on this machine reports nothing at all, exactly as `worker`
1815
+ # reports `elsewhere` and for the same reason: this machine cannot see it. The
1816
+ # JSON carries `null`, never 0. A zero would be a fabricated measurement rather
1817
+ # than a missing one, and it would be the WORST fabrication available here — it
1818
+ # reads as "changed this instant", the single most reassuring answer, for the
1819
+ # population nobody can observe.
1820
+ #
1821
+ # BRANCHES WITH NO LOCAL WORKTREE COST NOTHING. The worktree lookup is a string
1822
+ # match against a table already read once per run, and it returns before any
1823
+ # fork. That is load-bearing rather than incidental: the plan's cost argument
1824
+ # assumes the fleet's remote branches are skipped entirely, and a `git log`
1825
+ # spent to learn "elsewhere" would be paid on every branch on every teammate's
1826
+ # machine, on every poll.
1827
+ #
1828
+ # THE PUSHED BRANCH IS DELIBERATELY NOT COVERED — the plan's open point, decided
1829
+ # here. `git log -1 origin/<branch>` would catch a worker on ANOTHER machine
1830
+ # moving a ref, and it is declined on two grounds. The cost lands exactly on the
1831
+ # population that must stay free: a branch with no local worktree is the one
1832
+ # whose remote ref would be the ONLY source, so the call cannot be skipped for
1833
+ # precisely the branches the paragraph above skips. And the field would stop
1834
+ # meaning one thing — every other `local_*` signal answers *what THIS machine
1835
+ # can see*, and a remote ref is what the REFS say, which the scan already
1836
+ # reports as `state` and `claimed`. Deferred, not rejected: if the fleet ever
1837
+ # spans machines in practice, the right shape is a SEPARATE field with its own
1838
+ # absent value, not a second meaning bolted onto this one.
1839
+ #
1840
+
1841
+ changed_ago_of() { # $1=branch → "<seconds since>\t<epoch of>" for the newest evidence of work, or ""
1842
+ # TWO NUMBERS FROM ONE WALK, and the second is the one a change DETECTOR can
1843
+ # use. The age is what a reader wants on a row ("19h"); it is recomputed
1844
+ # against `now` every scan, so it moves once a second whether or not anything
1845
+ # happened. A consumer watching it for CHANGE therefore fires on every pulse
1846
+ # forever — measured on the live board 2026-08-24: 71805 → 71824 across 12
1847
+ # quiet seconds, on 16 rows nobody had touched in 19 hours.
1848
+ #
1849
+ # The INSTANT is stable. It moves only when a commit lands, a file is written
1850
+ # or the worker's log grows, which is exactly what "this row is not what it
1851
+ # was" means. Both are printed because both are wanted and the walk that finds
1852
+ # them is the same walk — asking twice would double the per-worktree cost the
1853
+ # note above exists to defend.
1854
+ local br="$1" row wt now newest="" t paths=()
1855
+ row=$(printf '%s' "$WORKTREES" | awk -F'\t' -v b="$br" '$1==b {print $2 "\t" $5; exit}')
1856
+ # No worktree here: this machine cannot answer, and says so by answering
1857
+ # nothing. Before any fork — see the cost note above.
1858
+ [ -n "$row" ] || return 1
1859
+ wt=$(printf '%s' "$row" | cut -f1)
1860
+ [ -n "$wt" ] && [ -d "$wt" ] || return 1
1861
+
1862
+ # 1. The newest commit. A REF QUESTION answered from this repository, the same
1863
+ # distinction `local_ahead_of` draws: worktrees share one ref database, so
1864
+ # `refs/heads/<br>` answers without a `git -C`. `%ct` is the COMMITTER date,
1865
+ # not `%at` the author date — a rebase or an amend rewrites the committer
1866
+ # date and leaves the author date at the original writing, and it is the
1867
+ # rewrite that is the evidence of work here.
1868
+ t=$(git log -1 --format=%ct "refs/heads/$br" </dev/null 2>/dev/null)
1869
+ case "$t" in ''|*[!0-9]*) ;; *) newest=$t ;; esac
1870
+
1871
+ # 2. The newest mtime of the real work on the floor — READ FROM THE TABLE,
1872
+ # not recomputed. The worktree sweep already ran `git status` once per
1873
+ # worktree and reduced its output to this one number there, applying
1874
+ # `plot_worker_dirty_filter` so the exclusions match `worker_dirty_paths`
1875
+ # exactly: a `.tmp1` that reset this clock while being excluded from that
1876
+ # list would be one file answering two questions two ways.
1877
+ #
1878
+ # Asking `plot_worker_dirty` here instead would run a SECOND `git status`
1879
+ # per worktree, which `fleet.test.mjs` counts and rejects — the board polls
1880
+ # this scan every 5 s.
1881
+ t=$(printf '%s' "$row" | cut -f2)
1882
+ case "$t" in
1883
+ ''|*[!0-9]*) ;;
1884
+ *) if [ -z "$newest" ] || [ "$t" -gt "$newest" ]; then newest=$t; fi ;;
1885
+ esac
1886
+
1887
+ # 3. The worker's log — the only source that keeps moving while a build runs.
1888
+ # Present only where plot-dispatch started the worker itself; absent for
1889
+ # every hand-started one, which is the normal case for as long as `Worker
1890
+ # command` is unset. Absent contributes nothing.
1891
+ #
1892
+ # NOT COVERED BY THE TABLE'S NUMBER, deliberately: the sweep's figure comes
1893
+ # from `plot_worker_dirty_filter`, which EXCLUDES Plot's own records
1894
+ # because they are not work on the floor. This asks when anything last
1895
+ # MOVED, and the log is the one file still moving during a build. Two
1896
+ # questions, two right answers about one file — so it is added here.
1897
+ #
1898
+ # THE PATH IS ASKED FOR, NEVER SPELLED HERE. What Plot's own records are
1899
+ # called is `plot-worker-state.sh`'s knowledge, and a read-only scan that
1900
+ # names `.plot-worker.` has started classifying workers again — the
1901
+ # duplication removed on 2026-08-18 after the two copies had drifted.
1902
+ # `workerstate.test.mjs` enforces exactly that, and caught this line.
1903
+ local log
1904
+ if log=$(plot_worker_log "$wt"); then
1905
+ t=$(file_mtime "$log")
1906
+ case "$t" in
1907
+ ''|*[!0-9]*) ;;
1908
+ *) if [ -z "$newest" ] || [ "$t" -gt "$newest" ]; then newest=$t; fi ;;
1909
+ esac
1910
+ fi
1911
+
1912
+ # Every source silent: a worktree with no commit, nothing on the floor and no
1913
+ # log. Nothing was OBSERVED, so nothing is claimed — the same answer the
1914
+ # absent worktree gives, for the same reason.
1915
+ [ -n "$newest" ] || return 1
1916
+
1917
+ now=$(date +%s)
1918
+ # Clamped at zero rather than allowed negative. A commit timestamp can sit in
1919
+ # the future — a skewed clock, or a rebase carrying a committer date from a
1920
+ # machine ahead of this one — and a negative age is not a thing a reader can
1921
+ # act on. Zero says "changed just now", which is the honest reading of
1922
+ # evidence dated later than the question.
1923
+ if [ "$newest" -gt "$now" ]; then printf '0\t%s' "$newest"; else printf '%s\t%s' "$((now - newest))" "$newest"; fi
1924
+ }
1925
+
1926
+ # A plan whose delivered symlink was touched inside the window. `find -newermt`
1927
+ # is not portable to every BSD find in the wild, so the cutoff is computed and
1928
+ # compared with `stat` — one stat per file, no parse.
1929
+ delivered_candidates() {
1930
+ local cutoff now link mtime
1931
+ now=$(date +%s)
1932
+ cutoff=$((now - DELIVERED_WINDOW_HOURS * 3600))
1933
+ for link in "$DELIVERED_DIR"*.md; do
1934
+ [ -e "$link" ] || continue
1935
+ # `stat` follows the symlink, which is what we want: the TARGET is the plan,
1936
+ # and a plan edited after delivery must still admit. An unreadable time
1937
+ # ADMITS rather than excludes — the pre-filter may only over-admit, and the
1938
+ # `Delivered:` record has the last word either way.
1939
+ mtime=$(file_mtime "$link") || { printf '%s\n' "$link"; continue; }
1940
+ [ "$mtime" -ge "$cutoff" ] && printf '%s\n' "$link"
1941
+ done
1942
+ }
1943
+
1944
+ # Does this plan's `Delivered:` record fall inside the window? The RECORD
1945
+ # decides — mtime only chose who got asked.
1946
+ #
1947
+ # "No date, no row." A delivered plan with an empty record does not appear at
1948
+ # all: no date means no membership in any window, the same rule the waiting age
1949
+ # already follows. Showing it always would create the one row that can never
1950
+ # age out of DONE, and the missing record is a bookkeeping fault
1951
+ # plot-reconcile-scan.sh exists to report — a view that quietly compensates
1952
+ # for it makes the fault harder to see.
1953
+ #
1954
+ # A BARE DATE IS ANCHORED AT THE END OF ITS DAY, not at midnight, and this is
1955
+ # the one detail that makes "rolling, not the calendar day" true rather than
1956
+ # merely stated. Every `Delivered:` record in this repo is a bare date, which
1957
+ # names no time — so anchoring at 00:00 measures from up to a day BEFORE the
1958
+ # delivery, and the window collapses back into exactly the calendar boundary
1959
+ # the rolling window exists to avoid: a plan delivered at 23:50 would be an
1960
+ # hour from expiry the moment it was written, and gone ten minutes later
1961
+ # mid-session while the branches it names are still on screen.
1962
+ #
1963
+ # Anchoring at 23:59:59 over-admits by at most the length of the delivery day.
1964
+ # That is the same direction the mtime pre-filter is allowed to err in, and for
1965
+ # the same reason: showing a finished plan slightly too long costs a row, while
1966
+ # dropping one mid-session costs the reader the work they were looking at. A
1967
+ # record that DOES carry a time is honoured exactly, so the imprecision belongs
1968
+ # to the record rather than to the rule.
1969
+ # The rule itself now runs inside the ONE estate parse below (`in_window`),
1970
+ # because asking it per plan meant an interpreter per plan. What it decides is
1971
+ # unchanged; only the number of processes that decide it is.
1972
+
1973
+ # ---------------------------------------------------------------------------
1974
+ # Plan enumeration: from the REF, not from the tree
1975
+ # ---------------------------------------------------------------------------
1976
+ #
1977
+ # THE SCAN NAMED A REF AND READ A DIRECTORY. Every fact below is derived from
1978
+ # `origin/$MAIN` and the banner says so, but the plan list was a filesystem
1979
+ # glob over `$ACTIVE_DIR` — and `git fetch` updates refs that a glob cannot
1980
+ # see. Measured in a two-clone sandbox, 2026-08-18:
1981
+ #
1982
+ # origin/main active plans (the REF): 3
1983
+ # working tree active plans: 2
1984
+ # scan --json reports: 2 plans
1985
+ #
1986
+ # The fetch SUCCEEDED. `origin/main` genuinely carried the third plan, pushed
1987
+ # by a second agent minutes earlier. The scan reported two and exited 0, so
1988
+ # nothing anywhere could tell that answer from a correct one. The board's plan
1989
+ # list was only ever as current as the operator's last `git pull`.
1990
+ #
1991
+ # It is worse during the fleet run the board exists to watch: rebases,
1992
+ # checkouts and worker commits rewrite the working tree continuously, so the
1993
+ # glob can return a different set on each 5 s poll while exiting 0 every time.
1994
+ # That is the flicker `bug/a-smaller-pulse-is-not-silently-better` guards
1995
+ # against; this is the cause it guards against the symptom of.
1996
+ #
1997
+ # Reading the ref makes the scan describe ONE ATOMIC COMMIT. Two polls of the
1998
+ # same ref return the same plans no matter what is happening on disk, which is
1999
+ # what makes the count stable and the banner true.
2000
+ #
2001
+ # WORKTREE OBSERVATION STAYS LOCAL, and the split is the whole design:
2002
+ # `local_dirty`, `local_worktree` and the `.git/index.lock` check describe
2003
+ # THIS MACHINE on purpose — they are the one place the scan knows more than
2004
+ # the refs do, and moving them to the ref would delete the signal rather than
2005
+ # fix it. Plan enumeration comes from the ref; worktree observation is local.
2006
+ #
2007
+ # UNCOMMITTED PLANS BECOME INVISIBLE, and that is the intended behaviour rather
2008
+ # than an accepted cost. The plan's Open Points flagged it: `/plot-idea` writes
2009
+ # a plan file before committing it. Three reasons it is right:
2010
+ # * The fleet view answers "what may a worker CLAIM". Workers are detached
2011
+ # agents in other worktrees and on other machines, and not one of them can
2012
+ # claim a plan that exists only in the operator's editor buffer. Showing it
2013
+ # advertises work nobody can take.
2014
+ # * The window is seconds wide. `/plot-idea` commits and pushes in the same
2015
+ # flow (see skills/plot-idea/SKILL.md), so an uncommitted plan is a state
2016
+ # inside one skill run, not a state anyone opens a board to watch.
2017
+ # * A board that mixes shared state with one machine's scratch is the bug
2018
+ # this file keeps fixing. `local_dirty` exists precisely so local facts
2019
+ # travel LABELLED as local; an unlabelled local plan row is the thing that
2020
+ # makes an operator trust a view that only they can see.
2021
+ #
2022
+ # The rule is: committed is shared, and the fleet view shows what is shared.
2023
+ #
2024
+ # WHEN THE REF CANNOT BE READ the scan falls back to the working tree and says
2025
+ # so. A fresh clone with no remote, a repo whose origin is unreachable and
2026
+ # whose refs were never fetched — for these `origin/$MAIN` names nothing, and
2027
+ # an empty plan list would be a confident lie in the one case where the
2028
+ # operator has no way to check it. Falling back is honest; falling back
2029
+ # SILENTLY would recreate this bug, so `plan_source` travels in --json.
2030
+ PLAN_SOURCE="ref"
2031
+ git rev-parse --verify --quiet "origin/$MAIN^{commit}" >/dev/null 2>&1 || PLAN_SOURCE="worktree"
2032
+
2033
+ # Paths in the ref, listed for one directory. Returns the blob paths as they
2034
+ # are spelled in the tree, so `$ACTIVE_DIR` prefixes survive into the loop that
2035
+ # pattern-matches on `$DELIVERED_DIR` below.
2036
+ #
2037
+ # `-z` and a NUL-delimited read: a path is user data, and a plan filename
2038
+ # containing a newline would otherwise split into two nonexistent plans.
2039
+ ref_ls() { # $1=dir → newline-separated paths under it in origin/$MAIN
2040
+ git ls-tree -z --name-only "origin/$MAIN" -- "$1" </dev/null 2>/dev/null \
2041
+ | tr '\0' '\n' | grep '\.md$' || true
2042
+ }
2043
+
2044
+ # The content of a ref path, materialized where plot-plan-meta.sh can parse it.
2045
+ #
2046
+ # THE PARSER TAKES FILES, not stdin: it is an awk pass keyed on FILENAME and it
2047
+ # checks `[ -f "$1" ]`. Rather than reshape the format contract from here — the
2048
+ # one script allowed to know what a plan looks like — the blob is written to a
2049
+ # temp file. The parser is unchanged, so the contract tests still describe it.
2050
+ #
2051
+ # SYMLINKS ARE RESOLVED IN REF-SPACE, and this is the subtlety the whole block
2052
+ # turns on. `$ACTIVE_DIR` holds symlinks into `$PLAN_DIR`, and in a git tree a
2053
+ # symlink is a mode-120000 blob whose CONTENT IS THE TARGET PATH. `readlink`
2054
+ # would answer from the working tree — the exact thing being moved away from —
2055
+ # so the link is followed with a second `git show` against the same ref. One
2056
+ # hop only: plot's indexes are links to files, never chains, and a bounded walk
2057
+ # beats a loop detector for a shape that cannot nest.
2058
+ # CREATED ONCE, EAGERLY, and that is a correction rather than a style choice:
2059
+ # `ref_plan_file` is called as `$(ref_plan_file ...)`, which runs it in a
2060
+ # SUBSHELL. A lazy `[ -z "$REF_TMP" ] && REF_TMP=$(mktemp -d)` inside it
2061
+ # assigns in the child and the parent never sees it — so every call made a
2062
+ # fresh directory, the parent's variable stayed empty, and the EXIT trap
2063
+ # cleaned nothing. Measured while writing this: three plans, three temp dirs,
2064
+ # none removed. The lifetime is owned out here, where the trap can see it.
2065
+ REF_TMP=""
2066
+ if [ "$PLAN_SOURCE" = "ref" ]; then
2067
+ REF_TMP=$(mktemp -d "${TMPDIR:-/tmp}/plot-fleet-ref.XXXXXX") || REF_TMP=""
2068
+ # The scan is read-only and short-lived, and the board polls it every 5 s —
2069
+ # a directory that outlives the run would accumulate one per poll.
2070
+ [ -n "$REF_TMP" ] && trap 'rm -rf "$REF_TMP"' EXIT INT TERM
2071
+ # No temp dir means no way to hand the parser a file, so the ref path cannot
2072
+ # work. Falling back to the checkout is the honest answer, and it announces
2073
+ # itself through `plan_source` exactly like an unreadable ref.
2074
+ [ -n "$REF_TMP" ] || PLAN_SOURCE="worktree"
2075
+ fi
2076
+
2077
+ # EVERY PLAN PATH'S MODE, IN ONE CALL — see `REMOTE_REFS` for the measurement.
2078
+ #
2079
+ # `ref_plan_file` asked `git ls-tree` once per plan for a single field: the file
2080
+ # MODE, which is how a symlink (120000) is told from a regular file. Profiled
2081
+ # 2026-08-20 that was 69 spawns of the scan's 459, and unlike `show-ref` these
2082
+ # are not cheap — reading a tree out of a packfile is real work.
2083
+ #
2084
+ # `ls-tree -r` over the plan directories answers for every path at once: 134
2085
+ # entries in 512 ms here, against 69 separate spawns at 31-56 ms of launch
2086
+ # overhead EACH before any work happens.
2087
+ #
2088
+ # Scoped to the two directories plot keeps plans in rather than the whole tree,
2089
+ # because that is all `ref_plan_file` is ever asked about, and an unbounded
2090
+ # `ls-tree -r` on a large repo would trade one cost for another.
2091
+ PLAN_MODES=$(git ls-tree -r "origin/$MAIN" \
2092
+ -- "$PLAN_DIR" "$ACTIVE_DIR" </dev/null 2>/dev/null)
2093
+
2094
+ # The mode of $1 in the ref, from the batch. Empty when the path is not there,
2095
+ # which is what a failed per-path `ls-tree` also produced.
2096
+ ref_mode_of() { # $1=path → mode digits, or ""
2097
+ printf '%s\n' "$PLAN_MODES" | awk -v p="$1" '
2098
+ { path = $0; sub(/^[^\t]*\t/, "", path) }
2099
+ path == p { print $1; exit }'
2100
+ }
2101
+
2102
+ # EVERY PLAN BLOB, WRITTEN IN ONE PROCESS.
2103
+ #
2104
+ # `ref_plan_file` handed the parser a temp file per plan, fetched with its own
2105
+ # `git show`. MEASURED 2026-08-20: one such `show` cost 407-621 ms — variable
2106
+ # because several worktrees were hitting one object store — and there were 68 of
2107
+ # them: ~31 s for a single call site, against a 30 s budget. `cat-file --batch`
2108
+ # read NINETEEN blobs in 559 ms, so reading EVERY plan costs about what reading
2109
+ # one did.
2110
+ #
2111
+ # THE FRAMING IS BY BYTE COUNT, NOT BY PATTERN, and that is the whole reason
2112
+ # this is perl rather than awk. `--batch` emits `<oid> blob <size>` then exactly
2113
+ # `<size>` bytes then a newline; a plan containing a line shaped like
2114
+ # `deadbeef blob 42` would desynchronise any split that looks for the header
2115
+ # instead of counting. Two earlier attempts here did exactly that — one wrote
2116
+ # nothing at all, silently, and every plan fell through to the per-plan `show`
2117
+ # with the spawn count unchanged. The count is read from the header and honoured.
2118
+ #
2119
+ # Only REGULAR files are written. A symlink's blob is its target path rather than
2120
+ # a plan, and resolving it needs the link followed first — `ref_plan_file` still
2121
+ # does that, then finds the resolved target already here.
2122
+ #
2123
+ # Failure is silent BY DESIGN: an unwritten file misses the cache in
2124
+ # `ref_plan_file` and takes the per-plan `git show` exactly as before. This is a
2125
+ # cache, so it may only save work, never change an answer.
2126
+ if [ -n "$REF_TMP" ] && [ -n "$PLAN_MODES" ] && command -v perl >/dev/null 2>&1; then
2127
+ printf '%s\n' "$PLAN_MODES" | awk -F'\t' '
2128
+ $0 ~ /^100644 / { name = $2; sub(/.*\//, "", name)
2129
+ split($1, h, " "); print h[3] "\t" name }' \
2130
+ > "$REF_TMP/.manifest" 2>/dev/null
2131
+ if [ -s "$REF_TMP/.manifest" ]; then
2132
+ awk -F'\t' '{print $1}' "$REF_TMP/.manifest" \
2133
+ | git cat-file --batch </dev/stdin 2>/dev/null \
2134
+ | perl -e '
2135
+ my (%name, $dir); $dir = $ARGV[0];
2136
+ open(my $m, "<", "$dir/.manifest") or exit 0;
2137
+ while (<$m>) { chomp; my ($o, $n) = split(/\t/); $name{$o} = $n if $n }
2138
+ close $m; binmode STDIN;
2139
+ while (my $hdr = <STDIN>) {
2140
+ chomp $hdr;
2141
+ my ($oid, $type, $size) = split(/ /, $hdr);
2142
+ last unless defined $size && $size =~ /^[0-9]+$/;
2143
+ my ($buf, $nl); read(STDIN, $buf, $size); read(STDIN, $nl, 1);
2144
+ next unless $type eq "blob" && $name{$oid};
2145
+ open(my $fh, ">", "$dir/$name{$oid}") or next;
2146
+ binmode $fh; print $fh $buf; close $fh;
2147
+ }' "$REF_TMP" 2>/dev/null
2148
+ fi
2149
+ fi
2150
+
2151
+ ref_plan_file() { # $1=path in ref → temp file path, or "" when unreadable
2152
+ local p="$1" mode target content out
2153
+ mode=$(ref_mode_of "$p")
2154
+ # A path the batch does not carry may still exist — the batch is scoped to the
2155
+ # plan directories, and a symlink can point outside them. Asked directly only
2156
+ # then, so the fallback costs one spawn for a case that does not arise here
2157
+ # rather than one per plan for the case that always does.
2158
+ [ -n "$mode" ] || mode=$(git ls-tree "origin/$MAIN" -- "$p" </dev/null 2>/dev/null | awk '{print $1; exit}')
2159
+ if [ "$mode" = "120000" ]; then
2160
+ target=$(git show "origin/$MAIN:$p" </dev/null 2>/dev/null) || return 1
2161
+ [ -n "$target" ] || return 1
2162
+ case "$target" in
2163
+ /*)
2164
+ # AN ABSOLUTE TARGET IS NOT A PATH IN THE TREE. `ln -s "$(pwd)/…"`
2165
+ # stores `/home/runner/work/…` in the blob, and a repository has no
2166
+ # such directory — prefixing it with the link's dirname produces a path
2167
+ # that resolves to nothing, which is how this first showed up: three
2168
+ # board suites went from 104 passing to 93, every failure a plan that
2169
+ # had silently vanished from the pulse.
2170
+ #
2171
+ # Only the BASENAME can be trusted, and only inside `$PLAN_DIR`. That
2172
+ # is not a guess about where the plan is: an absolute link is
2173
+ # machine-specific by construction, so its directory half describes a
2174
+ # filesystem this scan may not be running on, while plot keeps every
2175
+ # plan in one directory by config. Resolving there is the only reading
2176
+ # that can be right on another machine.
2177
+ p="$PLAN_DIR$(basename "$target")" ;;
2178
+ *)
2179
+ # Resolved against the LINK's directory, the same way the filesystem
2180
+ # would resolve a relative link.
2181
+ p=$(printf '%s' "$(dirname "$p")/$target" | sed 's#/\./#/#g')
2182
+ # Collapse `a/b/../c` → `a/c` so the result is a path the tree can name.
2183
+ while printf '%s' "$p" | grep -q '[^/][^/]*/\.\./'; do
2184
+ p=$(printf '%s' "$p" | sed 's#[^/][^/]*/\.\./##')
2185
+ done ;;
2186
+ esac
2187
+ fi
2188
+ # ALREADY MATERIALISED? The batch below wrote every plan blob in the plan
2189
+ # directory into `$REF_TMP` in ONE process. A hit here costs no git at all.
2190
+ #
2191
+ # THE MEASUREMENT that made this the change worth making, 2026-08-20: one
2192
+ # `git show` of a plan blob cost 407-621 ms — variable because four worktrees
2193
+ # were hitting one object store — while `cat-file --batch` read NINETEEN
2194
+ # blobs in 559 ms, in a single process. Reading every plan therefore costs
2195
+ # about what reading one did, and 68 `show` spawns were ~31 s of the scan on
2196
+ # their own: over the whole 30 s budget for that one call site.
2197
+ if [ -n "$REF_TMP" ] && [ -f "$REF_TMP/$(basename "$p")" ]; then
2198
+ printf '%s' "$REF_TMP/$(basename "$p")"
2199
+ return 0
2200
+ fi
2201
+ content=$(git show "origin/$MAIN:$p" </dev/null 2>/dev/null) || return 1
2202
+ [ -n "$content" ] || return 1
2203
+ [ -n "$REF_TMP" ] || return 1
2204
+ # Named after the RESOLVED path so the basename the report prints is the
2205
+ # plan's own filename, matching what the worktree enumeration produced.
2206
+ out="$REF_TMP/$(basename "$p")"
2207
+ printf '%s\n' "$content" > "$out" 2>/dev/null || return 1
2208
+ printf '%s' "$out"
2209
+ }
2210
+
2211
+ # Resolve which plans to report on.
2212
+ #
2213
+ # TWO PARALLEL ARRAYS, because a plan now has two paths that must not be
2214
+ # confused. `plans` keeps the path AS THE REF SPELLS IT — that is the plan's
2215
+ # identity, and it is what the reader sees. `plan_reads` holds the file to
2216
+ # PARSE, which in ref mode is a temp file holding the blob. In worktree mode
2217
+ # the two are equal, so every consumer below reads the same way in both modes.
2218
+ plans=()
2219
+ plan_reads=()
2220
+ # The declared phase of each plan, filled during enumeration and indexed
2221
+ # alongside `plans`. It is read where the delivered window used to test the
2222
+ # path prefix — see "the group is the phase" below.
2223
+ plan_phases=()
2224
+
2225
+ # Add one plan by its ref path, materializing the blob. A path that cannot be
2226
+ # read is SKIPPED rather than guessed at — a dangling index entry is a
2227
+ # bookkeeping fault plot-reconcile-scan.sh reports, and inventing a row for it
2228
+ # here would hide the fault behind a plausible-looking plan.
2229
+ add_ref_plan() { # $1=path in ref
2230
+ local f
2231
+ f=$(ref_plan_file "$1") || return 0
2232
+ [ -n "$f" ] || return 0
2233
+ plans+=("$1")
2234
+ plan_reads+=("$f")
2235
+ }
2236
+
2237
+ # ---------------------------------------------------------------------------
2238
+ # What makes a file a plan
2239
+ # ---------------------------------------------------------------------------
2240
+ #
2241
+ # A `.md` file directly in `$PLAN_DIR` whose PHASE PARSES — `phase` is anything
2242
+ # other than `NONE`. Nothing else qualifies, and the rule is the parser's own
2243
+ # answer rather than a second opinion about it: plot-plan-meta.sh is the format
2244
+ # contract (Manifesto Principle 3), so a scan that grepped for `Phase:` itself
2245
+ # would be a second implementation of the format, free to disagree with the
2246
+ # first.
2247
+ #
2248
+ # THIS HAD TO BE DECIDED rather than inherited. The old enumeration globbed
2249
+ # `$ACTIVE_DIR`, and the symlinks there happen to point only at plans — so
2250
+ # non-plans were excluded BY ACCIDENT, as a side effect of nobody having linked
2251
+ # them. Measured in this repo 2026-08-19: 64 `.md` files in `$PLAN_DIR`, of
2252
+ # which 62 are plans and two are notes that carry no `Phase:` field at all
2253
+ # (`2026-08-18-the-repair-exists-report.md`, `kanban-board-v1-open-questions.md`).
2254
+ # Enumerating the directory without a rule would report both as phase-less
2255
+ # plans with no branches — trading a list that is wrongly short for one that is
2256
+ # wrongly long.
2257
+ #
2258
+ # `UNKNOWN` COUNTS AS A PLAN, and that direction is deliberate. `UNKNOWN` means
2259
+ # the file declared a phase whose value the parser did not recognise — a typo,
2260
+ # or a phase word this version predates. That is a plan with a bad field, and
2261
+ # the whole point of this change is that a plan cannot be simultaneously valid
2262
+ # and invisible; hiding it for a misspelling would rebuild the failure one
2263
+ # level down, where it is harder to see than a missing symlink was. `NONE` is
2264
+ # the different case: no field at all, so nothing claimed to be a plan.
2265
+ #
2266
+ # `$PLAN_DIR` IS READ NON-RECURSIVELY, which is what keeps the index
2267
+ # directories out of the list. `$ACTIVE_DIR` and `$DELIVERED_DIR` live inside
2268
+ # `$PLAN_DIR` by default, and their symlinks resolve to files already
2269
+ # enumerated — counting both would double every plan. `git ls-tree` without
2270
+ # `-r` lists one level, and the worktree glob `"$PLAN_DIR"*.md` does not
2271
+ # descend either.
2272
+ is_plan_phase() { # $1=normalized phase → 0 when this file is a plan
2273
+ case "$1" in
2274
+ ""|NONE) return 1 ;;
2275
+ *) return 0 ;;
2276
+ esac
2277
+ }
2278
+
2279
+ # ---------------------------------------------------------------------------
2280
+ # ONE PARSE FOR THE WHOLE ESTATE
2281
+ # ---------------------------------------------------------------------------
2282
+ #
2283
+ # THE COST THIS REMOVES, measured on this repo 2026-08-27 against 154 plans:
2284
+ # 319 `plot-plan-meta.sh` spawns and 463 `python3` spawns for a single
2285
+ # `--offline` scan, which ran at 86.8 % CPU — the scan was COMPUTING, not
2286
+ # waiting, and more than half of that budget was interpreter startup.
2287
+ #
2288
+ # The shape was two spawns per plan, twice over. Enumeration asked the parser
2289
+ # for each file's phase; the plan loop then asked the parser for the SAME file
2290
+ # again and started a fresh `python3` per plan to re-parse that helper's own
2291
+ # output. So the scan parsed each plan, then started an interpreter to re-parse
2292
+ # the parse.
2293
+ #
2294
+ # `plot-plan-meta.sh` TAKES A LIST and always did — its own docstring says
2295
+ # "Accepts many files in one invocation and parses them in a single awk pass",
2296
+ # and `board.ts` already calls it that way. The scan was the caller that did
2297
+ # not. Measured the same day: one plan 0.01 s, ALL plans 0.19 s in a single
2298
+ # invocation — roughly 300× cheaper for the whole estate than for one plan
2299
+ # each.
2300
+ #
2301
+ # So the estate is parsed ONCE, here, and every later question reads the result.
2302
+ # The four Released scan-performance plans fixed the HOST API N+1 (one bulk
2303
+ # `pr-list` instead of one `pr-state` per branch); this is the local-subprocess
2304
+ # N+1, the same shape and a different cost. Nothing below touches the host path.
2305
+ #
2306
+ # WHY THE RESULT IS FLATTENED HERE rather than kept as JSON: the consumers are
2307
+ # bash, and re-reading JSON per plan is precisely the defect being removed. One
2308
+ # python pass emits a tab-separated record stream for every plan at once, and
2309
+ # the loop reads records. There is no second interpreter.
2310
+ #
2311
+ # `record` types, one per line, all tab-separated and all prefixed by the plan
2312
+ # file they describe:
2313
+ # P <file> <phase> <delivered_in_window> one per parsed file
2314
+ # W <file> <wave-idx> <branch> <deferred> <why> <wave-name> <claim>
2315
+ #
2316
+ # NO ASSOCIATIVE ARRAYS. `/bin/bash` on macOS is 3.2 and this script uses no
2317
+ # bash-4 feature anywhere; a `declare -A` here would silently narrow where Plot
2318
+ # runs. The records are read into INDEX-PARALLEL arrays, the idiom the
2319
+ # enumeration below already uses for `plans`/`plan_reads`/`plan_phases`.
2320
+ #
2321
+ # ONE BAD FILE MUST NOT TAKE THE ESTATE DOWN. Batching makes that a new failure
2322
+ # mode: one invocation now covers every plan, so a parser that died on the worst
2323
+ # file would report nothing about the good ones. `plot-plan-meta.sh` contracts to
2324
+ # exit 0 always and report parse problems IN the JSON; this pass holds up its end
2325
+ # by decoding each JSON LINE independently and skipping the ones that do not
2326
+ # decode. A file that cannot be read is absent from the records, which the
2327
+ # callers already treat as "not a plan" — the same answer the per-file parse
2328
+ # gave when it failed.
2329
+ plan_meta_files=()
2330
+ plan_meta_phases=()
2331
+ plan_meta_inwindow=()
2332
+ plan_meta_waves=()
2333
+
2334
+ # Parses every plan file given, filling the four arrays above. Called ONCE.
2335
+ parse_plan_estate() { # $@=files to parse
2336
+ [ $# -gt 0 ] || return 0
2337
+ local records
2338
+ records=$("$script_dir/plot-plan-meta.sh" "$@" --prefixes "$PREFIX_RE" 2>/dev/null \
2339
+ | python3 -c '
2340
+ import json, re, sys, time
2341
+
2342
+ window = float(sys.argv[1]) * 3600
2343
+ now = time.time()
2344
+
2345
+ def in_window(raw):
2346
+ """The `Delivered:` record against the rolling window — the same rule the
2347
+ per-plan test applied, moved into the one pass. A record whose date does
2348
+ not parse is dropped rather than coerced: Date-style leniency would turn a
2349
+ typo into a confident answer. A record with no time anchors at 23:59:59,
2350
+ so a plan delivered at 23:50 is not an hour from expiry the moment it is
2351
+ written. A FUTURE record is INSIDE (negative age), because hiding a live
2352
+ plan for a mistyped year costs more than showing one."""
2353
+ raw = (raw or "").strip()
2354
+ if not raw:
2355
+ return False
2356
+ m = re.match(r"(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?", raw)
2357
+ if not m:
2358
+ return False
2359
+ y, mo, dy, hh, mi = m.groups()
2360
+ timed = hh is not None
2361
+ try:
2362
+ at = time.mktime((int(y), int(mo), int(dy),
2363
+ int(hh) if timed else 23, int(mi) if timed else 59,
2364
+ 0 if timed else 59, 0, 0, -1))
2365
+ except (ValueError, OverflowError):
2366
+ return False
2367
+ return (now - at) <= window
2368
+
2369
+ def clean(s):
2370
+ return str(s).replace("\t", " ").replace("\n", " ")
2371
+
2372
+ for line in sys.stdin:
2373
+ line = line.strip()
2374
+ if not line:
2375
+ continue
2376
+ # PER LINE, so one unparseable plan costs only itself. The helper emits
2377
+ # JSON Lines and promises never to crash; this is the second half of that
2378
+ # promise, held on the consuming side.
2379
+ try:
2380
+ d = json.loads(line)
2381
+ except Exception:
2382
+ continue
2383
+ f = d.get("file")
2384
+ if not f:
2385
+ continue
2386
+ print("\t".join(["P", clean(f), clean(d.get("phase", "")),
2387
+ "1" if in_window(d.get("delivered_raw")) else "0"]))
2388
+ for i, w in enumerate(d.get("waves", []) or []):
2389
+ name = w.get("name")
2390
+ for b in w.get("branches", []) or []:
2391
+ ref = b.get("branch") or ""
2392
+ # Not every prefixed token in a ## Branches section is
2393
+ # implementation work. A cited file path (`docs/note.md`) matches
2394
+ # the docs/ branch prefix, and an idea/ branch carries the plan
2395
+ # itself — counting either as outstanding would keep a finished
2396
+ # wave blocked forever.
2397
+ if ref.startswith("idea/") or "." in ref.rsplit("/", 1)[-1]:
2398
+ continue
2399
+ # THE REASON FOR THE DEFERRAL rides with its flag, and it rides
2400
+ # BEFORE the claim note: tab is an IFS whitespace character, so a
2401
+ # run of tabs collapses to one separator and only the LAST field
2402
+ # may be optional. "-" stands in for empty everywhere, so no run
2403
+ # can form.
2404
+ print("\t".join(clean(x) for x in [
2405
+ "W", f, str(i), ref, str(b.get("deferred")).lower(),
2406
+ (b.get("deferred_reason") or "-"),
2407
+ name or "-", b.get("claimed") or "-"]))
2408
+ ' "$DELIVERED_WINDOW_HOURS" 2>/dev/null) || records=""
2409
+
2410
+ local kind file rest
2411
+ while IFS=$'\t' read -r kind file rest; do
2412
+ [ -n "$kind" ] || continue
2413
+ case "$kind" in
2414
+ P)
2415
+ plan_meta_files+=("$file")
2416
+ # `rest` is "<phase>\t<inwindow>"; both are single tokens with no tabs.
2417
+ plan_meta_phases+=("${rest%% *}")
2418
+ plan_meta_inwindow+=("${rest##* }")
2419
+ plan_meta_waves+=("")
2420
+ ;;
2421
+ W)
2422
+ # Wave rows follow their plan's P row, so the last-appended entry is
2423
+ # the one this row belongs to — the helper emits one object per file
2424
+ # and this pass prints them in that order.
2425
+ local last=$((${#plan_meta_files[@]} - 1))
2426
+ [ "$last" -ge 0 ] || continue
2427
+ [ "${plan_meta_files[$last]}" = "$file" ] || continue
2428
+ plan_meta_waves[$last]="${plan_meta_waves[$last]}$rest"$'\n'
2429
+ ;;
2430
+ esac
2431
+ done <<< "$records"
2432
+ }
2433
+
2434
+ # The index of a parsed file in the arrays above, or "" when it was not parsed
2435
+ # (an unreadable file, or one the helper could not decode). Linear, over an
2436
+ # array the size of the plan directory — the estate is parsed once, so this
2437
+ # replaces a SUBPROCESS per lookup with a string compare per lookup.
2438
+ plan_meta_index_of() { # $1=file → index on stdout, or ""
2439
+ local i
2440
+ for i in "${!plan_meta_files[@]}"; do
2441
+ if [ "${plan_meta_files[$i]}" = "$1" ]; then printf '%s' "$i"; return 0; fi
2442
+ done
2443
+ printf ''
2444
+ }
2445
+
2446
+ # The phase a file declares, or "" when it is not a plan. Read from the single
2447
+ # estate parse above rather than spawned per file.
2448
+ plan_phase_of() { # $1=file to parse → normalized phase on stdout
2449
+ local i
2450
+ i=$(plan_meta_index_of "$1")
2451
+ [ -n "$i" ] || { printf ''; return 0; }
2452
+ printf '%s' "${plan_meta_phases[$i]}"
2453
+ }
2454
+
2455
+ # A terminal phase belongs to the delivered group: the plan is finished, and it
2456
+ # appears only while its `Delivered:` record is inside the rolling window.
2457
+ #
2458
+ # `rejected` and `superseded` are terminal too, and they route here for the
2459
+ # same reason `/plot-deliver` files them under `$DELIVERED_DIR` (issue #33):
2460
+ # they are outcomes, not work. A worker may claim nothing under any of the
2461
+ # four.
2462
+ is_terminal_phase() { # $1=normalized phase → 0 when finished
2463
+ case "$1" in
2464
+ delivered|released|rejected|superseded) return 0 ;;
2465
+ *) return 1 ;;
2466
+ esac
2467
+ }
2468
+
2469
+ if [ -n "$slug" ]; then
2470
+ # A NAMED SLUG IS NOT A LIST, so it keeps its own resolution: the caller
2471
+ # already said which plan it means, and the phase rule would only be able to
2472
+ # refuse the answer. `$ACTIVE_DIR`/`$DELIVERED_DIR` stay in the search path
2473
+ # because a slug is the one place their stable, undated names are the
2474
+ # QUESTION — `plot-fleet-scan.sh plot-sprint-support` names a symlink, not a
2475
+ # dated file, and every caller that passes a slug got it from one.
2476
+ if [ "$PLAN_SOURCE" = "ref" ]; then
2477
+ # Same precedence as the worktree form: the dated plan file first, then the
2478
+ # active index, then delivered. `ref_ls` is filtered rather than globbed
2479
+ # because the ref has no shell to expand `*`.
2480
+ found=0
2481
+ for cand in $(ref_ls "$PLAN_DIR" | grep "$slug\.md$") \
2482
+ $(ref_ls "$ACTIVE_DIR" | grep "/$slug\.md$") \
2483
+ $(ref_ls "$DELIVERED_DIR" | grep "/$slug\.md$"); do
2484
+ add_ref_plan "$cand"
2485
+ [ ${#plans[@]} -gt 0 ] && { found=1; break; }
2486
+ done
2487
+ [ "$found" = 1 ] || true
2488
+ else
2489
+ for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
2490
+ [ -e "$cand" ] && {
2491
+ plans+=("$(cd "$(dirname "$cand")" && pwd)/$(basename "$cand")")
2492
+ plan_reads+=("${plans[${#plans[@]}-1]}")
2493
+ break
2494
+ }
2495
+ done
2496
+ fi
2497
+ # The named plan's phase, recorded the same way the enumerated ones are so
2498
+ # the loop below reads one array in both paths. A slug names ONE file, so the
2499
+ # batch is a batch of one — it routes through the same estate parse anyway,
2500
+ # because that is where every later question reads its answer from.
2501
+ if [ ${#plans[@]} -gt 0 ]; then
2502
+ parse_plan_estate "${plan_reads[0]}"
2503
+ plan_phases+=("$(plan_phase_of "${plan_reads[0]}")")
2504
+ fi
2505
+ else
2506
+ # ---------------------------------------------------------------------------
2507
+ # THE GROUP IS THE PHASE, not the symlink
2508
+ # ---------------------------------------------------------------------------
2509
+ #
2510
+ # The list came from a glob over `$ACTIVE_DIR` plus one over `$DELIVERED_DIR`,
2511
+ # so a plan was visible because a LINK existed and grouped by WHICH directory
2512
+ # held the link. Both facts are hand-maintained copies of something the plan
2513
+ # already says about itself, and a copy maintained by hand disagrees with its
2514
+ # original the moment somebody forgets.
2515
+ #
2516
+ # Measured 2026-08-18: an agent wrote a plan file directly rather than through
2517
+ # `/plot-idea`. It parsed `canonical`, carried `Phase: Approved`, named three
2518
+ # branches in two waves and sat on `origin/main` — and every unscoped scan
2519
+ # reported 12 plans without it. Two agents were already working its branches.
2520
+ # The failure is silent in the direction that matters: the scan does not say
2521
+ # "one plan is unindexed", it says nothing at all and its footer count is
2522
+ # simply lower than reality. Nothing in the output distinguishes *this plan
2523
+ # does not exist* from *this plan is not indexed*, which is why it was
2524
+ # misdiagnosed three times as a board defect before anyone looked at the index.
2525
+ #
2526
+ # So the plan directory is enumerated and each file is grouped by the phase it
2527
+ # DECLARES. `$ACTIVE_DIR` keeps working and keeps being written — this change
2528
+ # only stops anything DEPENDING on it being right. A stale link is now inert
2529
+ # in both directions: an unlinked Approved plan appears, and a link pointing
2530
+ # at a delivered plan cannot resurrect it, because neither link is consulted.
2531
+ #
2532
+ # COST, measured on this repo 2026-08-19 rather than assumed: 64 plans parse
2533
+ # in 371 ms, ~5.8 ms each, against a full scan this file's own comments record
2534
+ # at 500–1050 ms (18.3 s with the host round trips). The plan's fixture
2535
+ # measurement puts the worst realistic case at ~300 ms extra for 1000 plans,
2536
+ # a scale no Plot repo has reached, behind the board's 5 s cache.
2537
+ #
2538
+ # The delivered mtime PRE-FILTER is gone with the directory it read, and it
2539
+ # was buying less than it appeared to: it keyed off the `$DELIVERED_DIR`
2540
+ # symlink's mtime, and a fresh checkout stamps every symlink at once — 56 of
2541
+ # 56 delivered links admitted here, so the parse it was meant to avoid was
2542
+ # already being paid in full. `delivered_in_window` (the `Delivered:` record)
2543
+ # was always the filter that actually decided, and the pre-filter's own
2544
+ # contract was that it may only ever OVER-admit. Removing it takes that
2545
+ # contract to its limit — strictly more correct, and on this repo not even
2546
+ # more expensive.
2547
+ #
2548
+ # ORDER IS PRESERVED: active plans first, in enumeration order, with the
2549
+ # terminal ones appended. A reader's list must not reshuffle when something is
2550
+ # delivered, and it is the same order the two globs produced before.
2551
+ #
2552
+ # --next and --list-eligible skip the terminal group entirely rather than
2553
+ # filter it later. Their question is "what may a worker claim", and a finished
2554
+ # plan answers nothing to it: even an `open` branch under one is work somebody
2555
+ # decided was not needed. Naming one would send a dispatcher at finished work.
2556
+ terminal_plans=()
2557
+ terminal_reads=()
2558
+ terminal_phases=()
2559
+
2560
+ # One enumeration, two groups. `add_plan_by_phase` parses once and files the
2561
+ # result, so no path below asks the format contract the same question twice.
2562
+ add_plan_by_phase() { # $1=identity path, $2=file to parse
2563
+ local id="$1" src="$2" ph
2564
+ ph=$(plan_phase_of "$src")
2565
+ is_plan_phase "$ph" || return 0
2566
+ if is_terminal_phase "$ph"; then
2567
+ [ "$next_only" = 1 ] && return 0
2568
+ terminal_plans+=("$id")
2569
+ terminal_reads+=("$src")
2570
+ terminal_phases+=("$ph")
2571
+ else
2572
+ plans+=("$id")
2573
+ plan_reads+=("$src")
2574
+ plan_phases+=("$ph")
2575
+ fi
2576
+ }
2577
+
2578
+ # THE CANDIDATE LIST IS BUILT BEFORE ANYTHING IS PARSED, because the estate
2579
+ # is parsed in ONE call and a single call needs its whole argument list. In
2580
+ # ref mode that means every blob is materialized first: the phase decides the
2581
+ # group, so the file must exist before it can be asked, and it must be asked
2582
+ # together with all the others rather than one at a time.
2583
+ cand_ids=()
2584
+ cand_reads=()
2585
+ if [ "$PLAN_SOURCE" = "ref" ]; then
2586
+ while IFS= read -r plan_path; do
2587
+ [ -n "$plan_path" ] || continue
2588
+ plan_blob=$(ref_plan_file "$plan_path") || continue
2589
+ [ -n "$plan_blob" ] || continue
2590
+ cand_ids+=("$plan_path")
2591
+ cand_reads+=("$plan_blob")
2592
+ done <<< "$(ref_ls "$PLAN_DIR")"
2593
+ else
2594
+ for plan_path in "$PLAN_DIR"*.md; do
2595
+ [ -e "$plan_path" ] || continue
2596
+ cand_ids+=("$plan_path")
2597
+ cand_reads+=("$plan_path")
2598
+ done
2599
+ fi
2600
+
2601
+ # ONE INVOCATION FOR THE WHOLE ESTATE. Everything below reads its result.
2602
+ [ ${#cand_reads[@]} -gt 0 ] && parse_plan_estate "${cand_reads[@]}"
2603
+
2604
+ for cand_i in "${!cand_ids[@]}"; do
2605
+ add_plan_by_phase "${cand_ids[$cand_i]}" "${cand_reads[$cand_i]}"
2606
+ done
2607
+
2608
+ for i in "${!terminal_plans[@]}"; do
2609
+ plans+=("${terminal_plans[$i]}")
2610
+ plan_reads+=("${terminal_reads[$i]}")
2611
+ plan_phases+=("${terminal_phases[$i]}")
2612
+ done
2613
+ fi
2614
+
2615
+ if [ ${#plans[@]} -eq 0 ]; then
2616
+ # --next/--list-eligible must stay silent and exit 1: "nothing to start" is
2617
+ # the same answer whether the plans are all claimed or there are no plans at
2618
+ # all. Exiting 0 here would hand a caller an EMPTY branch name as if it were
2619
+ # valid work.
2620
+ [ "$next_only" = 1 ] && exit 1
2621
+ # A MACHINE CONSUMER FALLS THROUGH. An empty estate is a COMPLETE answer, and
2622
+ # this branch used to end the run before the emitter — so `--json` and
2623
+ # `--stream` were ignored entirely here and a consumer got human prose on
2624
+ # stdout. Under `--stream` that meant no terminal `pulse` line, and the
2625
+ # board's contract (":3407": *"a consumer that has seen `plan` lines and no
2626
+ # `pulse` line has a PARTIAL answer and must say so"*) made it report a
2627
+ # complete answer as a scan failure — forever, because the next scan said the
2628
+ # same. Measured 2026-08-28 against a board installed from npm: *"fleet scan
2629
+ # ended without a terminal pulse line"*, `ready:false`, every pulse.
2630
+ #
2631
+ # EVERY NEW USER HAS ZERO PLANS, so this was the first thing an installed
2632
+ # board did. Falling through costs nothing: every loop below is per-plan and
2633
+ # a no-op over none, and the emitter already renders `"plans":[]` with a
2634
+ # zeroed summary — the same document a populated estate produces, which is
2635
+ # the point. One emitter, one shape, no second place to drift.
2636
+ if [ "$as_json" != 1 ]; then
2637
+ # Names the directory that was actually READ. It named `$ACTIVE_DIR` while
2638
+ # the scan globbed it; pointing a reader at the index would now send them to
2639
+ # look for the cause of an empty list in a directory nothing consults.
2640
+ echo "No plans found in ${PLAN_DIR}."
2641
+ echo "summary: plans=0 waves=0 branches=0 claimed=0 eligible=0 blocked=0 deferred=0 main=$MAIN"
2642
+ exit 0
2643
+ fi
2644
+ fi
2645
+
2646
+ # A branch is merged when its remote ref is an ancestor of origin/<main>, or —
2647
+ # once the ref is gone — when the default branch carries a conforming PR-merge
2648
+ # commit naming it (see "the evidence that survives the ref" above). An absent
2649
+ # ref with no such commit means the work has not been taken yet.
2650
+ #
2651
+ # Every git call here redirects stdin from /dev/null: this function runs inside
2652
+ # `while read ... <<< "$states"` loops, and a child process inheriting that
2653
+ # here-string would swallow the loop's remaining lines.
2654
+ # Count commits beyond main that are NOT claim markers. A claim marker must be
2655
+ # BOTH titled `plot: claim ...` AND empty (its tree equals its parent's) — the
2656
+ # subject alone is not evidence. A human commit titled "plot: claim handling
2657
+ # refactor" carrying real files would otherwise read as an empty claim, and
2658
+ # with a deferred: annotation the reaper would offer to DELETE real work.
2659
+ # ONE `git log` PER BRANCH, NOT THREE SPAWNS PER COMMIT.
2660
+ #
2661
+ # THE MEASUREMENT, 2026-08-20: this loop was the largest single spawn source in
2662
+ # the scan. Per commit it ran `git log -1` for the subject and, for a
2663
+ # claim-titled one, two `git rev-parse` for the two trees. `rev-parse` was the
2664
+ # top per-branch call in the profile (14 for a single plan) and it all came from
2665
+ # here.
2666
+ #
2667
+ # `--shortstat` COLLAPSES THE TREE COMPARISON INTO THE SAME WALK. An empty
2668
+ # commit produces NO stat line, a commit with changes produces one — which is
2669
+ # exactly `tree == parent tree`, asked once for the whole range instead of twice
2670
+ # per commit. Verified against a real claim on this repo: the
2671
+ # `plot: claim feature/...` commit prints no stat line, its sibling prints
2672
+ # `1 file changed, 111 insertions(+)`.
2673
+ #
2674
+ # THE TEST IS UNCHANGED, AND MUST STAY SO: a claim marker is titled
2675
+ # `plot: claim ...` AND empty. Both, or it counts as real work — a human commit
2676
+ # titled "plot: claim handling refactor" carrying real files must still count,
2677
+ # because with a `deferred:` annotation the reaper would otherwise offer to
2678
+ # DELETE real work. This changes where the two facts come from, never what they
2679
+ # decide.
2680
+ #
2681
+ # A commit whose stat cannot be read counts as REAL, matching the old code's
2682
+ # behaviour when `rev-parse` failed: an unreadable tree is not evidence of
2683
+ # emptiness.
2684
+ # ONE WALK, BOTH COUNTS — `<total> <real>` on stdout.
2685
+ #
2686
+ # `branch_state` asked `git rev-list --count "origin/$MAIN..origin/$br"` for the
2687
+ # TOTAL and then called this for the REAL count, which walks the SAME range with
2688
+ # `git log`. Two spawns for one question, once per branch: 64 `rev-list` calls
2689
+ # measured on this repo 2026-08-20, the last per-branch block after #262 batched
2690
+ # the plan reads.
2691
+ #
2692
+ # The walk already visits every commit to classify it, so the total is a counter
2693
+ # it was throwing away. `ahead-behind` in `for-each-ref` would answer this
2694
+ # repo-wide in one call and needs git 2.41; 2.39 is what ships with macOS, so
2695
+ # that is not available here.
2696
+ #
2697
+ # BOTH NUMBERS FROM ONE READING keeps them consistent by construction: a total
2698
+ # and a real count taken from two walks could disagree if a ref moved between
2699
+ # them, and the caller compares the two.
2700
+ real_commits_beyond_main() { # $1=branch → "<total> <real>"
2701
+ local br="$1" n=0 total=0 line subj pending=0 _rcb_base _rcb_tip
2702
+ # NAMED AS SHAS, NOT AS REFS — see `REMOTE_REFS` for why. Both come from the
2703
+ # batch already in hand, so this costs no extra process.
2704
+ _rcb_base=$(remote_ref_oid "$MAIN")
2705
+ _rcb_tip=$(remote_ref_oid "$br")
2706
+ [ -n "$_rcb_base" ] && [ -n "$_rcb_tip" ] || { echo "0 0"; return; }
2707
+ # Records are `<sha>\t<subject>`, each optionally followed by a blank line and
2708
+ # a ` N files changed, ...` line. `pending` holds whether the record just read
2709
+ # is claim-titled and still waiting to learn if it was empty.
2710
+ while IFS= read -r line; do
2711
+ case "$line" in
2712
+ '') continue ;;
2713
+ *' file changed,'*|*' files changed,'*)
2714
+ # A stat line: the record before it had changes, so it is real work even
2715
+ # if it was claim-titled.
2716
+ [ "$pending" = 1 ] && { n=$((n + 1)); pending=0; }
2717
+ continue ;;
2718
+ esac
2719
+ # A new record. Anything still pending was claim-titled AND produced no stat
2720
+ # line — an empty claim marker, which does not count.
2721
+ pending=0
2722
+ # EVERY RECORD IS ONE COMMIT, which is what the separate `rev-list --count`
2723
+ # was spawned to learn. Counted here, it costs nothing.
2724
+ total=$((total + 1))
2725
+ subj=${line#*"$(printf '\t')"}
2726
+ case "$subj" in
2727
+ "plot: claim "*) pending=1 ;;
2728
+ *) n=$((n + 1)) ;;
2729
+ esac
2730
+ done <<EOF
2731
+ $(git log --format="%H%x09%s" --shortstat "$_rcb_base..$_rcb_tip" </dev/null 2>/dev/null)
2732
+ EOF
2733
+ echo "$total $n"
2734
+ }
2735
+
2736
+ branch_state() {
2737
+ local br="$1"
2738
+ # THE REF CHECK STAYS IN FRONT. DO NOT HOIST THE MERGE LOOKUP ABOVE IT.
2739
+ #
2740
+ # A branch name can be reused: merge `bug/flaky`, delete it, then recreate it
2741
+ # for a second attempt — a normal thing when work is reopened. The FIRST
2742
+ # attempt's merge subject is still on the default branch, and it is now stale
2743
+ # evidence: it describes work that landed, while the branch of that name
2744
+ # carries new work that has not.
2745
+ #
2746
+ # The merge lookup is safe only BY PLACEMENT — it lives in the no-ref arm,
2747
+ # and a recreated branch has a ref, so it never reaches the lookup and takes
2748
+ # the ancestry path below instead. Moving the lookup to the top reads like a
2749
+ # cheap early answer and would silently report in-flight work as `merged`,
2750
+ # opening the next wave on it. A test in fleet.test.mjs pins this ordering.
2751
+ if ! remote_ref_exists "$br"; then
2752
+ # No ref carries two meanings and this used to answer `open` for both: a
2753
+ # branch never started, and a branch merged with its ref deleted at merge.
2754
+ # The wave arithmetic reads `open` as OUTSTANDING, so a finished wave never
2755
+ # completed and --next named finished work as the next thing to start.
2756
+ #
2757
+ # `merged` is already the state that settles a wave, so the arithmetic does
2758
+ # not change and no new state enters the vocabulary. Where no evidence
2759
+ # exists — squash merges, a hand-rewritten subject, a branch genuinely
2760
+ # never started — today's `open` stands. The fix may only move a branch
2761
+ # from `open` to `merged`, and only on positive evidence.
2762
+ merged_by_subject "$br" && { echo "merged"; return; }
2763
+ # No merge commit names it — which is the ordinary case under a squash
2764
+ # merge, not an exotic one. The local walk is now out of evidence, so the
2765
+ # host is asked. It may only ever move this branch from `open` to `merged`:
2766
+ # a miss, a CLOSED PR, or a host that cannot answer all fall through to the
2767
+ # `open` below, exactly as before this call existed.
2768
+ merged_by_host "$br" && { echo "merged"; return; }
2769
+ echo "open"; return
2770
+ fi
2771
+ # A CLAIM is a branch whose only commits beyond main are claim commits —
2772
+ # empty markers a dispatcher pushed to take the work. They must be real
2773
+ # commits, not a bare pointer at main: two branches pointing at the same
2774
+ # commit do not diverge, so the second push would succeed and both sides
2775
+ # would think they held the claim (see plot-dispatch.sh, "THE CLAIM").
2776
+ # ONE WALK ANSWERS BOTH. See `real_commits_beyond_main` for the measurement:
2777
+ # the separate `rev-list --count` here asked for a number that walk was
2778
+ # already computing and discarding, at one extra spawn per branch.
2779
+ local _bs_counts
2780
+ _bs_counts=$(real_commits_beyond_main "$br")
2781
+ ahead=${_bs_counts%% *}
2782
+ real=${_bs_counts##* }
2783
+ if [ "${ahead:-0}" -gt 0 ]; then
2784
+ [ "${real:-0}" = "0" ] && { echo "claimed"; return; }
2785
+ # Real work that main does not yet contain: `wip`, and ONLY `wip`.
2786
+ #
2787
+ # This arm once asked `merge-base --is-ancestor origin/$br origin/$MAIN`
2788
+ # here — "has the work already landed?" — and returned `merged` when it did.
2789
+ # That question was already answered by the `ahead` count above it and could
2790
+ # never fire: `ahead > 0` means `$br` carries at least one commit unreachable
2791
+ # from `$MAIN`, and a branch with such a commit CANNOT be an ancestor of
2792
+ # `$MAIN`, so `--is-ancestor` was false on every branch that reached it. It
2793
+ # was one git spawn per `wip` branch spent to re-derive a fact already in
2794
+ # hand — the per-branch tail this plan set out to thin — and its `merged`
2795
+ # was dead code that changed no verdict.
2796
+ #
2797
+ # The landed-work case is not lost; it is answered ONE LEVEL UP. A branch
2798
+ # whose commits are all in `$MAIN` counts `ahead = 0` and falls through to
2799
+ # the `merged` below, and a merge that deleted the ref never reaches here at
2800
+ # all (the no-ref arm returns first). If a future change makes `ahead`
2801
+ # something other than "commits `$MAIN` lacks", THIS is the invariant that
2802
+ # would break — the ancestry must move back, not be missed.
2803
+ #
2804
+ # A RESURRECTED REF BREAKS THE PREMISE ABOVE, and the join already knows.
2805
+ # The reasoning "a merge that deleted the ref never reaches here" holds only
2806
+ # while the ref STAYS deleted. `delete_branch_on_merge` is on, so the host
2807
+ # removes it — and a worktree that still holds the branch can push it back
2808
+ # afterwards, which a fleet does routinely. The ref then exists again while
2809
+ # the work is on `$MAIN` under a DIFFERENT commit, because a squash merge
2810
+ # rewrites it: `ahead > 0` (the pre-squash commits are unreachable from
2811
+ # `$MAIN`), `real > 0` (they are real work), and this arm calls finished
2812
+ # work `wip`.
2813
+ #
2814
+ # Measured 2026-08-23: `bug/done-holds-finished-plans-only`, PR #356 merged,
2815
+ # read `wip` for three hours. Its wave reported "3 merged, the rest not yet"
2816
+ # over four merged branches and never completed, so the plan sat in
2817
+ # Development with nothing left to do.
2818
+ #
2819
+ # `wip` is the WORST of the wrong answers, which is why this earns a check
2820
+ # rather than a note: it means *an agent is working here*, so a leftover
2821
+ # worktree reads as an occupied desk and the row asks a reader to wait for
2822
+ # something that finished.
2823
+ #
2824
+ # FREE, and that is what licenses it HERE. The state comes from the cache
2825
+ # `prefill_pr_states` already filled from ONE repo-wide `pr-list`, so this
2826
+ # adds no host call — asking per branch on this arm would put 22 calls back
2827
+ # into every scan on this repo and undo the change that removed them. Where
2828
+ # the list did not arrive the cache is empty, `host_pr_state` answers `-`,
2829
+ # and the local walk decides exactly as it does today.
2830
+ #
2831
+ # ONLY `MERGED` MAY OVERRIDE the walk, and only toward `merged`. `OPEN`
2832
+ # means a PR exists for work still in flight — which is what `wip` already
2833
+ # says — and `CLOSED` or `NONE` are not evidence that anything landed.
2834
+ if [ "$(host_pr_state "$br")" = MERGED ]; then echo "merged"; return; fi
2835
+ echo "wip"; return
2836
+ fi
2837
+ # Nothing of its own. NOT a claim: that shape is indistinguishable from
2838
+ # merged work, which is exactly why claims carry a commit.
2839
+ echo "merged"
2840
+ }
2841
+
2842
+ # Prose is suppressed by BOTH alternate output modes. --json accumulates the
2843
+ # same derivation into a document instead of printing it; the arithmetic below
2844
+ # is untouched, which is what keeps the human report byte-identical.
2845
+ quiet=0
2846
+ [ "$next_only" = 1 ] && quiet=1
2847
+ [ "$as_json" = 1 ] && quiet=1
2848
+ # The ref this scan READ, and the ref the operator is STANDING ON. They are
2849
+ # different questions, and conflating them is the bug this block exists to
2850
+ # stop: every fact below is derived from `origin/$MAIN`, but the banner was
2851
+ # built from local `HEAD`. On `main` right after a fetch the two agree, which
2852
+ # is exactly why it survived — the common case made it look correct.
2853
+ #
2854
+ # When `origin/$MAIN` cannot be resolved (no remote, fresh clone) the ref is
2855
+ # reported as unknown. It does NOT fall back to `HEAD`: that would reintroduce
2856
+ # this bug in the one case where nothing can catch it, and a banner that says
2857
+ # "unknown" gets investigated in seconds where a real-looking SHA gets believed.
2858
+ READ_REF=$(git rev-parse --short "origin/$MAIN" 2>/dev/null) || READ_REF=""
2859
+ [ -n "$READ_REF" ] || READ_REF="unknown"
2860
+ LOCAL_HEAD=$(git rev-parse --short HEAD 2>/dev/null) || LOCAL_HEAD=""
2861
+ [ -n "$LOCAL_HEAD" ] || LOCAL_HEAD="unknown"
2862
+ # Kept as an alias for one release: the board reads `head` today (Agents tab),
2863
+ # and renaming a field out from under a live consumer is a break nobody asked
2864
+ # for. Removed once the board reads `read_ref`.
2865
+ HEAD_SHORT="$LOCAL_HEAD"
2866
+ json_plans=""
2867
+
2868
+ # Emit a JSON string with the six characters JSON forbids escaped. Branch names
2869
+ # and claim notes are user data: a plan may legitimately carry a quote or a
2870
+ # backslash, and an unescaped one would produce a document nothing can parse.
2871
+ json_str() {
2872
+ printf '%s' "$1" | LC_ALL=C sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' \
2873
+ -e 's/\t/\\t/g' -e 's/\r/\\r/g' -e 's/\x08/\\b/g' -e 's/\x0c/\\f/g'
2874
+ }
2875
+
2876
+ # A newline-separated list as a JSON array of strings — `[]` for nothing.
2877
+ #
2878
+ # Each element goes through `json_str`, for the reason `json_str` itself exists:
2879
+ # a path is user data, and one legitimate quote in a filename would otherwise
2880
+ # produce a document nothing can parse. Blank lines are dropped, because a
2881
+ # trailing newline is punctuation rather than an empty path.
2882
+ json_array() {
2883
+ local out="" line
2884
+ while IFS= read -r line; do
2885
+ [ -n "$line" ] || continue
2886
+ out+="${out:+,}\"$(json_str "$line")\""
2887
+ done <<< "$1"
2888
+ printf '[%s]' "$out"
2889
+ }
2890
+
2891
+ if [ "$next_only" != 1 ] && [ "$as_json" != 1 ]; then
2892
+ banner="plot-fleet pulse — $READ_REF on origin/$MAIN"
2893
+ # The local checkout and the ref this report was derived from disagree. That
2894
+ # is worth one clause: the operator is looking at a tree this report does not
2895
+ # describe. `behind` is how many commits of `origin/$MAIN` the checkout has
2896
+ # not got — empty when the two share no ancestry to count across.
2897
+ if [ "$READ_REF" != "unknown" ] && [ "$LOCAL_HEAD" != "$READ_REF" ]; then
2898
+ behind=$(git rev-list --count "HEAD..origin/$MAIN" 2>/dev/null) || behind=""
2899
+ # POINTS AT THE REPORT, NOT THE TREE. The measured failure was operators
2900
+ # believing a stale report, so the clause has to say what this report
2901
+ # describes — "your checkout is behind" is advice about the tree, and an
2902
+ # operator who reads it still has no reason to doubt the numbers below.
2903
+ #
2904
+ # The count is included only when git could compute it. `behind` counts
2905
+ # commits on origin/$MAIN the checkout lacks; on a diverged feature branch
2906
+ # that is true but partial, so the SHA leads and the count trails as a
2907
+ # parenthetical rather than being the claim.
2908
+ if [ -n "$behind" ] && [ "$behind" != 0 ]; then
2909
+ banner="$banner (not your checkout $LOCAL_HEAD, $behind behind)"
2910
+ else
2911
+ banner="$banner (not your checkout $LOCAL_HEAD)"
2912
+ fi
2913
+ fi
2914
+ if [ "$loose" = 1 ]; then
2915
+ if [ "$loose_verifiable" = 1 ]; then banner="$banner (loose eligibility)"
2916
+ else banner="$banner (--loose cannot verify PR readiness without a git host — using strict)"
2917
+ fi
2918
+ fi
2919
+ echo "$banner"; echo
2920
+ fi
2921
+
2922
+ n_plans=0 n_waves=0 n_branches=0 n_claimed=0 n_eligible=0 n_blocked=0 n_deferred=0
2923
+ claimable=()
2924
+ plan_files=()
2925
+
2926
+ plan_idx=-1
2927
+ for plan in "${plans[@]}"; do
2928
+ # `plan` is the plan's IDENTITY (the path as the ref or the tree spells it);
2929
+ # `plan_read` is the file to PARSE. They differ only in ref mode, where the
2930
+ # second is a materialized blob — see "Plan enumeration" above.
2931
+ plan_idx=$((plan_idx + 1))
2932
+ plan_read="${plan_reads[$plan_idx]}"
2933
+ # Per-plan reset. State that survives into the next iteration is how the
2934
+ # plan parser once leaked a `## Branches` flag across files — same shape of
2935
+ # bug, so the accumulator is cleared where the plan loop begins.
2936
+ json_waves=""
2937
+ # The estate was parsed ONCE, before this loop. A plan absent from that
2938
+ # result could not be read at all, which is the same answer the per-plan
2939
+ # parse gave by failing — so it is skipped here exactly as it was then.
2940
+ meta_i=$(plan_meta_index_of "$plan_read")
2941
+ [ -n "$meta_i" ] || continue
2942
+
2943
+ # The plan's own phase, carried onto the pulse so a consumer can derive a row
2944
+ # phase from the PAIR — plan state AND branch git state. It is reported, never
2945
+ # interpreted: this script collects and reports, and which column a row reads
2946
+ # is a judgment that belongs one layer up (Manifesto Principle 3).
2947
+ plan_phase="${plan_meta_phases[$meta_i]}"
2948
+
2949
+ # The delivered window, applied to the plans the PHASE put in the terminal
2950
+ # group. Enumeration grouped them; the `Delivered:` RECORD decides which of
2951
+ # them still appears.
2952
+ #
2953
+ # THE TEST IS THE PHASE, not the path. It read `case "$plan" in "$DELIVERED_DIR"*)`
2954
+ # — the directory the link sat in — and that made "which group is this plan
2955
+ # in" a fact about a symlink while "what phase is it" was a fact about the
2956
+ # file. The old comment here noted that an active plan carrying
2957
+ # `Phase: Delivered` was drift the window must not hide; under the phase rule
2958
+ # that drift cannot be constructed, because there is no second place for the
2959
+ # answer to live. One source, so nothing to disagree.
2960
+ #
2961
+ # Two exits, and both matter:
2962
+ # * the record's date has aged out of the window — ordinary expiry;
2963
+ # * there is NO record — "no date, no row". `reconcile-scan-accuracy.md` is
2964
+ # the live example; showing it would create the one row that can never
2965
+ # age out of DONE.
2966
+ # Both leave before a single git call is spent on the plan's branches.
2967
+ if is_terminal_phase "$plan_phase"; then
2968
+ [ "${plan_meta_inwindow[$meta_i]}" = "1" ] || continue
2969
+ fi
2970
+
2971
+ n_plans=$((n_plans + 1))
2972
+ plan_target=$(readlink "$plan" 2>/dev/null && echo "" || true)
2973
+
2974
+ # The plan's DISPLAY NAME: the dated filename, never an index alias.
2975
+ #
2976
+ # Unscoped enumeration now names `$PLAN_DIR` files directly, so the basename
2977
+ # is already the dated name and the `readlink` is a no-op that falls through
2978
+ # to `$plan`. It stays because a NAMED SLUG can still resolve to a symlink in
2979
+ # `$ACTIVE_DIR`/`$DELIVERED_DIR` (see "a named slug is not a list"), and that
2980
+ # path must keep printing the plan's own filename rather than the alias the
2981
+ # caller typed.
2982
+ #
2983
+ # In ref mode the resolution already happened in ref-space and the temp file
2984
+ # was named after its result, so its basename IS the answer — `readlink`
2985
+ # would ask the working tree, which the ref enumeration stopped doing.
2986
+ if [ "$PLAN_SOURCE" = "ref" ]; then
2987
+ plan_base=$(basename "$plan_read")
2988
+ else
2989
+ plan_base=$(basename "$(readlink "$plan" 2>/dev/null || echo "$plan")")
2990
+ fi
2991
+
2992
+ # --log-pulse WRITES, so it needs a path in the working tree — a pulse line
2993
+ # appended to a materialized blob would be discarded with the temp dir, and a
2994
+ # ref is not writable in the first place. Enumeration moved to the ref; the
2995
+ # one write this script performs stays where a write can persist and be
2996
+ # committed. A plan present in the ref but not on this machine is simply not
2997
+ # logged: the alternative is writing a file the operator never checked out.
2998
+ if [ "$PLAN_SOURCE" = "ref" ]; then
2999
+ [ -e "$PLAN_DIR$plan_base" ] && plan_files+=("$PLAN_DIR$plan_base")
3000
+ else
3001
+ plan_files+=("$plan")
3002
+ fi
3003
+
3004
+ # THE PLAN'S IDENTITY FOR THE TERMINAL CACHE — its CONTENT, hashed, not its
3005
+ # name or its mtime. An edited plan yields a different oid and its branches'
3006
+ # cached answers stop validating, which is the invalidation the plan requires:
3007
+ # a plan is an input to the derivation, so an answer derived under one
3008
+ # revision is not evidence about the next.
3009
+ #
3010
+ # `hash-object` reads a file without touching the object database. It is one
3011
+ # fork per PLAN — not per branch — so it does not reintroduce the per-branch
3012
+ # cost this whole change removes.
3013
+ TERMINAL_PLAN_OID=$(git hash-object "$plan_read" 2>/dev/null || echo "")
3014
+
3015
+ # The wave walk is driven by plot-plan-meta.sh's own output, flattened for
3016
+ # this plan by the ONE estate parse above. It was a fresh `python3` per plan
3017
+ # here — 463 interpreter starts on this repo, each re-parsing a JSON document
3018
+ # the scan had just finished parsing. The flattening rule is unchanged; only
3019
+ # the number of interpreters that apply it is.
3020
+ [ "$quiet" = 1 ] || echo "== $plan_base =="
3021
+
3022
+ wave_lines="${plan_meta_waves[$meta_i]}"
3023
+ # The records carry a trailing newline per row; the reader below expects the
3024
+ # same shape the per-plan shim produced, which had none on the last line.
3025
+ wave_lines="${wave_lines%$'\n'}"
3026
+
3027
+ [ -n "$wave_lines" ] || { [ "$quiet" = 1 ] || { echo " (no branches)"; echo; }; continue; }
3028
+
3029
+ # Pass 1: per-branch git state.
3030
+ #
3031
+ # Field order matters: tab is an IFS whitespace character, so bash collapses
3032
+ # a run of tabs into ONE separator. A branch with no claim note would shift
3033
+ # every later field left by one. Everything that must survive `read` is
3034
+ # therefore placed BEFORE the optional claim note, which stays last.
3035
+ # Emitted fields are never empty ("-" stands in), so no tab run can collapse.
3036
+ #
3037
+ # `why` — the deferral reason — is free prose in a MIDDLE column, which is
3038
+ # only safe because of that same rule: it is "-" when absent, never "". The
3039
+ # tabs inside it are replaced with spaces by the shim above, for the same
3040
+ # reason. It cannot go last; `claim` already is.
3041
+ states=""
3042
+ while IFS=$'\t' read -r idx br deferred why wname claim; do
3043
+ [ -n "$br" ] || continue
3044
+ if [ "$deferred" = "true" ]; then st="deferred"; else st=$(branch_state "$br"); fi
3045
+ states+="$idx $br $st $deferred $why $wname $claim"$'\n'
3046
+ done <<< "$wave_lines"
3047
+
3048
+ # Pass 2: wave verdicts. A wave is complete when none of its non-deferred
3049
+ # branches is outstanding; eligible when all PRIOR waves are complete.
3050
+ wave_ids=$(printf '%s' "$states" | cut -f1 | sort -un)
3051
+ prior_ok=1
3052
+ for wid in $wave_ids; do
3053
+ wname=$(printf '%s' "$states" | awk -F'\t' -v w="$wid" '$1==w {print $6; exit}')
3054
+ [ "$wname" = "-" ] && wname=""
3055
+ outstanding=0
3056
+ _loose_degraded_branches=""
3057
+ while IFS=$'\t' read -r idx br st deferred why nm claim; do
3058
+ [ "$idx" = "$wid" ] || continue
3059
+ [ "$st" = "deferred" ] && continue
3060
+ # strict (default): only a merged branch is settled.
3061
+ # loose: pushed work counts too — buys throughput, pays in rebase risk.
3062
+ case "$st" in
3063
+ merged) ;;
3064
+ wip)
3065
+ # Loose only counts pushed work as settled when its PR is verifiably
3066
+ # ready. Unverifiable → treat as outstanding (i.e. behave as strict).
3067
+ if [ "$loose_verifiable" = 1 ] && pr_ready "$br"; then :; else
3068
+ outstanding=$((outstanding + 1))
3069
+ # Track branches where --loose degraded to strict because the
3070
+ # rollup could not be verified — these deserve a warning.
3071
+ if [ -n "$_pr_ready_degraded" ]; then
3072
+ _loose_degraded_branches="${_loose_degraded_branches}${_loose_degraded_branches:+ }$br($_pr_ready_degraded)"
3073
+ fi
3074
+ fi ;;
3075
+ *) outstanding=$((outstanding + 1)) ;;
3076
+ esac
3077
+ done <<< "$states"
3078
+
3079
+ # `eligible` IS A CLAIM ABOUT STARTABILITY, not about wave ordering alone.
3080
+ #
3081
+ # Measured 2026-08-27: every one-wave plan in `not-started` on the live
3082
+ # board read `eligible`, and `plot-dispatch.sh` refused all six of them —
3083
+ # *"plan '<slug>' is still Draft on <ref> — nothing may be dispatched."*
3084
+ # Six of six. Both components were correct and they were answering
3085
+ # different questions: this computed *no earlier wave blocks this one*,
3086
+ # and the reader took it to mean *I can start this*. Those coincide only
3087
+ # for an approved plan.
3088
+ #
3089
+ # THE PHASE IS ALREADY IN HAND. `$plan_phase` was parsed above for the
3090
+ # terminal grouping, so consulting it here costs no read and no host call
3091
+ # — the fix adds a test, not a lookup.
3092
+ #
3093
+ # AN ALLOWLIST OF ONE GOOD PHASE, mirroring `plot-dispatch.sh`'s own gate
3094
+ # (`case "$gate_phase" in approved) ;;`) rather than testing for `draft`.
3095
+ # A denylist is the blocklist-collapse shape this codebase keeps removing:
3096
+ # `design` is documented as a phase whose work cannot yet be handed over,
3097
+ # and `UNKNOWN`/`NONE` are unreadable answers. Under a `draft`-only test
3098
+ # each of those would inherit the good word. The scan and the dispatcher
3099
+ # now refuse the same set, which is the disagreement this removes.
3100
+ #
3101
+ # ORDERING IS STILL COMPUTED FIRST, and `complete` still outranks it: a
3102
+ # wave whose branches have all merged IS complete whatever its plan says,
3103
+ # because that is a statement about work that already landed, not an
3104
+ # invitation to start any. Only the word a reader ACTS on is withheld.
3105
+ #
3106
+ # NOT `blocked`, deliberately. That word means *an earlier wave has not
3107
+ # landed* — an ordering fact that resolves by merging work. This resolves
3108
+ # by a person approving the plan. Folding both into one word would rebuild
3109
+ # the ambiguity one level down, and `blocked by <wave> — 1 branch` is a
3110
+ # sentence a row in this state cannot truthfully complete.
3111
+ if [ "$outstanding" -eq 0 ]; then verdict="complete"
3112
+ elif [ "$plan_phase" != "approved" ]; then verdict="unapproved"
3113
+ elif [ "$prior_ok" -eq 1 ]; then verdict="eligible"
3114
+ else verdict="blocked"; fi
3115
+
3116
+ [ "$quiet" = 1 ] || echo " ${wname:-(unnamed)} — $verdict"
3117
+ # A degradation that says nothing is indistinguishable from a bug.
3118
+ # When --loose falls back to strict because the rollup cannot be had,
3119
+ # say so — an operator who passed the flag and sees strict behaviour
3120
+ # has no way to tell "the rollup said not-green" from "the rollup
3121
+ # could not be had". Both are correct refusals; only one is about
3122
+ # their PR.
3123
+ if [ "$quiet" != 1 ] && [ "$loose" = 1 ] && [ -n "$_loose_degraded_branches" ]; then
3124
+ echo " (--loose degraded to strict: checks unavailable for ${_loose_degraded_branches})"
3125
+ fi
3126
+ json_branches=""
3127
+ while IFS=$'\t' read -r idx br st deferred why nm claim; do
3128
+ [ "$idx" = "$wid" ] || continue
3129
+ [ "$claim" = "-" ] && claim=""
3130
+ [ "$why" = "-" ] && why=""
3131
+ n_branches=$((n_branches + 1))
3132
+ case "$st" in
3133
+ # The REASON, where the plan recorded one. A bare `deferred` beside a
3134
+ # branch with no commits reads as two unrelated facts when the first is
3135
+ # the reason for the second, and the sentence that says so was already
3136
+ # written in the plan file.
3137
+ deferred) n_deferred=$((n_deferred + 1)); note="deferred${why:+ — $why}" ;;
3138
+ claimed) n_claimed=$((n_claimed + 1)); note="claimed${claim:+ ($claim)}" ;;
3139
+ merged) note="merged" ;;
3140
+ wip) note="in progress" ;;
3141
+ *) note="open" ;;
3142
+ esac
3143
+ if [ "$verdict" = "eligible" ] && [ "$st" = "open" ]; then
3144
+ n_eligible=$((n_eligible + 1))
3145
+ claimable+=("$br")
3146
+ fi
3147
+ [ "$quiet" = 1 ] || echo " $br — $note"
3148
+ if [ "$as_json" = 1 ]; then
3149
+ # The INTERNAL state ($st), never the prose label ($note): the board
3150
+ # must not parse a string that exists for humans to read.
3151
+ json_branches+="${json_branches:+,}{\"branch\":\"$(json_str "$br")\""
3152
+ json_branches+=",\"state\":\"$st\",\"deferred\":$deferred"
3153
+ # WHY it was deferred, straight from the plan's annotation. "" where the
3154
+ # branch is not deferred, and "" where it is deferred with nothing
3155
+ # recorded — the flag says which of those two a reader is looking at.
3156
+ json_branches+=",\"deferred_reason\":\"$(json_str "$why")\""
3157
+ json_branches+=",\"claimed\":\"$(json_str "$claim")\""
3158
+ # What this machine knows and the refs do not. Absent everywhere else:
3159
+ # `local_dirty:false` and `local_worktree:""` are what a branch checked
3160
+ # out on somebody else's laptop reports, which is the same answer it
3161
+ # gave before this field existed. Only the JSON carries it — the prose
3162
+ # report is a human interface and the row it feeds lives in the board.
3163
+ wt_row=$(local_worktree_of "$br")
3164
+ wt_dirty=$(printf '%s' "$wt_row" | cut -f2)
3165
+ wt_here=$(printf '%s' "$wt_row" | cut -f1)
3166
+ wt_lock=$(printf '%s' "$wt_row" | cut -f3)
3167
+ json_branches+=",\"local_dirty\":${wt_dirty:-false}"
3168
+ # A write in progress THIS INSTANT — a separate fact from dirtiness, and
3169
+ # false wherever this machine could not observe one, which is the answer
3170
+ # every branch elsewhere gives.
3171
+ json_branches+=",\"local_locked\":${wt_lock:-false}"
3172
+ json_branches+=",\"local_worktree\":\"$(json_str "$wt_here")\""
3173
+ # WHETHER SOMEBODY HOLDS THIS BRANCH — the derivation `local_worktree`
3174
+ # is one input to, not a rename of it. A branch is held when a worktree
3175
+ # here has it checked out AND its tip has not merged: the two facts this
3176
+ # loop already holds, `wt_here` and `$st`. The AND is the whole point.
3177
+ # `local_worktree` alone would also fire on a CLEAN worktree left on a
3178
+ # branch whose work has landed — a leftover directory, not a held branch
3179
+ # — and lifting that to WORKING is the merged-leftover misread the plan
3180
+ # forbids. Excluding `merged` here is what keeps location and holding
3181
+ # apart. `held` may only be true where a worktree is present, so it is
3182
+ # false on every branch on every other machine, exactly like its
3183
+ # neighbours: additive, never a downgrade of the refs answer. It is
3184
+ # emitted for consumers to READ instead of re-deriving `!merged`; it is
3185
+ # never fed back into the wave arithmetic below, which settles waves on
3186
+ # `merged` alone and is untouched by holding.
3187
+ if [ -n "$wt_here" ] && [ "$st" != "merged" ]; then
3188
+ json_branches+=",\"held\":true"
3189
+ else
3190
+ json_branches+=",\"held\":false"
3191
+ fi
3192
+ # WHETHER A REF HOLDS THIS BRANCH — the git fact `plot-dispatch.sh`
3193
+ # tests when it claims, published so consumers stop inferring it.
3194
+ #
3195
+ # NOT A RENAME OF ITS TWO NEIGHBOURS, and the three answer different
3196
+ # questions. `claimed` above is the PLAN FILE's human-written annotation
3197
+ # — the contract calls it "a REFLECTION of a claim, not the claim
3198
+ # itself — where the two disagree, git wins", and this is the git side
3199
+ # of exactly that disagreement. `held` is about a WORKTREE on THIS
3200
+ # machine. This one is about a REF on the remote, so it is the only
3201
+ # claim signal that reads the same from every machine: a branch claimed
3202
+ # by a detached worker on another host reports `held: false` here and
3203
+ # `ref_held: true`, which is the population the misread was measured on.
3204
+ #
3205
+ # FREE. `remote_ref_exists` answers from the `REMOTE_REFS` batch this
3206
+ # scan already read to derive `merged` and `wip` — no git spawn, and no
3207
+ # host call, which `Done when` item 5 pins with the existing no-network
3208
+ # tests.
3209
+ #
3210
+ # WHY THE REF AND NOT THE STATE. A consumer can almost infer this from
3211
+ # `state == "wip"`, and one does today; the inference is incomplete in
3212
+ # both directions. `wip` can be OVERRIDDEN to `merged` by a MERGED PR
3213
+ # while the ref still exists (the resurrected-ref arm in `branch_state`),
3214
+ # and a `claimed` branch — a ref carrying nothing but claim commits —
3215
+ # is a ref no `wip` test sees. Both are refs `plot-dispatch.sh` would
3216
+ # refuse to push over. Asking the refs directly cannot drift from them.
3217
+ #
3218
+ # A STATEMENT ABOUT THE REF ALONE. It does not say the work is
3219
+ # unfinished, that a worker lives, or that the branch should be left
3220
+ # alone — a merged branch whose ref survives reports true, because a ref
3221
+ # does hold it. Whether that matters is the CONSUMER's judgement, and
3222
+ # keeping it out of here is what stops this becoming a second, drifting
3223
+ # copy of the state vocabulary. It is never fed into the wave
3224
+ # arithmetic below, which settles waves on `merged` alone.
3225
+ if remote_ref_exists "$br"; then
3226
+ json_branches+=",\"ref_held\":true"
3227
+ else
3228
+ json_branches+=",\"ref_held\":false"
3229
+ fi
3230
+ # From the REFS, not from the worktree table above — a local branch with
3231
+ # no worktree still holds commits nobody can see. 0 wherever this
3232
+ # machine has no local ref, which is what every branch elsewhere reports.
3233
+ json_branches+=",\"local_ahead\":$(local_ahead_of "$br")"
3234
+ # WHEN THIS BRANCH LAST CHANGED — the one signal above that is a
3235
+ # DERIVATIVE rather than a state. `local_dirty` and `local_ahead` both
3236
+ # read identically for a finished branch and an abandoned one; this
3237
+ # tells them apart. See `changed_ago_of` for what it measures and, more
3238
+ # importantly, for what it deliberately does not conclude.
3239
+ #
3240
+ # `null`, NOT 0, WHERE THIS MACHINE CANNOT SEE. The other local signals
3241
+ # have an absent value that is also a real value — `false` and `0` are
3242
+ # what an unobserved branch honestly reports. Seconds have no such
3243
+ # value: 0 means "changed this instant", the most reassuring answer on
3244
+ # the board, and handing it to every branch on somebody else's machine
3245
+ # would be a fabrication pointing the wrong way. `null` is the only
3246
+ # shape that cannot be mistaken for a measurement.
3247
+ changed_pair=$(changed_ago_of "$br") || changed_pair=""
3248
+ changed_ago=$(printf '%s' "$changed_pair" | cut -f1)
3249
+ # The INSTANT, beside the age. A detector must watch this one: the age
3250
+ # ticks with the clock and would flash a quiet row on every pulse.
3251
+ changed_at=$(printf '%s' "$changed_pair" | cut -f2)
3252
+ json_branches+=",\"changed_ago_seconds\":${changed_ago:-null}"
3253
+ json_branches+=",\"changed_at\":${changed_at:-null}"
3254
+ # Whether anything is actually RUNNING on the branch — see `worker_of`.
3255
+ # The pid and the exit code travel as values rather than as something to
3256
+ # re-derive: a pid of 0 has already been rejected here, and re-deriving
3257
+ # liveness on the far side would spring that trap again.
3258
+ worker_row=$(worker_of "$br")
3259
+ json_branches+=",\"worker\":\"$(printf '%s' "$worker_row" | cut -f1)\""
3260
+ json_branches+=",\"worker_pid\":\"$(json_str "$(printf '%s' "$worker_row" | cut -f2)")\""
3261
+ json_branches+=",\"worker_exit\":\"$(json_str "$(printf '%s' "$worker_row" | cut -f3)")\""
3262
+ # WHETHER A RUNNING WORKER'S CHILD IS WORKING — the secondary cue, a
3263
+ # SIBLING FIELD for the same reason `worker_dirty_paths` is one: the
3264
+ # shared classifier's row is a load-bearing three-field tuple, and this
3265
+ # is a fourth fact measured beside it, not woven into it.
3266
+ #
3267
+ # ONLY ON `running`, because only there does it answer anything. The cue
3268
+ # is *which kind of running is this* — a child mid-work vs. a child that
3269
+ # crashed while the loop waits on it — and every other worker state has
3270
+ # its own word already. Beside `finished` or `stalled` a CPU sample says
3271
+ # nothing a reader needs, so it is "" there, the one absent-value shape
3272
+ # every sibling field uses.
3273
+ #
3274
+ # MEASURED FROM THE PID THE ROW ALREADY CARRIES, not re-derived: field 2
3275
+ # of the same row. `plot_worker_activity` samples that pid's whole
3276
+ # descendant tree twice — the shell is near-zero CPU in every case, so
3277
+ # the CHILD's clock is the discriminator.
3278
+ if [ "$(printf '%s' "$worker_row" | cut -f1)" = "running" ]; then
3279
+ json_branches+=",\"worker_activity\":\"$(json_str "$(plot_worker_activity "$(printf '%s' "$worker_row" | cut -f2)")")\""
3280
+ else
3281
+ json_branches+=",\"worker_activity\":\"\""
3282
+ fi
3283
+ # WHAT IS ON THE FLOOR, beside the verdict that named it.
3284
+ #
3285
+ # A COUNT WOULD HAVE BEEN CHEAPER AND IS NOT ENOUGH. `stalled` exists so
3286
+ # a person can decide whether to resume a branch, and "3 uncommitted
3287
+ # files" does not support that decision — three scratch notes and three
3288
+ # half-finished modules read identically. The names make the row
3289
+ # actionable without a second command, which is the only reason to
3290
+ # report it rather than merely count it.
3291
+ #
3292
+ # A SIBLING FIELD, NOT A FOURTH COLUMN on the worker row. Every answer
3293
+ # from the shared classifier carries exactly three tab-separated fields,
3294
+ # and that is load-bearing: POSIX `cut` prints a line UNCHANGED when it
3295
+ # holds no delimiter, so a row of a different width would land a
3296
+ # filename in the exit-code slot with nothing erroring. One computation
3297
+ # (`plot_worker_dirty`), two renderings — the split this whole file
3298
+ # keeps.
3299
+ #
3300
+ # ONLY ON `stalled`, because only there does it answer anything. Beside
3301
+ # `finished` the same list is the leftovers a merged branch happens to
3302
+ # hold, and printing it would invite exactly the reading `stalled` was
3303
+ # added to prevent.
3304
+ if [ "$(printf '%s' "$worker_row" | cut -f1)" = "stalled" ]; then
3305
+ json_branches+=",\"worker_dirty_paths\":$(json_array "$(plot_worker_dirty "$(local_worktree_of "$br" | cut -f1)")")"
3306
+ else
3307
+ # Empty rather than omitted: one absent-value shape for every
3308
+ # consumer, the rule the local signals above already follow.
3309
+ json_branches+=",\"worker_dirty_paths\":[]"
3310
+ fi
3311
+ # WHICH FILES would collide, and whether the question was asked at all.
3312
+ # The two travel together on purpose: an empty list means "merges
3313
+ # cleanly" ONLY beside `conflicts_known: true`, and reading the list
3314
+ # alone turns every unanswerable branch into a mergeable one.
3315
+ #
3316
+ # The SET, never a verdict on it. Whether a given set is the one
3317
+ # mechanically resolvable case is a decision one layer up — this script
3318
+ # collects and reports.
3319
+ cf_known=$(conflicts_known_of "$br" "$st")
3320
+ json_branches+=",\"conflicts_known\":$cf_known"
3321
+ if [ "$cf_known" = "true" ]; then
3322
+ json_branches+=",\"conflicts\":$(json_array "$(conflicts_of "$br")")"
3323
+ # What the branch touches — one of the three lines a CI failure is
3324
+ # reported with. Evidence for a reader, not an input to any rule here.
3325
+ json_branches+=",\"changed_paths\":$(json_array "$(changed_paths_of "$br")")"
3326
+ else
3327
+ # Not looked at, so nothing is claimed. Empty rather than omitted:
3328
+ # one absent-value shape for every consumer, the rule the local
3329
+ # signals above already follow.
3330
+ json_branches+=",\"conflicts\":[],\"changed_paths\":[]"
3331
+ fi
3332
+ json_branches+="}"
3333
+ fi
3334
+ done <<< "$states"
3335
+
3336
+ if [ "$as_json" = 1 ]; then
3337
+ json_waves+="${json_waves:+,}{\"name\":\"$(json_str "$wname")\""
3338
+ json_waves+=",\"verdict\":\"$verdict\",\"branches\":[$json_branches]}"
3339
+ fi
3340
+
3341
+ n_waves=$((n_waves + 1))
3342
+ [ "$verdict" = "complete" ] || prior_ok=0
3343
+ [ "$verdict" = "blocked" ] && n_blocked=$((n_blocked + 1))
3344
+ done
3345
+ if [ "$as_json" = 1 ]; then
3346
+ # ONE composition, two destinations — the property that makes --stream and
3347
+ # --json say the same thing rather than agreeing by inspection. A second
3348
+ # `printf` shaped like this one would be a second implementation of the
3349
+ # plan object, and the first field added to one and not the other is a
3350
+ # streamed board that quietly renders less than a batch one.
3351
+ #
3352
+ # `plan_base` was resolved once where the plan was admitted — in ref mode
3353
+ # from the ref, in worktree mode by readlink. Recomputing it here would
3354
+ # reintroduce a working-tree read on the JSON path only.
3355
+ json_plan="{\"file\":\"$(json_str "$plan_base")\""
3356
+ # The plan's own phase, reported verbatim. The board composes it with each
3357
+ # branch's git state into a row phase; nothing here decides which column
3358
+ # anything reads.
3359
+ json_plan+=",\"phase\":\"$(json_str "$plan_phase")\""
3360
+ json_plan+=",\"waves\":[$json_waves]}"
3361
+ json_plans+="${json_plans:+,}$json_plan"
3362
+ # THE STREAM'S POINT: this plan is fully derived, so a consumer can render
3363
+ # it now rather than when the eighty-fourth branch resolves. Emitted as one
3364
+ # line so a reader can split on newlines without parsing incrementally, and
3365
+ # tagged so the terminal `pulse` line cannot be mistaken for another plan.
3366
+ #
3367
+ # Flushed by `printf` on a line of its own: a consumer reading this stream
3368
+ # is reading it BECAUSE the whole document takes 18 s, so buffering the
3369
+ # lines until exit would give back exactly what the mode exists to remove.
3370
+ if [ "$stream" = 1 ]; then
3371
+ printf '{"kind":"plan","plan":%s}\n' "$json_plan"
3372
+ fi
3373
+ fi
3374
+ [ "$quiet" = 1 ] || echo
3375
+ done
3376
+
3377
+ # --next: name ONE branch a worker may claim, or stay silent with exit 1.
3378
+ # "Nothing to start" is a normal state, not a failure — the exit code is what
3379
+ # distinguishes it from a name, so callers can branch on it without parsing.
3380
+ if [ "$next_only" = 1 ]; then
3381
+ [ ${#claimable[@]} -gt 0 ] || exit 1
3382
+ if [ "$list_all" = 1 ]; then
3383
+ printf '%s\n' "${claimable[@]}"
3384
+ else
3385
+ printf '%s\n' "${claimable[0]}"
3386
+ fi
3387
+ exit 0
3388
+ fi
3389
+
3390
+ # --log-pulse: append ONE line per plan, clean pulses included. Without a
3391
+ # record of quiet pulses an idle fleet and a dead fleet are indistinguishable.
3392
+ # This is a LOG, not state: deleting it changes no behaviour, because the next
3393
+ # pulse re-derives everything from git.
3394
+ if [ "$log_pulse" = 1 ]; then
3395
+ stamp=$(date -u +%Y-%m-%dT%H:%MZ)
3396
+ line="<!-- pulse: $stamp — waves=$n_waves eligible=$n_eligible claimed=$n_claimed blocked=$n_blocked deferred=$n_deferred -->"
3397
+ for pf in ${plan_files[@]+"${plan_files[@]}"}; do
3398
+ real=$(cd "$(dirname "$pf")" && readlink "$(basename "$pf")" 2>/dev/null || true)
3399
+ target=$([ -n "$real" ] && echo "$(dirname "$pf")/$real" || echo "$pf")
3400
+ [ -f "$target" ] || continue
3401
+ if grep -q '^## Notes' "$target" 2>/dev/null; then
3402
+ awk -v ln="$line" '
3403
+ /^## Notes/ && !done { print; print ""; print ln; done=1; next }
3404
+ { print }
3405
+ ' "$target" > "$target.tmp" && mv "$target.tmp" "$target"
3406
+ else
3407
+ printf '\n## Notes\n\n%s\n' "$line" >> "$target"
3408
+ fi
3409
+ done
3410
+ fi
3411
+
3412
+ # --json: the same derivation as the prose above, rendered for machines. It is
3413
+ # an OUTPUT MODE and nothing more — it composes with --offline/--no-fetch/
3414
+ # --loose rather than implying any of them, so the board's data depends on what
3415
+ # it asked for, not on how it asked. --next wins over it (handled above): that
3416
+ # is a different question with a one-line answer.
3417
+ if [ "$as_json" = 1 ]; then
3418
+ # --stream wraps the SAME document in one tagged line rather than emitting a
3419
+ # second, smaller one. The terminal object is what proves the scan finished:
3420
+ # a consumer that has seen `plan` lines and no `pulse` line has a PARTIAL
3421
+ # answer and must say so — which is the whole distinction this mode adds, and
3422
+ # the reason the end is marked rather than inferred from the pipe closing.
3423
+ # A killed scan closes the pipe too.
3424
+ [ "$stream" = 1 ] && printf '{"kind":"pulse","pulse":'
3425
+ # `read_ref` is the ref this document was derived from; `local_head` is the
3426
+ # checkout it was derived ON. A consumer needs both to tell "the board is
3427
+ # current" from "the board is current about an old world".
3428
+ #
3429
+ # `head` repeats `local_head` as an alias for one release. The board reads it
3430
+ # today; it goes away once the board reads the pair.
3431
+ printf '{"main":"%s","read_ref":"%s","local_head":"%s","head":"%s",' \
3432
+ "$(json_str "$MAIN")" "$(json_str "$READ_REF")" "$(json_str "$LOCAL_HEAD")" \
3433
+ "$(json_str "$HEAD_SHORT")"
3434
+ # Three more facts about the EVIDENCE, not about the fleet — a consumer that
3435
+ # renders the numbers below should be able to say how much to trust them.
3436
+ # They answer the question `read_ref` raises: that field names the ref, and
3437
+ # these say whether reading it succeeded and whether the plans came from it.
3438
+ #
3439
+ # `fetch_failed` used to be discarded by `2>/dev/null`, so refs an hour old
3440
+ # were reported with the confidence of refs a second old. `plan_source` says
3441
+ # whether the plan list came from the ref or fell back to this checkout.
3442
+ printf '"fetch_failed":%s,"fetch_error":"%s","plan_source":"%s","plans":[%s],' \
3443
+ "$([ "$FETCH_FAILED" = 1 ] && echo true || echo false)" \
3444
+ "$(json_str "$FETCH_ERROR")" "$(json_str "$PLAN_SOURCE")" "$json_plans"
3445
+ printf '"summary":{"plans":%d,"waves":%d,"branches":%d,"claimed":%d,' \
3446
+ "$n_plans" "$n_waves" "$n_branches" "$n_claimed"
3447
+ printf '"eligible":%d,"blocked":%d,"deferred":%d,"merge_detect":"%s"}}' \
3448
+ "$n_eligible" "$n_blocked" "$n_deferred" "$MERGE_DETECT"
3449
+ [ "$stream" = 1 ] && printf '}'
3450
+ printf '\n'
3451
+ exit 0
3452
+ fi
3453
+
3454
+ # A saturated merge walk is STATED, never silent. A branch merged before the
3455
+ # cap reads `open`, which is acceptable only while the scan says it stopped
3456
+ # looking — a silent cap would make the report lie in the one direction this
3457
+ # check was written to stop.
3458
+ if [ "$MERGE_SCAN_TRUNCATED" = 1 ]; then
3459
+ echo " note: merge scan hit its limit of $MERGE_SCAN_LIMIT — older merges were not"
3460
+ echo " examined; a branch merged before that point may still read as open."
3461
+ fi
3462
+ # A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
3463
+ # hour-old refs read exactly like a scan of current ones — the same
3464
+ # over-confidence, one layer up, that this plan fixes in the plan list.
3465
+ if [ "$FETCH_FAILED" = 1 ]; then
3466
+ echo " note: git fetch failed — these refs are as current as your last"
3467
+ echo " successful fetch, not as current as origin/$MAIN."
3468
+ echo " $FETCH_ERROR"
3469
+ # A failed fetch also failed to PRUNE, and that has a sharper consequence
3470
+ # than staleness alone: an unpruned ref sends branch_state() down the
3471
+ # ancestry arm, where a squash merge reads `wip`. Said plainly, because the
3472
+ # symptom — a finished wave that will not complete — looks nothing like
3473
+ # "your fetch failed".
3474
+ echo " Stale remote-tracking refs were not pruned either, so a branch"
3475
+ echo " merged and deleted upstream may still read wip."
3476
+ fi
3477
+ # AN OFFLINE SCAN CANNOT PRUNE, and the answer that costs is not obvious.
3478
+ # --offline skips the fetch, so refs for branches deleted upstream survive, and
3479
+ # a surviving ref is what makes a squash-merged branch read `wip` and its wave
3480
+ # read blocked. Reporting the flag alone would leave the operator to derive
3481
+ # that; this states the consequence instead. Only under the prose report — the
3482
+ # machine renderings carry `fetch_failed` and the caller passed --offline
3483
+ # itself, so neither is being told something it does not know.
3484
+ if [ "$do_fetch" = 0 ]; then
3485
+ echo " note: --offline skipped the fetch, so stale remote-tracking refs were"
3486
+ echo " not pruned; a branch merged and deleted upstream may read wip"
3487
+ echo " and hold its wave blocked. Re-run without --offline to settle it."
3488
+ fi
3489
+ # The fallback announces itself. Silent degradation here would recreate the
3490
+ # very bug being fixed: a working-tree plan list reported as if it were the ref.
3491
+ if [ "$PLAN_SOURCE" != "ref" ]; then
3492
+ echo " note: origin/$MAIN could not be read — plans were listed from this"
3493
+ echo " checkout instead, so the list is only as current as your last pull."
3494
+ fi
3495
+ echo "Pulse complete. This report is derived — nothing was changed."
3496
+ echo "summary: plans=$n_plans waves=$n_waves branches=$n_branches claimed=$n_claimed eligible=$n_eligible blocked=$n_blocked deferred=$n_deferred merge_detect=$MERGE_DETECT main=$MAIN"