@plot-pm/board 0.14.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-plan-meta.sh CHANGED
@@ -107,8 +107,9 @@
107
107
  # placeholder
108
108
  # story story slug the plan belongs to (`## Status` `Story:` or
109
109
  # front matter `story:`); "" if absent or a placeholder
110
- # assignee github handle from the `## Approval` `Assignee:` line or
111
- # front matter `assignee:`; "" if absent
110
+ # assignee github handle from front matter `assignee:`, else the
111
+ # `## Approval` `Assignee:` line, else the `## Status`
112
+ # `Assignee:` line; "" if absent or a placeholder
112
113
  # branches branch names, sorted and unique, read from EITHER spelling:
113
114
  # the old `## Branches` section (a LIST ITEM whose first token
114
115
  # is the backtick-quoted name, matching the known prefixes) OR
@@ -199,6 +200,14 @@
199
200
  # every wave name is a label; ALWAYS present, so a consumer
200
201
  # never reads undefined. The plan still parses in full: waves[]
201
202
  # is unchanged and no name is shortened or dropped.
203
+ # unread_branch_headings
204
+ # slice headings that carry `Branch:` and whose wave holds no
205
+ # branch, verbatim, in document order — a report, not a
206
+ # refusal. The wave stays in waves[] with branches []; this
207
+ # separates a slice the parser could not read from a narrative
208
+ # heading, which carries no `Branch:`. Covers both slice
209
+ # consumers, so a heading lost to the first-heading latch
210
+ # (#1042) is named too. ALWAYS present; [] when none.
202
211
  # issues tracker issue numbers this plan answers, from the `## Status`
203
212
  # `Issue:` line or front matter `issue:` (sorted, unique).
204
213
  # A DEDICATED field, never a scan of the body for `#NNN`: a
@@ -285,7 +294,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
285
294
  fi
286
295
 
287
296
  for f in ${missing[@]+"${missing[@]}"}; do
288
- printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
297
+ printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"unread_branch_headings":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
289
298
  "$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
290
299
  done
291
300
 
@@ -415,7 +424,7 @@ function reset_state() {
415
424
  fm_delivered = ""; fm_design = ""
416
425
  fm_rounds = ""
417
426
  canon_state = ""; canon_phase = ""; canon_type = ""
418
- canon_sprint = ""; canon_story = ""; canon_assignee = ""
427
+ canon_sprint = ""; canon_story = ""; canon_assignee = ""; status_assignee = ""
419
428
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
420
429
  canon_delivered = ""; canon_design = ""
421
430
  canon_rounds = ""
@@ -432,6 +441,7 @@ function reset_state() {
432
441
  delete issues; n_issues = 0
433
442
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
434
443
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
444
+ delete branch_heading
435
445
  delete waits_of; delete waits_set
436
446
  delete builds_of; delete builds_set
437
447
  delete agent_of; delete agent_set
@@ -478,6 +488,9 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
478
488
  sprint = strip_placeholder((fm_sprint != "") ? fm_sprint : canon_sprint)
479
489
  story = strip_placeholder((fm_story != "") ? fm_story : canon_story)
480
490
  assignee = strip_placeholder((fm_assignee != "") ? fm_assignee : canon_assignee)
491
+ # `## Status` is the section both templates offer; `## Approval` is older and
492
+ # outranks it, so a plan writing both keeps the answer it parsed to before.
493
+ if (fm_assignee == "" && assignee == "") assignee = status_assignee
481
494
  review = strip_placeholder((fm_review != "") ? fm_review : canon_review)
482
495
  impl = strip_placeholder((fm_impl != "") ? fm_impl : canon_impl)
483
496
  design = strip_placeholder((fm_design != "") ? fm_design : canon_design)
@@ -645,6 +658,20 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
645
658
  }
646
659
  }
647
660
  out = out "]"
