@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-config.sh CHANGED
@@ -39,7 +39,11 @@
39
39
  # Non-numeric or empty falls back to the default. It bounds
40
40
  # a HUNG agent — one whose CLI crashed without exiting — so
41
41
  # one dead worker cannot hold a slot for hours.
42
- # Agent registry the directory the dispatcher writes agent manifests to,
42
+ # Temp sweep after hours before `plot-reap.sh --sweep-temp` removes an
43
+ # owned `$TMPDIR/plot-*` entry or a dead pid's budget
44
+ # memo that a SIGKILL left behind. Default 24; a value
45
+ # that is not a whole number is refused by the sweep.
46
+ # Agent registry the directory the dispatcher writes agent manifests to,
43
47
  # read by the board's registry. Default `.plot/agents`
44
48
  # (repo-relative, gitignored, hence per-worktree). A board
45
49
  # served from a worktree the dispatcher never wrote to
@@ -48,6 +52,53 @@
48
52
  # the board finds the registry wherever it was started.
49
53
  # Absent = the default, so a single-checkout project is
50
54
  # unaffected.
55
+ # Board artifact the board-server.mjs this repository runs, read by
56
+ # plot-board-probe.sh. Declared, it is resolved FIRST and
57
+ # reported as `artifact_source: checkout`; absent, the
58
+ # order stays plugin, npm, checkout — the adopting
59
+ # project's case, unchanged. It exists because a
60
+ # repository that BUILDS the artifact must run the one it
61
+ # built: without it `pnpm build:board` writes a file the
62
+ # board never reads whenever a plugin is installed, and
63
+ # the symptom looks like the fix not working.
64
+ # A relative value resolves against the MAIN CHECKOUT (the
65
+ # parent of `--git-common-dir`), never `--show-toplevel`,
66
+ # so a dispatch desk resolves the same file rather than
67
+ # its own copy; an absolute one is taken as given.
68
+ # A declared file that is missing reports `none` and does
69
+ # NOT fall back to the plugin — the key names which
70
+ # artifact runs, so a silent substitution is the wrong
71
+ # answer it removes. Absent = today's order.
72
+ # Agent settings a JSON settings file every `claude -p` the fleet starts
73
+ # is given, through `--settings`. It names the plugins this
74
+ # project's agents start WITHOUT. Every dispatched agent
75
+ # inherits every `SessionStart` hook the operator's plugins
76
+ # declare, and the fleet starts a session on every worker
77
+ # start, restart, retry and hop plus every agent-runner
78
+ # command the board runs: measured 2026-09-30, one plugin's
79
+ # lockless sync ran three times at once, the 1-minute load
80
+ # reached 195, and the supervisor did not tick for 12
81
+ # minutes.
82
+ # Resolved by `plot-agent-settings.sh`, which prints an
83
+ # absolute path (exit 0), nothing for an absent or empty key
84
+ # (exit 0), or nothing with the reason on stderr (exit 3)
85
+ # for a missing, unparseable or gate-disabling file. A
86
+ # relative value resolves against the MAIN CHECKOUT (the
87
+ # parent of `--git-common-dir`), never `--show-toplevel`,
88
+ # for `Board artifact`'s reason: a desk must resolve the
89
+ # same file, and one cut from an older main may not hold it.
90
+ # The path travels to every consumer as
91
+ # `PLOT_AGENT_SETTINGS`, and each command key interpolates
92
+ # `${PLOT_AGENT_SETTINGS:+--settings "$PLOT_AGENT_SETTINGS"}`
93
+ # itself — Plot rewrites no configured command.
94
+ # A file setting any `plot@…` plugin false, `disableAllHooks`
95
+ # true, or ANY `env` key is REFUSED: those switch Plot's own
96
+ # four gates off, and a settings `PATH` hiding the gates'
97
+ # tools makes them fail open. It is a check against an
98
+ # accidental switch-off, not a boundary — the file is
99
+ # project-owned and reviewed like this one.
100
+ # Absent or empty = no change, so an adopting project that
101
+ # sets nothing behaves exactly as today.
51
102
  # Worktree root where /plot-dispatch creates fleet worktrees. A relative
52
103
  # value resolves against the repo root, an absolute one is
53
104
  # taken as given. Absent = the default `repo_root/..` with
@@ -80,6 +131,13 @@
80
131
  # here can invoke a skill. Absent (or `none`) = the button
81
132
  # refuses and names this key as the fix, rather than
