@plot-pm/board 0.9.0 → 0.10.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
@@ -37,7 +37,7 @@
37
37
  # A file with neither is reported as format "none" (pre-plot / legacy plan).
38
38
  #
39
39
  # The IMPLEMENTATION section (which branches, in which waves, with which PRs)
40
- # has TWO spellings, and this parser reads both:
40
+ # has THREE spellings, and this parser reads all of them:
41
41
  #
42
42
  # ## Branches (old) the branch rides the list line, meta mixed with prose:
43
43
  # ### Removed
@@ -47,6 +47,14 @@
47
47
  # ### Removed (Branch: bug/foo, PR: #300)
48
48
  # - loses its half
49
49
  #
50
+ # ## Slices the design-spec word for the same shape as `## Waves`,
51
+ # read by the same handler. A Slice holds one branch and
52
+ # belongs to one plan; a Wave is the fleet cohort that
53
+ # spans plans. The section here was always the former, so
54
+ # `## Slices` is the accurate name and `## Waves` is the
55
+ # one 132 delivered plans already carry. No plan is
56
+ # rewritten: both are read, forever if need be.
57
+ #
50
58
  # Both emit the SAME branches/prs/waves arrays. The new shape is the format Plot
51
59
  # writes and documents; the old one is kept readable because a format change owes
52
60
  # its estate a migration that moves files one at a time, and a plan moved one
@@ -95,8 +103,9 @@
95
103
  # branches branch names, sorted and unique, read from EITHER spelling:
96
104
  # the old `## Branches` section (a LIST ITEM whose first token
97
105
  # is the backtick-quoted name, matching the known prefixes) OR
98
- # the new `## Waves` section (`Branch:` in a `### ` heading —
99
- # see below). A backticked branch name anywhere else under
106
+ # the new `## Waves` / `## Slices` section (`Branch:` in a
107
+ # `### ` heading — see below). A backticked branch name
108
+ # anywhere else under
100
109
  # `## Branches` — mid-sentence, in a blockquote, in a comment,
101
110
  # on a wrapped continuation line — is a CITATION and claims
102
111
  # nothing: plans name each other branches to declare
@@ -658,7 +667,14 @@ in_fence { next }
658
667
  # does. A plan carries one or the other — but the parser reads both while the
659
668
  # migration moves 85 files, so a file moved one commit early never reads
660
669
  # as silently empty.