661
+ # unread_branch_headings[]: a slice heading that carries `Branch:` and whose
662
+ # wave holds no branch, heading text verbatim, in document order. The wave
663
+ # itself stays in waves[] with branches []; this names WHY it is empty, since
664
+ # an empty wave with no `Branch:` is narrative and one with it is a slice the
665
+ # parser could not read. ALWAYS present, [] when every such heading was read.
666
+ out = out ",\"unread_branch_headings\":["
667
+ ubh = 0
668
+ for (w = 1; w <= n_waves; w++) {
669
+ if ((w in branch_heading) && wave_count[w] == 0) {
670
+ out = out (ubh > 0 ? "," : "") "\"" jesc(branch_heading[w]) "\""
671
+ ubh++
672
+ }
673
+ }
674
+ out = out "]"
648
675
  out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
649
676
  out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
650
677
  out = out ",\"design_raw\":\"" jesc(design) "\""
@@ -666,6 +693,16 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
666
693
  out = out "}"
667
694
  print out
668
695
  }
696
+ # Records the current `### ` heading when it carries `Branch:`. Called by BOTH
697
+ # slice consumers, because the list consumer also opens a wave per heading: a
698
+ # section whose FIRST heading is narrative routes every later heading there, and
699
+ # a `(Branch: …)` heading below it then yields nothing (#1042).
700
+ function note_branch_heading( h) {
701
+ if (index($0, "Branch:") == 0) return
702
+ h = trim(substr($0, 4))
703
+ sub(/[ \t]*<!--.*$/, "", h)
704
+ branch_heading[n_waves] = h
705
+ }
669
706
  # The longest wave name that still reads as a label, not prose. A JUDGEMENT, not
670
707
  # a measurement: the longest legitimate name in the estate is `Offered first`
671
708
  # (13), and the offender this exists to catch is a 53-character sentence, so the
@@ -823,6 +860,9 @@ section == "status" {
823
860
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
824
861
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
825
862
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
863
+ # Its own slot, not `canon_assignee`: the `## Approval` line outranks this one
864
+ # in `emit_record`, whichever section comes first.
865
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**assignee[:*]/ && status_assignee == "") status_assignee = strip_placeholder(val_after_colon($0))
826
866
  # EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
827
867
  #
828
868
  # A plan may hold both a record and an unfilled placeholder for the same
@@ -904,20 +944,26 @@ section == "changelog" {
904
944
  cl_open = 0
905
945
  next
906
946
  }
907
- # WHICH SHAPE THIS SECTION HOLDS, decided once from its first `### ` heading and
908
- # then fixed for the rest of the section.
947
+ # WHICH SHAPE THIS SECTION HOLDS, decided by ANY heading in it that names a
948
+ # branch — not by the first heading alone.
909
949
  #
910
950
  # `(Branch:` IS THE MARKER, and it is the only reliable one. A heading carrying
911
951
  # it is the new shape by construction — that parenthetical is where the new
912
- # layout puts the branch. A heading without it is the old shape, whose headings
952
+ # layout puts the branch. Headings without it are the old shape, whose headings
913
953
  # are bare names (`### Tracer`) and whose branches ride list items below.
914
954
  #
915
- # A SECTION WITH NO `### ` AT ALL is the old shape, and must be: a plan written
916
- # before subheadings existed is one unnamed wave of list items, which is exactly
917
- # what the old consumer produces. `slice_shape` therefore stays `""` until a
918
- # heading is seen, and `""` routes to the old consumer.
955
+ # THE FIRST HEADING IS NOT THE DECIDER, and a latch on it loses work. Measured
956
+ # 2026-09-28 over 357 plans: 56 sections open with a narrative heading, and in 2
957
+ # of them a branched heading sits below it, so the section routed to the list
958
+ # consumer and 5 declared slices were read as none. `slice_shape` therefore
959
+ # stays `""` until a BRANCHED heading appears, wherever it sits.
960
+ #
961
+ # A SECTION WITH NO BRANCHED `### ` AT ALL is the old shape, and must be: a plan
962
+ # written before subheadings existed is one unnamed wave of list items, which is
963
+ # exactly what the old consumer produces — 54 of those 56 plans. `""` routes
964
+ # there, so the absent latch and the old answer are one behaviour.
919
965
  section == "slices" && $0 ~ /^###[ \t]/ && slice_shape == "" {
920
- slice_shape = (index($0, "(Branch:") > 0) ? "heading" : "list"
966
+ if (index($0, "(Branch:") > 0) slice_shape = "heading"
921
967
  }
