@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/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 (backtick-quoted in the list
92
- # line, matching the known prefixes) OR the new `## Waves`
93
- # section (`Branch:` in a `### ` heading — see below). Both
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
- awk -v PREFIXES="$prefixes" '
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
- ni = 0
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
- for (j = 1; j <= ni && sorted_i[j] != issues[i]+0; j++) ;
375
- if (j <= ni) continue
376
- for (j = ni; j >= 1 && sorted_i[j] > issues[i]+0; j--) sorted_i[j+1] = sorted_i[j]
377
- sorted_i[j+1] = issues[i]+0; ni++
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++) out = out (i > 1 ? "," : "") sorted_i[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
- BEGIN { branch_re = "`(" PREFIXES ")/[^`]+`"; LONG_WAVE_NAME_MAX = 40 }
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
- line = $0
671
- while (match(line, branch_re)) {
672
- b = substr(line, RSTART + 1, RLENGTH - 2)
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