@plot-pm/board 0.14.4 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.14.4",
3
+ "version": "0.15.0",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
25
  "plot-agent-monitor.sh",
26
+ "plot-agent-manifest.sh",
26
27
  "plot-build-monitor.sh",
27
28
  "plot-monitor-subject.sh",
28
29
  "plot-approve.sh",
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE writer that clears an agent manifest's `branch` field.
3
+ #
4
+ # SOURCED, NOT RUN — the shape `plot-worker-state.sh` and `plot-pr-merged.sh`
5
+ # already take. Two scripts clear an assignment and must clear it one way:
6
+ #
7
+ # - `plot-worker-loop.sh` clears its own manifest when a slice finishes, so
8
+ # the window before the next hand-over is observable as `branch: ""`;
9
+ # - `plot-dispatch.sh --release <branch>` clears every manifest naming an
10
+ # abandoned slice, beside deleting the claim ref.
11
+ #
12
+ # The function lived inside `plot-worker-loop.sh` until the second caller
13
+ # arrived. The loop is a script, not a library, so sourcing it would run it; the
14
+ # body moved here unchanged and the loop sources this file instead.
15
+ #
16
+ # Defines one function and does nothing else on load.
17
+
18
+ # Clear `branch` when a slice finishes, so the window before the next one is
19
+ # observable.
20
+ #
21
+ # WHY THIS EXISTS. `free = process alive AND manifest names no branch`, and the
22
+ # second half was unreachable. `seal_declaration` runs the moment a branch is
23
+ # done; `update_manifest_on_hop` runs after `--next` answers and a worktree is
24
+ # built. Between those two points the agent genuinely holds no slice and the
25
+ # manifest still named the last one, so `isFree`'s empty-branch arm — written,
26
+ # exported and unit-tested since `a-dispatch-asks-for-a-free-agent` — had no
27
+ # production caller that could ever satisfy it. Measured 2026-09-02: 2
28
+ # manifests on this estate, neither ever carrying `branch: ""`.
29
+ #
30
+ # `branch` AND ONLY `branch`. `worktree` still names the desk the agent is
31
+ # sitting at — it has not moved, and clearing it would take the transcript join
32
+ # and the liveness check with it, since both are keyed on the worktree path.
33
+ # `wavesCount` counts hops and no hop has happened yet. The node one-liner
34
+ # round-trips the whole object, so every other field survives verbatim, the same
35
+ # property `update_manifest_on_hop` records.
36
+ #
37
+ # ADDED, NOT SUBSTITUTED. The hop still rewrites `branch` and `worktree`
38
+ # together; this writes the empty value that sits between two slices. A worker
39
+ # that finishes its last branch exits with the manifest cleared and the exit
40
+ # trap removes the file, so the empty value is never a leftover.
41
+ #
42
+ # ABSENT IS NOT A FAILURE. No manifest — a hand-started loop, an older
43
+ # dispatcher — means there is nothing to clear and nothing to report, so this
44
+ # returns 0 like `update_manifest_on_hop` does.
45
+ clear_manifest_branch() { # $1=manifest
46
+ local manifest="$1"
47
+ [ -f "$manifest" ] || return 0
48
+
49
+ local tmp="$manifest.plot-free-tmp"
50
+ node -e '
51
+ const fs = require("fs");
52
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
53
+ manifest.branch = "";
54
+ fs.writeFileSync(process.argv[2], JSON.stringify(manifest, null, 2) + "\n");
55
+ ' "$manifest" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
56
+
57
+ mv -f "$tmp" "$manifest" 2>/dev/null || { rm -f "$tmp"; return 1; }
58
+ }
package/plot-approve.sh CHANGED
@@ -227,7 +227,25 @@ else
227
227
  pr_branch="idea/$slug"
228
228
  fi
229
229
 
