@plot-pm/board 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-plan-meta.sh CHANGED
@@ -20,9 +20,17 @@
20
20
  # Two plan formats are recognized:
21
21
  #
22
22
  # canonical the plan template's `## Status` body section:
23
- # - **Phase:** Approved
23
+ # - **State:** Approved
24
24
  # - **Type:** feature
25
- # (bullet, bold, and plain `Phase: ...` variants all accepted)
25
+ # (bullet, bold, and plain `State: ...` variants all accepted)
26
+ #
27
+ # `State:` is the field Plot writes. `Phase:` is the name it
28
+ # carried until 2026-09-07 and is read as the alternate, the
29
+ # same way front matter reads `status:` over `phase:` below.
30
+ # THE DUAL READ IS PERMANENT, NOT SCAFFOLDING: a plan file may
31
+ # have been written a year ago or copied from another project,
32
+ # and a Plot that refused to read `Phase:` would be worse at its
33
+ # own job than the one that confused two words.
26
34
  #
27
35
  # frontmatter YAML front matter at the top of the file:
28
36
  # ---
@@ -88,7 +96,8 @@
88
96
  # which is a queue. (`ready-for-review`/`in-review` DO
89
97
  # normalize onto `approved` — those are synonyms; this is not.)
90
98
  # phase_alt_raw secondary value when the file carries two (front matter
91
- # status: AND phase:), else ""
99
+ # status: AND phase:, or a `## Status` body carrying both
100
+ # State: AND Phase:), else ""
92
101
  # phase_alt normalized phase_alt_raw (NONE when absent)
93
102
  # type normalized plan type (feature|bug|docs|infra or "")
94
103
  # title plan title: front matter `title:` wins, else the first H1
@@ -128,6 +137,19 @@
128
137
  # says: a prerequisite no plan declares still parses, and the
129
138
  # scan is what turns that into a verdict. `waits:` and
130
139
  # `deferred:` are independent — a branch may carry both.
140
+ # `<!-- builds: normalizeVersion, a shared helper -->` names
141
+ # what this slice BUILDS, reported as
142
+ # `waves[].branches[].builds`. OPTIONAL, like `Sprint:` and
143
+ # `Story:` — a docs plan, a rejection or a measurement builds
144
+ # nothing nameable, and nothing warns about its absence. The
145
+ # key is ABSENT where none was written, never "". The value
146
+ # runs to the closing marker rather than stopping at the first
147
+ # space the way `waits:` does: a prerequisite is a branch NAME
148
+ # and a deliverable is a name plus enough words to search for.
149
+ # An annotation rather than a `Builds:` field line precisely
150
+ # BECAUSE annotations already work in both slice dialects from
151
+ # one block of code — a field line would need two spellings,
152
+ # and the template writes the list dialect.
131
153
  # prs PR numbers, sorted and unique, read from EITHER spelling:
132
154
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
133
155
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -382,7 +404,7 @@ function reset_state() {
382
404
  fm_review = ""; fm_impl = ""; fm_approved = ""; fm_started = ""; fm_released = ""
383
405
  fm_delivered = ""; fm_design = ""
384
406
  fm_rounds = ""
385
- canon_phase = ""; canon_type = ""
407
+ canon_state = ""; canon_phase = ""; canon_type = ""
386
408
  canon_sprint = ""; canon_story = ""; canon_assignee = ""
387
409
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
388
410
  canon_delivered = ""; canon_design = ""
@@ -401,6 +423,7 @@ function reset_state() {
401
423
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
402
424
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
403
425
  delete waits_of; delete waits_set
426
+ delete builds_of; delete builds_set
404
427
  delete started; n_started = 0
405
428
  fm_changelog = ""
406
429
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -411,8 +434,14 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
411
434
  praw = (fm_status != "") ? fm_status : fm_phase
412
435
  palt_raw = (fm_status != "" && fm_phase != "") ? fm_phase : ""
413
436
  traw = fm_type
414
- } else if (canon_phase != "") {
415
- fmt = "canonical"; praw = canon_phase; palt_raw = ""; traw = canon_type
437
+ } else if (canon_state != "" || canon_phase != "") {
438
+ # `State:` is primary and `Phase:` the alternate, exactly as front matter
439
+ # reads `status:` over `phase:`. A file carrying both reports the
440
+ # disagreement rather than hiding it.
441
+ fmt = "canonical"
442
+ praw = (canon_state != "") ? canon_state : canon_phase
443
+ palt_raw = (canon_state != "" && canon_phase != "") ? canon_phase : ""
444
+ traw = canon_type
416
445
  } else {
417
446
  fmt = "none"; praw = ""; palt_raw = ""; traw = ""
418
447
  }
@@ -551,6 +580,10 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
551
580
  # reading `waits_on` gets a branch name or nothing — never a blank string
552
581
  # that reads as a prerequisite with no name.
553
582
  if (waits_set[i] == 1) out = out ",\"waits_on\":\"" jesc(waits_of[i]) "\""
583
+ # ABSENT, NOT EMPTY, for the same reason `waits_on` is: a slice that
584
+ # names no deliverable emits no key, so a consumer reads a name or
585
+ # nothing. An empty string would read as a deliverable called "".
586
+ if (builds_set[i] == 1) out = out ",\"builds\":\"" jesc(builds_of[i]) "\""
554
587
  out = out "}"
555
588
  first = 0
556
589
  }
