@plot-pm/board 0.8.1 → 0.9.1
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 +120 -111
- package/package.json +11 -2
- package/plot-approve.sh +628 -0
- package/plot-config.sh +14 -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 +133 -16
- 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
|
@@ -57,6 +57,11 @@
|
|
|
57
57
|
# the old shape invited (a second path-shaped token on a line read as a phantom
|
|
58
58
|
# branch) is structurally impossible.
|
|
59
59
|
#
|
|
60
|
+
# The old shape is now closed against the same defect from the other side: a
|
|
61
|
+
# claim must be a LIST ITEM that starts with the backticked name, so a branch
|
|
62
|
+
# cited in prose, a blockquote or an HTML comment under `## Branches` is read as
|
|
63
|
+
# the citation it is. See the anchor at `branch_claim_re` below.
|
|
64
|
+
#
|
|
60
65
|
# Phase values are normalized by scanning whitespace-separated tokens for the
|
|
61
66
|
# first known phase word — so decorated real-world values like
|
|
62
67
|
# "Delivered (2026-06-29) — split done" normalize to "delivered". A non-empty
|
|
@@ -88,9 +93,14 @@
|
|
|
88
93
|
# assignee github handle from the `## Approval` `Assignee:` line or
|
|
89
94
|
# front matter `assignee:`; "" if absent
|
|
90
95
|
# branches branch names, sorted and unique, read from EITHER spelling:
|
|
91
|
-
# the old `## Branches` section (
|
|
92
|
-
#
|
|
93
|
-
# section (`Branch:` in a `### ` heading —
|
|
96
|
+
# the old `## Branches` section (a LIST ITEM whose first token
|
|
97
|
+
# is the backtick-quoted name, matching the known prefixes) OR
|
|
98
|
+
# the new `## Waves` section (`Branch:` in a `### ` heading —
|
|
99
|
+
# see below). A backticked branch name anywhere else under
|
|
100
|
+
# `## Branches` — mid-sentence, in a blockquote, in a comment,
|
|
101
|
+
# on a wrapped continuation line — is a CITATION and claims
|
|
102
|
+
# nothing: plans name each other branches to declare
|
|
103
|
+
# dependencies, and doing so must not claim them. Both
|
|
94
104
|
# spellings emit the same array; a plan carries one or the
|
|
95
105
|
# other, and the parser reads both so a migration that moves
|
|
96
106
|
# files one at a time never makes a plan silently empty.
|
|
@@ -201,11 +211,18 @@
|
|
|
201
211
|
set -uo pipefail
|
|
202
212
|
|
|
203
213
|
prefixes='idea|feature|bug|docs|infra'
|
|
214
|
+
tracker_override='' # set by --tracker; overrides the config read below
|
|
215
|
+
tracker_override_set=0
|
|
204
216
|
files=()
|
|
205
217
|
missing=()
|
|
206
218
|
while [ $# -gt 0 ]; do
|
|
207
219
|
case "$1" in
|
|
208
220
|
--prefixes) prefixes="${2:?--prefixes needs a value}"; shift 2 ;;
|
|
221
|
+
# --tracker names the tracker directly, bypassing plot-config.sh. It exists
|
|
222
|
+
# for the contract tests (which parse fixtures outside any repo whose Plot
|
|
223
|
+
# Config could name a tracker) and for a caller that has already resolved
|
|
224
|
+
# the value once. An empty value means "GitHub", the same as no config.
|
|
225
|
+
--tracker) tracker_override="${2-}"; tracker_override_set=1; shift 2 ;;
|
|
209
226
|
-*) echo "plot-plan-meta: unknown flag: $1" >&2; shift ;;
|
|
210
227
|
*)
|
|
211
228
|
if [ -f "$1" ]; then files+=("$1"); else missing+=("$1"); fi
|
|
@@ -225,7 +242,33 @@ done
|
|
|
225
242
|
|
|
226
243
|
[ ${#files[@]} -gt 0 ] || exit 0
|
|
227
244
|
|
|
228
|
-
|
|
245
|
+
# Read the tracker config to determine whether tracker-key issue references
|
|
246
|
+
# (`PROJ-123`) are parsed in addition to GitHub's `#N`.
|
|
247
|
+
#
|
|
248
|
+
# THIS IS THE FIRST CONFIGURATION DEPENDENCY THIS SCRIPT HAS — keep it narrow:
|
|
249
|
+
# read ONE key, an unreadable or missing config means GitHub (today's
|
|
250
|
+
# behaviour), and NEVER fail a parse for want of configuration. plot-config.sh
|
|
251
|
+
# exits 0 for all cases and prints an empty string when the key is absent, so a
|
|
252
|
+
# repo with no `## Plot Config` at all still parses exactly as it does today.
|
|
253
|
+
if [ "$tracker_override_set" -eq 1 ]; then
|
|
254
|
+
tracker="$tracker_override"
|
|
255
|
+
else
|
|
256
|
+
script_dir="$(dirname "${BASH_SOURCE[0]}")"
|
|
257
|
+
tracker=$("$script_dir/plot-config.sh" get Tracker 2>/dev/null || true)
|
|
258
|
+
fi
|
|
259
|
+
# The value may carry a URL after the scheme (`jira https://…`), so match the
|
|
260
|
+
# FIRST token, lowercased. Only a tracker whose keys are `LETTERS-digits` —
|
|
261
|
+
# jira, linear — enables the key form; github, github-issues, plot, and absent
|
|
262
|
+
# all keep `#N`-only, unchanged. An unrecognized token stays GitHub too: a
|
|
263
|
+
# guess here would let `WONT-FIX` masquerade as an issue reference and hide a
|
|
264
|
+
# real ticket, which the plan interrogation explicitly rejected.
|
|
265
|
+
tracker_scheme=$(printf '%s' "$tracker" | tr '[:upper:]' '[:lower:]' | awk '{print $1}')
|
|
266
|
+
case "$tracker_scheme" in
|
|
267
|
+
jira|linear) parse_key_issues=1 ;;
|
|
268
|
+
*) parse_key_issues=0 ;;
|
|
269
|
+
esac
|
|
270
|
+
|
|
271
|
+
awk -v PREFIXES="$prefixes" -v PARSE_KEY_ISSUES="$parse_key_issues" '
|
|
229
272
|
function jesc(s) {
|
|
230
273
|
gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); gsub(/\t/, "\\t", s)
|
|
231
274
|
return s
|
|
@@ -316,7 +359,7 @@ function reset_state() {
|
|
|
316
359
|
fm_changelog = ""
|
|
317
360
|
delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
|
|
318
361
|
}
|
|
319
|
-
function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, i, j, out, sorted_b, sorted_p, sorted_i, nb, np, ni) {
|
|
362
|
+
function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, issue2, i, j, v, is_dup, out, sorted_b, sorted_p, sorted_i, nb, np, ni, num_issues, str_issues, n_num_i, n_str_i, issue_is_str) {
|
|
320
363
|
if (fm_status != "" || fm_phase != "") {
|
|
321
364
|
fmt = "frontmatter"
|
|
322
365
|
praw = (fm_status != "") ? fm_status : fm_phase
|
|
@@ -350,10 +393,27 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
350
393
|
# A LIST, because one plan can answer several signals; the plan that
|
|
351
394
|
# introduced this field subsumes three.
|
|
352
395
|
issue = strip_placeholder((fm_issue != "") ? fm_issue : canon_issue)
|
|
396
|
+
# GitHub-style `#N` is parsed everywhere, regardless of tracker.
|
|
353
397
|
while (match(issue, /#[0-9]+/)) {
|
|
354
398
|
issues[++n_issues] = substr(issue, RSTART + 1, RLENGTH - 1)
|
|
355
399
|
issue = substr(issue, RSTART + RLENGTH)
|
|
356
400
|
}
|
|
401
|
+
# Jira-style `PROJ-123` is parsed ONLY when Tracker: names a non-GitHub
|
|
402
|
+
# tracker (jira, linear). PARSE_KEY_ISSUES is set by the shell before the awk
|
|
403
|
+
# invocation, based on plot-config.sh reading the Tracker key. Missing config
|
|
404
|
+
# defaults to 0 (GitHub behaviour), so this never fires in a repo with no
|
|
405
|
+
# config — the test in this branch proves that (Done-when item 6).
|
|
406
|
+
#
|
|
407
|
+
# THE PATTERN is `[A-Z]+-[0-9]+` anchored to word boundaries by iterating
|
|
408
|
+
# through the string. A greedy match of the whole field would capture only
|
|
409
|
+
# one; the loop mirrors the `#N` extraction above.
|
|
410
|
+
if (PARSE_KEY_ISSUES == 1) {
|
|
411
|
+
issue2 = strip_placeholder((fm_issue != "") ? fm_issue : canon_issue)
|
|
412
|
+
while (match(issue2, /[A-Z][A-Z0-9]*-[0-9]+/)) {
|
|
413
|
+
issues[++n_issues] = substr(issue2, RSTART, RLENGTH)
|
|
414
|
+
issue2 = substr(issue2, RSTART + RLENGTH)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
357
417
|
# Insertion sort + dedupe (portable: no gawk asort).
|
|
358
418
|
nb = 0
|
|
359
419
|
for (i = 1; i <= n_branches; i++) {
|
|
@@ -369,13 +429,36 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
369
429
|
for (j = np; j >= 1 && sorted_p[j] > prs[i]+0; j--) sorted_p[j+1] = sorted_p[j]
|
|
370
430
|
sorted_p[j+1] = prs[i]+0; np++
|
|
371
431
|
}
|
|
372
|
-
|
|
432
|
+
# Issues: sort numeric (GitHub) issues first, then string (Jira) keys.
|
|
433
|
+
# Separate into two arrays, sort each, then concatenate.
|
|
434
|
+
n_num_i = 0; n_str_i = 0
|
|
373
435
|
for (i = 1; i <= n_issues; i++) {
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
436
|
+
v = issues[i]
|
|
437
|
+
if (v ~ /^[0-9]+$/) {
|
|
438
|
+
# Numeric: check for duplicate, then insert sorted.
|
|
439
|
+
is_dup = 0
|
|
440
|
+
for (j = 1; j <= n_num_i; j++) if (num_issues[j] == v + 0) { is_dup = 1; break }
|
|
441
|
+
if (!is_dup) {
|
|
442
|
+
for (j = n_num_i; j >= 1 && num_issues[j] > v + 0; j--) num_issues[j+1] = num_issues[j]
|
|
443
|
+
num_issues[j+1] = v + 0; n_num_i++
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
# String (Jira key): check for duplicate, then insert sorted.
|
|
447
|
+
is_dup = 0
|
|
448
|
+
for (j = 1; j <= n_str_i; j++) if (str_issues[j] == v) { is_dup = 1; break }
|
|
449
|
+
if (!is_dup) {
|
|
450
|
+
for (j = n_str_i; j >= 1 && str_issues[j] > v; j--) str_issues[j+1] = str_issues[j]
|
|
451
|
+
str_issues[j+1] = v; n_str_i++
|
|
452
|
+
}
|
|
453
|
+
}
|
|
378
454
|
}
|
|
455
|
+
# Concatenate: numeric first, then string (matches the field ordering the
|
|
456
|
+
# board expects — GitHub issues before Jira keys when both are present).
|
|
457
|
+
ni = 0
|
|
458
|
+
for (i = 1; i <= n_num_i; i++) sorted_i[++ni] = num_issues[i]
|
|
459
|
+
for (i = 1; i <= n_str_i; i++) sorted_i[++ni] = str_issues[i]
|
|
460
|
+
delete issue_is_str
|
|
461
|
+
for (i = 1; i <= n_str_i; i++) issue_is_str[str_issues[i]] = 1
|
|
379
462
|
out = "{\"file\":\"" jesc(cur_file) "\",\"format\":\"" fmt "\""
|
|
380
463
|
out = out ",\"phase_raw\":\"" jesc(praw) "\",\"phase\":\"" norm_phase(praw) "\""
|
|
381
464
|
out = out ",\"phase_alt_raw\":\"" jesc(palt_raw) "\",\"phase_alt\":\"" norm_phase(palt_raw) "\""
|
|
@@ -387,7 +470,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
387
470
|
out = out "],\"prs\":["
|
|
388
471
|
for (i = 1; i <= np; i++) out = out (i > 1 ? "," : "") sorted_p[i]
|
|
389
472
|
out = out "],\"issues\":["
|
|
390
|
-
for (i = 1; i <= ni; i++)
|
|
473
|
+
for (i = 1; i <= ni; i++) {
|
|
474
|
+
# Numeric issues output as JSON numbers; string issues (Jira keys) as quoted.
|
|
475
|
+
if (sorted_i[i] in issue_is_str)
|
|
476
|
+
out = out (i > 1 ? "," : "") "\"" jesc(sorted_i[i]) "\""
|
|
477
|
+
else
|
|
478
|
+
out = out (i > 1 ? "," : "") sorted_i[i]
|
|
479
|
+
}
|
|
391
480
|
out = out "],\"malformed_prs\":["
|
|
392
481
|
for (i = 1; i <= n_malformed_prs; i++) out = out (i > 1 ? "," : "") "\"" jesc(malformed_prs[i]) "\""
|
|
393
482
|
out = out "]"
|
|
@@ -463,7 +552,26 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
|
|
|
463
552
|
# (13), and the offender this exists to catch is a 53-character sentence, so the
|
|
464
553
|
# line sits well clear of both. Reported, never enforced — a name past it makes
|
|
465
554
|
# `long_wave_names`, and nothing refuses the plan.
|
|
466
|
-
|
|
555
|
+
# A CLAIM IS A LIST ITEM, and the anchor is what says so.
|
|
556
|
+
#
|
|
557
|
+
# This matched a backticked branch name ANYWHERE on a line, so a plan that
|
|
558
|
+
# merely CITED another plan branch under `## Branches` claimed it. Measured on
|
|
559
|
+
# the board 2026-08-23: two branches rendered twice, in two sections, wearing
|
|
560
|
+
# `claimed twice` — and /plot-dispatch would have fanned out a branch the plan
|
|
561
|
+
# does not own. Both second claims were dependency citations, written exactly as
|
|
562
|
+
# a `## Branches` section should write them.
|
|
563
|
+
#
|
|
564
|
+
# Rewording the citations was the old repair. That is a rule an author must
|
|
565
|
+
# remember in the one section where writing branch names is the entire point,
|
|
566
|
+
# and it had already been forgotten twice. Gates over rules: anchoring makes the
|
|
567
|
+
# parser UNABLE to read a citation as a claim.
|
|
568
|
+
#
|
|
569
|
+
# Licensed by a MEASUREMENT, not a preference. Swept across docs/plans/ on
|
|
570
|
+
# 2026-08-27: 259 lines under `## Branches` carry a backticked branch name and
|
|
571
|
+
# all 259 are anchored list items, so the stricter rule drops no real claim. The
|
|
572
|
+
# contract test re-runs that sweep differentially rather than pinning a total —
|
|
573
|
+
# the estate moves weekly, and an absolute number would fail a correct parser.
|
|
574
|
+
BEGIN { branch_claim_re = "^[ \t]*-[ \t]+`(" PREFIXES ")/[^`]+`"; LONG_WAVE_NAME_MAX = 40 }
|
|
467
575
|
FNR == 1 {
|
|
468
576
|
if (NR > 1) emit_record()
|
|
469
577
|
reset_state()
|
|
@@ -667,9 +775,19 @@ section == "branches" {
|
|
|
667
775
|
sub(/[ \t]*-->.*$/, "", _d)
|
|
668
776
|
defer_note = trim(_d)
|
|
669
777
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
778
|
+
# ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
|
|
779
|
+
#
|
|
780
|
+
# The old loop walked the line taking every backticked name on it, which is
|
|
781
|
+
# exactly how a citation became a second claim. Anchoring makes a second match
|
|
782
|
+
# impossible by construction, so the loop is gone rather than left as dead
|
|
783
|
+
# scaffolding that reads like several claims per item are still expected.
|
|
784
|
+
#
|
|
785
|
+
# The match now spans `- ` and the backticks, so the name is cut from the
|
|
786
|
+
# first backtick rather than from RSTART: RSTART lands on the indent.
|
|
787
|
+
if (match($0, branch_claim_re)) {
|
|
788
|
+
claim = substr($0, RSTART, RLENGTH)
|
|
789
|
+
b = substr(claim, index(claim, "`") + 1)
|
|
790
|
+
sub(/`$/, "", b)
|
|
673
791
|
branches[++n_branches] = b
|
|
674
792
|
if (n_waves == 0) { wave_names[++n_waves] = "" }
|
|
675
793
|
wave_of[n_branches] = n_waves
|
|
@@ -691,7 +809,6 @@ section == "branches" {
|
|
|
691
809
|
# is a reflection, not the claim: git refs remain authoritative.
|
|
692
810
|
claimed_of[n_branches] = claim_note
|
|
693
811
|
ordered_b[n_branches] = b
|
|
694
|
-
line = substr(line, RSTART + RLENGTH)
|
|
695
812
|
}
|
|
696
813
|
line = $0
|
|
697
814
|
# `→ #N` and `→ owner/repo#N` are both annotations: /plot-deliver instructs
|
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
|