@plot-pm/board 0.10.0 → 0.12.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-reap.sh CHANGED
@@ -1,6 +1,42 @@
1
1
  #!/usr/bin/env bash
2
- # Remove worktrees whose work has landed, their dead worker files, and the
3
- # registry manifests that named them.
2
+ # The estate sweep. It answers ONE question of everything it looks at — is
3
+ # anything here that nobody is coming back for? — and it does not care whether
4
+ # the cause was a dead agent, an interrupted dispatch, a `--stop`, or a merge
5
+ # somebody did on the host.
6
+ #
7
+ # FOUR KINDS OF LEFTOVER, and the first is the one this script started as:
8
+ #
9
+ # 1. worktrees whose work has landed, with their dead worker files, the
10
+ # registry manifests that named them, and the agent logs that described
11
+ # them
12
+ # 2. LOCAL BRANCHES the host merged that no worktree holds
13
+ # 3. ORPHANED CLAIM REFS a plan already recorded as deferred or moved
14
+ # 4. DIRTY TREES NOBODY OWNS — named, never deleted
15
+ #
16
+ # Kinds 2-4 were added 2026-09-03. Measured on this estate the day before:
17
+ # 85 of 98 local branches already merged and nothing looked at them, claim refs
18
+ # whose agent never existed, and 2 dirty desks holding 52 and 1 files that
19
+ # every run refused and nothing ever resolved.
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
+ #
30
+ # EVERY KIND KEEPS ONE SHAPE: `--dry-run` by default, acting on `--yes`,
31
+ # bounded by `--max N`. The bound is PER KIND, because the kinds are different
32
+ # acts on different populations — a run bounded to five worktrees has not
33
+ # thereby been asked to leave the 85th branch alone.
34
+ #
35
+ # THE ASYMMETRY BETWEEN KINDS IS DELIBERATE AND STAYS. A removed checkout comes
36
+ # back with `git worktree add` and a local branch is re-fetchable from origin,
37
+ # so both are swept estate-wide. A deleted REMOTE ref is not re-creatable at
38
+ # all, so `plot-release-refs.sh` deletes those under its own licence, its own
39
+ # five guards, and a blast radius bounded by one plan file.
4
40
  #
5
41
  # The gap this fills was named by a comment before it existed:
6
42
  # `plot-reconcile-scan.sh:323` says "with a deferred: annotation the reaper
@@ -15,6 +51,21 @@
15
51
  # delete?" can talk itself past any of the three. A script cannot, and
16
52
  # judgement's absence is exactly what licenses the delete.
17
53
  #
54
+ # AND THE DECIDING IS NOT HERE. This script GATHERS the readings, asks
55
+ # `packages/domain/src/rules/reapable.ts`, and ACTS on the answer; it holds no
56
+ # `if` about whether a worktree may go. The five refusals are named values the
57
+ # rule returns, so each is triggerable against a fixture — including the
58
+ # combinations this estate will not produce on demand, a marker and a live pid
59
+ # at once and a host that cannot be asked at all. In shell they were five
60
+ # `if`s nothing could test.
61
+ #
62
+ # SO THIS SCRIPT NEEDS NODE, where its first version deliberately did not.
63
+ # That constraint is retired rather than quietly broken: the alternative is a
64
+ # second implementation of the five refusals, in shell, where nothing can test
65
+ # it — and a copy drifting toward permissive fails in the direction that
66
+ # deletes work. A rule that cannot be asked REFUSES, so a missing `node` keeps
67
+ # every tree and says so per tree rather than skipping them silently.
68
+ #
18
69
  # DEFAULT IS --dry-run. Removal happens only under --yes.
19
70
  #
20
71
  # plot-reap.sh # report what WOULD be reaped
@@ -28,6 +79,27 @@
28
79
  # 4. a branch NO PR of which merged (the host is the authority)
29
80
  # 5. the main checkout, and any non-dispatch tree (not ours to remove)
30
81
  #
82
+ # THOSE FIVE REFUSALS ARE UNCHANGED BY THE THREE NEW KINDS, in this file and in
83
+ # `packages/domain/src/rules/reapable.ts` alike. They were written for exactly
84
+ # the population they sweep, and a backstop that guesses is worse than none.
85
+ # Each new kind brings its own gate instead, in `rules/sweepable.ts`:
86
+ #
87
+ # local branch the host says merged, AND no worktree holds it. NEVER
88
+ # `git branch -d`, which refuses a squash-merged branch for the
89
+ # wrong reason and would have kept all 85 of them.
90
+ # claim ref only what `plot-reconcile-scan.sh` section 3 ALREADY calls
91
+ # reapable — a `deferred:`/`moved:` annotation. A bare
92
+ # `claimed:` is reported and left for a person.
93
+ # dirty tree nothing. There is no deletion path: where this guard is
94
+ # wrong, destruction cannot be undone.
95
+ #
96
+ # A dispatch tree is recognised by `.plot-worker.pid`, which the dispatcher
97
+ # writes at creation, OR by the legacy `plot-wt-` path. Both are supported
98
+ # permanently. Identifying one by its path ALONE was the defect fixed on
99
+ # 2026-08-30: `plot-wt-` is only used when `Worktree root` is absent, so on a
100
+ # repo that configures one the reaper matched nothing and reported
101
+ # `reapable=0 kept=0` over nine trees.
102
+ #
31
103
  # THE MANIFEST GOES WITH THE WORKTREE. `readAgentRegistry` renders one row per
32
104
  # manifest, so a reap that removes only the checkout converts a finished agent
33
105
  # into an `unknown` row naming a directory that no longer exists — measured
@@ -35,11 +107,36 @@
35
107
  # Nothing further needs deciding to remove it: an entry whose worktree the five