@@ -748,7 +781,8 @@ in_comment {
748
781
  }
749
782
  section == "status" {
750
783
  lower = tolower($0)
751
- if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
784
+ if (lower ~ /^[ \t]*[-*]?[ \t]*\**state[:*]/ && canon_state == "") canon_state = val_after_colon($0)
785
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
752
786
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**type[:*]/ && canon_type == "") canon_type = val_after_colon($0)
753
787
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**sprint[:*]/ && canon_sprint == "") canon_sprint = val_after_colon($0)
754
788
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**story[:*]/ && canon_story == "") canon_story = val_after_colon($0)
@@ -925,6 +959,39 @@ section == "slices" && slice_shape != "heading" {
925
959
  # `has_waits` carries presence separately from the value, because ABSENT and
926
960
  # EMPTY are different answers — a branch declaring no prerequisite emits no
927
961
  # `waits_on` key at all.
962
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
963
+ #
964
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
965
+ # slice dialects with no dialect-specific code. Annotations bind to the line
966
+ # carrying the branch name — the list item in one spelling, the `### `
967
+ # heading in the other — and both dialects already read `deferred:`,
968
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
969
+ # would have had two spellings to parse, and the template writes the LIST
970
+ # dialect, so the heading-only version would be absent from every plan
971
+ # created from it.
972
+ #
973
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
974
+ # and each slice builds one; a plan-level list is searched as a whole and
975
+ # reported against the wrong slice.
976
+ #
977
+ # The value runs to the closing marker, the way `deferred:` does and unlike
978
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
979
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
980
+ # a shared helper` — and cutting it at the first space would leave the half
981
+ # that identifies it.
982
+ #
983
+ # `has_builds` carries presence separately from the value, exactly as
984
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
985
+ # a deliverable or nothing and never a blank string that looks like one.
986
+ builds_note = ""
987
+ has_builds = 0
988
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
989
+ _bl = $0
990
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
991
+ sub(/[ \t]*-->.*$/, "", _bl)
992
+ builds_note = trim(_bl)
993
+ if (builds_note != "") has_builds = 1
994
+ }
928
995
  waits_note = ""
929
996
  has_waits = 0
930
997
  if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
@@ -982,6 +1049,8 @@ section == "slices" && slice_shape != "heading" {
982
1049
  # so a branch that declares none emits no key.
983
1050
  waits_of[n_branches] = waits_note
984
1051
  waits_set[n_branches] = has_waits
1052
+ builds_of[n_branches] = builds_note
1053
+ builds_set[n_branches] = has_builds
985
1054
  ordered_b[n_branches] = b
986
1055
  }
987
1056
  line = $0
