@plot-pm/board 0.9.1 → 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/dist/board-server.mjs +170 -164
- package/package.json +6 -1
- package/plot-agent-monitor.sh +508 -0
- package/plot-approve.sh +145 -29
- package/plot-budget.sh +439 -0
- package/plot-config.sh +9 -0
- package/plot-deliver.sh +210 -117
- package/plot-dispatch.sh +829 -185
- package/plot-fleet-scan.sh +577 -65
- package/plot-host.sh +1329 -55
- package/plot-plan-meta.sh +246 -36
- package/plot-reap.sh +675 -57
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
package/plot-reap.sh
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
# the
|
|
42
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
166
|
-
#
|
|
167
|
-
|
|
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
|
-
#
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
#
|
|
208
|
-
#
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
|
|
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
|