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