82
133
  # accepting the click and doing nothing.
134
+ # Interrogate command how the board runs `/plot-panel <plan path>` on a Draft
135
+ # plan (the card's `Interrogate` button); the prompt is
136
+ # appended as one argument and names a FILE the board
137
+ # wrote. REQUIRED for the same reason as `Idea command`:
138
+ # a panel fans out N agents reading a plan, and no script
139
+ # can do that. Absent (or `none`) = the button renders
140
+ # disabled and names this key as the fix.
83
141
  # Brief command how /plot-dispatch runs an agent headless to WRITE a
84
142
  # missing hand-off brief. The prompt is appended as one
85
143
  # argument and asks for `/plot-implement <slug>`, whose
@@ -95,6 +153,16 @@
95
153
  # Hosts plans yes | no (no = refuse plan files)
96
154
  # Tracker plot | jira | github-issues | linear (+ URL)
97
155
  # (plot = plans in this repo ARE the tracker; absent = same)
156
+ # Tracker delivered status
157
+ # the status word a plan's issues are set to when the plan
158
+ # reaches Delivered, in the tracker's own vocabulary
159
+ # (`In Review`, `Done`). Read by plot-issue-status.sh.
160
+ # Absent or empty = Delivered writes nothing — never an
161
+ # empty status, and never the released word instead.
162
+ # Tracker released status
163
+ # the same for Released. A team whose *Done* means
164
+ # *shipped* sets only this one; a team watching progress
165
+ # sets both.
98
166
  # Ticket prefixes the tracker project keys this repository's work lives
99
167
  # in, comma-separated (`PROJ-A, PROJ-B`). NOT
100
168
  # `Branch prefixes`, which sits next to it and holds
@@ -142,7 +210,23 @@ if [ "$cmd" != "get" ]; then
142
210
  exit 1
143
211
  fi
144
212
 
145
- root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
213
+ # THE CALLER'S ROOT IS TAKEN WHERE IT OFFERS ONE. `git rev-parse` is ~5 ms and
214
+ # this script runs once per config key, so a caller reading several keys pays
215
+ # for the same constant repeatedly. Measured on CI 2026-09-25: one board build
216
+ # spawned `git rev-parse --show-toplevel` 21 times out of 42 git processes
217
+ # total, which `plan-read-shape.test.mjs` caught as the spawn count crossing
218
+ # its bound. The board already exports `PLOT_REPO_ROOT` (`index.ts:65`) and
219
+ # `plot-deliver.sh` and `plot-issue-status.sh` already read it.
220
+ #
221
+ # IT MUST BE A DIRECTORY, and a wrong one falls back rather than failing: an
222
+ # exported stale path would otherwise make every config read answer from a
223
+ # repository that is not this one, silently. Asking git is the safe answer and
224
+ # stays the default for every caller that offers nothing.
225
+ if [ -n "${PLOT_REPO_ROOT:-}" ] && [ -d "$PLOT_REPO_ROOT" ]; then
226
+ root="$PLOT_REPO_ROOT"
227
+ else
228
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || root="."
229
+ fi
146
230
 
147
231
  # Find the first repo-root file that contains a ## Plot Config section.
148
232
  # 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.
@@ -48,6 +51,7 @@
48
51
  set -uo pipefail
49
52
 
50
53
  script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
54
+ . "$script_dir/plot-tmp.sh"
51
55
 
52
56
  # The receipt this script leaves for plot-state-gate.sh, which refuses every
53
57
  # other writer of a `State:` line. Sourced rather than run: the gate and the
@@ -654,6 +658,13 @@ git -C "$tmpwt" add -- "${ACTIVE_DIR#/}" >/dev/null 2>&1 || true
654
658
  git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