@@ -1066,6 +1135,39 @@ section == "slices" && slice_shape == "heading" {
1066
1135
  # The prerequisite, read exactly as the list-item spelling reads it. Both
1067
1136
  # dialects emit the same waves[], so a field added to one only would break
1068
1137
  # that contract the first time a plan migrated.
1138
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
1139
+ #
1140
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
1141
+ # slice dialects with no dialect-specific code. Annotations bind to the line
1142
+ # carrying the branch name — the list item in one spelling, the `### `
1143
+ # heading in the other — and both dialects already read `deferred:`,
1144
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
1145
+ # would have had two spellings to parse, and the template writes the LIST
1146
+ # dialect, so the heading-only version would be absent from every plan
1147
+ # created from it.
1148
+ #
1149
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
1150
+ # and each slice builds one; a plan-level list is searched as a whole and
1151
+ # reported against the wrong slice.
1152
+ #
1153
+ # The value runs to the closing marker, the way `deferred:` does and unlike
1154
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
1155
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
1156
+ # a shared helper` — and cutting it at the first space would leave the half
1157
+ # that identifies it.
1158
+ #
1159
+ # `has_builds` carries presence separately from the value, exactly as
1160
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
1161
+ # a deliverable or nothing and never a blank string that looks like one.
1162
+ builds_note = ""
1163
+ has_builds = 0
1164
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
1165
+ _bl = $0
1166
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
1167
+ sub(/[ \t]*-->.*$/, "", _bl)
1168
+ builds_note = trim(_bl)
1169
+ if (builds_note != "") has_builds = 1
1170
+ }
1069
1171
  waits_note = ""
1070
1172
  has_waits = 0
1071
1173
  if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
@@ -1107,6 +1209,8 @@ section == "slices" && slice_shape == "heading" {
1107
1209
  claimed_of[n_branches] = claim_note
1108
1210
  waits_of[n_branches] = waits_note
1109
1211
  waits_set[n_branches] = has_waits
1212
+ builds_of[n_branches] = builds_note
1213
+ builds_set[n_branches] = has_builds
1110
1214
  ordered_b[n_branches] = b
1111
1215
  }