230
- pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>/dev/null) || pr_json=""
230
+ # THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
231
+ # with `state: NONE` when the host answered that the branch has no PR, and
232
+ # non-zero when the host could not be asked: 3 for a refused or failed call
233
+ # (a rate limit included), 4 for a backend with no answer at all. Only the
234
+ # first is an absence. The other two stop here with the host's own words and
235
+ # name no repair to the branch, because nothing about the branch was read.
236
+ pr_err_file=$(mktemp "${TMPDIR:-/tmp}/plot-approve-pr.XXXXXX")
237
+ pr_rc=0
238
+ pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
239
+ pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
240
+ if [ "$pr_rc" = 4 ]; then
241
+ die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
242
+ ${pr_err:-The host adapter gave no reason.}
243
+ This backend cannot report a PR's state, so the approval cannot read its gate. The plan was not approved and its phase is unchanged."
244
+ elif [ "$pr_rc" != 0 ]; then
245
+ die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
246
+ ${pr_err:-The host adapter gave no reason.}
247
+ The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
248
+ fi
231
249
  [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
232
250
  pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
233
251
  pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
@@ -420,6 +438,10 @@ append_approved_line() { # $1=in $2=out $3=record
420
438
  insert = start
421
439
  for (i = start + 1; i <= n; i++) {
422
440
  if (lines[i] ~ /^##[ \t]/) break
441
+ # An HTML comment ends the writable region. Checked BEFORE the
442
+ # placeholder arms so a commented-out `- **Approved:**` template line
443
+ # is never mistaken for the slot to fill.
444
+ if (lines[i] ~ /<!--/) break
423
445
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Approved:\*\*[ \t]*$/) { slot = i; break }
424
446
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
425
447
  }
package/plot-config.sh CHANGED
@@ -80,6 +80,13 @@
80
80
  # here can invoke a skill. Absent (or `none`) = the button
81
81
  # refuses and names this key as the fix, rather than
82
82
  # accepting the click and doing nothing.
83
+ # Interrogate command how the board runs `/plot-panel <plan path>` on a Draft
84
+ # plan (the card's `Interrogate` button); the prompt is
85
+ # appended as one argument and names a FILE the board
86
+ # wrote. REQUIRED for the same reason as `Idea command`:
87
+ # a panel fans out N agents reading a plan, and no script
88
+ # can do that. Absent (or `none`) = the button renders
89
+ # disabled and names this key as the fix.
83
90
  # Brief command how /plot-dispatch runs an agent headless to WRITE a
84
91
  # missing hand-off brief. The prompt is appended as one
85
92
  # argument and asks for `/plot-implement <slug>`, whose
@@ -95,6 +102,16 @@
95
102
  # Hosts plans yes | no (no = refuse plan files)
96
103
  # Tracker plot | jira | github-issues | linear (+ URL)
97
104
  # (plot = plans in this repo ARE the tracker; absent = same)
105
+ # Tracker delivered status
106
+ # the status word a plan's issues are set to when the plan
107
+ # reaches Delivered, in the tracker's own vocabulary
108
+ # (`In Review`, `Done`). Read by plot-issue-status.sh.
109
+ # Absent or empty = Delivered writes nothing — never an
110
+ # empty status, and never the released word instead.
111
+ # Tracker released status
112
+ # the same for Released. A team whose *Done* means
113
+ # *shipped* sets only this one; a team watching progress
114
+ # sets both.
98
115
  # Ticket prefixes the tracker project keys this repository's work lives
99
116
  # in, comma-separated (`PROJ-A, PROJ-B`). NOT
100
117
  # `Branch prefixes`, which sits next to it and holds
@@ -142,7 +159,23 @@ if [ "$cmd" != "get" ]; then
142
159
  exit 1
143
160
  fi
144
161
 
145
- root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
162
+ # THE CALLER'S ROOT IS TAKEN WHERE IT OFFERS ONE. `git rev-parse` is ~5 ms and
163
+ # this script runs once per config key, so a caller reading several keys pays
164
+ # for the same constant repeatedly. Measured on CI 2026-09-25: one board build
165
+ # spawned `git rev-parse --show-toplevel` 21 times out of 42 git processes
166
+ # total, which `plan-read-shape.test.mjs` caught as the spawn count crossing
167
+ # its bound. The board already exports `PLOT_REPO_ROOT` (`index.ts:65`) and
168
+ # `plot-deliver.sh` and `plot-issue-status.sh` already read it.
169
+ #
170
+ # IT MUST BE A DIRECTORY, and a wrong one falls back rather than failing: an
171
+ # exported stale path would otherwise make every config read answer from a
172
+ # repository that is not this one, silently. Asking git is the safe answer and
173
+ # stays the default for every caller that offers nothing.
174
+ if [ -n "${PLOT_REPO_ROOT:-}" ] && [ -d "$PLOT_REPO_ROOT" ]; then
175
+ root="$PLOT_REPO_ROOT"
176
+ else
177
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
178
+ fi
146
179
 
147
180
  # Find the first repo-root file that contains a ## Plot Config section.
148
181
  # CLAUDE.md wins for backwards compatibility; AGENTS.md is the modern fallback.
package/plot-deliver.sh CHANGED
@@ -5,7 +5,10 @@
5
5
  # --who the name recorded in the `Delivered:` line (default: git user.name)
6
6
  # <slug> the plan to deliver
7
7
  # Output: one `step:` line per step, then a machine-countable summary:
8
- # summary: phase=flipped record=written index=moved sprint=updated push=clean
8
+ # summary: phase=flipped record=written index=moved sprint=updated push=clean tracker=none
9
+ # `tracker=` is plot-issue-status.sh's outcome (none|written|no-target|
10
+ # unaskable|failed), or `skipped` when the push was rejected. It never
11
+ # changes the exit code: a failed status write is reported, not raised.
9
12
  # Exit 0 when the plan is Delivered on the default branch (whether this
10
13
  # run did the work or found it already done); 1 on a refusal or a
11
14
  # failure, with the reason on stderr.
@@ -654,6 +657,12 @@ git -C "$tmpwt" add -- "${ACTIVE_DIR#/}" >/dev/null 2>&1 || true
654
657
  git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
655
658
  [ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
656
659
 
660
+ # THE BOOKED PLAN, KEPT FOR THE TRACKER. The booking worktree is removed on
661
+ # every exit below, and the working tree may still read `Approved`; the issue
662
+ # status is decided from the file that reached the default branch.
663
+ booked_plan=$(mktemp "${TMPDIR:-/tmp}/plot-deliver-plan.XXXXXX")
664
+ cp "$tmpwt/$rel" "$booked_plan" 2>/dev/null || : > "$booked_plan"
665
+
657
666
  if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
658
667
  # THE IDEMPOTENT EXIT. Everything this run would have written was already
659
668
  # on the default branch, so there is nothing to push and nothing wrong.
@@ -694,13 +703,25 @@ else
694
703
  echo "plot-deliver: the delivery is committed on '$bookbr' but could not reach $MAIN." >&2
695
704
  echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
696
705
  git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
697
- echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
706
+ rm -f "$booked_plan"
707
+ # NOTHING REACHED THE DEFAULT BRANCH, so no status is owed yet.
708
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=skipped"
698
709
  exit 1
699
710
  fi
700
711
  fi
701
712
  fi
702
713
 
703
- echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
714
+ # THE TRACKER HEARS AFTER THE DELIVERY LANDED, and never decides it. The plan is
715
+ # delivered; the tracker holds a copy of one fact about it. Every outcome of
716
+ # plot-issue-status.sh, a failed write and an unreadable bundle included, is a
717
+ # report on the summary line and leaves this script's exit code alone.
718
+ tracker_out=$(bash "$script_dir/plot-issue-status.sh" "$booked_plan" 2>&1)
719
+ printf '%s\n' "$tracker_out" | grep -v '^summary: ' | sed '/^$/d; s/^/ tracker: /'
720
+ tracker_report=$(printf '%s' "$tracker_out" | sed -n 's/^summary: tracker=\([a-z-]*\).*/\1/p' | tail -1)
721
+ [ -n "$tracker_report" ] || tracker_report="failed"
722
+ rm -f "$booked_plan"
723
+
724
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=$tracker_report"
704
725
  # THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
705
726
  # `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
706
727
  # interrupted run can be repeated on the same licence: this script documents
package/plot-dispatch.sh CHANGED
@@ -4,6 +4,7 @@
4
4
  # [--max N] [--allow-local] <slug>
5
5
  # plot-dispatch.sh --start [N] [--dry-run]
6
6
  # plot-dispatch.sh --migrate [--yes] [--max N]
7
+ # plot-dispatch.sh --release <branch>
7
8
  # --status list fleet worktrees with worker pid, liveness, and last log
8
9
  # line; then exit. Works regardless of plan phase.
9
10
  # --stop <br> stop the worker on <br> (branch required — never "all").
@@ -23,6 +24,18 @@
23
24
  # word), on a live worker, and on a PLOT-BLOCKED marker. The
24
25
  # worktree is inherited exactly as it stands — uncommitted work
25
26
  # is what a stall leaves behind, and this must not destroy it.
27
+ # --release <br>
28
+ # return an ABANDONED slice to the queue: clear the `branch` of
29
+ # every agent manifest naming <br>, then delete the claim ref
30
+ # origin/<br>. --stop ends a worker and KEEPS the claim, because
31
+ # stopping is not abandoning; --release is the act that gives the
32
+ # slice up. Deleting the ref by hand is not the same act: the
33
+ # manifest still names the branch. Branch required. Refuses on a
34
+ # PR (open or merged), a host it cannot ask, a live worker (named
35
+ # by pid), real work (a file-changing commit on origin/<br>, or
36
+ # unpushed commits or uncommitted changes on the local desk), and
37
+ # a PLOT-BLOCKED marker. A refusal writes nothing. The desk is
38
+ # never touched.
26
39
  # --migrate move legacy worktrees into the configured `Worktree root:`. An
27
40
  # idle worktree (no live worker, no unlanded work) is moved; a
28
41
  # busy one is skipped with the reason. Requires a `Worktree root:`
@@ -145,8 +158,10 @@
145
158
  # think they won. Git is the lock only when the refs actually diverge.
146
159
  # - Worktrees are adopted, never duplicated. A dispatcher that dies halfway
147
160
  # through a fan-out is safe to re-run.
148
- # - Nothing is ever deleted. Cleanup belongs to /plot-reconcile, which can
149
- # tell a deliberately abandoned claim from a dead worker.
161
+ # - A fan-out deletes nothing. Cleanup belongs to /plot-reconcile, which can
162
+ # tell a deliberately abandoned claim from a dead worker. The one deletion
163
+ # here is `--release <branch>`, which a person runs after making that call,
164
+ # and which refuses wherever the claim is not abandoned.
150
165
  # - The `Started:` record is booked on the DEFAULT BRANCH, after the claims,
151
166
  # and only for branches this run newly claimed. A re-run books nothing it
152
167
  # merely re-adopted. If the booking cannot be pushed, the fan-out stands
@@ -191,6 +206,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
191
206
  # shellcheck source=plot-default-branch.sh
192
207
  . "$script_dir/plot-default-branch.sh"
193
208
 
209
+ # The ONE writer of a manifest's empty `branch` — `clear_manifest_branch`,
210
+ # shared with `plot-worker-loop.sh`. `--release` clears an abandoned slice's
211
+ # assignment with it rather than with a second writer.
212
+ # shellcheck source=plot-agent-manifest.sh
213
+ . "$script_dir/plot-agent-manifest.sh"
214
+
194
215
  # ---------------------------------------------------------------------------
195
216
  # WHERE THE WORKTREES LIVE, and by what name
196
217
  # ---------------------------------------------------------------------------
@@ -239,6 +260,7 @@ no_brief=0
239
260
  mode=dispatch
240
261
  stop_branch=""
241
262
  restart_branch=""
263
+ release_branch=""
242
264
  # EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
243
265
  # number and the argument for it; a literal here would be a second copy of a
244
266
  # decision that has one home. It is filled from the rule below.
@@ -275,6 +297,9 @@ while [ $# -gt 0 ]; do
275
297
  # plan for feature/x", which describes neither what was asked nor what
276
298
  # went wrong. The branch is consumed only when it looks like one.
277
299
  --restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
300
+ # The same rule once more: a bare `--release <slug>` must not delete a ref
301
+ # named after a plan slug.
302
+ --release) mode=release; case "${2:-}" in */*) release_branch="$2"; shift ;; esac ;;
278
303
  # `--start [N]` brings FREE agents into existence — registered, waiting, and
279
304
  # holding no slice. The count is OPTIONAL and only a bare number is consumed,
280
305
  # the same rule `--stop` and `--restart` apply to a branch: a value that does
@@ -312,10 +337,11 @@ while [ $# -gt 0 ]; do
312
337
  shift ;;
313
338
  # THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
314
339
  # does; adding `--agent` above pushed that line from 59 to 69, and
315
- # documenting the plan-declared kind pushed it from 69 to 78. Two records
340
+ # documenting the plan-declared kind pushed it from 69 to 78, and
341
+ # `--release` pushed it from 78 to 91. Two records
316
342
  # of one fact, and nothing compares them — a stale number here silently
317
343
  # truncates the help rather than failing, so it is checked by a test.
318
- -h|--help) sed -n '2,78p' "$0"; exit 0 ;;
344
+ -h|--help) sed -n '2,91p' "$0"; exit 0 ;;
319
345
  *) slug="$1" ;;
320
346
  esac
321
347
  shift
@@ -428,6 +454,23 @@ json_escape() {
428
454
  #
429
455
  # Written to a temp file and moved into place, so a scan reading the directory
430
456
  # never sees a half-written manifest. `mv` within one directory is atomic.
457
+ # The directory the `Agent registry` key names, resolved against a repo root.
458
+ #
459
+ # The case split is `resolve_wt_root`'s: absolute taken as given, relative
460
+ # joined onto the repo root, trailing slash trimmed as pure string work because
461
+ # the directory need not exist yet. One resolver, read by `start_worker` when it
462
+ # writes a manifest and by `--release` when it clears one, so the two cannot
463
+ # look in different places.
464
+ agent_registry_dir() { # $1=repo_root → prints the directory
465
+ local dir
466
+ dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
467
+ case "$dir" in
468
+ /*) ;;
469
+ *) dir="$1/$dir" ;;
470
+ esac
471
+ printf '%s' "${dir%/}"
472
+ }
473
+
431
474
  write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
432
475
  local out="$1" tmp="$1.plot-tmp"
433
476
  {
@@ -1119,12 +1162,7 @@ start_worker() {
1119
1162
  # resolving a configured directory is a second way to be wrong.
1120
1163
  local session manifest_dir
1121
1164
  session=$(plot_session_id)
1122
- manifest_dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
1123
- case "$manifest_dir" in
1124
- /*) ;;
1125
- *) manifest_dir="$repo_root/$manifest_dir" ;;
1126
- esac
1127
- manifest_dir="${manifest_dir%/}"
1165
+ manifest_dir=$(agent_registry_dir "$repo_root")
1128
1166
  mkdir -p "$manifest_dir" 2>/dev/null || true
1129
1167
  # `printf` per field with no interpretation: a command containing quotes,
1130
1168
  # newlines or backslashes must survive into valid JSON, and this is the one
@@ -1171,8 +1209,9 @@ start_worker() {
1171
1209
  echo " refusing to start $branch — its agent manifest could not be written:"
1172
1210
  echo " $manifest_dir/$session.json"
1173
1211
  echo " An unregistered worker cannot be seen, stopped or reaped, and holds"
1174
- echo " a claim nobody can release. The worktree and claim are untouched —"
1175
- echo " fix the path above (see the 'Agent registry' key) and dispatch again."
1212
+ echo " a claim only 'plot-dispatch.sh --release $branch' can give up. The worktree"
1213
+ echo " and claim are untouched — fix the path above (see the 'Agent registry'"
1214
+ echo " key) and dispatch again."
1176
1215
  return 1
1177
1216
  fi
1178
1217
  # TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
@@ -1699,7 +1738,8 @@ if [ "$mode" = "stop" ]; then
1699
1738
  exit 1; }
1700
1739
  # The worktree and its claim are left in place: the branch is still taken,
1701
1740
  # and deleting either would be the kind of write this design avoids.
1702
- echo " worktree kept at $wt — the claim stands until you release it"
1741
+ echo " worktree kept at $wt — the claim stands until you release it:"
1742
+ echo " plot-dispatch.sh --release $stop_branch"
1703
1743
  ;;
1704
1744
  finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
1705
1745
  *) echo "$stop_branch has no worker" ;;
@@ -1807,6 +1847,232 @@ if [ "$mode" = "restart" ]; then
1807
1847
  exit 0
1808
1848
  fi
1809
1849
 
1850
+ if [ "$mode" = "release" ]; then
1851
+ # THE COUNTERPART TO --stop THAT NOBODY WROTE. `--stop` ends a worker and
1852
+ # keeps the claim, because stopping is not abandoning. This is the act that
1853
+ # gives an abandoned slice back to the queue.
1854
+ #
1855
+ # THE ASSIGNMENT HAS TWO RECORDS, and this clears both. The remote claim ref
1856
+ # is what the scan and the registry's queue read as *somebody took this*; the
1857
+ # agent manifest's `branch` field is what the registry wrote when it handed
1858
+ # the slice over. Deleting only the ref — the repair `plot-reap.sh` and
1859
+ # `plot-reconcile-scan.sh` leave to a person — leaves the manifest naming a
1860
+ # slice the queue now offers to somebody else. Measured 2026-09-26:
1861
+ # `feature/the-board-filters-to-my-work` was handed out twice after exactly
1862
+ # that hand repair, and the second agent abandoned a desk with unpushed work.
1863
+ #
1864
+ # BEFORE THE PHASE GATE, beside --stop and --restart: a claimed branch is
1865
+ # work in flight whatever the plan's phase now says.
1866
+ if [ -z "$release_branch" ]; then
1867
+ echo "plot-dispatch: --release needs a branch name, e.g. --release feature/x" >&2
1868
+ echo " A slug is not enough: which claim is abandoned is your call, and" >&2
1869
+ echo " a release deletes a ref that cannot be re-created." >&2
1870
+ exit 1
1871
+ fi
1872
+ br="$release_branch"
1873
+ MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
1874
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1875
+ if [ -z "$MAIN" ]; then
1876
+ echo "plot-dispatch: cannot resolve the default branch — refusing to release $br." >&2
1877
+ echo " Nothing was written." >&2
1878
+ exit 1
1879
+ fi
1880
+
1881
+ # THE REMOTE IS READ FRESH. A stale remote-tracking ref would hide commits a
1882
+ # worker pushed since the last fetch, and those are the real work this must
1883
+ # refuse on. A fetch that fails is a remote this cannot read.
1884
+ if ! git fetch -q --prune origin </dev/null 2>/dev/null; then
1885
+ echo "plot-dispatch: cannot fetch origin — refusing to release $br." >&2
1886
+ echo " Without a fresh reading this cannot tell a claim from pushed work." >&2
1887
+ echo " Nothing was written." >&2
1888
+ exit 1
1889
+ fi
1890
+ ref_present=0
1891
+ git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1 && ref_present=1
1892
+
1893
+ # 1. A PULL REQUEST, ASKED FIRST — `handover_refusal`'s order and for its
1894
+ # reason: a worker's exit state says nothing about whether its work reached
1895
+ # review. A merged branch's ref belongs to `plot-release-refs.sh`; an open
1896
+ # one is work under review.
1897
+ #
1898
+ # UNREACHABLE IS NOT "NO PR". `reached_review` reads a failed call as no PR,
1899
+ # which is right for a rendering and wrong here: a deleted ref cannot be
1900
+ # re-created, so a host that cannot be asked refuses. `--offline` promises no
1901
+ # host call, so it refuses too.
1902
+ if [ -n "$offline" ]; then
1903
+ echo "plot-dispatch: --release asks the host whether $br has a PR, and --offline forbids that — refusing." >&2
1904
+ echo " Nothing was written." >&2
1905
+ exit 1
1906
+ fi
1907
+ if ! pr_json=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
1908
+ echo "plot-dispatch: the host could not be asked whether $br has a PR — refusing." >&2
1909
+ echo " A release deletes a ref that cannot be re-created, so silence is not" >&2
1910
+ echo " permission. Check the host (plot-host.sh pr-state $br) and retry." >&2
1911
+ echo " Nothing was written." >&2
1912
+ exit 1
1913
+ fi
1914
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
1915
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
1916
+ # ANY MERGED PR, NOT ONLY THE NEWEST — `pr-merged` reads `mergedAt` over every
1917
+ # PR for the branch, because a newer unmerged PR can mask a merged one.
1918
+ merged_answer=$("$script_dir/plot-host.sh" pr-merged "$br" </dev/null 2>/dev/null) || merged_answer=unknown
1919
+ case "$pr_state:$merged_answer" in
1920
+ OPEN:*|MERGED:*|*:merged)
1921
+ echo "plot-dispatch: $br has a pull request (#${pr_num:-?}, ${pr_state:-MERGED}) — refusing." >&2
1922
+ echo " An open PR is work under review; a merged branch's ref is released by" >&2
1923
+ echo " plot-release-refs.sh when its plan is delivered. Nothing was written." >&2
1924
+ exit 1
1925
+ ;;
1926
+ *:unknown|*:)
1927
+ echo "plot-dispatch: the host could not say whether $br ever merged — refusing." >&2
1928
+ echo " Check it (plot-host.sh pr-merged $br) and retry. Nothing was written." >&2
1929
+ exit 1
1930
+ ;;
1931
+ esac
1932
+
1933
+ # THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
1934
+ # other verb here follows. A manifest naming this branch may name a desk git
1935
+ # does not list (removed by hand), so its `worktree` is the fallback.
1936
+ release_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
1937
+ /^worktree / { path = substr($0, 10) }
1938
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
1939
+ registry_dir=$(agent_registry_dir "$repo_root_early")
1940
+ named_manifests=()
1941
+ if [ -d "$registry_dir" ]; then
1942
+ for m in "$registry_dir"/*.json; do
1943
+ [ -f "$m" ] || continue
1944
+ m_branch=$(node -e '
1945
+ try {
1946
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1947
+ process.stdout.write(typeof m.branch === "string" ? m.branch : "");
1948
+ } catch { process.stdout.write(""); }
1949
+ ' "$m" 2>/dev/null)
1950
+ [ "$m_branch" = "$br" ] || continue
1951
+ named_manifests+=("$m")
1952
+ if [ -z "$release_wt" ]; then
1953
+ m_wt=$(node -e '
1954
+ try {
1955
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1956
+ process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
1957
+ } catch { process.stdout.write(""); }
1958
+ ' "$m" 2>/dev/null)
1959
+ [ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
1960
+ fi
1961
+ done
1962
+ fi
1963
+ [ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
1964
+
1965
+ # 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
1966
+ # classifier, never `pgrep` by name. A live pid means somebody is working,
1967
+ # and the one case where a claim is not abandoned.
1968
+ if [ -n "$release_wt" ]; then
1969
+ release_state=$(plot_worker_state "$release_wt" "" | cut -f1,2)
1970
+ case "$release_state" in
1971
+ running*)
1972
+ echo "plot-dispatch: a worker is alive on $br (pid $(printf '%s' "$release_state" | cut -f2)) — refusing." >&2
1973
+ echo " A live worker has not abandoned its claim. Stop it first if you mean to:" >&2
1974
+ echo " plot-dispatch.sh --stop $br" >&2
1975
+ echo " Nothing was written." >&2
1976
+ exit 1
1977
+ ;;
1978
+ esac
1979
+ fi
1980
+
1981
+ # 3. REAL WORK. A claim is an EMPTY commit on the default branch's tip, so
1982
+ # it changes no file; `-- .` limits the count to commits that change one,
1983
+ # which is what separates a claim-only ref from pushed work. The local desk
1984
+ # is asked too, because the measured victim held its work in UNPUSHED
1985
+ # commits and uncommitted files that no remote reading can see. A count of
1986
+ # commits, not an ancestry verdict: a count above zero only ever REFUSES.
1987
+ if [ "$ref_present" = 1 ]; then
1988
+ remote_work=$(git rev-list --count "refs/remotes/origin/$MAIN..refs/remotes/origin/$br" -- . </dev/null 2>/dev/null) || remote_work=""
1989
+ if [ -z "$remote_work" ]; then
1990
+ echo "plot-dispatch: cannot count the commits on origin/$br — refusing." >&2
1991
+ echo " Nothing was written." >&2
1992
+ exit 1
1993
+ fi
1994
+ if [ "$remote_work" -gt 0 ]; then
1995
+ echo "plot-dispatch: origin/$br carries $remote_work commit(s) that change files — refusing." >&2
1996
+ echo " That is work, not an abandoned claim. Review it, or open its PR:" >&2
1997
+ echo " plot-open-pr.sh $br" >&2
1998
+ echo " Nothing was written." >&2
1999
+ exit 1
2000
+ fi
2001
+ fi
2002
+ if [ -n "$release_wt" ]; then
2003
+ desk_dirty=$(plot_worker_dirty "$release_wt")
2004
+ if [ "$ref_present" = 1 ]; then desk_base="refs/remotes/origin/$br"; else desk_base="refs/remotes/origin/$MAIN"; fi
2005
+ # Local commits the remote lacks exist nowhere else, so any refuses.
2006
+ desk_unpushed=$(git -C "$release_wt" rev-list --count "$desk_base..HEAD" -- . </dev/null 2>/dev/null) || desk_unpushed=0
2007
+ if [ -n "$desk_dirty" ] || [ "${desk_unpushed:-0}" -gt 0 ]; then
2008
+ echo "plot-dispatch: the desk at $release_wt holds work for $br — refusing." >&2
2009
+ [ "${desk_unpushed:-0}" -gt 0 ] && echo " $desk_unpushed unpushed commit(s) that change files" >&2
2010
+ [ -n "$desk_dirty" ] && echo " uncommitted changes: $(printf '%s' "$desk_dirty" | tr '\n' ' ')" >&2
2011
+ echo " Releasing would hand the slice to an agent that cannot see this work." >&2
2012
+ echo " Push it, or hand the branch to a new worker: plot-dispatch.sh --restart $br" >&2
2013
+ echo " Nothing was written." >&2
2014
+ exit 1
2015
+ fi
2016
+
2017
+ # 4. A PLOT-BLOCKED MARKER: the agent is waiting on a person, and a slice
2018
+ # waiting on an answer is not abandoned. Asked through
2019
+ # `plot_worker_blocked_file`, where the marker's spelling lives.
2020
+ if marker=$(plot_worker_blocked_file "$release_wt") && [ -n "$marker" ]; then
2021
+ echo "plot-dispatch: $br is blocked on a question — refusing." >&2
2022
+ echo " the question is in $release_wt/$marker" >&2
2023
+ echo " Answer it and delete the marker; the agent's slice is not abandoned." >&2
2024
+ echo " Nothing was written." >&2
2025
+ exit 1
2026
+ fi
2027
+ fi
2028
+
2029
+ if [ "$ref_present" = 0 ] && [ "${#named_manifests[@]}" -eq 0 ]; then
2030
+ echo "$br holds no claim: origin/$br does not exist and no manifest names it."
2031
+ echo " Nothing to release."
2032
+ exit 0
2033
+ fi
2034
+
2035
+ # THE MANIFESTS FIRST, THEN THE REF. If the ref deletion fails, the manifest
2036
+ # is already free and the ref still locks, so the scan still reads the slice
2037
+ # as claimed and nothing hands it out twice. The reverse order would leave the
2038
+ # measured failure on disk: no ref, and a manifest still naming the branch.
2039
+ released_manifests=0
2040
+ if [ "${#named_manifests[@]}" -eq 0 ]; then
2041
+ echo " no manifest in $registry_dir names $br"
2042
+ fi
2043
+ for m in ${named_manifests[@]+"${named_manifests[@]}"}; do
2044
+ if ! clear_manifest_branch "$m"; then
2045
+ echo "plot-dispatch: could not clear the assignment in $m — refusing to delete the ref." >&2
2046
+ echo " The claim on origin/$br stands; fix the file and run --release again." >&2
2047
+ exit 1
2048
+ fi
2049
+ released_manifests=$((released_manifests + 1))
2050
+ echo " cleared the assignment in $m"
2051
+ done
2052
+
2053
+ if [ "$ref_present" = 1 ]; then
2054
+ if ! git push -q origin --delete "$br" </dev/null 2>/dev/null; then
2055
+ # VERIFIED, NOT TRUSTED: the host has returned 503 on a push that landed.
2056
+ git fetch -q --prune origin </dev/null 2>/dev/null || true
2057
+ if git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1; then
2058
+ echo "plot-dispatch: could not delete origin/$br — the claim still locks the slice." >&2
2059
+ echo " The manifests are already free, so nothing hands it out twice. Retry:" >&2
2060
+ echo " plot-dispatch.sh --release $br" >&2
2061
+ exit 1
2062
+ fi
2063
+ fi
2064
+ echo " deleted the claim ref origin/$br"
2065
+ else
2066
+ echo " origin/$br does not exist — only the assignment needed releasing"
2067
+ fi
2068
+ if [ -n "$release_wt" ]; then
2069
+ echo " the desk at $release_wt still holds $br and is left as it is"
2070
+ fi
2071
+ echo "released $br — the slice returns to the queue"
2072
+ echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent)"
2073
+ exit 0
2074
+ fi
2075
+
1810
2076
  if [ "$mode" = "start" ]; then
1811
2077
  # THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
1812
2078
  # registry matches them to free agents, but until this verb existed nothing
@@ -3063,6 +3329,10 @@ append_started_line() { # $1=file $2=date $3=who $4=branch
3063
3329
  insert = start
3064
3330
  for (i = start + 1; i <= n; i++) {
3065
3331
  if (lines[i] ~ /^##[ \t]/) break
3332
+ # An HTML comment ends the writable region. Checked BEFORE the
3333
+ # placeholder arms so a commented-out `- **Started:**` template line
3334
+ # is never mistaken for the slot to fill.
3335
+ if (lines[i] ~ /<!--/) break
3066
3336
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
3067
3337
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
3068
3338
  }