922
968
 
923
969
  section == "slices" && slice_shape != "heading" {
@@ -925,6 +971,7 @@ section == "slices" && slice_shape != "heading" {
925
971
  # unnamed wave, so a pre-wave plan parses as exactly one wave.
926
972
  if ($0 ~ /^###[ \t]/) {
927
973
  wave_names[++n_waves] = trim(substr($0, 4))
974
+ note_branch_heading()
928
975
  next
929
976
  }
930
977
  # Claim reflection, written by the worker after its ref push succeeds. This is
@@ -1180,6 +1227,7 @@ section == "slices" && slice_shape == "heading" {
1180
1227
  sub(/[ \t]*\(Branch:.*$/, "", wname)
1181
1228
  wname = trim(wname)
1182
1229
  wave_names[++n_waves] = wname
1230
+ note_branch_heading()
1183
1231
 
1184
1232
  # Claim/deferral annotations bind to the line carrying the branch name, which
1185
1233
  # is the heading. Read before any match() below, which clobbers RSTART/RLENGTH.
@@ -1284,10 +1332,17 @@ section == "slices" && slice_shape == "heading" {
1284
1332
  # heading with no readable branch still opened a wave above — so a `## Waves`
1285
1333
  # section is never silently empty, which is the failure this plan refuses: a
1286
1334
  # consumer sees a wave it could not extract a branch from, not an absence.
1335
+ #
1336
+ # THE VALUE MAY BE BACKTICKED. `(Branch: \`bug/foo\`)` is unambiguous, and a
1337
+ # backtick between `Branch:` and the prefix made the anchored match fail, so
1338
+ # the heading opened a wave and yielded nothing. The backticks are optional on
1339
+ # both sides and stripped from the name. Measured 2026-09-28 over 357 plans:
1340
+ # 2 records change, 355 byte-identical.
1287
1341
  hmeta = $0
1288
- if (match(hmeta, "Branch:[ \t]*(" PREFIXES ")/[^ \t,)]+")) {
1342
+ if (match(hmeta, "Branch:[ \t]*`?(" PREFIXES ")/[^ \t,)`]+`?")) {
1289
1343
  b = substr(hmeta, RSTART, RLENGTH)
1290
1344
  sub(/^Branch:[ \t]*/, "", b)
1345
+ gsub(/`/, "", b)
1291
1346
  branches[++n_branches] = b
1292
1347
  wave_of[n_branches] = n_waves
1293
1348
  wave_seq[n_branches] = ++wave_count[n_waves]
package/plot-pr-merged.sh CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "did the host merge ANY PR for this branch?"
3
3
  #
4
- # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged` and
5
- # `pr_open`; the file does nothing else on load. That is what makes sourcing it
4
+ # SOURCED, NOT RUN. `. "$script_dir/plot-pr-merged.sh"` defines `pr_merged`,
5
+ # `pr_open` and `pr_merged_heads`; the file does nothing else on load. That is what makes sourcing it
6
6
  # safe, and it is the same shape — and the same reason — as
7
7
  # `plot-worker-state.sh`: the logic could not simply stay in `plot-reap.sh`,
8
8
  # because that file parses `$@` and `exit 2`s on an unknown argument at load
@@ -178,3 +178,30 @@ pr_open() {
178
178
  answer=$(_plot_landed none "$(_plot_open_lookup "$1")") || return 1
179
179
  case "$answer" in *" open-pr") return 0 ;; *) return 1 ;; esac
180
180
  }
181
+
182
+ # Which commits did the host merge for this branch? One `headRefOid` per merged
183
+ # PR, one per line.
184
+ #
185
+ # A THIRD QUESTION, asked by `plot-reap.sh` only. After a squash merge the
186
+ # host deletes the branch (`delete_branch_on_merge`), `git fetch --prune`
187
+ # drops the remote-tracking ref, and the branch's own commits are then on no
188
+ # remote ref at all. So "which commits did no remote ever hold?" needs the
189
+ # head the host merged: a commit reachable from it was pushed; a commit beyond
190
+ # it exists only on the desk.
191
+ #
192
+ # Returns 1 when the host cannot be asked. The caller reads that as
193
+ # `unknown`, which keeps the desk: here a failure to observe is the case that
194
+ # loses work. It prints nothing and returns 0 when the host answered and no PR
195
+ # merged.
196
+ pr_merged_heads() {
197
+ local br="$1" out
198
+ command -v gh >/dev/null 2>&1 || return 1
199
+ out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt,headRefOid 2>/dev/null) \
200
+ || return 1
201
+ printf '%s' "$out" | node -e '
202
+ let s = "";
203
+ process.stdin.on("data", (d) => { s += d; }).on("end", () => {
204
+ const rows = JSON.parse(s);
205
+ for (const r of rows) if (r.mergedAt && r.headRefOid) console.log(r.headRefOid);
206
+ });' 2>/dev/null || return 1
207
+ }
package/plot-reap.sh CHANGED
@@ -146,12 +146,36 @@
146
146
  # words and lives INSIDE the tree, so it goes when the tree does and is not
147
147
  # swept here. This is the dispatcher's record of what it started. Two files,
148
148
  # two lifetimes, and CLAUDE.md already distinguishes them.
149
+ #
150
+ # `--sweep-temp` IS A SEPARATE MODE, and it runs INSTEAD of the four kinds. A
151
+ # trap does not run on SIGKILL — the board ends a scan at its timeout,
152
+ # `bounded.sh` escalates to SIGKILL, a person kills a hung script — so some temp
153
+ # paths outlive every trap. It removes two populations, each owned by this user
154
+ # and older than `Temp sweep after` hours (default 24), by the entry's own
155
+ # modification time:
156
+ #
157
+ # - `$TMPDIR/plot-?*` entries directly under `$TMPDIR` — `plot-` and at least
158
+ # one more character, any separator: `mkdtempSync` appends six characters
159
+ # with no dot, so `plot-host-pTFuyG` is the common shape. Never `plot`,
160
+ # `plotter-old` or any `tmp.*`: that is every template-less `mktemp` on the
161
+ # machine, and neither owner nor age separates Plot's from another
162
+ # program's. A `plot-reg.<pid>` exit registry is kept while its pid lives,
163
+ # because a worker loop registers its exit command and runs for days.
164
+ # - `$PLOT_BUDGET_HOME/memo/<pid>` directories (default `~/.plot/state/memo`)
165
+ # whose pid is not alive.
166
+ #
167
+ # It lists each candidate with `find` and removes it by the full path it
168
+ # listed; it never passes a glob to `rm`, and it never reads `/tmp` or
169
+ # `/var/folders` when `$TMPDIR` points elsewhere. The age bound is safe because
170
+ # every Plot temp path belongs to one script call, one scan or one board
171
+ # request, and 24 h is about 1,000 times the scan's 90 s timeout.
149
172
  set -u
150
173
 
151
- DRY=1; MAX=0
174
+ DRY=1; MAX=0; SWEEP_TEMP=0
152
175
  while [ $# -gt 0 ]; do
153
176
  case "$1" in
154
177
  --yes) DRY=0 ;;
178
+ --sweep-temp) SWEEP_TEMP=1 ;;
155
179
  --dry-run) DRY=1 ;;
156
180
  --max) MAX="${2:-0}"; shift ;;
157
181
  # The header, however long it has become. A hardcoded last line silently
@@ -163,6 +187,65 @@ while [ $# -gt 0 ]; do
163
187
  shift
164
188
  done
165
189
 
190
+ # Is a pid alive? `ps -p` answers for another user's process too, where
191
+ # `kill -0` reports EPERM, so a reused pid always keeps its entry.
192
+ pid_alive() { ps -p "$1" >/dev/null 2>&1; }
193
+
194
+ # One entry: report it, and remove it by the exact path unless this is a dry run.
195
+ sweep_one() { # $1=path $2=why
196
+ temp_swept=$((temp_swept + 1))
197
+ if [ "$DRY" = 1 ]; then
198
+ echo "temp: would remove $1 ($2)"
199
+ elif rm -rf -- "$1" 2>/dev/null; then
200
+ echo "temp: removed $1 ($2)"
201
+ temp_removed=$((temp_removed + 1))
202
+ else
203
+ echo "temp: could not remove $1 ($2)"
204
+ fi
205
+ }
206
+
207
+ sweep_temp() {
208
+ local hours root me memo entry name
209
+ hours=$("$(dirname "${BASH_SOURCE[0]}")/plot-config.sh" get "Temp sweep after" 24 2>/dev/null) || hours=24
210
+ case "$hours" in
211
+ ''|*[!0-9]*) echo "plot-reap: 'Temp sweep after' must be a whole number of hours, not '$hours'" >&2; return 2 ;;
212
+ esac
213
+ root="${TMPDIR:-/tmp}"; root="${root%/}"
214
+ me=$(id -un)
215
+ temp_swept=0; temp_removed=0; temp_live=0
216
+ while IFS= read -r entry; do
217
+ [ -n "$entry" ] || continue
218
+ [ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
219
+ name=${entry##*/}
220
+ case "$name" in
221
+ plot-reg.*)
222
+ if pid_alive "${name#plot-reg.}"; then temp_live=$((temp_live + 1)); continue; fi ;;
223
+ esac
224
+ sweep_one "$entry" "older than ${hours}h"
225
+ done <<LIST
226
+ $(find "$root" -mindepth 1 -maxdepth 1 -user "$me" -name 'plot-?*' -mmin +$((hours * 60)) -print 2>/dev/null)
227
+ LIST
228
+ memo="${PLOT_BUDGET_HOME:-${HOME:-}/.plot/state}/memo"
229
+ if [ -d "$memo" ]; then
230
+ while IFS= read -r entry; do
231
+ [ -n "$entry" ] || continue
232
+ [ "$MAX" -gt 0 ] && [ "$temp_swept" -ge "$MAX" ] && break
233
+ name=${entry##*/}
234
+ case "$name" in ''|*[!0-9]*) continue ;; esac
235
+ if pid_alive "$name"; then temp_live=$((temp_live + 1)); continue; fi
236
+ sweep_one "$entry" "memo of dead pid $name, older than ${hours}h"
237
+ done <<LIST
238
+ $(find "$memo" -mindepth 1 -maxdepth 1 -type d -user "$me" -mmin +$((hours * 60)) -print 2>/dev/null)
239
+ LIST
240
+ fi
241
+ echo "temp-summary: swept=$temp_swept removed=$temp_removed kept_live=$temp_live bound_hours=$hours root=$root dry_run=$DRY"
242
+ }
243
+
244
+ if [ "$SWEEP_TEMP" = 1 ]; then
245
+ sweep_temp
246
+ exit $?
247
+ fi
248
+
166
249
  command -v git >/dev/null 2>&1 || { echo "plot-reap: git not found" >&2; exit 2; }
167
250
  ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || {
168
251
  echo "plot-reap: not a git repository" >&2; exit 2; }
@@ -187,6 +270,109 @@ git fetch origin "$DEFAULT" --quiet 2>/dev/null || true
187
270
  # newest — and defines `pr_merged` and nothing else on load.
188
271
  . "$(dirname "${BASH_SOURCE[0]}")/plot-pr-merged.sh"
189
272
 
273
+ # Which uncommitted paths count as unlanded work. SOURCED, because the reap
274
+ # reading, the dirty sweep below and `plot-reconcile-scan.sh` section 21 must
275
+ # read one tree the same way; the helper names each excused path.
276
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-desk-dirt.sh"
277
+
278
+ # Is an AGENT still working at a desk? SOURCED from `plot-worker-state.sh`,
279
+ # the ONE classifier `plot-dispatch.sh --stop` also asks. Reading only whether
280
+ # the recorded pid answers `ps` asked about the wrapper shell, which outlives
281
+ # its agent: a desk whose agent had exited read `worker alive` here while
282
+ # `--stop` answered `finished` for the same tree, so it could be neither
283
+ # stopped nor reaped.
284
+ # shellcheck source=plot-worker-state.sh
285
+ . "$(dirname "${BASH_SOURCE[0]}")/plot-worker-state.sh"
286
+
287
+ # The live worker's pid at a desk, or empty. The FIVE process states map to
288
+ # one reading: `running` is live, and `finished`, `failed`, `ended` and `none`
289
+ # are not.
290
+ #
291
+ # `waiting` and `stalled` are DISCARDED BY NAME. They are desk facts — a marker
292
+ # for a person, work on the floor — which `plot_worker_state` reaches only once
293
+ # no agent runs, and they answer what the agent still OWES. That question is
294
+ # the blocked-marker, uncommitted and unpushed readings' below; only the
295
+ # process fact under the two words (no agent is running) crosses into the
296
+ # liveness reading.
297
+ #
298
+ # A word this mapping does not know keeps the desk: it reports the recorded
299
+ # pid, or `unknown`, rather than guessing the process is gone.
300
+ desk_worker_pid() { # $1=worktree → the live worker's pid, or empty
301
+ local row state spid
302
+ row=$(plot_worker_state "$1" "")
303
+ state=$(printf '%s' "$row" | cut -f1)
304
+ spid=$(printf '%s' "$row" | cut -f2)
305
+ case "$state" in
306
+ running) printf '%s' "${spid:-unknown}" ;;
307
+ finished|failed|ended|none) ;;
308
+ waiting|stalled) ;;
309
+ *) printf '%s' "${spid:-unknown}" ;;
310
+ esac
311
+ }
312
+
313
+ # The commits on a desk's HEAD that no remote holds, as short shas, one per
314
+ # line. Returns 1 when they cannot be counted, which the rule reads as
315
+ # `unknown` and refuses on.
316
+ #
317
+ # NOT `@{upstream}..HEAD`. The host deletes a branch when its PR merges, and
318
+ # the reaper only removes desks whose PR merged, so after `git fetch --prune`
319
+ # every desk it serves has lost its upstream — and an absent upstream counts as
320
+ # nothing. The reading is `HEAD --not --remotes` instead: what no remote-tracking
321
+ # ref holds. After a squash merge and a pruned ref, the branch's own commits
322
+ # are on no remote ref either, so the head the host MERGED is excluded too
323
+ # (`pr_merged_heads`): a commit beyond it was never pushed.
324
+ desk_unpushed() { # $1=worktree $2=branch $3=merge reading → short shas
325
+ local wt="$1" br="$2" merge="$3" list heads h
326
+ local -a excl=()
327
+ list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes 2>/dev/null) || return 1
328
+ [ -n "$list" ] || return 0
329
+ if [ -n "$br" ] && [ "$merge" = merged ]; then
330
+ heads=$(pr_merged_heads "$br") || return 1
331
+ for h in $heads; do
332
+ git -C "$wt" cat-file -e "$h^{commit}" 2>/dev/null && excl+=("$h")
333
+ done
334
+ if [ "${#excl[@]}" -gt 0 ]; then
335
+ list=$(git -C "$wt" rev-list --abbrev-commit HEAD --not --remotes "${excl[@]}" 2>/dev/null) \
336
+ || return 1
337
+ else
338
+ # NO MERGED HEAD THIS DESK CONTAINS, and the host still said merged. The
339
+ # subtraction above cannot run, and without it `--not --remotes` reports
340
+ # EVERY commit the branch ever had — so the desk would be held forever
341
+ # for having done the work that merged (#1033).
342
+ #
343
+ # Two ways to reach here, both normal. A SQUASH merge rewrites the
344
+ # commits, so the head the host names exists nowhere in this history —
345
+ # the same property that makes `plot-pr-merged.sh` read `mergedAt` and
346
+ # never ancestry, measured here as ancestry clearing 1 of 29 finished
347
+ # trees against the host's 28. And a host answer that carries no head at
348
+ # all leaves nothing to subtract.
349
+ #
350
+ # THE READING IS PATCH-ID: `git cherry` marks a commit `-` when its
351
+ # change is already upstream and `+` when it is not. A `+` commit that no
352
+ # remote holds is work the merge did not take — a commit made after the
353
+ # merge — and it holds the desk. No clock is consulted: a committer date
354
+ # holds a rebased merged desk forever, and an author date reaps an old
355
+ # patch committed today (#1038). The rule stays in shell because the
356
+ # logic is the prefix test and nothing else; a second conditional here is
357
+ # the signal to move it into the domain with a corpus entry.
358
+ #
359
+ # It fails toward keeping: an unreadable base or a failing `git cherry`
360
+ # returns 1, which the rule reads as `unknown` and refuses on.
361
+ local full cherry
362
+ full=$(git -C "$wt" rev-list HEAD --not --remotes 2>/dev/null) || return 1
363
+ cherry=$(git -C "$wt" cherry "origin/$DEFAULT" HEAD 2>/dev/null) || return 1
364
+ list=""
365
+ for h in $(printf '%s\n' "$cherry" | sed -n 's/^+ //p'); do
366
+ case $'\n'"$full"$'\n' in
367
+ *$'\n'"$h"$'\n'*) list+="$(git -C "$wt" rev-parse --short "$h")"$'\n' ;;
368
+ esac
369
+ done
370
+ list=${list%$'\n'}
371
+ fi
372
+ fi
373
+ printf '%s\n' "$list"
374
+ }
375
+
190
376
  # Where the registry lives, resolved through `plot-config.sh` — the SAME key and