1112
1216
 
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
3
+ #
4
+ # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged` and
5
+ # `pr_open`; the file does nothing else on load. That is what makes sourcing it
6
+ # safe, and it is the same shape — and the same reason — as
7
+ # `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
8
+ # because that file parses `$@` and `exit 2`s on an unknown argument at load
9
+ # time, so sourcing it would run the reaper's argument parser against its
10
+ # caller's arguments.
11
+ #
12
+ # FOUR CALLERS. `plot-reap.sh`, `plot-release-refs.sh`, `plot-dispatch.sh`, and
13
+ # `plot-quiet-stretch.sh` — the last sourcing it guarded and calling it behind
14
+ # `command -v pr_merged`, so it still works with this file absent.
15
+ #
16
+ # WHY IT WAS EXTRACTED. `plot-reap.sh` and `plot-release-refs.sh` gate on the
17
+ # SAME fact — has this branch's work landed — and they must never disagree
18
+ # about it. The reaper removes a checkout, which is re-creatable with
19
+ # `git worktree add`; ref deletion is not re-creatable at all. A second
20
+ # implementation that drifted toward permissive would therefore fail in the
21
+ # direction that cannot be undone. One function, one answer, no drift.
22
+ #
23
+ # THIS FILE IS NOW THE ADAPTER, AND THE DECISION IS THE DOMAIN'S. The two
24
+ # functions still ask the host, because the host is asked in shell; what they no
25
+ # longer do is decide. Each turns its lookup into one of three READINGS —
26
+ # `found`, `none`, `unaskable` — and hands the pair to
27
+ # `board/plot-landed.mjs`, which bundles `rules/landed.ts`. The layering runs
28
+ # one way: caller → this adapter → the rule.
29
+ #
30
+ # WHAT MOVED IS THE COUPLING. `pr_merged` and `pr_open` fail in the SAME
31
+ # direction and to OPPOSITE effect — an unreachable host makes the first refuse
32
+ # a removal and the second release its veto — so neither is safe alone and the
33
+ # pair is. That was a comment in this file and could not be checked. It is now
34
+ # `mayRemove` in the rule, asserted over all nine combinations of the two
35
+ # readings, and exactly one of them permits a removal.
36
+ #
37
+ # WHY THIS STILL ASKS `gh` AND NOT `plot-host.sh`. Measured 2026-09-06, and it
38
+ # is the answer to a question this file's exemption in
39
+ # `scripts/check-host-cli-callers.sh` was left open for.
40
+ #
41
+ # `plot-host.sh pr-merged` prints `merged`/`not-merged`/`unknown`, three words
42
+ # that read like the three readings below. THEY DO NOT MATCH. An ABSENT CLI
43
+ # arrives from the adapter as `not-merged`, where `_plot_merged_lookup` answers
44
+ # `unaskable`:
45
+ #
46
+ # plot-host.sh pr-merged <branch> → not-merged (exit 0)
47
+ # _plot_merged_lookup <branch> → unaskable
48
+ #
49
+ # The cause is the adapter's `is_lookup_miss`. A missing binary makes the shell
50
+ # say `bash: gh: command not found`, and that matches the same `not found` it
51
+ # uses to recognise a genuine "no pull requests found" — one phrase covering two
52
+ # conditions the rule below exists to keep apart.
53
+ #
54
+ # THE DIRECTION IS WHY IT BLOCKS RATHER THAN ANNOYS. `not-merged` reads as
55
+ # `none` — the host spoke and said nothing merged — so `mayRemove` may permit a
56
+ # removal where `unaskable` refuses. `plot-release-refs.sh` deletes remote refs
57
+ # on this answer and a deleted ref is not re-creatable.
58
+ #
59
+ # AND `pr_open` HAS NO OP TO CALL AT ALL. It needs found/none/unaskable about
60
+ # ANY open PR; `pr-state` answers about ONE — the newest — and reports a failed
61
+ # lookup with the same `state:"NONE"` payload as a real absence. It can express
62
+ # neither "any" nor "unaskable".
63
+ #
64
+ # THE COST WAS MEASURED TOO, and it is the smaller objection. Ten sequential
65
+ # calls on this machine: 4.75 s direct, 9.25 s through the adapter — +450 ms per
66
+ # branch, from the connector's slot and budget accounting rather than from the
67
+ # spawn. Across the 48 branches the fleet scan walks that is ~21 s. Real, but it
68
+ # is the correctness gap above that decides this, not the clock.
69
+ #
70
+ # Both halves are pinned in `test/reconcile/host.test.mjs`, so the exemption
71
+ # rests on tests that fail when it stops being true.
72
+ #
73
+ # `mergedAt` IS READ, NEVER `state`. A merged PR reports state CLOSED, and
74
+ # trusting `state` would refuse every squash-merged branch — which is the whole
75
+ # population these scripts exist for. Squash-merge rewrites the commits, so the
76
+ # branch stays "ahead of main" forever and ancestry alone can never clear it.
77
+ #
78
+ # AND THE QUESTION IS "ANY", NOT "THE NEWEST". This asked with `--limit 1`
79
+ # until 2026-08-27, which returns only the most recent PR — so a newer,
80
+ # unmerged PR sitting in front of the real merge reported `no merged PR` about
81
+ # a branch whose work was on main. Measured that day against the live host:
82
+ #
83
+ # an-unreachable-host-says-so newest #473 null → real merge #446
84
+ # the-scan-sees-a-stale-sprint-tally newest #464 null → real merge #463
85
+ # a-plan-cites-a-jira-key newest #476 null → real merge #447
86
+ #
87
+ # The masking PRs were ones the fleet opened ITSELF on already-merged waves,
88
+ # which closes a loop: a leftover worktree lets auto-dispatch adopt a merged
89
+ # branch, its worker opens a duplicate, the duplicate is newer, the reaper
90
+ # keeps the worktree — the input to step one. Reading only the newest PR is the
91
+ # SAME error as reading `state`, one level out: the newest PR is not the merge,
92
+ # just as the state is not the merge.
93
+ #
94
+ # 100 rather than unbounded: `gh` has no "all" sentinel, and this runs on paths
95
+ # where the estate may hold dozens of branches. A branch carrying more than 100
96
+ # PRs whose only merge is the oldest would still be missed — a far narrower
97
+ # window than "any duplicate at all", and it fails SAFE, toward keeping.
98
+
99
+ # Where the rule lives, resolved from THIS file rather than from the cwd.
100
+ #
101
+ # Both callers run with their cwd wherever the operator invoked them, and the
102
+ # reconcile suite runs them against sandbox repos in the temp directory. The
103
+ # artifact sits beside this script in the plot checkout and in the published
104
+ # npm package alike, which is why both are vendored together.
105
+ _plot_landed_mjs="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/board/plot-landed.mjs"
106
+
107
+ # Ask the rule about one branch's two lookups.
108
+ #
109
+ # $1=merged reading, $2=open reading, both `found`/`none`/`unaskable`.
110
+ # Prints the rule's two words; returns non-zero when it could not be asked at
111
+ # all, which every caller below reads as the refusing direction.
112
+ _plot_landed() {
113
+ [ -f "$_plot_landed_mjs" ] || return 1
114
+ printf '%s\t%s\n' "$1" "$2" | node "$_plot_landed_mjs" 2>/dev/null
115
+ }
116
+
117
+ # What the host says about ANY PR on this branch: found / none / unaskable.
118
+ #
119
+ # A MISSING CLI, AN UNAUTHED ONE AND A NETWORK FAILURE ARE ALL `unaskable`, and
120
+ # they are not `none`. The whole rule turns on that difference — a lookup that
121
+ # ran and matched nothing is the host speaking, while a lookup that did not run
122
+ # is silence, and silence is never permission.
123
+ _plot_merged_lookup() {
124
+ local br="$1" out
125
+ command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
126
+ out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt 2>/dev/null) \
127
+ || { echo unaskable; return; }
128
+ case "$out" in *'"mergedAt":"'*) echo found ;; *) echo none ;; esac
129
+ }
130
+
131
+ # What the host says about an OPEN PR on this branch: found / none / unaskable.
132
+ _plot_open_lookup() {
133
+ local br="$1" out
134
+ command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
135
+ out=$(gh pr list --head "$br" --state open --limit 1 --json number 2>/dev/null) \
136
+ || { echo unaskable; return; }
137
+ case "$out" in *'"number"'*) echo found ;; *) echo none ;; esac
138
+ }
139
+
140
+ # Did the host merge ANY PR for this branch?
141
+ #
142
+ # Returns 0 (merged) / 1 (not merged, or the host cannot be asked). The failure
143
+ # direction is deliberate and load-bearing: an unreachable host, an unauthed
144
+ # `gh`, a missing CLI all answer "not merged", so every caller KEEPS what it
145
+ # was considering removing. Silence is never permission.
146
+ #
147
+ # The rule answers `unknown` on that silence and this function reports it as a
148
+ # refusal, which is the same contract the four callers were written against —
149
+ # `plot-reap.sh:385` says so explicitly. A rule that cannot be reached at all
150
+ # refuses here too, for the same reason.
151
+ pr_merged() {
152
+ local answer
153
+ answer=$(_plot_landed "$(_plot_merged_lookup "$1")" none) || return 1
154
+ case "$answer" in landed*) return 0 ;; *) return 1 ;; esac
155
+ }
156
+
157
+ # Does the branch have an OPEN PR right now?
158
+ #
159
+ # A DIFFERENT question from `pr_merged`, and it exists for a case measured by
160
+ # hand on 2026-08-28. `changeset-release/main` is merged — repeatedly — and
161
+ # Changesets RECREATES and reuses the very same branch for the next release.
162
+ # Its ref carries a live release PR while an older PR of its own has merged, so
163
+ # the merged gate alone says "delete" about a branch somebody is actively
164
+ # using.
165
+ #
166
+ # So an open PR VETOES a deletion even where an older one merged. That is
167
+ # strictly narrower than the merge gate rather than a second opinion on it: it
168
+ # can only ever keep a ref, never release one.
169
+ #
170
+ # Returns 0 when an open PR exists. An unreachable host returns 1 — and note
171
+ # that this is the SAME direction as `pr_merged`'s failure, but it has the
172
+ # OPPOSITE effect, since this answer vetoes. The safety therefore does not come
173
+ # from this function: it comes from `pr_merged` already having refused on the
174
+ # same silence, so a host that cannot be asked deletes nothing regardless.
175
+ # `mayRemove` is where that pair is now asserted rather than described.
176
+ pr_open() {
177
+ local answer
178
+ answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
179
+ case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
180
+ }
package/plot-reap.sh CHANGED
@@ -18,6 +18,15 @@
18
18
  # whose agent never existed, and 2 dirty desks holding 52 and 1 files that