36
108
  # tests above just cleared is covered by exactly those measurements.
37
109
  #
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.
110
+ # AND THE LOG GOES WITH THE WORKTREE TOO. Measured 2026-08-30: 190 log files,
111
+ # 2.6 MB beside the repository, the oldest from 2026-08-17, and NOT ONE
112
+ # belonging to live work. This script took the worktree and the manifest every
113
+ # time and left the log forever, so a finished agent's last act was to leave a
114
+ # file nobody would ever open again.
115
+ #
116
+ # It is the branch's own `plot-resolve-<branch>` run — log, `.state` and
117
+ # `.prompt.md` together, since a sweep that took the log alone would leave half
118
+ # a run behind. NOT the per-plan `plot-dispatch-<slug>.log`, which is appended
119
+ # to by every dispatch of a plan and outlives any one of its branches.
120
+ #
121
+ # ORDER: worktree FIRST, manifest second, log LAST. The first two are ordered
122
+ # because the reverse leaves a live worktree with no registration, which
123
+ # `readAgentRegistry` answers by SYNTHESIZING an `unknown` entry — the same bad
124
+ # row, earned a different way. A failure between them this way round leaves an
125
+ # orphaned manifest, which the sweep below clears on the next run.
126
+ #
127
+ # The log is last because it is the only one that is PURE CLEANUP: a missing
128
+ # manifest orphans an agent, a missing worktree loses a desk, and a missing log
129
+ # costs a record of work the host already merged. So a failure before it has
130
+ # cost the least, and its own failure costs nothing.
131
+ #
132
+ # A MISSING LOG IS NOT A REFUSAL. The five refusals above are about work that
133
+ # might be lost; a log describes work that has already landed. `rm -f`
134
+ # semantics — if it is not there, that is the desired state.
135
+ #
136
+ # AND IT IS NOT THE TRANSCRIPT. `<worktree>/.plot-worker.log` is the agent's own
137
+ # words and lives INSIDE the tree, so it goes when the tree does and is not
138
+ # swept here. This is the dispatcher's record of what it started. Two files,
139
+ # two lifetimes, and CLAUDE.md already distinguishes them.
43
140
  set -u
44
141
 
45
142
  DRY=1; MAX=0
@@ -48,7 +145,10 @@ while [ $# -gt 0 ]; do
48
145
  --yes) DRY=0 ;;
49
146
  --dry-run) DRY=1 ;;
50
147
  --max) MAX="${2:-0}"; shift ;;
51
- -h|--help) sed -n '2,42p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
148
+ # The header, however long it has become. A hardcoded last line silently
149
+ # truncates help mid-sentence the first time the header grows — measured
150
+ # here, when it grew past 42.
151
+ -h|--help) sed -n '2,/^[^#]/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
52
152
  *) echo "plot-reap: unknown argument: $1" >&2; exit 2 ;;
53
153
  esac
54
154
  shift
@@ -90,6 +190,13 @@ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
90
190
  # the default on a checkout whose exec bits did not survive — and a reaper
91
191
  # reading the wrong directory reports success over a manifest the board still
92
192
  # renders, which is exactly the failure #420 fixed on the board's own side.
193
+ # The rule that decides whether a worktree may go. Resolved from THIS script's
194
+ # location rather than the cwd, and to a `file://` URL because `import()` needs
195
+ # one for an absolute path. Missing or unreadable, the decision below reports
196
+ # "could not be asked" and keeps every tree.
197
+ RULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)/packages/domain/src/rules/reapable.ts"
198
+ RULE="file://$RULE_PATH"
199
+
93
200
  CONFIG="$(dirname "${BASH_SOURCE[0]}")/plot-config.sh"
94
201
  MANIFEST_DIR=".plot/agents"
95
202
  if [ -r "$CONFIG" ]; then
@@ -97,6 +204,75 @@ if [ -r "$CONFIG" ]; then
97
204
  fi
