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