19
19
  # every run refused and nothing ever resolved.
20
20
  #
21
+ # AND ONE THING IT ONLY REPORTS: a worktree git lists whose directory is gone.
22
+ # `git worktree list --porcelain` calls it `prunable`, and it is NOT a sixth
23
+ # refusal. A refusal says *do not remove this* and sends an operator to look; a
24
+ # vanished entry says *there is nothing to remove and the entry is stale*, with
25
+ # `git worktree prune` as the repair. It is reported before the refusals are
26
+ # asked, because it is the prior question: four of the five measure something
27
+ # inside a tree that is not there. Measured 2026-09-06 on this estate, 3 of 20
28
+ # worktrees were prunable and this script named none of them.
29
+ #
21
30
  # EVERY KIND KEEPS ONE SHAPE: `--dry-run` by default, acting on `--yes`,
22
31
  # bounded by `--max N`. The bound is PER KIND, because the kinds are different
23
32
  # acts on different populations — a run bounded to five worktrees has not
@@ -317,12 +326,41 @@ manifest_for() {
317
326
  return 1
318
327
  }
319
328
 
320
- reap=0; kept=0; removed=0; cleared=0
329
+ reap=0; kept=0; removed=0; cleared=0; vanished=0
321
330
  printf '%-8s %-52s %s\n' "verdict" "branch" "why"