191
377
  # default the board's reader uses (`resolveManifestDir` in `registry.ts` shells
192
378
  # out to exactly this). Two implementations of "where is the registry" is how
@@ -501,25 +687,21 @@ while IFS=$'\037' read -r wt br prunable; do
501
687
 
502
688
  # The process table: the live worker's pid, or empty. Read but not judged —
503
689
  # an empty pid file is not a live process, and which of those two it is is
504
- # the rule's to say.
505
- pid=""
506
- if [ -f "$wt/.plot-worker.pid" ]; then
507
- p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
508
- if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then pid="$p"; fi
509
- fi
690
+ # the rule's to say. "Live" means an agent runs under the recorded pid, the
691
+ # answer `--stop` reads; a wrapper whose agent exited is not live.
692
+ pid=$(desk_worker_pid "$wt")
510
693
 
511
694
  # The tree: a PLOT-BLOCKED marker, and the first uncommitted path.
512
695
  #
513
- # The tiny-garden pulse is excused because every board suite rewrites it — a
514
- # worker that did nothing but run the tests would otherwise never be
515
- # reapable. Any OTHER dirty path is still reported, which keeps this an
516
- # exception rather than a hole. It is filtered HERE, in the reading, because
517
- # it is a fact about this repository's fixtures and not about whether a
518
- # worktree may go.
696
+ # `desk_dirt` excuses the paths the estate writes itself — the tiny-garden
697
+ # pulse and a root `PLOT-CORRECTION.md` — and reports every OTHER dirty path,
698
+ # which keeps this an exception rather than a hole. It is filtered HERE, in
699
+ # the reading, because those are facts about files this estate writes and
700
+ # not about whether a worktree may go. The filter runs before `head`, so a
701
+ # correction beside a real file names the real file.
519
702
  marker=false