98
205
  case "$MANIFEST_DIR" in /*) ;; *) MANIFEST_DIR="$ROOT/$MANIFEST_DIR" ;; esac
99
206
 
207
+ # Where the agent logs live, resolved through `plot-config.sh` from the SAME
208
+ # `Worktree root` key `resolve_wt_root()` and `agentLogDir` read. Three readers
209
+ # of one key, and none of them may invent a second: a reaper sweeping a
210
+ # directory the board never writes to reports success over a file that is still
211
+ # there, which is the failure this slice exists to stop.
212
+ #
213
+ # THE FALLBACK IS THE PARENT DIRECTORY, NOT AN ERROR — `agentLogDir`'s rule,
214
+ # stated the same way here. A repository with no key has no `.worktrees/`, and
215
+ # the logs it wrote are beside it; a reaper that refused to look there would
216
+ # clean nothing on exactly the repositories that never migrated.
217
+ #
218
+ # The case split is `resolve_wt_root()`'s: absolute taken as given, relative
219
+ # joined onto the repo root, trailing slash trimmed as pure string work because
220
+ # the directory need not exist. A second convention for resolving a configured
221
+ # directory is a second way to be wrong.
222
+ LOG_DIR="$(cd "$ROOT/.." && pwd)"
223
+ if [ -r "$CONFIG" ]; then
224
+ d=$(bash "$CONFIG" get "Worktree root" "" 2>/dev/null) || d=""
225
+ if [ -n "$d" ]; then
226
+ case "$d" in
227
+ /*) LOG_DIR="$d" ;;
228
+ *) LOG_DIR="$ROOT/$d" ;;
229
+ esac
230
+ LOG_DIR="${LOG_DIR%/}"
231
+ fi
232
+ fi
233
+
234
+ # The files ONE branch's agent run leaves beside its worktree, removed with it.
235
+ #
236
+ # WHICH LOG THIS IS, since the plan says "the dispatcher log" and the estate
237
+ # holds two shapes of one. `plot-resolve-<branch>` is keyed by BRANCH with its
238
+ # slashes flattened (`repairLogPath`), so it maps one-to-one onto the worktree
239
+ # this loop is removing. `plot-dispatch-<slug>` is keyed by PLAN and opened for
240
+ # APPEND across every dispatch of that plan — `dispatch.ts:150` states it: "a
241
+ # dispatcher log belongs to a plan, a worker log to a branch". Reaping one
242
+ # branch of a five-branch plan must not delete the record the other four are
243
+ # still writing to, so the per-plan log is deliberately NOT swept here. It dies
244
+ # with its plan, which is a different lifetime and so a different question.
245
+ #
246
+ # All three extensions go together. `agent-log.ts` puts the `.state` and
247
+ # `.prompt.md` beside the log precisely so a sweep takes the whole run: one
248
+ # that knew about the log alone would leave half of it behind, which is the
249
+ # accumulation this plan measured rather than a smaller version of it.
250
+ #
251
+ # `rm -f` semantics, and A MISSING LOG IS NOT A REFUSAL. The five refusals
252
+ # guard work that might be lost; a log describes work the host already merged.
253
+ # Not being there is the desired state, so it is reported as nothing at all.
254
+ branch_log_files() { # $1=branch → the paths this branch's run may have left
255
+ local flat=${1//\//-}
256
+ printf '%s\n' \
257
+ "$LOG_DIR/plot-resolve-$flat.log" \
258
+ "$LOG_DIR/plot-resolve-$flat.state" \
259
+ "$LOG_DIR/plot-resolve-$flat.prompt.md"
260
+ }
261
+
262
+ # The ones that are actually there, as a comma-separated list of basenames for
263
+ # the report, or empty. Reading is separated from removing so `--dry-run` can
264
+ # NAME what a real run would take — the plan asks for that by name, and a
265
+ # preview that said "and its log" without checking would promise a file that is
266
+ # not there.
267
+ present_logs() { # $1=branch → "plot-resolve-x.log, plot-resolve-x.state" or ""
268
+ local f out=""
269
+ while IFS= read -r f; do
270
+ [ -f "$f" ] || continue
271
+ out="${out:+$out, }$(basename "$f")"
272
+ done < <(branch_log_files "$1")
273
+ printf '%s' "$out"
274
+ }
275
+
100
276
  # The manifest naming a given worktree, or nothing.
101
277
  #
102
278
  # Manifests are keyed by SESSION id, not by branch, so the file cannot be
@@ -150,69 +326,178 @@ manifest_for() {
150
326
  return 1
151
327
  }
152
328
 
153
- reap=0; kept=0; removed=0; cleared=0
329
+ reap=0; kept=0; removed=0; cleared=0; vanished=0
154
330
  printf '%-8s %-52s %s\n' "verdict" "branch" "why"
155
331
 
156
- while IFS=$'\t' read -r wt br; do
332
+ while IFS=$'\t' read -r wt br prunable; do
157
333
  [ -n "$wt" ] || continue
158
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
159
364
 
160
365
  # 5. Only dispatch trees. A hand-made worktree and the main checkout are not
161
366
  # 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
367
+ #
368
+ # ASKED OF THE DISK, NOT OF THE PATH. `.plot-worker.pid` is written by the
369
+ # dispatcher at creation, so it is a marker Plot itself left rather than a
370
+ # name Plot hopes was used. The path test alone recognised only the LEGACY
371
+ # `plot-wt-` layout, which `plot-dispatch.sh:129` uses when `Worktree root`
372
+ # is absent so on this repo, which sets `Worktree root: .worktrees`,
373
+ # every tree is `.worktrees/<branch-with-dashes>` and matched nothing.
374
+ # Measured 2026-08-30: nine dispatch trees, `kept=0` rather than `kept=9`.
375
+ # A refusal counts and a skip does not, so `reapable=0` read as *nothing to
376
+ # clean* and meant *nothing was looked at*.
377
+ #
378
+ # Both signals are accepted and neither is in transition: `plot-wt-` is
379
+ # supported permanently, and a legacy tree predating the marker keeps
380
+ # being recognised by its name. A dispatch tree whose pid file was deleted
381
+ # and whose path does not match goes unrecognised — which fails by
382
+ # REFUSING, the same safe direction the path test failed in, and for one
383
+ # tree instead of all of them.
384
+ if [ ! -f "$wt/.plot-worker.pid" ]; then
385
+ case "$wt" in *"/plot-wt-"*) ;; *) continue ;; esac
172
386
  fi
173
387
 
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
388
+ # THE READINGS. Everything from here to the rule call MEASURES; nothing
389
+ # decides. Each of the four sources that can answer is read once, into a
390
+ # variable named for what it holds rather than for the verdict it implies.
391
+
392
+ # The process table: the live worker's pid, or empty. Read but not judged —
393
+ # an empty pid file is not a live process, and which of those two it is is
394
+ # the rule's to say.
395
+ pid=""
396
+ if [ -f "$wt/.plot-worker.pid" ]; then
397
+ p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
398
+ if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then pid="$p"; fi
178
399
  fi
179
400
 
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.
401
+ # The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
402
+ #
403
+ # The tiny-garden pulse is excused because every board suite rewrites it — a
404
+ # worker that did nothing but run the tests would otherwise never be
405
+ # reapable. Any OTHER dirty path is still reported, which keeps this an
406
+ # exception rather than a hole. It is filtered HERE, in the reading, because
407
+ # it is a fact about this repository's fixtures and not about whether a
408
+ # worktree may go.
409
+ marker=false
410
+ ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
185
411
  dirty=$(git -C "$wt" status --porcelain 2>/dev/null \
186
412
  | 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
413
 
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.
414
+ # The host: whether ANY PR for this branch merged.
197
415
  #
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=""
416
+ # `merged` never `state` a merged PR reports CLOSED and ANY PR never the
417
+ # newest, both of which `plot-pr-merged.sh` carries. Ancestry is consulted
418
+ # FIRST only because it needs no network; it can only ever ADD a merged
419
+ # answer, never withhold one, since a squash-merge leaves the branch
420
+ # permanently ahead and falls through to the host.
421
+ #
422
+ # `unreachable` is not distinguished from `not-merged` here: `pr_merged`
423
+ # returns one exit code for both, deliberately, since both must keep the
424
+ # tree. The rule accepts the distinction so a fixture can trigger it; this
425
+ # reading simply cannot supply it, and reporting the stronger claim would be
426
+ # a lie about what was measured.
427
+ merge=not-merged; why=""
210
428
  if [ -n "$short" ] && [ "$(git -C "$wt" rev-list --count "origin/$DEFAULT..$short" 2>/dev/null || echo 1)" = "0" ]; then
211
- why="merged into $DEFAULT"
429
+ merge=merged; why="merged into $DEFAULT"
212
430
  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
431
+ merge=merged; why="PR merged (squash)"
432
+ fi
433
+
434
+ # THE DECISION. One call, and the script holds no `if` about whether a
435
+ # worktree may go — only about what to do with the answer.
436
+ #
437
+ # The rule is `packages/domain/src/rules/reapable.ts`, imported directly:
438
+ # node 24 strips the types, so there is no build step between this script and
439
+ # the decision it asks for. The same shape, and the same reason, as
440
+ # `scripts/check-changeset-packages.sh` — the JS arrives on STDIN from a
441
+ # QUOTED heredoc so the shell expands none of it.
442
+ #
443
+ # THIS SCRIPT NOW NEEDS NODE, and its header said it must run where node does
444
+ # not. That constraint is retired rather than quietly broken: the alternative
445
+ # is a second implementation of the five refusals living in shell where
446
+ # nothing can test it, and a copy that drifted toward permissive would delete
447
+ # work. `manifest_for` keeps its `sed` parser, which is a different question
448
+ # — reading one flat string out of a file Plot wrote, not deciding anything.
449
+ #
450
+ # A rule that cannot be asked REFUSES: `node` missing, the import failing,
451
+ # the module throwing all leave `verdict` empty, and an empty verdict keeps
452
+ # the tree and says why. Silence is never permission, on this path either.
453
+ verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
454
+ PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
455
+ PLOT_RULE="$RULE" \
456
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
457
+ // Imported from an ABSOLUTE path derived from this script, never from the
458
+ // cwd. The reaper runs with its cwd wherever the operator invoked it and the
459
+ // reconcile suite runs it against sandbox repos in the temp directory, so a
460
+ // relative specifier resolves to a `packages/` that is not there — which the
461
+ // fail-safe turns into "rule could not be asked" and every tree kept. Correct
462
+ // direction, useless reaper. Same discipline as `plot-host.sh`,
463
+ // `plot-config.sh` and `plot-pr-merged.sh`, which are all found via
464
+ // `BASH_SOURCE`.
465
+ const { firstReapRefusal } = await import(process.env.PLOT_RULE);
466
+
467
+ const problem = firstReapRefusal({
468
+ branch: process.env.PLOT_BRANCH,
469
+ defaultBranch: process.env.PLOT_DEFAULT,
470
+ // The main checkout is excluded before the loop reaches here, so the only
471
+ // way this reading is true is the branch test the rule makes anyway.
472
+ isMain: false,
473
+ workerPid: process.env.PLOT_PID === "" ? null : process.env.PLOT_PID,
474
+ dirtyPath: process.env.PLOT_DIRTY,
475
+ blockedMarker: process.env.PLOT_MARKER === "true",
476
+ merge: process.env.PLOT_MERGE,
477
+ });
478
+
479
+ // `reap` when nothing refused; otherwise the refusal and its reading, which
480
+ // the shell renders into the prose an operator reads.
481
+ process.stdout.write(problem === null ? "reap\t" : `${problem.refusal}\t${problem.detail}`);
482
+ NODE_EOF
483
+ )
484
+
485
+ refusal=${verdict%%$'\t'*}
486
+ detail=${verdict#*$'\t'}
487
+
488
+ # RENDERING, not deciding. The rule named the measurement; this names what it
489
+ # means to someone reading the report, which is the caller's half because
490
+ # only the caller knows it is printing a table.
491
+ if [ "$refusal" != "reap" ]; then
492
+ case "$refusal" in
493
+ live-worker) reason="worker alive (pid $detail)" ;;
494
+ blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
495
+ uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
496
+ on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
497
+ no-merged-pr) reason="unlanded work — no merged PR" ;;
498
+ *) reason="rule could not be asked — keeping" ;;
499
+ esac
500
+ printf '%-8s %-52s %s\n' "keep" "$short" "$reason"; kept=$((kept+1)); continue
216
501
  fi
217
502
 
218
503
  if [ "$MAX" -gt 0 ] && [ "$reap" -ge "$MAX" ]; then
@@ -224,9 +509,15 @@ while IFS=$'\t' read -r wt br; do
224
509
  # follow, and the manifest's spelling would never converge with git's.
225
510
  wt_real=$(canonical "$wt")
226
511
 
512
+ # Read BEFORE the removal for the same reason `wt_real` is: the report names
513
+ # what was there when the run decided, and a dry run must name exactly what a
514
+ # real run would take. Empty when the branch left no log, which is silent —
515
+ # a missing log is the desired state, not an event.
516
+ logs=$(present_logs "$short")
517
+
227
518
  reap=$((reap+1))
228
519
  if [ "$DRY" -eq 1 ]; then
229
- printf '%-8s %-52s %s\n' "would" "$short" "$why"
520
+ printf '%-8s %-52s %s\n' "would" "$short" "$why${logs:+, log $logs}"
230
521
  else
231
522
  if git worktree remove --force "$wt" 2>/dev/null; then
232
523
  # The worktree is gone; NOW the manifest may go. Inside the success arm
@@ -237,13 +528,33 @@ while IFS=$'\t' read -r wt br; do
237
528
  if m=$(manifest_for "$wt_real"); then
238
529
  rm -f "$m" && why="$why, manifest cleared"
239
530
  fi
531
+ # AND THE LOG LAST, because it is the only one that is pure cleanup. A
532
+ # missing manifest orphans an agent and a missing worktree loses a desk;
533
+ # a missing log costs a record of work the host already merged. So it
534
+ # goes where a failure before it has cost the least, and its own failure
535
+ # costs nothing at all.
536
+ #
537
+ # Inside the success arm with the manifest: a log describes the worktree,
538
+ # so a removal that refused must keep it — an operator sent to look at a
539
+ # tree that survived needs the words explaining why it is there.
540
+ #
541
+ # `rm -f` and the result ignored. Not being there is the desired state,
542
+ # and a log that cannot be unlinked is not a reason to report a reap that
543
+ # happened as one that did not.
544
+ if [ -n "$logs" ]; then
545
+ while IFS= read -r f; do rm -f "$f" 2>/dev/null; done < <(branch_log_files "$short")
546
+ why="$why, log removed"
547
+ fi
240
548
  printf '%-8s %-52s %s\n' "reaped" "$short" "$why"; removed=$((removed+1))
241
549
  else
242
550
  printf '%-8s %-52s %s\n' "FAILED" "$short" "git worktree remove refused"; kept=$((kept+1))
243
551
  fi
244
552
  fi
245
553
  done < <(git worktree list --porcelain \
246
- | 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 }')
247
558
 
248
559
  [ "$DRY" -eq 0 ] && git worktree prune 2>/dev/null
249
560
 
@@ -278,9 +589,356 @@ if [ -d "$MANIFEST_DIR" ]; then
278
589
  done
279
590
  fi
280
591
 
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"
592
+
593
+ # ===========================================================================
594
+ # THE OTHER THREE KINDS OF LEFTOVER.
595
+ #
596
+ # The sweep answers ONE question — is anything here that nobody is coming back
597
+ # for? — and it does not care whether the cause was a dead agent, an
598
+ # interrupted dispatch, a `--stop`, or a merge somebody did on the host.
599
+ # Everything above answers it about WORKTREES. Measured 2026-09-02 on this
600
+ # estate, three more populations answer it and nothing looks at them:
601
+ #
602
+ # local branches 85 of 98 already merged ← the largest, swept below
603
+ # orphaned claim refs a claim whose agent never existed
604
+ # dirty trees nobody owns 2 desks, 52 and 1 files ← reported, never deleted
605
+ #
606
+ # EVERY KIND KEEPS THE SHAPE THIS SCRIPT ALREADY HAS: `--dry-run` by default,
607
+ # acting on `--yes`, bounded by `--max N`. The bound is per kind, because the
608
+ # kinds are different acts on different populations — a run bounded to five
609
+ # worktrees has not therefore been asked to leave the 85th branch alone.
610
+ #
611
+ # AND THE DECIDING IS NOT HERE EITHER. Each kind gathers readings and asks
612
+ # `packages/domain/src/rules/sweepable.ts`, exactly as the worktree loop asks
613
+ # `reapable.ts`. `reapable.ts` is UNTOUCHED: its five refusals were written for
614
+ # the population it sweeps, and a backstop that guesses is worse than none.
615
+ # A rule that cannot be asked REFUSES, so a missing `node` sweeps nothing.
616
+ # ===========================================================================
617
+
618
+ SWEEP_RULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)/packages/domain/src/rules/sweepable.ts"
619
+ SWEEP_RULE="file://$SWEEP_RULE_PATH"
620
+
621
+ swept_branches=0; deleted_branches=0; kept_branches=0
622
+ swept_claims=0; deleted_claims=0; kept_claims=0
623
+ dirty_trees=0
624
+
625
+ # ---------------------------------------------------------------------------
626
+ # KIND 2: LOCAL BRANCHES.
627
+ #
628
+ # THE GATE IS THE REAPER'S, NOT GIT'S:
629
+ #
630
+ # the host says merged, AND no worktree holds it → delete
631
+ #
632
+ # `git branch -d` is NOT the gate. It refuses an unmerged branch, which sounds
633
+ # like the safety this needs — except squash-merge leaves a branch permanently
634
+ # ahead of main, the trap `plot-pr-merged.sh` exists for, so `-d` would refuse
635
+ # all 85 for the wrong reason. This uses `-D` and puts the safety in the two
636
+ # measurements above it, where a test can reach it.
637
+ #
638
+ # WHY A LOCAL BRANCH JOINS THE ESTATE-WIDE SIDE. The asymmetry between kinds is
639
+ # deliberate: a removed checkout comes back with `git worktree add`, a deleted
640
+ # REMOTE ref does not, so `plot-release-refs.sh` stays plan-scoped. A local
641
+ # branch whose PR merged is re-fetchable from origin — it is A copy, not THE
642
+ # copy — so the argument that protects remote refs does not transfer, and this
643
+ # sweeps estate-wide like the worktrees do.
644
+ #
645
+ # REPORT-ONLY WAS THE ALTERNATIVE, AND 85 ROWS IS THE ARGUMENT AGAINST IT. A
646
+ # sweep that reports and never acts becomes one more thing a person has to
647
+ # clear — the problem this plan exists to remove, reintroduced one level up.
648
+ # ---------------------------------------------------------------------------
649
+
650
+ # Every branch checked out ANYWHERE, for the second half of the gate.
651
+ #
652
+ # Read once before the loop, the same reading and the same reason as
653
+ # `plot-release-refs.sh:126` — that file's guard 4 collects exactly this, and
654
+ # the brief asks for it to be reused rather than written twice. A worktree
655
+ # created mid-run holds a branch this run has not yet reached, and the next run
656
+ # sees it.
657
+ sweep_checked_out=$(git worktree list --porcelain 2>/dev/null \
658
+ | sed -n 's|^branch refs/heads/||p')
659
+
660
+ sweep_is_checked_out() {
661
+ printf '%s\n' "$sweep_checked_out" | grep -qxF "$1"
662
+ }
663
+
664
+ # Ask the rule about one branch. Empty output means the rule could not be
665
+ # asked, which the caller renders as a refusal — silence is never permission.
666
+ sweep_branch_verdict() { # $1=branch $2=merged(true/false) $3=checked_out(true/false)
667
+ PLOT_BRANCH="$1" PLOT_DEFAULT="$DEFAULT" PLOT_MERGED="$2" PLOT_CHECKED="$3" \
668
+ PLOT_RULE="$SWEEP_RULE" \
669
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
670
+ const { firstBranchRefusal } = await import(process.env.PLOT_RULE);
671
+ const refusal = firstBranchRefusal({
672
+ branch: process.env.PLOT_BRANCH,
673
+ defaultBranch: process.env.PLOT_DEFAULT,
674
+ hasMergedPr: process.env.PLOT_MERGED === "true",
675
+ checkedOut: process.env.PLOT_CHECKED === "true",
676
+ });
677
+ process.stdout.write(refusal === null ? "sweep" : refusal);
678
+ NODE_EOF
679
+ }
680
+
681
+ echo
682
+ echo "-- local branches --"
683
+
684
+ while IFS= read -r br; do
685
+ [ -n "$br" ] || continue
686
+
687
+ # The host, asked exactly as the worktree loop asks it. Ancestry FIRST only
688
+ # because it needs no network; it can only ever ADD a merged answer, never
689
+ # withhold one, since squash-merge leaves the branch permanently ahead and
690
+ # falls through to `pr_merged`.
691
+ merged=false
692
+ if [ "$(git rev-list --count "origin/$DEFAULT..$br" 2>/dev/null || echo 1)" = "0" ]; then
693
+ merged=true; bwhy="merged into $DEFAULT"
694
+ elif pr_merged "$br"; then
695
+ merged=true; bwhy="PR merged (squash)"
696
+ fi
697
+
698
+ held=false
699
+ sweep_is_checked_out "$br" && held=true
700
+
701
+ bverdict=$(sweep_branch_verdict "$br" "$merged" "$held")
702
+
703
+ if [ "$bverdict" != "sweep" ]; then
704
+ case "$bverdict" in
705
+ default-branch) breason="the default branch — never deleted" ;;
706
+ no-merged-pr) breason="unlanded work — no merged PR" ;;
707
+ checked-out) breason="checked out in a worktree — somebody is reading it" ;;
708
+ *) breason="rule could not be asked — keeping" ;;
709
+ esac
710
+ printf '%-8s %-52s %s\n' "keep" "$br" "$breason"; kept_branches=$((kept_branches+1)); continue
711
+ fi
712
+
713
+ if [ "$MAX" -gt 0 ] && [ "$swept_branches" -ge "$MAX" ]; then
714
+ printf '%-8s %-52s %s\n' "keep" "$br" "--max $MAX reached"; kept_branches=$((kept_branches+1)); continue
715
+ fi
716
+
717
+ swept_branches=$((swept_branches+1))
718
+ if [ "$DRY" -eq 1 ]; then
719
+ printf '%-8s %-52s %s\n' "would" "$br" "$bwhy, no worktree holds it"
720
+ else
721
+ # `-D`, not `-d`. The safety is the two measurements above, and `-d` would
722
+ # veto every squash-merged branch — the whole population — for a reason
723
+ # that has nothing to do with whether the work landed.
724
+ if git branch -D "$br" >/dev/null 2>&1; then
725
+ printf '%-8s %-52s %s\n' "deleted" "$br" "$bwhy, local ref deleted"; deleted_branches=$((deleted_branches+1))
726
+ else
727
+ printf '%-8s %-52s %s\n' "FAILED" "$br" "git branch -D refused"; kept_branches=$((kept_branches+1))
728
+ fi
729
+ fi
730
+ done < <(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)
731
+
732
+ # ---------------------------------------------------------------------------
733
+ # KIND 3: ORPHANED CLAIM REFS.
734
+ #
735
+ # A claim whose agent never existed. `plot-reconcile-scan.sh:423` defines the
736
+ # marker precisely and this uses THE SAME definition: a claim commit is titled
737
+ # `plot: claim ...` AND empty, its tree equal to its parent's. The subject
738
+ # alone is not evidence — a human commit titled "plot: claim handling
739
+ # refactor" carrying real files would otherwise read as an empty claim, and
740
+ # the sweep would offer to delete real work.
741
+ #
742
+ # ONLY WHAT THE SCAN ALREADY CALLS REAPABLE IS SWEPT. Section 3 classifies
743
+ # these: a `deferred:`/`moved:` annotation in the plan means reapable, a bare
744
+ # `claimed:` needs judgment. The judgment cases are left for a person and keep
745
+ # being reported — a slow worker and a dead one leave the identical empty
746
+ # branch, and one of them is doing real work.
747
+ #
748
+ # Git cannot answer how a claim ended, so the plan annotation is the only
749
+ # signal. Reading it here is the same deliberate exception the scan states: it
750
+ # decides CLEANUP, not work, so a wrong annotation costs at most a missed
751
+ # cleanup — never lost or duplicated work.
752
+ # ---------------------------------------------------------------------------
753
+
754
+ ACTIVE_DIR_SWEEP="docs/plans/active/"
755
+ if [ -r "$CONFIG" ]; then
756
+ d=$(bash "$CONFIG" get "Active index" "docs/plans/active/" 2>/dev/null) && [ -n "$d" ] && ACTIVE_DIR_SWEEP="$d"
757
+ fi
758
+ case "$ACTIVE_DIR_SWEEP" in /*) ;; *) ACTIVE_DIR_SWEEP="$ROOT/$ACTIVE_DIR_SWEEP" ;; esac
759
+
760
+ # Does this branch carry ONLY empty claim commits? The scan's definition,
761
+ # applied to a LOCAL ref — this sweep runs over local branches, where the scan
762
+ # reads `origin/`.
763
+ sweep_is_empty_claim() { # $1=branch
764
+ local br="$1" ahead c subj real=0
765
+ ahead=$(git rev-list --count "origin/$DEFAULT..$br" 2>/dev/null || echo 0)
766
+ [ "${ahead:-0}" -gt 0 ] || return 1 # nothing of its own → merged work, not a claim
767
+ for c in $(git rev-list "origin/$DEFAULT..$br" </dev/null 2>/dev/null); do
768
+ subj=$(git log -1 --format=%s "$c" </dev/null 2>/dev/null)
769
+ case "$subj" in
770
+ "plot: claim "*)
771
+ # Titled AND empty. Both, or it counts as real work.
772
+ if [ "$(git rev-parse "$c^{tree}" </dev/null 2>/dev/null)" \
773
+ = "$(git rev-parse "$c^^{tree}" </dev/null 2>/dev/null)" ]; then
774
+ continue
775
+ fi ;;
776
+ esac
777
+ real=$((real+1))
778
+ done
779
+ [ "$real" = "0" ]
780
+ }
781
+
782
+ # How the plan annotation classified this claim — the scan's `claim_disposition`,
783
+ # same predicate, same directory.
784
+ sweep_claim_disposition() { # $1=branch → abandoned | unresolved
785
+ local br="$1" l line
786
+ for l in "$ACTIVE_DIR_SWEEP"/*.md; do
787
+ [ -e "$l" ] || continue
788
+ line=$(grep -F -- "\`$br\`" "$l" 2>/dev/null | head -1)
789
+ [ -n "$line" ] || continue
790
+ case "$line" in
791
+ *"<!-- deferred:"*|*"<!-- moved:"*) echo "abandoned"; return ;;
792
+ esac
793
+ done
794
+ echo "unresolved"
795
+ }
796
+
797
+ sweep_claim_verdict() { # $1=branch $2=empty(true/false) $3=disposition
798
+ PLOT_BRANCH="$1" PLOT_EMPTY="$2" PLOT_DISP="$3" PLOT_RULE="$SWEEP_RULE" \
799
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
800
+ const { firstClaimRefusal } = await import(process.env.PLOT_RULE);
801
+ const refusal = firstClaimRefusal({
802
+ branch: process.env.PLOT_BRANCH,
803
+ isEmptyClaim: process.env.PLOT_EMPTY === "true",
804
+ disposition: process.env.PLOT_DISP === "abandoned" ? "abandoned" : "unresolved",
805
+ });
806
+ process.stdout.write(refusal === null ? "sweep" : refusal);
807
+ NODE_EOF
808
+ }
809
+
810
+ echo
811
+ echo "-- orphaned claim refs --"
812
+
813
+ while IFS= read -r br; do
814
+ [ -n "$br" ] || continue
815
+ [ "$br" = "$DEFAULT" ] && continue
816
+
817
+ empty=false
818
+ sweep_is_empty_claim "$br" && empty=true
819
+ # Only branches that ARE empty claims belong to this kind at all. Anything
820
+ # else is another kind's population or none, and reporting it here would say
821
+ # "this is a claim we declined" about a branch carrying real work.
822
+ [ "$empty" = "true" ] || continue
823
+
824
+ disp=$(sweep_claim_disposition "$br")
825
+ cverdict=$(sweep_claim_verdict "$br" "$empty" "$disp")
826
+
827
+ if [ "$cverdict" != "sweep" ]; then
828
+ case "$cverdict" in
829
+ needs-judgment) creason="still claimed, no commits → needs judgment (worker thinking, or dead)" ;;
830
+ not-an-empty-claim) creason="carries real work — not a claim" ;;
831
+ *) creason="rule could not be asked — keeping" ;;
832
+ esac
833
+ printf '%-8s %-52s %s\n' "keep" "$br" "$creason"; kept_claims=$((kept_claims+1)); continue
834
+ fi
835
+
836
+ if [ "$MAX" -gt 0 ] && [ "$swept_claims" -ge "$MAX" ]; then
837
+ printf '%-8s %-52s %s\n' "keep" "$br" "--max $MAX reached"; kept_claims=$((kept_claims+1)); continue
838
+ fi
839
+
840
+ swept_claims=$((swept_claims+1))
841
+ if [ "$DRY" -eq 1 ]; then
842
+ printf '%-8s %-52s %s\n' "would" "$br" "abandoned claim (plan says deferred/moved)"
843
+ else
844
+ if git branch -D "$br" >/dev/null 2>&1; then
845
+ printf '%-8s %-52s %s\n' "deleted" "$br" "abandoned claim — local ref deleted"; deleted_claims=$((deleted_claims+1))
846
+ else
847
+ printf '%-8s %-52s %s\n' "FAILED" "$br" "git branch -D refused"; kept_claims=$((kept_claims+1))
848
+ fi
849
+ fi
850
+ done < <(git for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null)
851
+
852
+ # ---------------------------------------------------------------------------
853
+ # KIND 4: DIRTY TREES NOBODY OWNS.
854
+ #
855
+ # REFUSED TODAY AND NEVER RESOLVED, and this KEEPS it refused. `uncommitted-
856
+ # changes` is a refusal for the same reason the create-or-reset guard does not
857
+ # `reset --hard`: the case where the guard is wrong is exactly the case where
858
+ # destruction cannot be undone. A guard that misjudges should leave a desk the
859
+ # sweep reports, not deleted work.
860
+ #
861
+ # SO THIS KIND HAS NO `--yes` PATH AT ALL, and that absence is the design. What
862
+ # it adds is the NAME: the tree is reported as a leftover whose owner is
863
+ # `nobody`, loudly enough that a person clears it. The population was refused
864
+ # and never resolved precisely because nothing ever said whose it was.
865
+ #
866
+ # It runs over EVERY worktree, not only dispatch trees: a tree nobody owns is
867
+ # by construction one that may carry no `.plot-worker.pid`, so the marker the
868
+ # reap loop gates on is exactly what a leftover of this kind lacks.
869
+ #
870
+ # EXCEPT THE MAIN CHECKOUT, which is a person's desk by definition. Measured
871
+ # while writing this: the operator's own checkout carried 2 uncommitted files
872
+ # and no `.plot-worker.pid`, so it read as a leftover nobody owns — and it is
873
+ # the one tree on the estate somebody is certainly at. `$ROOT` is not the test,
874
+ # because this script runs from whichever worktree invoked it; the main
875
+ # checkout is the PARENT of `--git-common-dir`, which every worktree agrees on.
876
+ # ---------------------------------------------------------------------------
877
+
878
+ MAIN_CHECKOUT=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd -P) || MAIN_CHECKOUT=""
879
+
880
+ sweep_dirty_owner() { # $1=pid $2=manifest → owner word
881
+ PLOT_PID="$1" PLOT_MANIFEST="$2" PLOT_RULE="$SWEEP_RULE" \
882
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
883
+ const { dirtyTreeOwner } = await import(process.env.PLOT_RULE);
884
+ process.stdout.write(dirtyTreeOwner({
885
+ path: "",
886
+ branch: "",
887
+ dirtyCount: 1,
888
+ workerPid: process.env.PLOT_PID === "" ? null : process.env.PLOT_PID,
889
+ manifest: process.env.PLOT_MANIFEST,
890
+ }));
891
+ NODE_EOF
892
+ }
893
+
894
+ echo
895
+ echo "-- dirty trees nobody owns --"
896
+
897
+ while IFS=$'\t' read -r wt br; do
898
+ [ -n "$wt" ] || continue
899
+ [ -d "$wt" ] || continue
900
+ # The main checkout is a person's desk, and its dirt is a person's work in
901
+ # progress. Compared canonically, since git reports resolved paths and macOS
902
+ # spells `/tmp`, `/var` and `/etc` two ways.
903
+ [ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
904
+ dshort=${br#refs/heads/}
905
+
906
+ dcount=$(git -C "$wt" status --porcelain 2>/dev/null \
907
+ | grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
908
+ [ "${dcount:-0}" -gt 0 ] || continue
909
+
910
+ dpid=""
911
+ if [ -f "$wt/.plot-worker.pid" ]; then
912
+ p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
913
+ if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
914
+ fi
915
+
916
+ dmanifest=""
917
+ if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi
918
+
919
+ downer=$(sweep_dirty_owner "$dpid" "$dmanifest")
920
+ # A rule that could not be asked answers nothing, and nothing is not
921
+ # `nobody`. An unaskable rule must not be what promotes a tree to a finding.
922
+ [ "$downer" = "nobody" ] || continue
923
+
924
+ dirty_trees=$((dirty_trees+1))
925
+ # NEVER a `would` or a `deleted`: there is no act to preview. `LEFTOVER` is
926
+ # the verdict, and the owner is the finding.
927
+ printf '%-8s %-52s %s\n' "LEFTOVER" "${dshort:-(detached)}" \
928
+ "$dcount uncommitted, owner: nobody — clear it by hand: $wt"
929
+ done < <(git worktree list --porcelain \
930
+ | awk '/^worktree /{p=$2} /^branch /{print p"\t"$2} /^detached/{print p"\t"}')
931
+
932
+ if [ "$dirty_trees" -gt 0 ]; then
933
+ echo " ^ $dirty_trees dirty tree(s) nobody owns. Nothing was deleted from them,"
934
+ echo " deliberately: where this guard is wrong, destruction cannot be undone."
935
+ fi
936
+
937
+ # THE REMOTE refs are untouched, deliberately. This deletes LOCAL branches,
938
+ # which are re-fetchable from origin — a copy, not the copy — so the act is
939
+ # bounded to a local ref and a reflog. A remote ref is not re-creatable at all,
940
+ # which is why `plot-release-refs.sh` deletes those, plan-scoped, under its own
941
+ # licence and its own five guards. The asymmetry between the kinds is the whole
942
+ # safety argument and it stays.
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"
286
944
  exit 0