322
331
 
323
- while IFS=$'\t' read -r wt br; do
332
+ while IFS=$'\t' read -r wt br prunable; do
324
333
  [ -n "$wt" ] || continue
325
334
  short=${br#refs/heads/}
335
+ [ "$wt" = "$ROOT" ] && continue
336
+
337
+ # 4a. GIT'S OWN ANSWER THAT THE DIRECTORY IS GONE, and it is a REPORT rather
338
+ # than a sixth refusal. The five below each say *do not remove this* and
339
+ # send an operator to look; this says *there is nothing to remove and the
340
+ # entry is stale*, and names the repair. Blurring the two would tell
341
+ # somebody to go and inspect a directory that is not there.
342
+ #
343
+ # IT IS ASKED FIRST because it is the PRIOR question. Four of the five
344
+ # refusals measure something inside the tree — a pid file, a marker, the
345
+ # porcelain status, the checked-out branch — and every one of them is
346
+ # unanswerable here. `rules/reapable.ts` says the same thing in the
347
+ # domain: a `vanished` tree makes those four conditions `unknown`.
348
+ #
349
+ # AND IT IS ASKED BEFORE THE DISPATCH-TREE FILTER, which would otherwise
350
+ # hide exactly this population. That filter accepts a tree by its
351
+ # `.plot-worker.pid` file or its legacy `plot-wt-` path — and a vanished
352
+ # desk under `Worktree root` has neither, the pid file having gone with
353
+ # the directory. Measured 2026-09-06: 3 of 20 worktrees here were
354
+ # prunable and the reaper reported none of them.
355
+ #
356
+ # NOTHING IS PRUNED ON THIS PATH. The reading came from the listing the
357
+ # loop already makes, no `git` call was added for it, and whether to
358
+ # prune stays the operator's decision — the same discipline that makes
359
+ # every refusal a measurement rather than an act.
360
+ if [ "$prunable" = "yes" ]; then
361
+ printf '%-8s %-52s %s\n' "vanished" "$short" "directory gone — 'git worktree prune' clears the entry"
362
+ vanished=$((vanished+1)); continue
363
+ fi
326
364
 
327
365
  # 5. Only dispatch trees. A hand-made worktree and the main checkout are not
328
366
  # this script's to remove, whatever state they are in.
@@ -346,7 +384,6 @@ while IFS=$'\t' read -r wt br; do
346
384
  if [ ! -f "$wt/.plot-worker.pid" ]; then
347
385
  case "$wt" in *"/plot-wt-"*) ;; *) continue ;; esac
348
386
  fi
349
- [ "$wt" = "$ROOT" ] && continue
350
387
 
351
388
  # THE READINGS. Everything from here to the rule call MEASURES; nothing
352
389
  # decides. Each of the four sources that can answer is read once, into a
@@ -514,7 +551,10 @@ NODE_EOF
514
551
  fi
515
552
  fi
516
553
  done < <(git worktree list --porcelain \
517
- | awk '/^worktree /{p=$2} /^branch /{print p"\t"$2}')
554
+ | awk '/^worktree /{ if (br != "") print p"\t"br"\t"pr; p=$2; br=""; pr="no"; next }
555
+ /^branch / { br=$2; next }
556
+ /^prunable/ { pr="yes"; next }
557
+ END { if (br != "") print p"\t"br"\t"pr }')
518
558
 
519
559
  [ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
520
560
 
@@ -900,5 +940,5 @@ fi
900
940
  # which is why `plot-release-refs.sh` deletes those, plan-scoped, under its own
901
941
  # licence and its own five guards. The asymmetry between the kinds is the whole
902
942
  # safety argument and it stays.
903
- echo "summary: reapable=$reap removed=$removed kept=$kept cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
943
+ echo "summary: reapable=$reap removed=$removed kept=$kept vanished=$vanished cleared=$cleared branches=$swept_branches branches_deleted=$deleted_branches branches_kept=$kept_branches claims=$swept_claims claims_deleted=$deleted_claims claims_kept=$kept_claims dirty_trees=$dirty_trees dry_run=$DRY"
904
944
  exit 0