661
- else if ($0 ~ /^## Waves/) { section = waves_seen ? "" : "waves"; waves_seen = 1 }
670
+ # `## Slices` is the spelling the design spec uses, and `## Waves` is what 132
671
+ # plans already say. They are ONE section here, sharing waves_seen, because the
672
+ # shape is identical: the branch and PR ride the `### ` heading either way. A
673
+ # third arm would be a second implementation of a re-spelling, and the two
674
+ # would drift. No existing plan is rewritten to say Slices — a delivered plan
675
+ # describes what was built in the vocabulary of its day, and churning 132
676
+ # files git blame for a word buys nothing. New plans may use either.
677
+ else if ($0 ~ /^## Waves/ || $0 ~ /^## Slices/) { section = waves_seen ? "" : "waves"; waves_seen = 1 }
662
678
  else if ($0 ~ /^## Approval/) section = "approval"
663
679
  # First `## Changelog` wins, for the same reason `## Branches` does: a plan
664
680
  # about the plan format quotes the section in prose, and the later heading is
package/plot-reap.sh ADDED
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env bash
2
+ # Remove worktrees whose work has landed, their dead worker files, and the
3
+ # registry manifests that named them.
4
+ #
5
+ # The gap this fills was named by a comment before it existed:
6
+ # `plot-reconcile-scan.sh:323` says "with a deferred: annotation the reaper
7
+ # would offer to DELETE real work" — describing a reaper that was never
8
+ # written. The scan reports; nothing reaped. Measured 2026-08-25 on this
9
+ # estate: 56 worktrees, 42 of them dispatch trees, of which 29 were finished.
10
+ #
11
+ # WHY A SCRIPT RATHER THAN AN AGENT (Manifesto Principle 3, and the licence
12
+ # `plot-resolve-artifact.sh` states for the one other automatic write): every
13
+ # refusal below is a MEASUREMENT, not a judgement. Is a process alive; is the
14
+ # tree dirty; did the host merge the PR. An agent asked "is this safe to
15
+ # delete?" can talk itself past any of the three. A script cannot, and
16
+ # judgement's absence is exactly what licenses the delete.
17
+ #
18
+ # DEFAULT IS --dry-run. Removal happens only under --yes.
19
+ #
20
+ # plot-reap.sh # report what WOULD be reaped
21
+ # plot-reap.sh --yes # actually remove them
22
+ # plot-reap.sh --yes --max 5 # bound it
23
+ #
24
+ # What is NEVER reaped, in the order the tests run:
25
+ # 1. a worktree with a LIVE worker process (a desk someone is at)
26
+ # 2. a worktree with uncommitted changes (work that exists nowhere else)
27
+ # 3. a worktree carrying a PLOT-BLOCKED* marker (a worker waiting on a person)
28
+ # 4. a branch NO PR of which merged (the host is the authority)
29
+ # 5. the main checkout, and any non-dispatch tree (not ours to remove)
30
+ #
31
+ # THE MANIFEST GOES WITH THE WORKTREE. `readAgentRegistry` renders one row per
32
+ # manifest, so a reap that removes only the checkout converts a finished agent
33
+ # into an `unknown` row naming a directory that no longer exists — measured
34
+ # 2026-08-26, twelve worktrees removed and seven such rows appearing at once.
35
+ # Nothing further needs deciding to remove it: an entry whose worktree the five
36
+ # tests above just cleared is covered by exactly those measurements.
37
+ #
38
+ # ORDER: worktree FIRST, manifest second. The reverse leaves a live worktree
39
+ # with no registration, which `readAgentRegistry` answers by SYNTHESIZING an
40
+ # `unknown` entry — the same bad row, earned a different way. A failure between
41
+ # the two steps this way round leaves an orphaned manifest, which the sweep
42
+ # below clears on the next run.
43
+ set -u
44
+
45
+ DRY=1; MAX=0
46
+ while [ $# -gt 0 ]; do
47
+ case "$1" in
48
+ --yes) DRY=0 ;;
49
+ --dry-run) DRY=1 ;;
50
+ --max) MAX="${2:-0}"; shift ;;
51
+ -h|--help) sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
52
+ *) echo "plot-reap: unknown argument: $1" >&2; exit 2 ;;
53
+ esac
54
+ shift
55
+ done
56
+
57
+ command -v git >/dev/null 2>&1 || { echo "plot-reap: git not found" >&2; exit 2; }
58
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
59
+ echo "plot-reap: not a git repository" >&2; exit 2; }
60
+
61
+ # The default branch, via the host adapter when it can answer and `main`
62
+ # otherwise. A wrong answer here would only ever make the ancestry test MORE
63
+ # conservative, never less.
64
+ HOST="$(dirname "${BASH_SOURCE[0]}")/plot-host.sh"
65
+ DEFAULT=main
66
+ if [ -x "$HOST" ]; then
67
+ d=$("$HOST" default-branch 2>/dev/null) && [ -n "$d" ] && DEFAULT="$d"
68
+ fi
69
+ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
70
+
71
+ # Does the host say ANY PR for this branch merged?
72
+ #
73
+ # SOURCED from `plot-pr-merged.sh` rather than defined here, since 2026-08-28.
74
+ # It lived in this file until `plot-release-refs.sh` needed the SAME gate: both
75
+ # scripts ask "has this branch's work landed", and a second implementation that
76
+ # drifted toward permissive would delete a ref that cannot be restored. The
77
+ # helper carries the reasoning — `mergedAt` never `state`, ANY PR never the
78
+ # newest — and defines `pr_merged` and nothing else on load.
79
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-pr-merged.sh"
80
+
81
+ # Where the registry lives, resolved through `plot-config.sh` — the SAME key and
82
+ # default the board's reader uses (`resolveManifestDir` in `registry.ts` shells
83
+ # out to exactly this). Two implementations of "where is the registry" is how
84
+ # they drift, so this asks the config rather than hard-coding `.plot/agents`: a
85
+ # project whose board is served from another checkout points the key elsewhere,
86
+ # and a reaper writing to the wrong directory would report success over a
87
+ # manifest the board still renders.
88
+ # Tested with -r, not -x: the helper is invoked through `bash "$CONFIG"`, which
89
+ # needs the file READABLE and not executable. `-x` would silently fall back to
90
+ # the default on a checkout whose exec bits did not survive — and a reaper
91
+ # reading the wrong directory reports success over a manifest the board still
92
+ # renders, which is exactly the failure #420 fixed on the board's own side.
93
+ CONFIG="$(dirname "${BASH_SOURCE[0]}")/plot-config.sh"
94
+ MANIFEST_DIR=".plot/agents"
95
+ if [ -r "$CONFIG" ]; then
96
+ d=$(bash "$CONFIG" get "Agent registry" ".plot/agents" 2>/dev/null) && [ -n "$d" ] && MANIFEST_DIR="$d"
97
+ fi
98
+ case "$MANIFEST_DIR" in /*) ;; *) MANIFEST_DIR="$ROOT/$MANIFEST_DIR" ;; esac
99
+
100
+ # The manifest naming a given worktree, or nothing.
101
+ #
102
+ # Manifests are keyed by SESSION id, not by branch, so the file cannot be
103
+ # derived from the worktree path — it is found by reading the `worktree` field
104
+ # out of each one. The match is on the exact recorded path: a prefix match would
105
+ # let `plot-wt-foo` claim `plot-wt-foo-bar`'s manifest.
106
+ #
107
+ # Parsed with `sed`, not a JSON reader, deliberately — this script must run
108
+ # where node does not, and the field it needs is one flat string written by the
109
+ # dispatcher. A manifest whose `worktree` cannot be read simply does not match,
110
+ # which keeps an unparseable file OUT of the removal set rather than in it.
111
+ # A path with its symlinks resolved, or the path unchanged when it does not
112
+ # exist (nothing to resolve, and the caller still needs a string to compare).
113
+ #
114
+ # NOT cosmetic. `git worktree list` reports RESOLVED paths, while a manifest
115
+ # records whatever the dispatcher was handed — and on macOS `/tmp`, `/var` and
116
+ # `/etc` are symlinks into `/private`, so the same directory arrives as two
117
+ # different strings. Measured while writing this: a worktree git called
118
+ # `/private/var/.../repo` against a manifest saying `/var/.../repo`, matching
119
+ # nothing and stranding the manifest the reap was supposed to take.
120
+ canonical() {
121
+ local p="$1"
122
+ [ -n "$p" ] || return 0
123
+ # Resolve through the filesystem while the directory is still there — the
124
+ # authoritative answer, and the only one that handles an arbitrary symlink.
125
+ if [ -d "$p" ]; then
126
+ p=$( (cd "$p" 2>/dev/null && pwd -P) || printf '%s' "$p" )
127
+ fi
128
+ # Then normalise the macOS `/private` prefix TEXTUALLY, because the manifest
129
+ # side is compared AFTER its directory has been removed and there is no
130
+ # longer anything to resolve. `/tmp`, `/var` and `/etc` are symlinks into
131
+ # `/private`, so git's `/private/var/...` and a manifest's `/var/...` name
132
+ # one directory; stripping the prefix from both makes them one string
133
+ # whether or not either still exists.
134
+ case "$p" in
135
+ /private/tmp/*|/private/var/*|/private/etc/*) p=${p#/private} ;;
136
+ esac
137
+ printf '%s\n' "$p"
138
+ }
139
+
140
+ manifest_for() {
141
+ local target="$1" f wt
142
+ [ -d "$MANIFEST_DIR" ] || return 1
143
+ target=$(canonical "$target")
144
+ for f in "$MANIFEST_DIR"/*.json; do
145
+ [ -f "$f" ] || continue
146
+ wt=$(sed -n 's/.*"worktree"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | head -1)
147
+ [ -n "$wt" ] || continue
148
+ [ "$(canonical "$wt")" = "$target" ] && { printf '%s\n' "$f"; return 0; }
149
+ done
150
+ return 1
151
+ }
152
+
153
+ reap=0; kept=0; removed=0; cleared=0
154
+ printf '%-8s %-52s %s\n' "verdict" "branch" "why"
155
+
156
+ while IFS=$'\t' read -r wt br; do
157
+ [ -n "$wt" ] || continue
158
+ short=${br#refs/heads/}
159
+
160
+ # 5. Only dispatch trees. A hand-made worktree and the main checkout are not
161
+ # this script's to remove, whatever state they are in.
162
+ case "$wt" in *"/plot-wt-"*) ;; *) continue ;; esac
163
+ [ "$wt" = "$ROOT" ] && continue
164
+
165
+ # 1. A live worker outranks every other signal. Checked FIRST because it is
166
+ # the only one describing a person or process acting right now.
167
+ if [ -f "$wt/.plot-worker.pid" ]; then
168
+ pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
169
+ if [ -n "$pid" ] && ps -p "$pid" >/dev/null 2>&1; then
170
+ printf '%-8s %-52s %s\n' "keep" "$short" "worker alive (pid $pid)"; kept=$((kept+1)); continue
171
+ fi
172
+ fi
173
+
174
+ # 3. A marker means a worker stopped to ask a person something. Reaping it
175
+ # discards the question along with the tree.
176
+ if ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1; then
177
+ printf '%-8s %-52s %s\n' "keep" "$short" "PLOT-BLOCKED marker — needs a person"; kept=$((kept+1)); continue
178
+ fi
179
+
180
+ # 2. Uncommitted work exists in exactly one place. The tiny-garden pulse is
181
+ # excused because every board suite rewrites it — a worker that did
182
+ # nothing but run the tests would otherwise never be reapable. Any OTHER
183
+ # dirty path still keeps the tree, which is what keeps this an exception
184
+ # rather than a hole.
185
+ dirty=$(git -C "$wt" status --porcelain 2>/dev/null \
186
+ | grep -v 'tiny-garden/\.plot/state' | head -1)
187
+ if [ -n "$dirty" ]; then
188
+ printf '%-8s %-52s %s\n' "keep" "$short" "uncommitted: ${dirty:0:40}"; kept=$((kept+1)); continue
189
+ fi
190
+
191
+ # 4a. A tree sitting ON the default branch answers the ancestry test
192
+ # trivially — `origin/main..main` is empty — and would be reaped with the
193
+ # reason "merged into main", which says nothing about the work it was
194
+ # dispatched for. Measured here 2026-08-25: one dispatch tree had been
195
+ # left on `main` by its worker, and the first draft of this script
196
+ # offered to reap it for a reason that was true and irrelevant.
197
+ #
198
+ # It is KEPT and named. Deleting a tree whose dispatched branch is no
199
+ # longer checked out means deleting something whose state was never
200
+ # measured — and "probably fine" is the judgement this script exists to
201
+ # not make.
202
+ if [ "$short" = "$DEFAULT" ]; then
203
+ printf '%-8s %-52s %s\n' "keep" "$short" "on $DEFAULT — dispatched branch not checked out"
204
+ kept=$((kept+1)); continue
205
+ fi
206
+
207
+ # 4b. Landed, by either route: ancestry for a merge commit, the host for a
208
+ # squash. Ancestry is tried first because it needs no network.
209
+ why=""
210
+ if [ -n "$short" ] && [ "$(git -C "$wt" rev-list --count "origin/$DEFAULT..$short" 2>/dev/null || echo 1)" = "0" ]; then
211
+ why="merged into $DEFAULT"
212
+ elif [ -n "$short" ] && pr_merged "$short"; then
213
+ why="PR merged (squash)"
214
+ else
215
+ printf '%-8s %-52s %s\n' "keep" "$short" "unlanded work — no merged PR"; kept=$((kept+1)); continue
216
+ fi
217
+
218
+ if [ "$MAX" -gt 0 ] && [ "$reap" -ge "$MAX" ]; then
219
+ printf '%-8s %-52s %s\n' "keep" "$short" "--max $MAX reached"; kept=$((kept+1)); continue
220
+ fi
221
+
222
+ # Resolved BEFORE the removal, because `canonical` needs the directory to
223
+ # still exist to resolve it. After `git worktree remove` there is nothing to
224
+ # follow, and the manifest's spelling would never converge with git's.
225
+ wt_real=$(canonical "$wt")
226
+
227
+ reap=$((reap+1))
228
+ if [ "$DRY" -eq 1 ]; then
229
+ printf '%-8s %-52s %s\n' "would" "$short" "$why"
230
+ else
231
+ if git worktree remove --force "$wt" 2>/dev/null; then
232
+ # The worktree is gone; NOW the manifest may go. Inside the success arm
233
+ # and nowhere else — a manifest removed before a removal that then
234
+ # refuses leaves a live worktree unregistered, which the registry answers
235
+ # by synthesizing an `unknown` row. Failing this way round strands a
236
+ # manifest instead, which the sweep below clears.
237
+ if m=$(manifest_for "$wt_real"); then
238
+ rm -f "$m" && why="$why, manifest cleared"
239
+ fi
240
+ printf '%-8s %-52s %s\n' "reaped" "$short" "$why"; removed=$((removed+1))
241
+ else
242
+ printf '%-8s %-52s %s\n' "FAILED" "$short" "git worktree remove refused"; kept=$((kept+1))
243
+ fi
244
+ fi
245
+ done < <(git worktree list --porcelain \
246
+ | awk '/^worktree /{p=$2} /^branch /{print p"\t"$2}')
247
+
248
+ [ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
249
+
250
+ # The manifests whose worktree is ALREADY gone.
251
+ #
252
+ # Every reap before this script learned about the registry left one, and the
253
+ # board renders each as an `unknown` row naming a directory that does not
254
+ # exist. They are the population this plan was written from — seven of them,
255
+ # measured 2026-08-26 — and a fix that only stops NEW ones leaves those on the
256
+ # board forever.
257
+ #
258
+ # The predicate is the same one the loop above satisfies by construction: the
259
+ # recorded worktree is not there. It needs no PR check and no liveness check —
260
+ # nothing runs in a directory that does not exist, which is the strongest
261
+ # evidence of "dead" available, not the weakest.
262
+ #
263
+ # A manifest recording NO worktree path is left alone: it names an agent
264
+ # between checkouts, and absence of a path is not absence of an agent.
265
+ if [ -d "$MANIFEST_DIR" ]; then
266
+ for m in "$MANIFEST_DIR"/*.json; do
267
+ [ -f "$m" ] || continue
268
+ mwt=$(sed -n 's/.*"worktree"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$m" | head -1)
269
+ [ -n "$mwt" ] || continue
270
+ [ -d "$mwt" ] && continue
271
+ cleared=$((cleared+1))
272
+ if [ "$DRY" -eq 1 ]; then
273
+ printf '%-8s %-52s %s\n' "would" "$(basename "${mwt}")" "orphaned manifest — worktree absent"
274
+ else
275
+ rm -f "$m"
276
+ printf '%-8s %-52s %s\n' "cleared" "$(basename "${mwt}")" "orphaned manifest — worktree absent"
277
+ fi
278
+ done
279
+ fi
280
+
281
+ # The branches and refs are untouched, deliberately: this removes CHECKOUTS and
282
+ # the registrations that named them. A reaped tree is re-creatable with
283
+ # `git worktree add`, so the destructive act is bounded to disk space and to a
284
+ # record of an agent that has already finished — never to history.
285
+ echo "summary: reapable=$reap removed=$removed kept=$kept cleared=$cleared dry_run=$DRY"
286
+ exit 0
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bash
2
+ # Delete the REMOTE REFS of a delivered plan's merged branches.
3
+ #
4
+ # Usage: plot-release-refs.sh [--yes] [--max N] <slug>
5
+ #
6
+ # <slug> the plan whose branches to release
7
+ # --yes actually delete; without it this reports and deletes nothing
8
+ # --max N bound the number of deletions
9
+ #
10
+ # WHY THIS EXISTS: branches are what the scan actually costs. Measured
11
+ # 2026-08-27 across four runs of the fleet scan:
12
+ #
13
+ # worktrees branches scan
14
+ # 54 43 462.9 s
15
+ # 42 43 51.3 s
16
+ # 11 43 218.5 s
17
+ # 11 34 111.5 s
18
+ #
19
+ # Worktree count does not order those runs — 11 worktrees was SLOWER than 42.
20
+ # What moved reliably was deleting nine merged branches: 218.5 s → 111.5 s,
21
+ # roughly halving it. The estate the scan walks is branches, and merged ones
22
+ # are pure cost. Reaping clears desks; this is what the scan notices.
23
+ #
24
+ # WHY A SEPARATE SCRIPT AND NOT PART OF `plot-reap.sh`. The reaper ends by
25
+ # saying what it is: "the branches and refs are untouched, deliberately — this
26
+ # removes CHECKOUTS... A reaped tree is re-creatable with `git worktree add`,
27
+ # so the destructive act is bounded to disk space... never to history." That is
28
+ # a stated LICENCE, and it does not extend here. A deleted ref is not
29
+ # re-creatable, so this act needs its own argument, its own guards and its own
30
+ # `--yes`. Folding it into the reaper would silently widen a licence that was
31
+ # written narrow on purpose.
32
+ #
33
+ # It is also SCOPED TO ONE PLAN, where the reaper is deliberately slug-blind.
34
+ # The reaper sweeps every worktree because a checkout is cheap to restore; this
35
+ # touches only the branches its plan names. A sweep that deleted every merged
36
+ # ref on the estate would satisfy "a delivered plan's merged branches lose
37
+ # their refs" and destroy unlanded work belonging to plans nobody delivered.
38
+ # The blast radius is bounded by the plan file.
39
+ #
40
+ # WHAT IS NEVER DELETED, in the order the tests run:
41
+ # 1. a branch annotated `deferred:` or `moved:` (given up, not finished)
42
+ # 2. a branch NO PR of which merged (unlanded work)
43
+ # 3. a branch with an OPEN PR (changeset-release/main)
44
+ # 4. a branch checked out in ANY worktree (somebody is reading it)
45
+ # 5. the default branch itself (never ours to delete)
46
+ #
47
+ # THE RULE THIS MUST NOT BREAK. `/plot-implement` says plainly: *"leave the ref
48
+ # in place — never delete a remote ref another session may be reading."* Read in
49
+ # context that rule governs GIVING A BRANCH UP — work that turned out
50
+ # unnecessary, wrongly cut, or blocked — and its reason is that
51
+ # `/plot-reconcile` needs the ref PLUS its `deferred:`/`moved:` annotation to
52
+ # tell deliberate abandonment from a dead worker.
53
+ #
54
+ # A branch whose PR merged is neither abandoned nor ambiguous: its work is on
55
+ # main, its PR is closed, and there is nothing for `/plot-reconcile` to resolve.
56
+ # The rule protects UNLANDED refs, and this touches only landed ones. Guards 1
57
+ # and 2 are that reconciliation, enforced.
58
+ set -u
59
+
60
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
61
+
62
+ DRY=1; MAX=0; slug=""
63
+ while [ $# -gt 0 ]; do
64
+ case "$1" in
65
+ --yes) DRY=0 ;;
66
+ --dry-run) DRY=1 ;;
67
+ --max) MAX="${2:-0}"; shift ;;
68
+ -h|--help) sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
69
+ -*) echo "plot-release-refs: unknown argument: $1" >&2; exit 2 ;;
70
+ *) slug="$1" ;;
71
+ esac
72
+ shift
73
+ done
74
+
75
+ die() { echo "plot-release-refs: $*" >&2; exit 2; }
76
+
77
+ [ -n "$slug" ] || die "need a plan slug (usage: plot-release-refs.sh [--yes] <slug>)"
78
+ command -v git >/dev/null 2>&1 || die "git not found"
79
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
80
+
81
+ # The SAME gate the reaper uses, sourced rather than re-derived. `pr_merged`
82
+ # reads `mergedAt` on ANY PR (never `state`, never ancestry); `pr_open` answers
83
+ # the veto in guard 3.
84
+ . "$script_dir/plot-pr-merged.sh"
85
+
86
+ cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
87
+
88
+ PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
89
+ ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
90
+ DELIVERED_DIR=$(cfg "Delivered index" "docs/plans/delivered/")
91
+
92
+ # The plan is resolved exactly as `plot-deliver.sh` resolves it, including the
93
+ # `delivered/` directory — which is not incidental. This runs AFTER a delivery,
94
+ # so by the time it looks the symlink has already moved, and a resolver that
95
+ # knew only `active/` would find nothing for every plan it is called about.
96
+ plan_file=""
97
+ for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
98
+ [ -e "$cand" ] && { plan_file="$cand"; break; }
99
+ done
100
+ [ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $PLAN_DIR, $ACTIVE_DIR, $DELIVERED_DIR"
101
+
102
+ # The prefixes come from `Branch prefixes`, never a hardcoded list — the same
103
+ # derivation `plot-deliver.sh:144` and `plot-fleet-scan.sh:187` use. Without it
104
+ # this reads the parser's built-in default, and a project with its own prefixes
105
+ # would have EVERY branch of a plan silently disappear before the loop: the
106
+ # script would report `releasable=0` and look like it had nothing to do. That
107
+ # exact bug cost `plot-deliver.sh` four undeliverable plans on 2026-08-27.
108
+ #
109
+ # It fails safe (nothing is deleted) and is wrong all the same, and being wrong
110
+ # quietly is what makes it worth passing explicitly.
111
+ prefix_re=$(bash "$script_dir/plot-config.sh" get "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
112
+ | tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' - )
113
+ [ -n "$prefix_re" ] || prefix_re="idea|feature|bug|docs|infra"
114
+
115
+ meta=$(bash "$script_dir/plot-plan-meta.sh" --prefixes "$prefix_re" "$plan_file" 2>/dev/null) || meta=""
116
+ [ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
117
+
118
+ # The default branch, via the host adapter when it can answer. Guard 5 compares
119
+ # against it, and a wrong answer here can only ever protect MORE.
120
+ HOST="$script_dir/plot-host.sh"
121
+ DEFAULT=main
122
+ if [ -x "$HOST" ]; then
123
+ d=$("$HOST" default-branch 2>/dev/null) && [ -n "$d" ] && DEFAULT="$d"
124
+ fi
125
+
126
+ # Every branch currently checked out ANYWHERE, for guard 4.
127
+ #
128
+ # Collected once, before the loop, rather than asked per branch: `git worktree
129
+ # list` walks the whole estate and this script runs on the delivery path where
130
+ # that estate may hold dozens of trees. The answer cannot change underneath a
131
+ # single run in a way that matters — a worktree created mid-run holds a branch
132
+ # whose ref this run has not yet reached, and the next run sees it.
133
+ checked_out=$(git worktree list --porcelain 2>/dev/null \
134
+ | sed -n 's|^branch refs/heads/||p')
135
+
136
+ is_checked_out() {
137
+ printf '%s\n' "$checked_out" | grep -qxF "$1"
138
+ }
139
+
140
+ released=0; kept=0; deleted=0
141
+ printf '%-8s %-52s %s\n' "verdict" "branch" "why"
142
+
143
+ # Branch and its deferred flag, one per line, from the plan's own parser.
144
+ #
145
+ # `plot-plan-meta.sh` is the plan-format contract, and asking it rather than
146
+ # grepping the file is what keeps this working across both plan dialects —
147
+ # `## Branches` lists and `## Waves` headings — without this script knowing
148
+ # which one it is reading.
149
+ while IFS=$'\t' read -r br deferred; do
150
+ [ -n "$br" ] || continue
151
+
152
+ # 5. The default branch is never ours to delete, whatever a plan says. A plan
153
+ # that names it is malformed, and acting on that is unrecoverable.
154
+ if [ "$br" = "$DEFAULT" ]; then
155
+ printf '%-8s %-52s %s\n' "keep" "$br" "the default branch — never deleted"
156
+ kept=$((kept+1)); continue
157
+ fi
158
+
159
+ # 1. Given up, not finished. A `deferred:`/`moved:` annotation is what
160
+ # `/plot-reconcile` reads to tell deliberate abandonment from a dead
161
+ # worker, and it needs the REF to be there to read it against. Checked
162
+ # before the host is even asked: this is a decision a person already
163
+ # recorded, and no merge state overturns it.
164
+ if [ "$deferred" = "true" ]; then
165
+ printf '%-8s %-52s %s\n' "keep" "$br" "deferred — a given-up branch keeps its ref"
166
+ kept=$((kept+1)); continue
167
+ fi
168
+
169
+ # 2. THE GATE. Unlanded work keeps its ref, always — `Done when` item 12, and
170
+ # the assertion a naive implementation passes without, since a sweep that
171
+ # deletes every ref of a delivered plan satisfies item 11 and destroys
172
+ # work that exists nowhere else. `pr_merged` also returns false when the
173
+ # host cannot be asked, so silence keeps the ref.
174
+ if ! pr_merged "$br"; then
175
+ printf '%-8s %-52s %s\n' "keep" "$br" "unlanded work — no merged PR"
176
+ kept=$((kept+1)); continue
177
+ fi
178
+
179
+ # 3. An OPEN PR vetoes, even where an older PR merged. Measured by hand on
180
+ # 2026-08-28: `changeset-release/main` is merged repeatedly, and Changesets
181
+ # RECREATES and reuses that same branch for the next release — so its ref
182
+ # carries a live release PR while an older PR of its own has merged.
183
+ # Deleting it disturbs the release in flight.
184
+ if pr_open "$br"; then
185
+ printf '%-8s %-52s %s\n' "keep" "$br" "an open PR is using this branch"
186
+ kept=$((kept+1)); continue
187
+ fi
188
+
189
+ # 4. A ref another checkout is sitting on is one somebody is reading, and
190
+ # deleting it pulls the branch out from under them. Measured 2026-08-28:
191
+ # `bug/a-head-counts-its-own-waves` was merged AND checked out. This runs
192
+ # after the reap, so a worktree still here is one the reaper's own five
193
+ # measurements declined to remove — its verdict is inherited, not
194
+ # second-guessed.
195
+ if is_checked_out "$br"; then
196
+ printf '%-8s %-52s %s\n' "keep" "$br" "checked out in a worktree — somebody is reading it"
197
+ kept=$((kept+1)); continue
198
+ fi
199
+
200
+ if [ "$MAX" -gt 0 ] && [ "$released" -ge "$MAX" ]; then
201
+ printf '%-8s %-52s %s\n' "keep" "$br" "--max $MAX reached"
202
+ kept=$((kept+1)); continue
203
+ fi
204
+
205
+ released=$((released+1))
206
+ if [ "$DRY" -eq 1 ]; then
207
+ printf '%-8s %-52s %s\n' "would" "$br" "merged — ref would be deleted"
208
+ else
209
+ # The REMOTE ref only. The local branch is left alone deliberately: it costs
210
+ # the scan nothing (the scan derives from `origin/<branch>`), and a local
211
+ # branch is the last copy of a reflog somebody may still want.
212
+ if git push origin --delete "$br" >/dev/null 2>&1; then
213
+ printf '%-8s %-52s %s\n' "released" "$br" "merged — remote ref deleted"
214
+ deleted=$((deleted+1))
215
+ else
216
+ # A ref already gone is the common case on a re-run, and it is a SUCCESS
217
+ # for this script's purpose: the end state asked for is the ref's absence.
218
+ if git ls-remote --exit-code --heads origin "$br" >/dev/null 2>&1; then
219
+ printf '%-8s %-52s %s\n' "FAILED" "$br" "git push --delete refused"
220
+ kept=$((kept+1))
221
+ else
222
+ printf '%-8s %-52s %s\n' "released" "$br" "remote ref already absent"
223
+ deleted=$((deleted+1))
224
+ fi
225
+ fi
226
+ fi
227
+ done < <(printf '%s' "$meta" | jq -r '
228
+ ([.waves[]?.branches[]?] as $w
229
+ | if ($w | length) > 0 then $w
230
+ else [.branches[]? | {branch: ., deferred: false}] end)
231
+ | .[] | [.branch, (.deferred | tostring)] | @tsv' 2>/dev/null)
232
+
233
+ echo "summary: releasable=$released deleted=$deleted kept=$kept dry_run=$DRY"
234
+ exit 0