520
703
  ls "$wt"/PLOT-BLOCKED* >/dev/null 2>&1 && marker=true
521
- dirty=$(git -C "$wt" status --porcelain 2>/dev/null \
522
- | grep -v 'tiny-garden/\.plot/state' | head -1)
704
+ dirty=$(desk_dirt "$wt" | head -1)
523
705
 
524
706
  # The host: whether ANY PR for this branch merged.
525
707
  #
@@ -560,6 +742,12 @@ while IFS=$'\037' read -r wt br prunable; do
560
742
  merge=merged; why="detached, nothing to land"
561
743
  fi
562
744
 
745
+ # The desk again: commits only this checkout holds. Taken AFTER the merge
746
+ # reading, because the head the host merged is what separates a pushed
747
+ # commit from an unpushed one once the remote ref is gone. Lines, or the
748
+ # word `unknown` when they could not be counted.
749
+ if unpushed=$(desk_unpushed "$wt" "$short" "$merge"); then :; else unpushed=unknown; fi
750
+
563
751
  # THE DECISION. One call, and the script holds no `if` about whether a
564
752
  # worktree may go — only about what to do with the answer.
565
753
  #
@@ -581,7 +769,7 @@ while IFS=$'\037' read -r wt br prunable; do
581
769
  # the tree and says why. Silence is never permission, on this path either.