655
659
  [ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
656
660
 
661
+ # THE BOOKED PLAN, KEPT FOR THE TRACKER. The booking worktree is removed on
662
+ # every exit below, and the working tree may still read `Approved`; the issue
663
+ # status is decided from the file that reached the default branch.
664
+ booked_plan=""
665
+ plot_tmpfile booked_plan deliver-plan
666
+ cp "$tmpwt/$rel" "$booked_plan" 2>/dev/null || : > "$booked_plan"
667
+
657
668
  if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
658
669
  # THE IDEMPOTENT EXIT. Everything this run would have written was already
659
670
  # on the default branch, so there is nothing to push and nothing wrong.
@@ -694,13 +705,25 @@ else
694
705
  echo "plot-deliver: the delivery is committed on '$bookbr' but could not reach $MAIN." >&2
695
706
  echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
696
707
  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"
708
+ rm -f "$booked_plan"
709
+ # NOTHING REACHED THE DEFAULT BRANCH, so no status is owed yet.
710
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=skipped"
698
711
  exit 1
699
712
  fi
700
713
  fi
701
714
  fi
702
715
 
703
- echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
716
+ # THE TRACKER HEARS AFTER THE DELIVERY LANDED, and never decides it. The plan is
717
+ # delivered; the tracker holds a copy of one fact about it. Every outcome of
718
+ # plot-issue-status.sh, a failed write and an unreadable bundle included, is a
719
+ # report on the summary line and leaves this script's exit code alone.
720
+ tracker_out=$(bash "$script_dir/plot-issue-status.sh" "$booked_plan" 2>&1)
721
+ printf '%s\n' "$tracker_out" | grep -v '^summary: ' | sed '/^$/d; s/^/ tracker: /'
722
+ tracker_report=$(printf '%s' "$tracker_out" | sed -n 's/^summary: tracker=\([a-z-]*\).*/\1/p' | tail -1)
723
+ [ -n "$tracker_report" ] || tracker_report="failed"
724
+ rm -f "$booked_plan"
725
+
726
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report tracker=$tracker_report"
704
727
  # THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
705
728
  # `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
706
729
  # 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
@@ -163,6 +178,7 @@
163
178
  set -uo pipefail
164
179
 
165
180
  script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
181
+ . "$script_dir/plot-tmp.sh"
166
182
 
167
183
  # The shared worker classifier. Sourced by both this script and
168
184
  # plot-fleet-scan.sh so a worker has ONE state, not one per reader.
@@ -191,6 +207,12 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
191
207
  # shellcheck source=plot-default-branch.sh
192
208
  . "$script_dir/plot-default-branch.sh"
193
209
 
210
+ # The ONE writer of a manifest's empty `branch` — `clear_manifest_branch`,
211
+ # shared with `plot-worker-loop.sh`. `--release` clears an abandoned slice's
212
+ # assignment with it rather than with a second writer.
213
+ # shellcheck source=plot-agent-manifest.sh
214
+ . "$script_dir/plot-agent-manifest.sh"
215
+
194
216
  # ---------------------------------------------------------------------------
195
217
  # WHERE THE WORKTREES LIVE, and by what name
196
218
  # ---------------------------------------------------------------------------
@@ -239,6 +261,7 @@ no_brief=0
239
261
  mode=dispatch
240
262
  stop_branch=""
241
263
  restart_branch=""
264
+ release_branch=""
242
265
  # EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
243
266
  # number and the argument for it; a literal here would be a second copy of a
244
267
  # decision that has one home. It is filled from the rule below.
@@ -275,6 +298,9 @@ while [ $# -gt 0 ]; do
275
298
  # plan for feature/x", which describes neither what was asked nor what
276
299
  # went wrong. The branch is consumed only when it looks like one.
277
300
  --restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
301
+ # The same rule once more: a bare `--release <slug>` must not delete a ref
302
+ # named after a plan slug.
303
+ --release) mode=release; case "${2:-}" in */*) release_branch="$2"; shift ;; esac ;;
278
304
  # `--start [N]` brings FREE agents into existence — registered, waiting, and
279
305
  # holding no slice. The count is OPTIONAL and only a bare number is consumed,
280
306
  # the same rule `--stop` and `--restart` apply to a branch: a value that does
@@ -312,10 +338,11 @@ while [ $# -gt 0 ]; do
312
338
  shift ;;
313
339
  # THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
314
340
  # 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
341
+ # documenting the plan-declared kind pushed it from 69 to 78, and
342
+ # `--release` pushed it from 78 to 91. Two records
316
343
  # of one fact, and nothing compares them — a stale number here silently
317
344
  # truncates the help rather than failing, so it is checked by a test.
318
- -h|--help) sed -n '2,78p' "$0"; exit 0 ;;
345
+ -h|--help) sed -n '2,91p' "$0"; exit 0 ;;
319
346
  *) slug="$1" ;;
320
347
  esac
321
348
  shift
@@ -366,28 +393,9 @@ fi
366
393
  # until this moved. Nothing here executes at definition time; only the position
367
394
  # changed.
368
395
 
369
- # A session id, in the shape the runtime uses for its transcript filename.
370
- #
371
- # `uuidgen` where it exists (macOS and most Linux), falling back to `/dev/urandom`
372
- # — never to `$RANDOM` or a timestamp. Two workers launched in the same second by
373
- # the same fan-out would collide on either, and a collision here silently merges
374
- # two agents into one manifest.
375
- #
376
- # Lowercased because the runtime writes its transcript filename in lowercase and
377
- # the board joins on exact string equality; `uuidgen` on macOS returns uppercase.
378
- plot_session_id() {
379
- local id=""
380
- if command -v uuidgen >/dev/null 2>&1; then
381
- id=$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')
382
- fi
383
- if [ -z "$id" ]; then
384
- # 16 random bytes rendered as a v4-shaped id. The shape matters only for
385
- # recognisability; nothing parses it.
386
- id=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' \
387
- | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
388
- fi
389
- printf '%s' "$id"
390
- }
396
+ # `plot_session_id` — the session id a launch records — is SOURCED from
397
+ # `plot-agent-manifest.sh` above, because the worker loop mints one on a hop to
398
+ # a new branch and two generators of one id drift.
391
399
 
392
400
  # JSON-escape one string for a manifest value.
393
401
  #
@@ -428,6 +436,23 @@ json_escape() {
428
436
  #
429
437
  # Written to a temp file and moved into place, so a scan reading the directory
430
438
  # never sees a half-written manifest. `mv` within one directory is atomic.
439
+ # The directory the `Agent registry` key names, resolved against a repo root.
440
+ #
441
+ # The case split is `resolve_wt_root`'s: absolute taken as given, relative
442
+ # joined onto the repo root, trailing slash trimmed as pure string work because
443
+ # the directory need not exist yet. One resolver, read by `start_worker` when it
444
+ # writes a manifest and by `--release` when it clears one, so the two cannot
445
+ # look in different places.
446
+ agent_registry_dir() { # $1=repo_root → prints the directory
447
+ local dir
448
+ dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
449
+ case "$dir" in
450
+ /*) ;;
451
+ *) dir="$1/$dir" ;;
452
+ esac
453
+ printf '%s' "${dir%/}"
454
+ }
455
+
431
456
  write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
432
457
  local out="$1" tmp="$1.plot-tmp"
433
458
  {
@@ -763,8 +788,20 @@ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
763
788
  # this outlives the dispatch run by design, because the fan-out must not block
764
789
  # on a `claude -p` session of unknown length. `setsid` is not used — it does
765
790
  # not exist on macOS, where most of this fleet runs.
791
+ # THE SETTINGS FILE THIS PROJECT STARTS ITS AGENTS WITH. The `Brief command` is
792
+ # a `claude -p` session like any other agent and inherits every `SessionStart`
793
+ # hook the operator's plugins declare, so it carries the same variable the
794
+ # worker loop exports — and the configured command interpolates
795
+ # `${PLOT_AGENT_SETTINGS:+--settings "$PLOT_AGENT_SETTINGS"}` itself.
796
+ #
797
+ # SET INLINE rather than exported, because this spawn already names its
798
+ # environment on the command line. A refusal or an absent key leaves it EMPTY,
799
+ # which `${VAR:+…}` reads as nothing — so a broken config cannot stop a brief
800
+ # being written.
801
+ _brief_settings="$(bash "$(dirname "${BASH_SOURCE[0]}")/plot-agent-settings.sh" 2>/dev/null || echo "")"
766
802
  ( cd "$repo_root" \
767
803
  && PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
804
+ PLOT_AGENT_SETTINGS="$_brief_settings" \
768
805
  nohup sh -c "$cmd \"\$@\"" plot-brief \
769
806
  "$(brief_prompt "$branch" "$bslug")" \
770
807
  >"$log" 2>&1 </dev/null & ) 2>/dev/null
@@ -1119,12 +1156,7 @@ start_worker() {
1119
1156
  # resolving a configured directory is a second way to be wrong.
1120
1157
  local session manifest_dir
1121
1158
  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%/}"
1159
+ manifest_dir=$(agent_registry_dir "$repo_root")
1128
1160
  mkdir -p "$manifest_dir" 2>/dev/null || true
1129
1161
  # `printf` per field with no interpretation: a command containing quotes,
1130
1162
  # newlines or backslashes must survive into valid JSON, and this is the one
@@ -1171,8 +1203,9 @@ start_worker() {
1171
1203
  echo " refusing to start $branch — its agent manifest could not be written:"
1172
1204
  echo " $manifest_dir/$session.json"
1173
1205
  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."
1206
+ echo " a claim only 'plot-dispatch.sh --release $branch' can give up. The worktree"
1207
+ echo " and claim are untouched — fix the path above (see the 'Agent registry'"
1208
+ echo " key) and dispatch again."
1176
1209
  return 1
1177
1210
  fi
1178
1211
  # TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
@@ -1699,7 +1732,8 @@ if [ "$mode" = "stop" ]; then
1699
1732
  exit 1; }
1700
1733
  # The worktree and its claim are left in place: the branch is still taken,
1701
1734
  # 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"
1735
+ echo " worktree kept at $wt — the claim stands until you release it:"
1736
+ echo " plot-dispatch.sh --release $stop_branch"
1703
1737
  ;;
1704
1738
  finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
1705
1739
  *) echo "$stop_branch has no worker" ;;
@@ -1807,6 +1841,232 @@ if [ "$mode" = "restart" ]; then
1807
1841
  exit 0
1808
1842
  fi
1809
1843
 
1844
+ if [ "$mode" = "release" ]; then
1845
+ # THE COUNTERPART TO --stop THAT NOBODY WROTE. `--stop` ends a worker and
1846
+ # keeps the claim, because stopping is not abandoning. This is the act that
1847
+ # gives an abandoned slice back to the queue.
1848
+ #
1849
+ # THE ASSIGNMENT HAS TWO RECORDS, and this clears both. The remote claim ref
1850
+ # is what the scan and the registry's queue read as *somebody took this*; the
1851
+ # agent manifest's `branch` field is what the registry wrote when it handed
1852
+ # the slice over. Deleting only the ref — the repair `plot-reap.sh` and
1853
+ # `plot-reconcile-scan.sh` leave to a person — leaves the manifest naming a
1854
+ # slice the queue now offers to somebody else. Measured 2026-09-26:
1855
+ # `feature/the-board-filters-to-my-work` was handed out twice after exactly
1856
+ # that hand repair, and the second agent abandoned a desk with unpushed work.
1857
+ #
1858
+ # BEFORE THE PHASE GATE, beside --stop and --restart: a claimed branch is
1859
+ # work in flight whatever the plan's phase now says.
1860
+ if [ -z "$release_branch" ]; then
1861
+ echo "plot-dispatch: --release needs a branch name, e.g. --release feature/x" >&2
1862
+ echo " A slug is not enough: which claim is abandoned is your call, and" >&2
1863
+ echo " a release deletes a ref that cannot be re-created." >&2
1864
+ exit 1
1865
+ fi
1866
+ br="$release_branch"
1867
+ MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
1868
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1869
+ if [ -z "$MAIN" ]; then
1870
+ echo "plot-dispatch: cannot resolve the default branch — refusing to release $br." >&2
1871
+ echo " Nothing was written." >&2
1872
+ exit 1
1873
+ fi
1874
+
1875
+ # THE REMOTE IS READ FRESH. A stale remote-tracking ref would hide commits a
1876
+ # worker pushed since the last fetch, and those are the real work this must
1877
+ # refuse on. A fetch that fails is a remote this cannot read.
1878
+ if ! git fetch -q --prune origin </dev/null 2>/dev/null; then
1879
+ echo "plot-dispatch: cannot fetch origin — refusing to release $br." >&2
1880
+ echo " Without a fresh reading this cannot tell a claim from pushed work." >&2
1881
+ echo " Nothing was written." >&2
1882
+ exit 1
1883
+ fi
1884
+ ref_present=0
1885
+ git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1 && ref_present=1
1886
+
1887
+ # 1. A PULL REQUEST, ASKED FIRST — `handover_refusal`'s order and for its
1888
+ # reason: a worker's exit state says nothing about whether its work reached
1889
+ # review. A merged branch's ref belongs to `plot-release-refs.sh`; an open
1890
+ # one is work under review.
1891
+ #
1892
+ # UNREACHABLE IS NOT "NO PR". `reached_review` reads a failed call as no PR,
1893
+ # which is right for a rendering and wrong here: a deleted ref cannot be
1894
+ # re-created, so a host that cannot be asked refuses. `--offline` promises no
1895
+ # host call, so it refuses too.
1896
+ if [ -n "$offline" ]; then
1897
+ echo "plot-dispatch: --release asks the host whether $br has a PR, and --offline forbids that — refusing." >&2
1898
+ echo " Nothing was written." >&2
1899
+ exit 1
1900
+ fi
1901
+ if ! pr_json=$("$script_dir/plot-host.sh" pr-state "$br" </dev/null 2>/dev/null); then
1902
+ echo "plot-dispatch: the host could not be asked whether $br has a PR — refusing." >&2
1903
+ echo " A release deletes a ref that cannot be re-created, so silence is not" >&2
1904
+ echo " permission. Check the host (plot-host.sh pr-state $br) and retry." >&2
1905
+ echo " Nothing was written." >&2
1906
+ exit 1
1907
+ fi
1908
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
1909
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
1910
+ # ANY MERGED PR, NOT ONLY THE NEWEST — `pr-merged` reads `mergedAt` over every
1911
+ # PR for the branch, because a newer unmerged PR can mask a merged one.
1912
+ merged_answer=$("$script_dir/plot-host.sh" pr-merged "$br" </dev/null 2>/dev/null) || merged_answer=unknown
1913
+ case "$pr_state:$merged_answer" in
1914
+ OPEN:*|MERGED:*|*:merged)
1915
+ echo "plot-dispatch: $br has a pull request (#${pr_num:-?}, ${pr_state:-MERGED}) — refusing." >&2
1916
+ echo " An open PR is work under review; a merged branch's ref is released by" >&2
1917
+ echo " plot-release-refs.sh when its plan is delivered. Nothing was written." >&2
1918
+ exit 1
1919
+ ;;
1920
+ *:unknown|*:)
1921
+ echo "plot-dispatch: the host could not say whether $br ever merged — refusing." >&2
1922
+ echo " Check it (plot-host.sh pr-merged $br) and retry. Nothing was written." >&2
1923
+ exit 1
1924
+ ;;
1925
+ esac
1926
+
1927
+ # THE DESK, ASKED OF GIT — never rebuilt from the branch name, the rule every
1928
+ # other verb here follows. A manifest naming this branch may name a desk git
1929
+ # does not list (removed by hand), so its `worktree` is the fallback.
1930
+ release_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
1931
+ /^worktree / { path = substr($0, 10) }
1932
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
1933
+ registry_dir=$(agent_registry_dir "$repo_root_early")
1934
+ named_manifests=()
1935
+ if [ -d "$registry_dir" ]; then
1936
+ for m in "$registry_dir"/*.json; do
1937
+ [ -f "$m" ] || continue
1938
+ m_branch=$(node -e '
1939
+ try {
1940
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1941
+ process.stdout.write(typeof m.branch === "string" ? m.branch : "");
1942
+ } catch { process.stdout.write(""); }
1943
+ ' "$m" 2>/dev/null)
1944
+ [ "$m_branch" = "$br" ] || continue
1945
+ named_manifests+=("$m")
1946
+ if [ -z "$release_wt" ]; then
1947
+ m_wt=$(node -e '
1948
+ try {
1949
+ const m = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
1950
+ process.stdout.write(typeof m.worktree === "string" ? m.worktree : "");
1951
+ } catch { process.stdout.write(""); }
1952
+ ' "$m" 2>/dev/null)
1953
+ [ -n "$m_wt" ] && [ -d "$m_wt" ] && release_wt="$m_wt"
1954
+ fi
1955
+ done
1956
+ fi
1957
+ [ -n "$release_wt" ] && [ -d "$release_wt" ] || release_wt=""
1958
+
1959
+ # 2. A LIVE WORKER — the measurement `--restart` makes, through the shared
1960
+ # classifier, never `pgrep` by name. A live pid means somebody is working,
1961
+ # and the one case where a claim is not abandoned.
1962
+ if [ -n "$release_wt" ]; then
1963
+ release_state=$(plot_worker_state "$release_wt" "" | cut -f1,2)
1964
+ case "$release_state" in
1965
+ running*)
1966
+ echo "plot-dispatch: a worker is alive on $br (pid $(printf '%s' "$release_state" | cut -f2)) — refusing." >&2
1967
+ echo " A live worker has not abandoned its claim. Stop it first if you mean to:" >&2
1968
+ echo " plot-dispatch.sh --stop $br" >&2
1969
+ echo " Nothing was written." >&2
1970
+ exit 1
1971
+ ;;
1972
+ esac
1973
+ fi
1974
+
1975
+ # 3. REAL WORK. A claim is an EMPTY commit on the default branch's tip, so
1976
+ # it changes no file; `-- .` limits the count to commits that change one,
1977
+ # which is what separates a claim-only ref from pushed work. The local desk
1978
+ # is asked too, because the measured victim held its work in UNPUSHED
1979
+ # commits and uncommitted files that no remote reading can see. A count of
1980
+ # commits, not an ancestry verdict: a count above zero only ever REFUSES.
1981
+ if [ "$ref_present" = 1 ]; then
1982
+ remote_work=$(git rev-list --count "refs/remotes/origin/$MAIN..refs/remotes/origin/$br" -- . </dev/null 2>/dev/null) || remote_work=""
1983
+ if [ -z "$remote_work" ]; then
1984
+ echo "plot-dispatch: cannot count the commits on origin/$br — refusing." >&2
1985
+ echo " Nothing was written." >&2
1986
+ exit 1
1987
+ fi
1988
+ if [ "$remote_work" -gt 0 ]; then
1989
+ echo "plot-dispatch: origin/$br carries $remote_work commit(s) that change files — refusing." >&2
1990
+ echo " That is work, not an abandoned claim. Review it, or open its PR:" >&2
1991
+ echo " plot-open-pr.sh $br" >&2
1992
+ echo " Nothing was written." >&2
1993
+ exit 1
1994
+ fi
1995
+ fi
1996
+ if [ -n "$release_wt" ]; then
1997
+ desk_dirty=$(plot_worker_dirty "$release_wt")
1998
+ if [ "$ref_present" = 1 ]; then desk_base="refs/remotes/origin/$br"; else desk_base="refs/remotes/origin/$MAIN"; fi
1999
+ # Local commits the remote lacks exist nowhere else, so any refuses.
2000
+ desk_unpushed=$(git -C "$release_wt" rev-list --count "$desk_base..HEAD" -- . </dev/null 2>/dev/null) || desk_unpushed=0
2001
+ if [ -n "$desk_dirty" ] || [ "${desk_unpushed:-0}" -gt 0 ]; then
2002
+ echo "plot-dispatch: the desk at $release_wt holds work for $br — refusing." >&2
2003
+ [ "${desk_unpushed:-0}" -gt 0 ] && echo " $desk_unpushed unpushed commit(s) that change files" >&2
2004
+ [ -n "$desk_dirty" ] && echo " uncommitted changes: $(printf '%s' "$desk_dirty" | tr '\n' ' ')" >&2
2005
+ echo " Releasing would hand the slice to an agent that cannot see this work." >&2
2006
+ echo " Push it, or hand the branch to a new worker: plot-dispatch.sh --restart $br" >&2
2007
+ echo " Nothing was written." >&2
2008
+ exit 1
2009
+ fi
2010
+
2011
+ # 4. A PLOT-BLOCKED MARKER: the agent is waiting on a person, and a slice
2012
+ # waiting on an answer is not abandoned. Asked through
2013
+ # `plot_worker_blocked_file`, where the marker's spelling lives.
2014
+ if marker=$(plot_worker_blocked_file "$release_wt") && [ -n "$marker" ]; then
2015
+ echo "plot-dispatch: $br is blocked on a question — refusing." >&2
2016
+ echo " the question is in $release_wt/$marker" >&2
2017
+ echo " Answer it and delete the marker; the agent's slice is not abandoned." >&2
2018
+ echo " Nothing was written." >&2
2019
+ exit 1
2020
+ fi
2021
+ fi
2022
+
2023
+ if [ "$ref_present" = 0 ] && [ "${#named_manifests[@]}" -eq 0 ]; then
2024
+ echo "$br holds no claim: origin/$br does not exist and no manifest names it."
2025
+ echo " Nothing to release."
2026
+ exit 0
2027
+ fi
2028
+
2029
+ # THE MANIFESTS FIRST, THEN THE REF. If the ref deletion fails, the manifest
2030
+ # is already free and the ref still locks, so the scan still reads the slice
2031
+ # as claimed and nothing hands it out twice. The reverse order would leave the
2032
+ # measured failure on disk: no ref, and a manifest still naming the branch.
2033
+ released_manifests=0
2034
+ if [ "${#named_manifests[@]}" -eq 0 ]; then
2035
+ echo " no manifest in $registry_dir names $br"
2036
+ fi
2037
+ for m in ${named_manifests[@]+"${named_manifests[@]}"}; do
2038
+ if ! clear_manifest_branch "$m"; then
2039
+ echo "plot-dispatch: could not clear the assignment in $m — refusing to delete the ref." >&2
2040
+ echo " The claim on origin/$br stands; fix the file and run --release again." >&2
2041
+ exit 1
2042
+ fi
2043
+ released_manifests=$((released_manifests + 1))
2044
+ echo " cleared the assignment in $m"
2045
+ done
2046
+
2047
+ if [ "$ref_present" = 1 ]; then
2048
+ if ! git push -q origin --delete "$br" </dev/null 2>/dev/null; then
2049
+ # VERIFIED, NOT TRUSTED: the host has returned 503 on a push that landed.
2050
+ git fetch -q --prune origin </dev/null 2>/dev/null || true
2051
+ if git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null 2>&1; then
2052
+ echo "plot-dispatch: could not delete origin/$br — the claim still locks the slice." >&2
2053
+ echo " The manifests are already free, so nothing hands it out twice. Retry:" >&2
2054
+ echo " plot-dispatch.sh --release $br" >&2
2055
+ exit 1
2056
+ fi
2057
+ fi
2058
+ echo " deleted the claim ref origin/$br"
2059
+ else
2060
+ echo " origin/$br does not exist — only the assignment needed releasing"
2061
+ fi
2062
+ if [ -n "$release_wt" ]; then
2063
+ echo " the desk at $release_wt still holds $br and is left as it is"
2064
+ fi
2065
+ echo "released $br — the slice returns to the queue"
2066
+ echo "summary: released=1 manifests=$released_manifests ref=$([ "$ref_present" = 1 ] && echo deleted || echo absent)"
2067
+ exit 0
2068
+ fi
2069
+
1810
2070
  if [ "$mode" = "start" ]; then
1811
2071
  # THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
1812
2072
  # registry matches them to free agents, but until this verb existed nothing
@@ -2393,18 +2653,18 @@ fi
2393
2653
  # materialised into a temp file rather than parsed here — the parser stays the
2394
2654
  # one place that knows what a plan file looks like.
2395
2655
  #
2396
- # The template's X's must TRAIL: BSD mktemp (macOS) rejects a template with a
2397
- # suffix after them, while GNU accepts it. The first version wrote
2398
- # `plot-gate-XXXXXX.md` and failed on macOS — and because the failure fell back
2399
- # to the working tree, the gate silently went back to reading the exact surface
2400
- # this fix exists to stop reading. Hence also: NO working-tree fallback below.
2401
- # If the shared blob cannot be materialised, the gate refuses.
2656
+ # `plot-tmp.sh` puts the template's X's last: BSD mktemp (macOS) rejects a
2657
+ # template with a suffix after them, while GNU accepts it. The first version
2658
+ # wrote `plot-gate-XXXXXX.md` and failed on macOS — and because the failure fell
2659
+ # back to the working tree, the gate silently went back to reading the exact
2660
+ # surface this fix exists to stop reading. Hence also: NO working-tree fallback
2661
+ # below. If the shared blob cannot be materialised, the gate refuses.
2402
2662
  plan_file="$plan_path"
2403
2663
  gate_blob=""
2404
2664
  if [ -n "$gate_sha" ]; then
2405
- gate_dir=$(mktemp -d "${TMPDIR:-/tmp}/plot-gate-XXXXXX") || gate_dir=""
2665
+ gate_dir=""
2666
+ plot_tmpdir gate_dir gate || gate_dir=""
2406
2667
  if [ -n "$gate_dir" ]; then
2407
- trap 'rm -rf "$gate_dir"' EXIT
2408
2668
  gate_blob="$gate_dir/$(basename "$plan_path")"
2409
2669
  git show "$gate_ref:$plan_path" >"$gate_blob" 2>/dev/null || gate_blob=""
2410
2670
  fi
@@ -3063,6 +3323,10 @@ append_started_line() { # $1=file $2=date $3=who $4=branch
3063
3323
  insert = start
3064
3324
  for (i = start + 1; i <= n; i++) {
3065
3325
  if (lines[i] ~ /^##[ \t]/) break
3326
+ # An HTML comment ends the writable region. Checked BEFORE the
3327
+ # placeholder arms so a commented-out `- **Started:**` template line
3328
+ # is never mistaken for the slot to fill.
3329
+ if (lines[i] ~ /<!--/) break
3066
3330
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
3067
3331
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
3068
3332
  }