582
770
  verdict=$(PLOT_BRANCH="$short" PLOT_DEFAULT="$DEFAULT" PLOT_PID="$pid" \
583
771
  PLOT_DIRTY="$dirty" PLOT_MARKER="$marker" PLOT_MERGE="$merge" \
584
- PLOT_RULE="$RULE" \
772
+ PLOT_UNPUSHED="$unpushed" PLOT_RULE="$RULE" \
585
773
  node --input-type=module - <<'NODE_EOF' 2>/dev/null
586
774
  // Imported from an ABSOLUTE path derived from this script, never from the
587
775
  // cwd. The reaper runs with its cwd wherever the operator invoked it and the
@@ -603,6 +791,9 @@ const problem = firstReapRefusal({
603
791
  dirtyPath: process.env.PLOT_DIRTY,
604
792
  blockedMarker: process.env.PLOT_MARKER === "true",
605
793
  merge: process.env.PLOT_MERGE,
794
+ unpushed: process.env.PLOT_UNPUSHED === "unknown"
795
+ ? "unknown"
796
+ : process.env.PLOT_UNPUSHED.split("\n").filter((l) => l !== ""),
606
797
  });
607
798
 
608
799
  // `reap` when nothing refused; otherwise the refusal and its reading, which
@@ -622,6 +813,7 @@ NODE_EOF
622
813
  live-worker) reason="worker alive (pid $detail)" ;;
623
814
  blocked-marker) reason="PLOT-BLOCKED marker — needs a person" ;;
624
815
  uncommitted-changes) reason="uncommitted: ${detail:0:40}" ;;
816
+ unpushed-commits) reason="unpushed commits: ${detail:0:40}" ;;
625
817
  on-default-branch) reason="on $DEFAULT — dispatched branch not checked out" ;;
626
818
  no-merged-pr) reason="unlanded work — no merged PR" ;;
627
819
  *) reason="rule could not be asked — keeping" ;;
@@ -1032,15 +1224,12 @@ while IFS=$'\t' read -r wt br; do
1032
1224
  [ -n "$MAIN_CHECKOUT" ] && [ "$(canonical "$wt")" = "$(canonical "$MAIN_CHECKOUT")" ] && continue
1033
1225
  dshort=${br#refs/heads/}
1034
1226
 
1035
- dcount=$(git -C "$wt" status --porcelain 2>/dev/null \
1036
- | grep -v 'tiny-garden/\.plot/state' | wc -l | tr -d ' ')
1227
+ dcount=$(desk_dirt "$wt" | wc -l | tr -d ' ')
1037
1228
  [ "${dcount:-0}" -gt 0 ] || continue
1038
1229
 
1039
- dpid=""
1040
- if [ -f "$wt/.plot-worker.pid" ]; then
1041
- p=$(cat "$wt/.plot-worker.pid" 2>/dev/null)
1042
- if [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1; then dpid="$p"; fi
1043
- fi
1230
+ # The same liveness reading the reap loop takes, so a desk the reaper keeps
1231
+ # for a live worker and one the sweep names as owned are the same desks.
1232
+ dpid=$(desk_worker_pid "$wt")
1044
1233
 
1045
1234
  dmanifest=""
1046
1235
  if m=$(manifest_for "$(canonical "$wt")"); then dmanifest=$(basename "$m"); fi
@@ -70,6 +70,7 @@
70
70
  set -uo pipefail
71
71
 
72
72
  script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
73
+ . "$script_dir/plot-tmp.sh"
73
74
 
74
75
  # THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
75
76
  #
@@ -231,8 +232,9 @@ if ! mkdir "$lock" 2>/dev/null; then
231
232
  fi
232
233
  # Released on every exit, including a kill. A lock that outlives its process
233
234
  # would make one interrupted repair block the branch forever — and the repair is
234
- # idempotent, so there is nothing to protect after the process is gone.
235
- trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
235
+ # idempotent, so there is nothing to protect after the process is gone. A TERM
236
+ # or INT stops the repair (143 or 130) after the lock is released.
237
+ plot_on_exit 'rmdir "$lock" 2>/dev/null || true'
236
238
 
237
239
  if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
238
240
  # A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the