@plot-pm/board 0.9.0 → 0.10.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.
@@ -0,0 +1,2028 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: fan out one worktree + one worker per eligible branch.
3
+ # Usage: plot-dispatch.sh [--dry-run] [--no-start] [--no-brief] [--offline]
4
+ # [--max N] [--allow-local] <slug>
5
+ # plot-dispatch.sh --migrate [--yes] [--max N]
6
+ # --status list fleet worktrees with worker pid, liveness, and last log
7
+ # line; then exit. Works regardless of plan phase.
8
+ # --stop <br> stop the worker on <br> (branch required — never "all").
9
+ # --restart <br>
10
+ # start a worker on <br>, which already holds a claim — the
11
+ # counterpart to --stop, and the only way to hand a stopped
12
+ # branch to a new worker through Plot. Branch required, never a
13
+ # slug: deciding that a stopped worker should be replaced rather
14
+ # than its work reviewed, reaped or abandoned is a person's call.
15
+ # Refuses on an open or merged PR (asked FIRST, before the state
16
+ # word), on a live worker, and on a PLOT-BLOCKED marker. The
17
+ # worktree is inherited exactly as it stands — uncommitted work
18
+ # is what a stall leaves behind, and this must not destroy it.
19
+ # --migrate move legacy worktrees into the configured `Worktree root:`. An
20
+ # idle worktree (no live worker, no unlanded work) is moved; a
21
+ # busy one is skipped with the reason. Requires a `Worktree root:`
22
+ # config — without one there is no destination. --dry-run by
23
+ # default; --yes to actually move.
24
+ # --dry-run print what would happen; create nothing, push nothing
25
+ # --yes with --migrate, actually move the worktrees (default is dry-run)
26
+ # --no-start create worktrees and claim refs, but start no workers
27
+ # --no-brief start a worker even when its branch has no brief. The named
28
+ # escape for the brief gate: a missing brief PREPARES (worktree +
29
+ # claim) but does not START, because the worker's first
30
+ # instruction is to read `.plot/briefs/<branch>.md` and it has
31
+ # nothing to read. --no-brief overrides that and says so.
32
+ # --offline skip `git fetch`
33
+ # --max N dispatch at most N branches this run (default: all eligible)
34
+ # --allow-local read the plan's phase from the working tree when
35
+ # origin/<main> cannot be resolved (no remote, fresh clone).
36
+ # The explicit escape for a remote-less repo — never a default,
37
+ # because a working-tree read is what this gate exists to avoid.
38
+ # <slug> the plan to fan out
39
+ # Output: one line per branch, each optionally followed by an indented
40
+ # `in flight:` line naming a branch that already holds files, then the
41
+ # summary block — an optional prose consequence line, then a
42
+ # machine-countable footer.
43
+ # A branch whose worktree exists with UNMERGED work is refused rather
44
+ # than dispatched — counted `skipped`, with the worktree path named,
45
+ # in `--dry-run` identically to a real run. See "THE HELD-BRANCH GATE".
46
+ # 3 worktrees prepared, 0 workers started, no `Worker command` configured
47
+ # summary: dispatched=2 reused=0 skipped=1 started=2 brief=missing worker=unconfigured
48
+ #
49
+ # THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
50
+ # always said "no 'Worker command' configured" beside the branch it could not
51
+ # start — buried in per-branch output, after the fan-out already happened. On
52
+ # 2026-08-17 that message was printed and missed five times: worktrees sat
53
+ # claimed with nobody working on them, and the last line a caller read said
54
+ # `started=0` with no reason beside it. A caller reading only the summary is
55
+ # the case this exists for, so the fact travels twice: as `worker=` in the
56
+ # footer for machines, and as one prose line above it for people.
57
+ #
58
+ # `worker=` is unconfigured | declined | configured | suppressed:
59
+ # unconfigured — no `Worker command` in Plot Config, and nobody has been
60
+ # asked. This is the state the summary line exists for.
61
+ # declined — `Worker command: none`. Asked, and answered "we start them
62
+ # by hand". A DELIBERATE absence, distinct from a missing key
63
+ # precisely so the skill stops asking: an empty answer is a
64
+ # first-class answer, and a prompt that returns every dispatch
65
+ # is a nag. `none` is never run as a command.
66
+ # configured — a command exists (whether or not every start succeeded)
67
+ # suppressed — `--no-start`, which means exactly what it says and implies
68
+ # nothing else. The inspect-first workflow is deliberate, so
69
+ # its zero is reported as a choice rather than as a defect.
70
+ #
71
+ # `brief=missing` is CONSTANT, and that is the point: this script cannot write a
72
+ # hand-off brief and never will. A brief is interpretation (which alternatives
73
+ # the plan rejected, and what killed them), and no script here invokes a skill —
74
+ # bash cannot reach one at all. /plot-implement owns the brief; the plot-dispatch
75
+ # SKILL invokes it after a fan-out. The field reports the gap so a direct call
76
+ # says what it left undone instead of leaving a claimed worktree looking handed
77
+ # over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
78
+ # and a gate that blocks looking-before-leaping is a gate in the wrong place.
79
+ #
80
+ # THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
81
+ # (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
82
+ # write here is either idempotent or refused:
83
+ #
84
+ # - Claim by ref push, where the claim carries an empty COMMIT. Two
85
+ # independent claims diverge, so the loser's push is rejected as
86
+ # non-fast-forward — that rejection is the concurrency control. Pushing a
87
+ # branch that merely points at origin/<main> would NOT work: the remote
88
+ # already has that commit, so both pushes succeed and both dispatchers
89
+ # think they won. Git is the lock only when the refs actually diverge.
90
+ # - Worktrees are adopted, never duplicated. A dispatcher that dies halfway
91
+ # through a fan-out is safe to re-run.
92
+ # - Nothing is ever deleted. Cleanup belongs to /plot-reconcile, which can
93
+ # tell a deliberately abandoned claim from a dead worker.
94
+ # - The `Started:` record is booked on the DEFAULT BRANCH, after the claims,
95
+ # and only for branches this run newly claimed. A re-run books nothing it
96
+ # merely re-adopted. If the booking cannot be pushed, the fan-out stands
97
+ # and the script says the record is missing — see book_started.
98
+ #
99
+ # Eligibility is NOT decided here: this script asks plot-fleet-scan.sh, which
100
+ # owns the wave arithmetic. Dispatch only acts on the answer. Keeping the rule
101
+ # in one place is why a blocked wave can never be fanned out by accident.
102
+ #
103
+ # Workers are started DETACHED, one per worktree, so the fleet outlives the
104
+ # dispatching session — close the laptop and they keep going. That is also why
105
+ # the reaper (/plot-reconcile) is load-bearing rather than a nicety: a detached
106
+ # worker dies without telling anyone.
107
+ set -uo pipefail
108
+
109
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
110
+
111
+ # The shared worker classifier. Sourced by both this script and
112
+ # plot-fleet-scan.sh so a worker has ONE state, not one per reader.
113
+ # shellcheck source=plot-worker-state.sh
114
+ . "$script_dir/plot-worker-state.sh"
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # WHERE THE WORKTREES LIVE, and by what name
118
+ # ---------------------------------------------------------------------------
119
+ #
120
+ # Two facts, resolved together because the second is a PROPERTY OF THE FIRST:
121
+ # the root directory the worktrees sit in, and the prefix their directory names
122
+ # carry. `plot-wt-` exists to make Plot's worktrees identifiable AMONG UNRELATED
123
+ # directories — it is a workaround for sharing a parent with other projects.
124
+ # Under a dedicated `Worktree root:` the directory already says what they are,
125
+ # so the prefix answers a question nobody is asking and is dropped. The legacy
126
+ # default keeps it, where it is still doing its job. Two conventions coexist
127
+ # permanently, and that is the intended outcome, not a transition cost.
128
+ #
129
+ # `Worktree root:` absent → repo_root/.. , prefix `plot-wt-` (today's behaviour)
130
+ # relative value → repo_root/<value> , NO prefix
131
+ # absolute value → <value> as given , NO prefix
132
+ #
133
+ # THIS FUNCTION ONLY COMPOSES A ROOT AND A PREFIX. It is the CREATION side. Every
134
+ # read of "which worktree holds this branch" asks `git worktree list` instead —
135
+ # see THE HELD-BRANCH GATE. A second naming convention gives path-guessing a
136
+ # second way to be wrong, so path-guessing is confined to creation alone.
137
+ resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
138
+ local rr="$1" configured
139
+ configured=$("$script_dir/plot-config.sh" get "Worktree root" "")
140
+ if [ -z "$configured" ]; then
141
+ # The legacy default: beside the repo, prefixed. No existing checkout moves.
142
+ wt_root=$(cd "$rr/.." && pwd)
143
+ wt_prefix="plot-wt-"
144
+ return
145
+ fi
146
+ case "$configured" in
147
+ /*) wt_root="$configured" ;; # absolute: taken as given
148
+ *) wt_root="$rr/$configured" ;; # relative: against the repo root
149
+ esac
150
+ # Normalise away a trailing slash so composed paths never double it. The
151
+ # directory may not exist yet (created on first dispatch), so this is pure
152
+ # string work, not a `cd`.
153
+ wt_root="${wt_root%/}"
154
+ wt_prefix=""
155
+ }
156
+
157
+ dry_run=0
158
+ no_start=0
159
+ no_brief=0
160
+ mode=dispatch
161
+ stop_branch=""
162
+ restart_branch=""
163
+ offline=""
164
+ allow_local=0
165
+ max=0
166
+ slug=""
167
+ migrate_yes=0
168
+ while [ $# -gt 0 ]; do
169
+ case "$1" in
170
+ --dry-run) dry_run=1 ;;
171
+ --status) mode=status ;;
172
+ --migrate) mode=migrate ;;
173
+ --yes) migrate_yes=1 ;;
174
+ # Only a value containing "/" is taken as the branch: otherwise a bare
175
+ # `--stop <slug>` would silently treat the plan slug as a branch name and
176
+ # stop the wrong thing (or nothing) without saying so.
177
+ --stop) mode=stop; case "${2:-}" in */*) stop_branch="$2"; shift ;; esac ;;
178
+ # Same rule as --stop, for the same reason and one more: a bare
179
+ # `--restart <slug>` would fall through to the plan gate and report "no
180
+ # plan for feature/x", which describes neither what was asked nor what
181
+ # went wrong. The branch is consumed only when it looks like one.
182
+ --restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
183
+ --no-start) no_start=1 ;;
184
+ --no-brief) no_brief=1 ;;
185
+ --offline|--no-fetch) offline="--offline" ;;
186
+ --allow-local) allow_local=1 ;;
187
+ --max) max="${2:?--max needs a value}"
188
+ case "$max" in
189
+ ''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
190
+ esac
191
+ shift ;;
192
+ -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
193
+ *) slug="$1" ;;
194
+ esac
195
+ shift
196
+ done
197
+
198
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
199
+ [ -n "$slug" ] || [ "$mode" != dispatch ] || { echo "plot-dispatch: need a plan slug" >&2; exit 1; }
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Worker launch, and the identity it records
203
+ # ---------------------------------------------------------------------------
204
+ #
205
+ # DEFINED HERE, ABOVE THE INSPECTION BLOCK, because `--restart` calls
206
+ # start_worker and that block exits before the fan-out is ever reached. Bash
207
+ # resolves a function when the call RUNS, so a definition further down the file
208
+ # is not yet in scope — the restart path found `start_worker: command not found`
209
+ # until this moved. Nothing here executes at definition time; only the position
210
+ # changed.
211
+
212
+ # A session id, in the shape the runtime uses for its transcript filename.
213
+ #
214
+ # `uuidgen` where it exists (macOS and most Linux), falling back to `/dev/urandom`
215
+ # — never to `$RANDOM` or a timestamp. Two workers launched in the same second by
216
+ # the same fan-out would collide on either, and a collision here silently merges
217
+ # two agents into one manifest.
218
+ #
219
+ # Lowercased because the runtime writes its transcript filename in lowercase and
220
+ # the board joins on exact string equality; `uuidgen` on macOS returns uppercase.
221
+ plot_session_id() {
222
+ local id=""
223
+ if command -v uuidgen >/dev/null 2>&1; then
224
+ id=$(uuidgen 2>/dev/null | tr 'A-Z' 'a-z')
225
+ fi
226
+ if [ -z "$id" ]; then
227
+ # 16 random bytes rendered as a v4-shaped id. The shape matters only for
228
+ # recognisability; nothing parses it.
229
+ id=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' \
230
+ | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
231
+ fi
232
+ printf '%s' "$id"
233
+ }
234
+
235
+ # JSON-escape one string for a manifest value.
236
+ #
237
+ # `printf %s` through a substitution chain rather than `jq`: Plot's helpers must
238
+ # run where only POSIX tools exist, and a Worker command routinely contains
239
+ # double quotes and newlines — this repo's is a 1,400-character prompt full of
240
+ # both. Backslash first, or it re-escapes what the later rules add.
241
+ json_escape() {
242
+ printf '%s' "$1" \
243
+ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' \
244
+ | awk 'BEGIN{ORS=""} {if (NR>1) print "\\n"; print}'
245
+ }
246
+
247
+ # Write one agent manifest: launch-time facts, keyed on the session id.
248
+ #
249
+ # Model and context are still absent on purpose: they belong to the runtime and
250
+ # are read from the transcript, so a manifest that named them would be a guess.
251
+ # The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
252
+ # its own child — see `stamp_manifest_pid`. The dispatcher does not know the
253
+ # agent pid at this line (only the wrapper does, from its `$!`), so it writes the
254
+ # field as a placeholder the wrapper fills rather than guessing it now.
255
+ #
256
+ # Written to a temp file and moved into place, so a scan reading the directory
257
+ # never sees a half-written manifest. `mv` within one directory is atomic.
258
+ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
259
+ local out="$1" tmp="$1.plot-tmp"
260
+ {
261
+ printf '{\n'
262
+ printf ' "session": "%s",\n' "$(json_escape "$2")"
263
+ printf ' "branch": "%s",\n' "$(json_escape "$3")"
264
+ printf ' "worktree": "%s",\n' "$(json_escape "$4")"
265
+ printf ' "command": "%s",\n' "$(json_escape "$5")"
266
+ printf ' "pid": "",\n'
267
+ printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
268
+ printf '}\n'
269
+ } > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
270
+ mv "$tmp" "$out" 2>/dev/null || { rm -f "$tmp"; return 1; }
271
+ }
272
+
273
+ # THE MANIFEST PID IS STAMPED BY THE WRAPPER, NOT HERE. The agent's pid is
274
+ # knowable only to the wrapper (`$!` of its own backgrounded child), so the stamp
275
+ # is inline in the wrapper's `sh -c` below — a fresh shell that cannot reach a
276
+ # function defined in this bash script, the same isolation that makes the wrapper
277
+ # own `.plot-worker.pid`. The write above leaves `"pid": ""` as a placeholder the
278
+ # wrapper replaces; the mechanics and their safety are documented at that call.
279
+
280
+ # Start one DETACHED worker per worktree. Detached is the whole point: the
281
+ # fleet must outlive the dispatching session. Logs go beside the worktree so a
282
+ # human can read them without knowing anything about how the worker was started.
283
+ #
284
+ # The worker command is configurable because "how do I run an agent headless"
285
+ # is a per-project, per-tool answer that Plot must not hardcode (Principle 5).
286
+ # THE BRIEF GATE. A branch's hand-off brief is its specification: the `Worker
287
+ # command`'s first instruction is "Read `.plot/briefs/<branch-suffix>.md` first
288
+ # — it is the specification". Without it the worker reads nothing and improvises,
289
+ # the one thing the brief exists to prevent (measured 2026-08-20: 2:12 against a
290
+ # 700-line wave with no spec). So a missing brief PREPARES but does not START —
291
+ # the worktree and claim above are correct and stay; only the launch is refused.
292
+ #
293
+ # READABLE AND NON-EMPTY, not merely present. A zero-byte or permission-denied
294
+ # file is not a specification, and `[ -f ]` alone passes for an empty one — the
295
+ # naive check the plan calls out. This is STRICTER than the board's `briefState`
296
+ # row hint (which treats any existing file as present, because a person will look
297
+ # either way): here the cost of guessing wrong is an agent burning minutes on
298
+ # nothing, so an unreadable brief reads as missing.
299
+ #
300
+ # The path is the branch after its last `/` — the same convention
301
+ # `/plot-implement` writes and `briefPathOf` reads on the board side.
302
+ #
303
+ # READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
304
+ # gate above does: the question is not "does a brief exist in this filesystem?"
305
+ # but "will the WORKER find one?". The worker's worktree is created from
306
+ # `origin/$MAIN` (see `git worktree add` below), so a brief committed nowhere —
307
+ # or committed locally and never pushed — is invisible to it. Checking the
308
+ # working tree passes the gate and starts a worker into an empty specification,
309
+ # which is the exact failure this gate exists to prevent.
310
+ #
311
+ # Both directions were measured 2026-08-27. Running the filesystem check from a
312
+ # checkout 8 commits behind main reported three branches' briefs missing while
313
+ # all three existed on `origin/main` at 4-5 KB — a benign refusal. The inverse
314
+ # is not benign, and it is the one a working-tree read permits.
315
+ #
316
+ # NON-EMPTY, not merely present: a zero-byte brief is what a half-finished write
317
+ # leaves behind, and `cat-file -e` alone passes for it. This is STRICTER than the
318
+ # board's `briefState` row hint (which treats any existing file as present,
319
+ # because a person will look either way): here the cost of guessing wrong is an
320
+ # agent burning minutes on nothing.
321
+ brief_path() { printf '.plot/briefs/%s.md' "${1##*/}"; }
322
+ brief_ref() { printf 'origin/%s:%s' "$MAIN" "$(brief_path "$1")"; }
323
+ brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
324
+ local sz
325
+ sz=$(git cat-file -s "$(brief_ref "$1")" 2>/dev/null) || return 1
326
+ [ "${sz:-0}" -gt 0 ]
327
+ }
328
+
329
+ start_worker() {
330
+ local branch="$1" wt="$2"
331
+ local cmd
332
+ cmd=$("$script_dir/plot-config.sh" get "Worker command" "")
333
+ # `none` means "asked, and this repo starts them by hand". Running it would
334
+ # spawn a worker per branch that fails with `none: command not found` — a
335
+ # deliberate answer turned into N crashed workers.
336
+ case "$cmd" in none|NONE|None) cmd="" ;; esac
337
+ if [ -z "$cmd" ]; then
338
+ # Not an error: Plot deliberately hardcodes no agent tooling (Principle 5).
339
+ # Word it as the next step rather than a failure, or a first run reads as
340
+ # "it did nothing".
341
+ #
342
+ # This line carries the ONE thing the summary cannot: which worktree to cd
343
+ # into. The consequence itself — that nothing started, and why — is stated
344
+ # in the summary, because per-branch output is exactly where it was missed
345
+ # five times on 2026-08-17. Saying it in both places would train the reader
346
+ # to skip both.
347
+ if [ "$worker_cmd_declined" = 1 ]; then
348
+ echo " worktree ready — start it yourself:"
349
+ else
350
+ echo " worktree ready — no 'Worker command' configured, so start it yourself:"
351
+ fi
352
+ echo " cd $wt # branch $branch is claimed and waiting"
353
+ return 1
354
+ fi
355
+ local log="$wt/.plot-worker.log"
356
+ rm -f "$wt/.plot-worker.exit"
357
+
358
+ # THE MANIFEST, AND WHY IT IS KEYED ON A SESSION ID RATHER THAN A BRANCH.
359
+ #
360
+ # An agent survives the branch it was launched on: it finishes one and takes
361
+ # another, and everything the board knows about it today lives INSIDE a
362
+ # worktree — `.plot-worker.pid` is a file in it, and the transcript directory
363
+ # is derived from its path. So an agent that moves on loses every identity the
364
+ # board holds, and the states that matter most (`waiting`, and an agent between
365
+ # branches) are exactly the ones no worktree can express.
366
+ #
367
+ # The manifest is the identity that outlives the worktree. It records ONLY
368
+ # launch-time knowledge — what this function has in hand at this line — because
369
+ # a record that infers is a record that can be wrong about the past. Model and
370
+ # context are absent here on purpose: they belong to the runtime and are read
371
+ # from the transcript, which the board joins by the id below.
372
+ #
373
+ # THE DISPATCHER MINTS THE ID. The plan assumed the runtime was already invoked
374
+ # with `--session-id`, but this repo's `Worker command` carries none, so reading
375
+ # one back would mean guessing at the newest file in a directory that holds one
376
+ # to eight of them (measured 2026-08-20) — the guess the manifest exists to
377
+ # remove. Minting keeps it launch-time knowledge, and exporting it as
378
+ # `PLOT_SESSION_ID` lets a `Worker command` forward it so the runtime's
379
+ # transcript lands where the manifest points. A command that ignores the
380
+ # variable still gets a complete manifest; only the transcript join degrades,
381
+ # to the absence the board already treats as the honest answer.
382
+ #
383
+ # WRITTEN BEFORE THE LAUNCH, for the reason the pid file's own comment gives
384
+ # one paragraph down: there is a window between spawn and first write, and a
385
+ # scan landing inside it must not read a started agent as absent. The manifest
386
+ # carries the identity, so its window would be worse than the pid's.
387
+ #
388
+ # WHERE THE MANIFEST GOES IS THE `Agent registry` KEY'S ANSWER, not this
389
+ # dispatcher's cwd. `readAgentRegistry` has honoured that key since #420 and
390
+ # `resolveManifestDir` resolves it; this writer did not, so it wrote
391
+ # `$repo_root/.plot/agents` — and `repo_root` is `git rev-parse
392
+ # --show-toplevel` from wherever the dispatcher was invoked. Auto-dispatch is
393
+ # invoked from the BOARD's checkout (`dispatch.ts` passes `cwd: repoRoot`), so
394
+ # its manifests landed in a directory nothing reads. Measured 2026-08-27: five
395
+ # live workers, five manifests, and the board reporting `2 manifests, 9
396
+ # synthesized` — every agent had one, two were reachable.
397
+ #
398
+ # The case split is `resolve_wt_root`'s, deliberately: absolute taken as given,
399
+ # relative joined onto the repo root, trailing slash trimmed as pure string
400
+ # work because the directory need not exist yet. A second convention for
401
+ # resolving a configured directory is a second way to be wrong.
402
+ local session manifest_dir
403
+ session=$(plot_session_id)
404
+ manifest_dir=$("$script_dir/plot-config.sh" get "Agent registry" ".plot/agents")
405
+ case "$manifest_dir" in
406
+ /*) ;;
407
+ *) manifest_dir="$repo_root/$manifest_dir" ;;
408
+ esac
409
+ manifest_dir="${manifest_dir%/}"
410
+ mkdir -p "$manifest_dir" 2>/dev/null || true
411
+ # `printf` per field with no interpretation: a command containing quotes,
412
+ # newlines or backslashes must survive into valid JSON, and this is the one
413
+ # place a Worker command's full text is recorded.
414
+ write_agent_manifest "$manifest_dir/$session.json" \
415
+ "$session" "$branch" "$wt" "$cmd" || true
416
+ #
417
+ # THE GATE: NO MANIFEST, NO WORKER.
418
+ #
419
+ # Both writes above are `|| true`, so until this check existed a worker whose
420
+ # manifest could not be written started anyway and was invisible to the
421
+ # registry for its whole life. `always write a manifest` is a RULE the code
422
+ # already believed it followed — and did; the file was simply unreachable.
423
+ # The enforceable condition is that the manifest is WHERE THE READER LOOKS,
424
+ # which only a test at the resolved path can establish, so this asserts the
425
+ # post-condition rather than either write's exit status: a future edit may
426
+ # rearrange the writing, and this still holds.
427
+ #
428
+ # REFUSE RATHER THAN LAUNCH. An agent outside the registry cannot be seen,
429
+ # stopped, restarted or reaped through the board, and it holds a claim nobody
430
+ # can release. A worker that cannot be registered is worse than one that never
431
+ # started, because the second state is VISIBLE. The worktree and the claim are
432
+ # left exactly as they are, so the operator retries for free once the cause is
433
+ # fixed — this refuses a launch, it does not undo the setup.
434
+ #
435
+ # BEFORE THE SPAWN, AND THE ORDERING IS THE WHOLE DESIGN. The spawn is ~75
436
+ # lines below, deliberately: there is a spawn-to-first-write window a scan
437
+ # must not misread as an absent agent. So this has a launch to PREVENT rather
438
+ # than a process to kill — no race, no kill path, no orphan risk. An earlier
439
+ # draft of the plan said *assert after launch, then kill*; that would have
440
+ # built a teardown path for a state that cannot arise.
441
+ #
442
+ # It NAMES THE PATH. The defect this closes was a directory nobody could see;
443
+ # a refusal reading only "could not start" would send the operator into the
444
+ # script to learn where it had looked. `return 1` is the same refusal contract
445
+ # the briefless and no-Worker-command arms above use, so the fan-out does not
446
+ # count this branch as started.
447
+ #
448
+ # `/api/continue`'s tolerance of a manifest-less worktree is NOT this gate and
449
+ # must stay tolerant: that is about CONTINUING a worker in a worktree older
450
+ # than manifests. This is CREATION, where the dispatcher has just minted a
451
+ # session id and there is no older-worktree case to tolerate.
452
+ if [ ! -f "$manifest_dir/$session.json" ]; then
453
+ echo " refusing to start $branch — its agent manifest could not be written:"
454
+ echo " $manifest_dir/$session.json"
455
+ echo " An unregistered worker cannot be seen, stopped or reaped, and holds"
456
+ echo " a claim nobody can release. The worktree and claim are untouched —"
457
+ echo " fix the path above (see the 'Agent registry' key) and dispatch again."
458
+ return 1
459
+ fi
460
+ # TWO PIDS, TWO NAMES. `.plot-worker.pid` must name the AGENT — the process
461
+ # doing the work, which is what the panel, `--status` and the scan describe.
462
+ # `$!` from the parent names the `sh -c` WRAPPER, and recording that is the
463
+ # bug this fixes: every field read correctly off the dispatcher's shell rather
464
+ # than off the agent. The wrapper is the one thing that knows its own child,
465
+ # so the wrapper writes the agent's pid; only the wrapper can, and a `pgrep`
466
+ # by command string is the failure this repo already recorded (`wait on your
467
+ # own PID, not a process name`).
468
+ #
469
+ # The wrapper's own pid is KEPT, under `.plot-worker.wrapper.pid`, because the
470
+ # wrapper is what writes `.plot-worker.exit` when the agent exits and that must
471
+ # keep working — `--stop` kills the agent, the wrapper survives to record the
472
+ # code. The paths travel as env vars so no quoting level inside the
473
+ # single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
474
+ # already does.
475
+ #
476
+ # The agent runs backgrounded inside the wrapper so the wrapper can capture its
477
+ # `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
478
+ # starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
479
+ # absent pid file as `none` — honest, never "running" off a stale value.
480
+ #
481
+ # THE WRAPPER ALSO STAMPS THE MANIFEST PID, for the same reason it writes the
482
+ # pid file: it is the one process that knows the agent's own pid. The manifest
483
+ # path travels as `PLOT_MANIFEST_FILE`, beside the exit/pid paths, so no quoting
484
+ # level inside the single-quoted `sh -c` mangles a path with spaces. The stamp
485
+ # is inline `awk` rather than a bash helper, because a helper would live in this
486
+ # bash script and the detached `sh -c` is a fresh shell with no access to it —
487
+ # the same isolation that makes the wrapper own the pid.
488
+ #
489
+ # ONE CONTRACT, TWO IMPLEMENTATIONS. This inline `awk` is the mechanical twin of
490
+ # `manifest-stamp.ts`'s `stampManifest`; `/api/continue` calls that helper, and a
491
+ # parity test (`manifest-stamp-parity.test.ts`) runs THIS awk against the same
492
+ # inputs and asserts a byte-identical result. The two exist because the callers
493
+ # cannot share code — a detached `sh -c` reaches no TypeScript, and a bash helper
494
+ # is out of a fresh shell's reach — but they must not drift, the
495
+ # `plot-worker-state.sh` lesson after five of six states diverged in duplicate.
496
+ #
497
+ # It replaces ANY `pid` line, not only the empty placeholder — a full-line
498
+ # anchored match on bytes we control, so nothing in the command value (one
499
+ # escaped JSON string on its own line) can be mistaken for it. On a FIRST
500
+ # dispatch the placeholder is empty: the pid is filled and nothing else changes,
501
+ # byte-identical to the manifest before relaunch bookkeeping existed. On a
502
+ # RELAUNCH the line already holds a pid: it is overwritten, `startedAt` is
503
+ # rewritten to now, and two lines are inserted after `pid` — `previousPid` (the
504
+ # corpse displaced) and `relaunches` (the restart count, +1 from any it carried).
505
+ # The dispatcher mints a fresh session per launch so its own manifest is always a
506
+ # first stamp; the relaunch arms exist for parity with `/api/continue`, which
507
+ # reuses a worktree's existing manifest. A pid is digits, so no JSON escaping is
508
+ # needed. Rewritten through a temp file and `mv`, atomic like the original write.
509
+ # Any failure leaves the pid untouched — the registry reads an absent one as
510
+ # `unknown`.
511
+ #
512
+ # The awk reads the manifest TWICE — the same file passed as two arguments, so
513
+ # `FNR==NR` is the pre-scan. Pass one learns whether the pid is already filled
514
+ # (a relaunch) and the count any prior `relaunches` line held; pass two rewrites.
515
+ # This mirror of a two-pass read is what lets a SECOND relaunch increment rather
516
+ # than reset: the old count sits AFTER the pid line, so a single pass could not
517
+ # know it when it must emit the new `relaunches` immediately after `pid`.
518
+ #
519
+ # On the pid line: an empty placeholder is filled and nothing else changes (a
520
+ # first stamp, byte-identical to before); a filled pid is overwritten and the
521
+ # two relaunch records — `previousPid` then `relaunches` — are emitted right
522
+ # after it, then any stale copies of those lines are dropped and `startedAt` is
523
+ # rewritten to the current run. This is exactly `stampManifest`, line for line,
524
+ # which the parity test pins byte for byte.
525
+ local stamp_now
526
+ stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
527
+ ( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
528
+ PLOT_SLUG="$slug" \
529
+ PLOT_SESSION_ID="$session" \
530
+ PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
531
+ PLOT_STAMP_STARTED="$stamp_now" \
532
+ PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
533
+ nohup sh -c '( '"$cmd"' ) & agent=$!; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" '"'"'
534
+ BEGIN { relaunch = 0; count = 1; stamped = 0 }
535
+ FNR == NR {
536
+ if ($0 ~ /^ "pid": "[^"]*",$/) {
537
+ p = $0; sub(/^ "pid": "/, "", p); sub(/",$/, "", p)
538
+ if (p != "") { relaunch = 1; displaced = p }
539
+ }
540
+ if ($0 ~ /^ "relaunches": [0-9]+,$/) {
541
+ n = $0; sub(/^ "relaunches": /, "", n); sub(/,$/, "", n); count = n + 1
542
+ }
543
+ next
544
+ }
545
+ !stamped && $0 ~ /^ "pid": "[^"]*",$/ {
546
+ stamped = 1
547
+ print " \"pid\": \"" pid "\","
548
+ if (relaunch) {
549
+ print " \"previousPid\": \"" displaced "\","
550
+ print " \"relaunches\": " count ","
551
+ }
552
+ next
553
+ }
554
+ relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
555
+ relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
556
+ relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
557
+ { print }
558
+ '"'"' "$PLOT_MANIFEST_FILE" "$PLOT_MANIFEST_FILE" > "$PLOT_MANIFEST_FILE.plot-pid-tmp" 2>/dev/null && mv "$PLOT_MANIFEST_FILE.plot-pid-tmp" "$PLOT_MANIFEST_FILE" 2>/dev/null || rm -f "$PLOT_MANIFEST_FILE.plot-pid-tmp"; fi; wait "$agent"; rc=$?; printf "%s" "$rc" > "$PLOT_EXIT_FILE"' \
559
+ >"$log" 2>&1 </dev/null & echo $! >"$wt/.plot-worker.wrapper.pid" )
560
+ echo " started worker (log: $log)"
561
+ return 0
562
+ }
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Inspection and shutdown
566
+ # ---------------------------------------------------------------------------
567
+ #
568
+ # Deliberately BEFORE the phase gate: work that is already running must stay
569
+ # inspectable and stoppable even if the plan was since delivered or rejected.
570
+ # Refusing to show a running worker because of a phase change would strand it.
571
+ repo_root_early=$(git rev-parse --show-toplevel)
572
+ resolve_wt_root "$repo_root_early"
573
+ wt_root_early="$wt_root"
574
+ wt_prefix_early="$wt_prefix"
575
+
576
+ # States: "running <pid>" | "finished <pid>" | "waiting <pid> (answer it)"
577
+ # | "stalled <pid> (work unfinished)" | "failed <pid> (exit N)"
578
+ # | "ended <pid> (status unknown)" | "no worker"
579
+ #
580
+ # THE CLASSIFICATION LIVES IN plot-worker-state.sh, sourced above and shared
581
+ # with plot-fleet-scan.sh. This function is now only the RENDERING half: it
582
+ # turns the shared facts into the prose `--status` has always printed. The scan
583
+ # renders the same facts as tab-separated JSON fields.
584
+ #
585
+ # The two copies of this logic agreed on five of six states and split on the
586
+ # sixth (a non-numeric exit code), which is what a duplicate does while nobody
587
+ # is looking. `no worker` is spelled that way HERE and `none` in the scan —
588
+ # both are the shared `none`, rendered for their own audience.
589
+ # Has this branch's work reached review — an open or merged PR?
590
+ #
591
+ # ASKED HERE TOO, AND THAT IS THE POINT. The PR fact outranks every local signal
592
+ # in the classification, so a consumer that cannot supply it reports `stalled`
593
+ # where the other reports `finished` — the same one-fact-two-verdicts drift
594
+ # wave 1 removed, re-entering through the new parameter. The contract test
595
+ # drives both consumers from one fixture precisely to catch that.
596
+ #
597
+ # AFFORDABLE HERE, unlike in the scan's inner loop. `--status` runs when a
598
+ # person types it and iterates the handful of `plot-wt-*` worktrees on this
599
+ # disk; the scan is polled by the board every 5 s across every branch of every
600
+ # plan, which is why IT caches one reply per branch per run. Same question, two
601
+ # costs, and only one of them needs the machinery.
602
+ #
603
+ # `--offline` IS HONOURED, because it promises no network and a flag that lied
604
+ # would be worse than a slower answer. Offline, or with no backend, the fact is
605
+ # simply not supplied and the local signals answer alone — `stalled` rather
606
+ # than `finished`, which sends a reader to look rather than telling them to stop.
607
+ reached_review() { # $1=branch → 0 when an open or merged PR exists
608
+ [ -z "$offline" ] || return 1
609
+ [ -n "$1" ] && [ "$1" != "?" ] || return 1
610
+ [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || return 1
611
+ local js st
612
+ # Exit code first: a non-zero is a transport failure and its stdout is not an
613
+ # answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
614
+ # trusted the payload on failure would have called every branch reviewed.
615
+ js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || return 1
616
+ st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
617
+ case "$st" in OPEN|MERGED) return 0 ;; *) return 1 ;; esac
618
+ }
619
+
620
+ worker_state() { # $1=worktree [$2=branch]
621
+ local row state pid code pr_fact=""
622
+ reached_review "${2:-}" && pr_fact="pr"
623
+ row=$(plot_worker_state "$1" "$pr_fact")
624
+ state=$(printf '%s' "$row" | cut -f1)
625
+ pid=$(printf '%s' "$row" | cut -f2)
626
+ code=$(printf '%s' "$row" | cut -f3)
627
+ case "$state" in
628
+ running) echo "running $pid" ;;
629
+ finished) echo "finished $pid" ;;
630
+ # THE TWO TASK STATES, rendered as prose here and as bare words in the
631
+ # scan's JSON — one computation, two renderings, the split this file's
632
+ # `worker_state` exists to keep. Each names the move rather than the
633
+ # condition: a reader of `--status` is deciding what to do next, and
634
+ # "answer it" versus "resume it" is that decision.
635
+ waiting) echo "waiting $pid (answer it)" ;;
636
+ stalled) echo "stalled $pid (work unfinished)" ;;
637
+ failed) echo "failed $pid (exit $code)" ;;
638
+ ended) echo "ended $pid (status unknown)" ;;
639
+ *) echo "no worker" ;;
640
+ esac
641
+ }
642
+
643
+ if [ "$mode" = "status" ]; then
644
+ n_live=0 n_done=0 n_waiting=0 n_stalled=0 n_failed=0 n_ended=0 n_none=0
645
+ for wt in "$wt_root_early"/"$wt_prefix_early"*; do
646
+ [ -d "$wt" ] || continue
647
+ br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
648
+ st=$(worker_state "$wt" "$br")
649
+ case "$st" in
650
+ running*) n_live=$((n_live + 1)) ;;
651
+ finished*) n_done=$((n_done + 1)) ;;
652
+ waiting*) n_waiting=$((n_waiting + 1)) ;;
653
+ stalled*) n_stalled=$((n_stalled + 1)) ;;
654
+ failed*) n_failed=$((n_failed + 1)) ;;
655
+ ended*) n_ended=$((n_ended + 1)) ;;
656
+ *) n_none=$((n_none + 1)) ;;
657
+ esac
658
+ echo " $br — $st"
659
+ echo " worktree: $wt"
660
+ if [ -f "$wt/.plot-worker.log" ]; then
661
+ echo " log: $wt/.plot-worker.log"
662
+ echo " last: $(tail -1 "$wt/.plot-worker.log" 2>/dev/null)"
663
+ fi
664
+ done
665
+ [ $((n_live + n_done + n_waiting + n_stalled + n_failed + n_ended + n_none)) -gt 0 ] \
666
+ || echo " (no fleet worktrees under $wt_root_early)"
667
+ echo "summary: running=$n_live finished=$n_done waiting=$n_waiting stalled=$n_stalled failed=$n_failed ended=$n_ended no_worker=$n_none"
668
+ exit 0
669
+ fi
670
+
671
+ if [ "$mode" = "stop" ]; then
672
+ # An explicit branch is REQUIRED. A --stop that could mean "all" is one
673
+ # fat-finger away from killing a whole fleet.
674
+ if [ -z "$stop_branch" ]; then
675
+ echo "plot-dispatch: --stop needs a branch name, e.g. --stop feature/x" >&2
676
+ echo " Refusing to guess — stopping the wrong worker discards its work." >&2
677
+ exit 1
678
+ fi
679
+ wt="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
680
+ [ -d "$wt" ] || { echo "plot-dispatch: no worktree for '$stop_branch' at $wt" >&2; exit 1; }
681
+ st=$(worker_state "$wt" "$stop_branch")
682
+ case "$st" in
683
+ running*)
684
+ pid=${st#running }
685
+ kill "$pid" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
686
+ || { echo "plot-dispatch: could not stop pid $pid" >&2; exit 1; }
687
+ # The worktree and its claim are left in place: the branch is still taken,
688
+ # and deleting either would be the kind of write this design avoids.
689
+ echo " worktree kept at $wt — the claim stands until you release it"
690
+ ;;
691
+ finished*|waiting*|stalled*|failed*|ended*) echo "$stop_branch is not running ($st)" ;;
692
+ *) echo "$stop_branch has no worker" ;;
693
+ esac
694
+ exit 0
695
+ fi
696
+
697
+ if [ "$mode" = "restart" ]; then
698
+ # THE COUNTERPART TO --stop, AND THE WHOLE FEATURE. `--stop` kills a worker;
699
+ # nothing started one on a branch that already holds a claim, because the
700
+ # dispatcher asks the scan for `--next` and `--next` fills claimable[] only
701
+ # where the branch is `open` — meaning NO REF EXISTS. A branch that has ever
702
+ # been claimed is `claimed` or `wip`, so it was never offered and
703
+ # `plot-dispatch.sh <slug>` answered `dispatched=0`: not a refusal with a
704
+ # reason, an empty set, which has nothing to say about what it filtered out.
705
+ #
706
+ # That `open`-only rule is Plot's LOCK and must not widen — three callers
707
+ # consume `--next`, and the board's auto-dispatch would begin restarting
708
+ # stalled work on a five-second timer with nobody deciding anything. So this
709
+ # is a SECOND QUESTION, asked only when a person asks it.
710
+ #
711
+ # HERE, BESIDE --stop AND BEFORE THE PHASE GATE, for the reason that block
712
+ # already gives: a branch that is already claimed and already has a worktree
713
+ # is work in flight, and the plan's phase says nothing about whether a
714
+ # stopped worker on it should be replaced. Refusing on phase would strand
715
+ # exactly the branch this exists to rescue.
716
+ if [ -z "$restart_branch" ]; then
717
+ echo "plot-dispatch: --restart needs a branch name, e.g. --restart feature/x" >&2
718
+ echo " A slug is not enough: which stopped branch to hand to a new worker" >&2
719
+ echo " is your call, not something this should guess." >&2
720
+ exit 1
721
+ fi
722
+
723
+ # ASK GIT WHICH WORKTREE HOLDS THE BRANCH — never rebuild the path from the
724
+ # name. This file's own rule (see resolve_wt_root): path-guessing is confined
725
+ # to CREATION, because a second naming convention gives it a second way to be
726
+ # wrong. It matters more here than anywhere: the population this verb serves
727
+ # includes the worktree a person made by hand after the tool had no verb for
728
+ # them, and a hand-made worktree rarely follows dispatch's naming.
729
+ restart_wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$restart_branch" '
730
+ /^worktree / { path = substr($0, 10) }
731
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
732
+ if [ -z "$restart_wt" ] || [ ! -d "$restart_wt" ]; then
733
+ echo "plot-dispatch: no worktree holds '$restart_branch' — nothing to restart." >&2
734
+ echo " --restart hands an EXISTING checkout to a new worker; it creates none." >&2
735
+ echo " To start this branch fresh, dispatch its plan." >&2
736
+ exit 1
737
+ fi
738
+
739
+ # THE PR IS ASKED FIRST, BEFORE THE STATE WORD. This ordering is the round-one
740
+ # correction to the plan, and it comes from a measurement: five of five
741
+ # `failed` worktrees in this estate held a PR — four open, one already merged.
742
+ #
743
+ # `plot-worker-state.sh` refines `finished` by the TREE (an open or merged PR
744
+ # turns it into "the work reached review") but deliberately does NOT refine
745
+ # `failed`, `ended` or `none`, because "a recorded non-zero exit is already a
746
+ # specific answer about the process." True about the PROCESS, and silent about
747
+ # the WORK: a worker that opened its PR and then exited non-zero reads
748
+ # `failed` with nothing left to redo. A gate written on the state word alone
749
+ # would have restarted all five and discarded exactly what the `finished`
750
+ # refusal exists to protect.
751
+ #
752
+ # Same lesson plot-reap.sh learned from the other side: it reads `mergedAt`
753
+ # and never `state`, because a merged PR reports CLOSED. There the state word
754
+ # lies about merging; here the exit code lies about completion.
755
+ if reached_review "$restart_branch"; then
756
+ pr_json=$("$script_dir/plot-host.sh" pr-state "$restart_branch" </dev/null 2>/dev/null || true)
757
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
758
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
759
+ echo "plot-dispatch: $restart_branch has a pull request (#${pr_num:-?}, ${pr_state:-OPEN}) — refusing." >&2
760
+ echo " The work reached review, whatever the worker's exit code says. A" >&2
761
+ echo " restart here redoes work someone is already looking at." >&2
762
+ echo " Review it, or reap the worktree once it merges." >&2
763
+ exit 1
764
+ fi
765
+
766
+ # Only now the process. `worker_state` is the ONE answer to "is a worker
767
+ # running here" — asked rather than re-derived, so this cannot drift from
768
+ # `--status` and the scan the way five of six states already did once.
769
+ restart_state=$(worker_state "$restart_wt" "$restart_branch")
770
+ case "$restart_state" in
771
+ running*)
772
+ # THE REFUSAL THAT PREVENTS TWO WORKERS ON ONE BRANCH. There is no
773
+ # --force: a flag overriding this is the flag typed reflexively, and what
774
+ # it would override is another agent's work in progress.
775
+ echo "plot-dispatch: a worker is alive on $restart_branch (pid ${restart_state#running }) — refusing." >&2
776
+ echo " Stop it first if you mean to replace it:" >&2
777
+ echo " plot-dispatch.sh --stop $restart_branch" >&2
778
+ exit 1
779
+ ;;
780
+ waiting*)
781
+ # A person owes this branch an answer. A new worker meets the same
782
+ # question and writes the same marker.
783
+ # ASKED, NOT RE-GLOBBED. The marker's spelling lives with the
784
+ # classification in plot-worker-state.sh and only there; a copy of the
785
+ # glob here is the drift `workerstate.test.mjs` pins against.
786
+ marker=$(plot_worker_blocked_file "$restart_wt" || true)
787
+ echo "plot-dispatch: $restart_branch is blocked on a question — refusing." >&2
788
+ echo " the question is in $restart_wt/${marker:-the marker file}" >&2
789
+ echo " Answer it and delete the marker, then restart." >&2
790
+ exit 1
791
+ ;;
792
+ esac
793
+ # `stalled`, `failed`, `ended` and `no worker` all restart. The PR question
794
+ # above is what makes `failed` safe to include — and including it is the
795
+ # point: a gate that simply refused `failed` would pass every refusal test
796
+ # here and leave the verb unable to do the one thing it exists for.
797
+
798
+ echo "restarting $restart_branch ($restart_state)"
799
+ echo " worktree: $restart_wt"
800
+
801
+ # THE TREE IS INHERITED EXACTLY AS IT STANDS. A `stalled` worktree holds
802
+ # uncommitted work — that is what `stalled` MEANS, and a measured stall in
803
+ # this repo left 324 finished lines on the floor. Nothing here cleans,
804
+ # resets or stashes: a restart that discards that is worse than the missing
805
+ # affordance, because it looks like a supported operation. The new worker's
806
+ # brief already tells it to commit and push before verifying.
807
+ if [ -n "$(git -C "$restart_wt" status --porcelain </dev/null 2>/dev/null)" ]; then
808
+ echo " uncommitted work in the tree is kept — the new worker inherits it"
809
+ fi
810
+
811
+ # start_worker is the ORDINARY DISPATCH PATH, and reusing it is half this
812
+ # feature. The bypass this plan replaces produced an unregistered agent, so
813
+ # the board showed a branch name in the agent-name slot; a restart that
814
+ # spawned a worker without a manifest would reproduce the exact defect it
815
+ # exists to prevent. One writer, so the two cannot drift.
816
+ #
817
+ # These two globals are set below the phase gate, which this path exits
818
+ # before reaching. `slug` stays empty on purpose: a restart is not a plan
819
+ # fan-out, and the manifest records what this line actually knows.
820
+ repo_root="$repo_root_early"
821
+ worker_cmd_declined=0
822
+ case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
823
+ none|NONE|None) worker_cmd_declined=1 ;;
824
+ esac
825
+ start_worker "$restart_branch" "$restart_wt" || exit 1
826
+ exit 0
827
+ fi
828
+
829
+ # ---------------------------------------------------------------------------
830
+ # Migration mode: move legacy worktrees into the configured root
831
+ # ---------------------------------------------------------------------------
832
+ #
833
+ # THE REFUSALS ARE THE FEATURE. `git worktree move` on a checkout an agent is
834
+ # writing to breaks it mid-run. So this mode moves a worktree only when it has
835
+ # NO LIVE WORKER AND NO UNLANDED WORK, and names every one it skipped with the
836
+ # reason. Modelled on plot-reap.sh, which refuses on five MEASUREMENTS rather
837
+ # than judgements.
838
+ #
839
+ # A MIXED ESTATE IS AN ORDINARY STATE, NOT A TRANSITION TO COMPLETE. Existing
840
+ # worktrees stay where they are and keep working; every read asks git, so a
841
+ # mixed estate is not a special case. `--migrate` must never be required — a
842
+ # repo that adopts `Worktree root:` and never migrates is correctly configured.
843
+ #
844
+ # That is why this is opt-in and idempotent rather than automatic, and why a
845
+ # worktree it refuses is not an error.
846
+ if [ "$mode" = "migrate" ]; then
847
+ # Resolve where worktrees SHOULD go — the configured root.
848
+ configured_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
849
+ if [ -z "$configured_root" ]; then
850
+ echo "plot-dispatch --migrate: no 'Worktree root:' configured — nothing to migrate."
851
+ echo " When Worktree root is absent, worktrees live beside the repo (plot-wt-*)."
852
+ echo " To migrate, first add a 'Worktree root:' key to ## Plot Config."
853
+ exit 0
854
+ fi
855
+
856
+ # Resolve the target root to an absolute path.
857
+ case "$configured_root" in
858
+ /*) target_root="$configured_root" ;;
859
+ *) target_root="$repo_root_early/$configured_root" ;;
860
+ esac
861
+ target_root="${target_root%/}"
862
+
863
+ # Create the target directory if needed.
864
+ if [ "$migrate_yes" = 1 ] && [ ! -d "$target_root" ]; then
865
+ mkdir -p "$target_root" 2>/dev/null || {
866
+ echo "plot-dispatch --migrate: cannot create '$target_root'" >&2
867
+ exit 1
868
+ }
869
+ fi
870
+
871
+ # The legacy location: beside the repo, with `plot-wt-` prefix.
872
+ legacy_root=$(cd "$repo_root_early/.." && pwd)
873
+ legacy_prefix="plot-wt-"
874
+
875
+ # If the configured root is the same as the legacy root, there is nothing to
876
+ # migrate — the worktrees are already in the right place (only the prefix
877
+ # would change, and renaming worktrees for a prefix is not worth the churn).
878
+ if [ "$target_root" = "$legacy_root" ]; then
879
+ echo "plot-dispatch --migrate: target root matches legacy root ($legacy_root)."
880
+ echo " Worktrees are already in the right place — nothing to migrate."
881
+ exit 0
882
+ fi
883
+
884
+ n_moved=0 n_skipped=0 n_would=0
885
+ dry_label="would move"
886
+ [ "$migrate_yes" = 1 ] && dry_label="moved"
887
+
888
+ printf '%-8s %-52s %s\n' "verdict" "worktree" "reason"
889
+
890
+ for wt in "$legacy_root"/"$legacy_prefix"*; do
891
+ [ -d "$wt" ] || continue
892
+ # Extract the branch from the worktree.
893
+ br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "")
894
+
895
+ # If we hit --max, stop processing.
896
+ if [ "$max" -gt 0 ] && [ "$((n_moved + n_would))" -ge "$max" ]; then
897
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "--max $max reached"
898
+ n_skipped=$((n_skipped + 1))
899
+ continue
900
+ fi
901
+
902
+ # TWO INDEPENDENT CONDITIONS, because the brief names two: a worktree moves
903
+ # only with NO LIVE WORKER **AND** NO UNLANDED WORK. They are separate
904
+ # measurements, exactly as they are in plot-reap.sh, and folding them into
905
+ # one verdict is a hole: plot_worker_state answers "is a WORKER running or
906
+ # waiting here", and it is keyed on the worker RECORDS (`.plot-worker.pid`,
907
+ # `.plot-worker.exit`). A hand-made worktree that never ran a Plot worker has
908
+ # no records and reads `none` no matter how dirty its tree is — and the
909
+ # hand-made worktrees are precisely the estate this mode exists to tidy. So
910
+ # liveness and unlanded-work are asked as two questions below.
911
+
912
+ # REFUSAL 1 & 2 — a LIVE WORKER, from the ONE shared answer. The brief is
913
+ # explicit: plot_worker_state is the single answer to "is a worker running
914
+ # in this worktree", sourced by both dispatch and the fleet scan. It carries
915
+ # what a bare `ps` cannot — pid-reuse detection via the manifest's
916
+ # `startedAt`, and the `waiting` state a PLOT-BLOCKED* marker produces.
917
+ # Re-implementing either here is the drift the codebase fought to remove.
918
+ wstate_row=$(plot_worker_state "$wt")
919
+ state=$(printf '%s' "$wstate_row" | cut -f1)
920
+ case "$state" in
921
+ running)
922
+ pid=$(printf '%s' "$wstate_row" | cut -f2)
923
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "worker alive (pid $pid)"
924
+ n_skipped=$((n_skipped + 1))
925
+ continue ;;
926
+ waiting)
927
+ # The shared classifier reports `waiting` when a blocked marker exists:
928
+ # a worker stopped to ask a person something. Moving it breaks the
929
+ # checkout the answer is owed to.
930
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "blocked marker — needs a person"
931
+ n_skipped=$((n_skipped + 1))
932
+ continue ;;
933
+ esac
934
+
935
+ # REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
936
+ # `plot_worker_dirty` applies the shared filter (editor leftovers and Plot's
937
+ # own bookkeeping do not count), so this fires on real work only.
938
+ dirty=$(plot_worker_dirty "$wt" | head -1 | cut -c1-40)
939
+ if [ -n "$dirty" ]; then
940
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "uncommitted: $dirty"
941
+ n_skipped=$((n_skipped + 1))
942
+ continue
943
+ fi
944
+
945
+ # REFUSAL 4 — UNPUSHED COMMITS. Work that exists only on this machine.
946
+ # Only the branch's OWN upstream answers "pushed?"; an absent upstream leaves
947
+ # the question unanswerable, and an unanswered question is not a refusal —
948
+ # the same principle plot_worker_task_state reached the hard way (counting
949
+ # against origin/main marked every clean branch stalled in a remote-less
950
+ # repo). So no upstream falls through to "movable", not to "keep".
951
+ if [ -n "$br" ]; then
952
+ ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null || echo "")
953
+ case "$ahead" in
954
+ ''|0|*[!0-9]*) ;; # no upstream, or nothing ahead: not a refusal
955
+ *) printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "unpushed commits ($ahead ahead)"
956
+ n_skipped=$((n_skipped + 1))
957
+ continue ;;
958
+ esac
959
+ fi
960
+
961
+ # This worktree is idle — it can be moved.
962
+ # Compute the destination path: the target root plus the branch name
963
+ # (flattened, no prefix).
964
+ if [ -n "$br" ]; then
965
+ dest_name=$(printf '%s' "$br" | tr '/' '-')
966
+ else
967
+ # Fallback: use the existing directory name minus the legacy prefix.
968
+ dest_name=$(basename "$wt")
969
+ dest_name="${dest_name#$legacy_prefix}"
970
+ fi
971
+ dest="$target_root/$dest_name"
972
+
973
+ if [ "$migrate_yes" = 1 ]; then
974
+ # Actually move the worktree.
975
+ if git worktree move "$wt" "$dest" 2>/dev/null; then
976
+ printf '%-8s %-52s %s\n' "moved" "$(basename "$wt")" "→ $dest"
977
+ n_moved=$((n_moved + 1))
978
+ else
979
+ printf '%-8s %-52s %s\n' "FAILED" "$(basename "$wt")" "git worktree move refused"
980
+ n_skipped=$((n_skipped + 1))
981
+ fi
982
+ else
983
+ printf '%-8s %-52s %s\n' "would" "$(basename "$wt")" "→ $dest"
984
+ n_would=$((n_would + 1))
985
+ fi
986
+ done
987
+
988
+ if [ "$migrate_yes" = 1 ]; then
989
+ echo "summary: moved=$n_moved skipped=$n_skipped"
990
+ else
991
+ echo "summary: would_move=$n_would skipped=$n_skipped dry_run=1"
992
+ if [ "$n_would" -gt 0 ]; then
993
+ echo " Run with --yes to actually move the worktrees."
994
+ fi
995
+ fi
996
+ exit 0
997
+ fi
998
+
999
+ # ---------------------------------------------------------------------------
1000
+ # Phase and ceremony gate
1001
+ # ---------------------------------------------------------------------------
1002
+ #
1003
+ # A GATE, not a rule (CLAUDE.md, "Gates Over Rules"): the check lives here, in
1004
+ # the script, because prose in a SKILL.md is something an agent can rationalise
1005
+ # around and a human calling this script directly bypasses entirely. Fanning
1006
+ # out is the one place a user can do real damage — branches and worker
1007
+ # processes for a plan nobody approved.
1008
+ #
1009
+ # FAIL CLOSED, unlike plot-phase-gate.sh. That one is a PreToolUse hook, so a
1010
+ # broken gate would lock every commit in the repo and it must fail open. This
1011
+ # is a command the user invoked: if the plan's phase cannot be read, refusing
1012
+ # costs one confused re-run, while proceeding costs several agents doing
1013
+ # unapproved work. The damage is asymmetric, so the default is too.
1014
+ #
1015
+ # THE PHASE IS READ FROM THE SHARED REF, NOT THE WORKING TREE. The working tree
1016
+ # is the least trustworthy surface in a repo with several agents in it: it
1017
+ # carries whatever branch was last checked out plus whatever is uncommitted,
1018
+ # and neither is a fact anyone else shares. Reading it got this gate wrong in
1019
+ # BOTH directions, both reproduced 2026-08-18:
1020
+ #
1021
+ # - it REFUSED approved work, when a concurrent agent's `git checkout` parked
1022
+ # the shared checkout on a branch carrying an older copy of the plan. The
1023
+ # approval was on origin/<main> the whole time.
1024
+ # - it PERMITTED unapproved work, when an approval was committed to a local
1025
+ # branch and never pushed. Manifesto P2 is "plans are approved before
1026
+ # implementation"; a gate that accepts an approval nobody else can see
1027
+ # enforces "someone typed Approved in this filesystem" instead.
1028
+ #
1029
+ # So the question the gate asks is: has this plan been approved WHERE EVERYONE
1030
+ # CAN SEE IT? `git show origin/<main>:<path>` is that question. There is
1031
+ # deliberately NO fallback to the working tree — that would reintroduce the bug
1032
+ # exactly where nothing can catch it. --allow-local is the explicit escape, and
1033
+ # it is named in the refusal so an operator learns it exists when they need it.
1034
+ MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
1035
+ [ -n "$MAIN" ] || MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
1036
+ [ -n "$MAIN" ] || MAIN="main"
1037
+ [ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
1038
+
1039
+ PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
1040
+ ACTIVE_DIR_CFG=$("$script_dir/plot-config.sh" get "Active index" "docs/plans/active/")
1041
+
1042
+ gate_ref="origin/$MAIN"
1043
+ gate_sha=$(git rev-parse --verify --quiet "$gate_ref^{commit}" 2>/dev/null) || gate_sha=""
1044
+
1045
+ # The plan as it exists on the shared ref. Path resolution runs against the ref
1046
+ # too (`git ls-tree`), not the filesystem: a plan that exists only locally must
1047
+ # not be found here, and a plan whose local copy was deleted must still gate.
1048
+ #
1049
+ # The active index is a directory of SYMLINKS, and git stores a symlink as mode
1050
+ # 120000 whose blob content is the TARGET PATH — so `git show <ref>:active/g.md`
1051
+ # yields the string "../2026-01-01-g.md", not the plan. On a filesystem `[ -e ]`
1052
+ # follows the link and this never comes up; against a ref it must be
1053
+ # dereferenced by hand, or the gate parses a one-line path as a plan and reports
1054
+ # an unreadable phase instead of the real one.
1055
+ deref_on_ref() { # $1=path on $gate_ref → prints the path the blob really lives at
1056
+ local p="$1" target dir hops=0
1057
+ while [ "$(git ls-tree "$gate_ref" -- "$p" 2>/dev/null | awk '{print $1}')" = "120000" ]; do
1058
+ hops=$((hops + 1))
1059
+ [ "$hops" -le 8 ] || return 1 # a symlink cycle must not hang the gate
1060
+ target=$(git show "$gate_ref:$p" 2>/dev/null) || return 1
1061
+ case "$target" in
1062
+ /*) return 1 ;; # absolute: not a path within the ref
1063
+ *) dir=$(dirname "$p")
1064
+ # Normalise ../ and ./ without touching the filesystem.
1065
+ p=$(printf '%s\n' "$dir/$target" | awk -F/ '{
1066
+ n=0
1067
+ for (i=1; i<=NF; i++) {
1068
+ if ($i == "" || $i == ".") continue
1069
+ if ($i == "..") { if (n>0) n--; continue }
1070
+ s[++n]=$i
1071
+ }
1072
+ out=""
1073
+ for (i=1; i<=n; i++) out = (i==1 ? s[i] : out "/" s[i])
1074
+ print out
1075
+ }') ;;
1076
+ esac
1077
+ done
1078
+ printf '%s\n' "$p"
1079
+ }
1080
+
1081
+ plan_path=""
1082
+ if [ -n "$gate_sha" ]; then
1083
+ for cand in "$ACTIVE_DIR_CFG$slug.md" \
1084
+ $(git ls-tree -r --name-only "$gate_ref" -- "$PLAN_DIR_CFG" 2>/dev/null \
1085
+ | grep -E "/[0-9]{4}-[0-9]{2}-[0-9]{2}-${slug}\.md$|/${slug}\.md$"); do
1086
+ git cat-file -e "$gate_ref:$cand" 2>/dev/null || continue
1087
+ plan_path=$(deref_on_ref "$cand") || { plan_path=""; continue; }
1088
+ [ -n "$plan_path" ] && break
1089
+ done
1090
+ fi
1091
+
1092
+ # --allow-local: read the working tree instead, and say so. The escape for a
1093
+ # repo with no remote at all; never reached silently.
1094
+ if [ -z "$gate_sha" ] && [ "$allow_local" = 1 ]; then
1095
+ echo "plot-dispatch: cannot resolve '$gate_ref' — reading the working tree (--allow-local)." >&2
1096
+ for cand in "$ACTIVE_DIR_CFG$slug.md" "$PLAN_DIR_CFG"*"$slug".md; do
1097
+ [ -e "$cand" ] && { plan_path="$cand"; break; }
1098
+ done
1099
+ fi
1100
+
1101
+ if [ -z "$gate_sha" ] && [ "$allow_local" != 1 ]; then
1102
+ echo "plot-dispatch: cannot resolve '$gate_ref' — refusing to dispatch." >&2
1103
+ echo " The phase gate reads the plan as it exists on the shared ref, so an" >&2
1104
+ echo " approval only you can see cannot open it. Run \`git fetch origin $MAIN\`," >&2
1105
+ echo " or pass --allow-local to gate on the working tree instead." >&2
1106
+ exit 1
1107
+ fi
1108
+
1109
+ if [ -z "$plan_path" ]; then
1110
+ if [ "$allow_local" = 1 ]; then
1111
+ echo "plot-dispatch: no plan found for '$slug' — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
1112
+ else
1113
+ echo "plot-dispatch: no plan for '$slug' on $gate_ref — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
1114
+ echo " A plan that exists only in this working tree has not been shared yet: push it first." >&2
1115
+ fi
1116
+ exit 1
1117
+ fi
1118
+
1119
+ # plot-plan-meta.sh is the format contract and takes a PATH, so the blob is
1120
+ # materialised into a temp file rather than parsed here — the parser stays the
1121
+ # one place that knows what a plan file looks like.
1122
+ #
1123
+ # The template's X's must TRAIL: BSD mktemp (macOS) rejects a template with a
1124
+ # suffix after them, while GNU accepts it. The first version wrote
1125
+ # `plot-gate-XXXXXX.md` and failed on macOS — and because the failure fell back
1126
+ # to the working tree, the gate silently went back to reading the exact surface
1127
+ # this fix exists to stop reading. Hence also: NO working-tree fallback below.
1128
+ # If the shared blob cannot be materialised, the gate refuses.
1129
+ plan_file="$plan_path"
1130
+ gate_blob=""
1131
+ if [ -n "$gate_sha" ]; then
1132
+ gate_dir=$(mktemp -d "${TMPDIR:-/tmp}/plot-gate-XXXXXX") || gate_dir=""
1133
+ if [ -n "$gate_dir" ]; then
1134
+ trap 'rm -rf "$gate_dir"' EXIT
1135
+ gate_blob="$gate_dir/$(basename "$plan_path")"
1136
+ git show "$gate_ref:$plan_path" >"$gate_blob" 2>/dev/null || gate_blob=""
1137
+ fi
1138
+ if [ -z "$gate_blob" ]; then
1139
+ echo "plot-dispatch: could not read '$gate_ref:$plan_path' — refusing to dispatch." >&2
1140
+ echo " The gate does not fall back to the working tree: an approval only you" >&2
1141
+ echo " can see must not open it. Pass --allow-local if that is what you mean." >&2
1142
+ exit 1
1143
+ fi
1144
+ else
1145
+ gate_blob="$plan_path" # --allow-local only; guarded above
1146
+ fi
1147
+
1148
+ # What the gate actually read, for messages: the shared ref by default, the
1149
+ # working tree only under --allow-local. A refusal that names `origin/main@<sha>`
1150
+ # is debuggable in seconds; "still Draft" alone sent an operator looking at a
1151
+ # file that already said Approved.
1152
+ if [ -n "$gate_sha" ]; then
1153
+ gate_source="$gate_ref@${gate_sha:0:8}:$plan_path"
1154
+ else
1155
+ gate_source="$plan_path (working tree, --allow-local)"
1156
+ fi
1157
+
1158
+ gate_meta=$("$script_dir/plot-plan-meta.sh" "$gate_blob" 2>/dev/null) || gate_meta=""
1159
+ gate_phase=$(printf '%s' "$gate_meta" | sed -n 's/.*"phase":"\([^"]*\)".*/\1/p')
1160
+ gate_impl=$(printf '%s' "$gate_meta" | sed -n 's/.*"impl":"\([^"]*\)".*/\1/p')
1161
+
1162
+ case "$gate_phase" in
1163
+ approved) ;;
1164
+ draft)
1165
+ echo "plot-dispatch: plan '$slug' is still Draft on $gate_source — nothing may be dispatched." >&2
1166
+ echo " The gate reads the plan as it exists on the shared ref. If you approved it" >&2
1167
+ echo " locally, push that approval; an approval nobody else can see is not one." >&2
1168
+ echo " Review it, then: /plot-approve $slug" >&2
1169
+ exit 1 ;;
1170
+ delivered|released)
1171
+ echo "plot-dispatch: plan '$slug' is already $gate_phase — its work is done." >&2
1172
+ exit 1 ;;
1173
+ "")
1174
+ echo "plot-dispatch: cannot read the phase of '$slug' ($gate_source)." >&2
1175
+ echo " Refusing rather than guessing — dispatching starts real work." >&2
1176
+ exit 1 ;;
1177
+ *)
1178
+ echo "plot-dispatch: plan '$slug' is in phase '$gate_phase', not Approved." >&2
1179
+ exit 1 ;;
1180
+ esac
1181
+
1182
+ # Fan-out only makes sense where implementation happens on its own branches
1183
+ # here. NONE means a pre-Plot-2 plan that never recorded an answer — allowed,
1184
+ # since those predate the question.
1185
+ case "$gate_impl" in
1186
+ own-branches|NONE|"") ;;
1187
+ same-branch)
1188
+ echo "plot-dispatch: plan '$slug' records 'Impl: same branch' — plan and code" >&2
1189
+ echo " travel on one branch, so there is nothing to fan out." >&2
1190
+ exit 1 ;;
1191
+ other-repo)
1192
+ echo "plot-dispatch: plan '$slug' records 'Impl: other repo' — implementation" >&2
1193
+ echo " happens elsewhere. Dispatch from the implementation repo instead." >&2
1194
+ exit 1 ;;
1195
+ none)
1196
+ echo "plot-dispatch: plan '$slug' records 'Impl: none' — knowledge-only work," >&2
1197
+ echo " nothing to implement." >&2
1198
+ exit 1 ;;
1199
+ *)
1200
+ echo "plot-dispatch: plan '$slug' records an unrecognised 'Impl:' answer" >&2
1201
+ echo " ('$gate_impl'). Refusing rather than guessing." >&2
1202
+ exit 1 ;;
1203
+ esac
1204
+
1205
+ # MAIN was resolved and origin fetched above, before the phase gate — the gate
1206
+ # needs the shared ref to read the plan from it.
1207
+
1208
+ # Where the worktrees live and what their names carry — see resolve_wt_root.
1209
+ # The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
1210
+ # key relocates them (and drops the prefix, which was only earning its keep
1211
+ # among unrelated sibling directories). A nested root is made invisible to
1212
+ # `git status` and the marker grep by a `.gitignore` line, not by living
1213
+ # outside the repo.
1214
+ repo_root=$(git rev-parse --show-toplevel)
1215
+ resolve_wt_root "$repo_root"
1216
+
1217
+ n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
1218
+
1219
+ # Whether this run COULD have started anything, read once and up front.
1220
+ #
1221
+ # Read here rather than inside start_worker so the answer exists even on the
1222
+ # paths start_worker never reaches: --dry-run, --no-start, and a run where
1223
+ # every candidate was skipped. Those are precisely the runs whose `started=0`
1224
+ # used to arrive with no explanation attached.
1225
+ #
1226
+ # --no-start wins over the config, and does not mean the config is missing:
1227
+ # a repo that HAS a `Worker command` and was told not to use it is reporting a
1228
+ # choice, not a gap. Conflating them would be the one-label-two-states mistake
1229
+ # this whole plan exists to remove.
1230
+ #
1231
+ # `none` is a DELIBERATE absence, and it is not the same as a missing key —
1232
+ # it is the repo's established sentinel (`Implementation home: none`) and here
1233
+ # it records that the question was asked and answered "I start them myself".
1234
+ # The skill writes it so it stops asking; the script must never try to RUN it,
1235
+ # which is what a bare emptiness check would do.
1236
+ worker_cmd_configured=0
1237
+ worker_cmd_declined=0
1238
+ case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
1239
+ "") ;;
1240
+ none|NONE|None) worker_cmd_declined=1 ;;
1241
+ *) worker_cmd_configured=1 ;;
1242
+ esac
1243
+
1244
+ worker_state_field() {
1245
+ if [ "$no_start" = 1 ]; then echo "suppressed"
1246
+ elif [ "$worker_cmd_configured" = 1 ]; then echo "configured"
1247
+ elif [ "$worker_cmd_declined" = 1 ]; then echo "declined"
1248
+ else echo "unconfigured"
1249
+ fi
1250
+ }
1251
+
1252
+ # The summary block: an optional prose line, then the machine-countable footer.
1253
+ #
1254
+ # The prose line is printed only when there is a consequence to state — a
1255
+ # summary that always explains itself teaches the reader to skip it, and then
1256
+ # it is worth nothing on the day it matters. `worktrees prepared` counts
1257
+ # dispatched + reused, because a re-adopted worktree is equally a desk nobody
1258
+ # was sat at.
1259
+ print_summary() { # $1=dispatched $2=reused $3=skipped $4=started
1260
+ local prepared=$(( $1 + $2 )) worker
1261
+ worker=$(worker_state_field)
1262
+ if [ "$prepared" -gt 0 ] && [ "$4" = 0 ]; then
1263
+ case "$worker" in
1264
+ unconfigured)
1265
+ echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started, no \`Worker command\` configured" ;;
1266
+ declined)
1267
+ # Asked and answered: this repo starts its workers by hand. Stating the
1268
+ # count without calling it a gap — hand-starting is a legitimate
1269
+ # workflow, and repeating "not configured" at someone who decided that
1270
+ # on purpose is the nag the plan rules out.
1271
+ echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started — this repo starts them by hand" ;;
1272
+ suppressed)
1273
+ echo "$prepared worktree$([ "$prepared" = 1 ] || echo s) prepared, 0 workers started (--no-start)" ;;
1274
+ esac
1275
+ fi
1276
+ echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$worker"
1277
+ }
1278
+
1279
+ # ---------------------------------------------------------------------------
1280
+ # Parallel-agents cap: warn and raise when exceeded
1281
+ # ---------------------------------------------------------------------------
1282
+ #
1283
+ # THE CAP GATES AUTO-DISPATCH AND WARNS A PERSON (a-worker-asks-for-the-next-wave,
1284
+ # "Counted" wave). maybeAutoDispatch REFUSES at the cap; plot-dispatch.sh WARNS
1285
+ # and PROCEEDS. An operator running `/plot-dispatch` has asked for something
1286
+ # specific; refusing them to defend a setting they can change is the wrong
1287
+ # direction. But proceeding past a cap must not leave the cap behind: a stored
1288
+ # `3` beside six running workers is a number the board itself knows to be false.
1289
+ # So exceeding the cap UPDATES it.
1290
+ #
1291
+ # LIVE STATES occupy a slot: `running` and `waiting`. This matches LIVE_STATES
1292
+ # in auto-dispatch.ts — the two must agree, or the cap means different things
1293
+ # to different readers.
1294
+ FLEET_CONTROLS_FILE="$repo_root/.plot/state/fleet-controls.json"
1295
+
1296
+ # Read the current parallel-agents cap from the fleet controls file.
1297
+ # Returns the default (3) if the file does not exist or cannot be parsed.
1298
+ read_parallel_agents_cap() {
1299
+ if [ ! -f "$FLEET_CONTROLS_FILE" ]; then
1300
+ echo 3
1301
+ return
1302
+ fi
1303
+ # A simple extraction: the file is {"autoDispatch":..., "parallelAgents": N}
1304
+ local cap
1305
+ cap=$(sed -n 's/.*"parallelAgents"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
1306
+ [ -n "$cap" ] && echo "$cap" || echo 3
1307
+ }
1308
+
1309
+ # Count workers in live states (running or waiting) across all fleet worktrees.
1310
+ # These are the slots that count against the cap.
1311
+ count_live_workers() {
1312
+ local n=0 wt br st
1313
+ for wt in "$wt_root"/"$wt_prefix"*; do
1314
+ [ -d "$wt" ] || continue
1315
+ br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
1316
+ st=$(worker_state "$wt" "$br")
1317
+ case "$st" in
1318
+ running*|waiting*) n=$((n + 1)) ;;
1319
+ esac
1320
+ done
1321
+ echo "$n"
1322
+ }
1323
+
1324
+ # Get the branches currently occupying slots (for the warning message).
1325
+ live_worker_branches() {
1326
+ local wt br st
1327
+ for wt in "$wt_root"/"$wt_prefix"*; do
1328
+ [ -d "$wt" ] || continue
1329
+ br=$(git -C "$wt" branch --show-current 2>/dev/null || echo "?")
1330
+ st=$(worker_state "$wt" "$br")
1331
+ case "$st" in
1332
+ running*|waiting*) echo "$br" ;;
1333
+ esac
1334
+ done
1335
+ }
1336
+
1337
+ # Update the parallel-agents cap in the fleet controls file.
1338
+ # Creates the file (and directory) if needed, preserving autoDispatch if present.
1339
+ update_parallel_agents_cap() { # $1 = new cap
1340
+ local new_cap="$1"
1341
+ local auto_dispatch="false"
1342
+
1343
+ mkdir -p "$(dirname "$FLEET_CONTROLS_FILE")" 2>/dev/null || true
1344
+
1345
+ if [ -f "$FLEET_CONTROLS_FILE" ]; then
1346
+ # Preserve the existing autoDispatch setting
1347
+ local existing
1348
+ existing=$(sed -n 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
1349
+ [ -n "$existing" ] && auto_dispatch="$existing"
1350
+ fi
1351
+
1352
+ # Write atomically through a temp file, the same discipline as writeFleetControls
1353
+ local tmp="${FLEET_CONTROLS_FILE}.$$-dispatch.tmp"
1354
+ printf '{"autoDispatch":%s,"parallelAgents":%d}' "$auto_dispatch" "$new_cap" > "$tmp"
1355
+ mv "$tmp" "$FLEET_CONTROLS_FILE"
1356
+ }
1357
+
1358
+ # Check if the dispatch exceeded the cap; if so, warn and raise it.
1359
+ # Called AFTER the dispatch, with the count of newly started workers.
1360
+ check_and_update_cap() { # $1 = n_started this run
1361
+ local n_started="$1"
1362
+ [ "$n_started" -gt 0 ] || return 0
1363
+
1364
+ local cap live_before live_after
1365
+ cap=$(read_parallel_agents_cap)
1366
+ # Count AFTER starting — the workers we just started are now live
1367
+ live_after=$(count_live_workers)
1368
+
1369
+ if [ "$live_after" -gt "$cap" ]; then
1370
+ local branches
1371
+ branches=$(live_worker_branches | paste -sd', ' -)
1372
+ echo "WARNING: dispatch exceeded parallel-agents cap ($cap → $live_after)"
1373
+ echo " Slots now held by: $branches"
1374
+ echo " Raising cap to $live_after so auto-dispatch sees the true count"
1375
+ update_parallel_agents_cap "$live_after"
1376
+ fi
1377
+ }
1378
+
1379
+ # Branches CLAIMED by this run, for the `Started:` record. Only newly claimed
1380
+ # ones: a reused worktree was dispatched by an earlier run, which booked it.
1381
+ declare -a claimed_now=()
1382
+
1383
+ # Branches this run cannot dispatch. --next re-asks each iteration (pull
1384
+ # semantics), and a branch that is never CLAIMED keeps coming back — without
1385
+ # this the loop spins forever on the first undispatchable branch.
1386
+ declare -a exhausted=()
1387
+ is_exhausted() {
1388
+ local x
1389
+ for x in ${exhausted[@]+"${exhausted[@]}"}; do [ "$x" = "$1" ] && return 0; done
1390
+ return 1
1391
+ }
1392
+
1393
+ # Record what was started, ON THE DEFAULT BRANCH.
1394
+ #
1395
+ # WHERE THIS IS WRITTEN IS THE WHOLE DIFFICULTY. The plan file found above
1396
+ # lives in this dispatcher's LOCAL WORKING TREE — `docs/plans/active/<slug>.md`
1397
+ # relative to whatever branch happens to be checked out here. The board reads
1398
+ # the plan from the DEFAULT BRANCH. Appending the record to the local file and
1399
+ # committing it would book the start on the dispatcher's branch, where the
1400
+ # board never looks: the plan would keep reading as Ready while agents edit its
1401
+ # branches. That is not hypothetical — it had to be back-filled by hand twice
1402
+ # on this repo on 2026-08-16, which is why the naive version is called out here
1403
+ # rather than merely avoided.
1404
+ #
1405
+ # So dispatch books the way every other Plot command books: through a
1406
+ # disposable branch off origin/<default>, pushed with plot-push-main.sh.
1407
+ #
1408
+ # A SEPARATE WORKTREE, not `git checkout -b` in this one. The plan's sketch
1409
+ # said checkout, but the dispatcher's working tree belongs to the user and may
1410
+ # carry uncommitted work; switching it out from under them to save a note is
1411
+ # exactly the kind of write this script otherwise refuses. A throwaway worktree
1412
+ # reaches the same commit without touching anyone's checkout, and is removed
1413
+ # whether the push succeeded or not.
1414
+ #
1415
+ # plot-push-main.sh rather than a bare `git push`, so a repo whose protection
1416
+ # is configured but not enforced hears about the bypass instead of it passing
1417
+ # silently — the reason that helper exists at all.
1418
+ book_started() { # $@ = branches dispatched this run
1419
+ [ $# -gt 0 ] || return 0
1420
+
1421
+ if write_started_record "$@"; then
1422
+ return 0
1423
+ fi
1424
+
1425
+ # A FAILED BOOKING NEVER UNWINDS A FAN-OUT. By the time we are here the
1426
+ # worktrees exist and the claims are pushed, and those are the real state;
1427
+ # the record is a report ABOUT that state. Rolling back real work because a
1428
+ # note could not be saved is the larger damage, and aborting mid-fan-out
1429
+ # would leave exactly the inconsistency the record exists to prevent.
1430
+ #
1431
+ # Said ONCE, here, and on STDOUT beside the summary it qualifies. Why the
1432
+ # write failed belongs on stderr and was printed there; that the record is
1433
+ # missing while the work is running is part of this command's report, and a
1434
+ # caller reading only stdout would otherwise see a clean fan-out with no hint
1435
+ # that the plan still reads as Ready.
1436
+ echo " note: Started: could not be recorded on $MAIN — the fan-out stands."
1437
+ echo " Record it by hand, or re-run this dispatch once the push works."
1438
+ return 1
1439
+ }
1440
+
1441
+ # The write itself. Every failure path returns non-zero after saying WHY on
1442
+ # stderr; the caller owns the one user-facing consequence line.
1443
+ write_started_record() { # $@ = branches
1444
+ local who date rel tmpwt bookbr rc=0
1445
+ who="${PLOT_CLAIM_WHO:-$(git config user.name 2>/dev/null || echo plot)}"
1446
+ date=$(date +%Y-%m-%d)
1447
+
1448
+ # The CANONICAL plan file, not the index symlink: the record belongs in the
1449
+ # dated file both indexes point at, or a later `active/` → `delivered/` move
1450
+ # would carry the symlink and leave the record behind.
1451
+ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
1452
+ if [ -z "$rel" ]; then
1453
+ echo "plot-dispatch: $plan_file is outside the repository root" >&2
1454
+ return 1
1455
+ fi
1456
+
1457
+ bookbr="plot/start-$slug"
1458
+ tmpwt="$wt_root/.plot-start-$slug.$$"
1459
+
1460
+ # Fetch even under --offline: booking is a push, so the network is already
1461
+ # required. Without a fresh origin/<default> the branch would fork from a
1462
+ # stale tip and the push would be a guaranteed non-fast-forward.
1463
+ git fetch -q origin "$MAIN" 2>/dev/null
1464
+
1465
+ # -B: a leftover branch from an earlier failed booking must not block this
1466
+ # one. It is disposable by construction — created here, pushed, deleted.
1467
+ if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
1468
+ echo "plot-dispatch: could not prepare a booking worktree at $tmpwt" >&2
1469
+ return 1
1470
+ fi
1471
+
1472
+ if [ -f "$tmpwt/$rel" ]; then
1473
+ local br
1474
+ for br in "$@"; do
1475
+ append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
1476
+ echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
1477
+ rc=1
1478
+ break
1479
+ }
1480
+ done
1481
+ if [ "$rc" = 0 ]; then
1482
+ git -C "$tmpwt" add -- "$rel" 2>/dev/null
1483
+ git -C "$tmpwt" -c "user.name=$who" commit -q \
1484
+ -m "plot: record start of $slug" 2>/dev/null || rc=1
1485
+ fi
1486
+ else
1487
+ echo "plot-dispatch: $rel is not on origin/$MAIN" >&2
1488
+ rc=1
1489
+ fi
1490
+
1491
+ # The helper's own words, indented: which rules were stepped over and which
1492
+ # checks did not run is information only the remote has. Its stderr is folded
1493
+ # in so a `rejected` report is visible rather than swallowed by 2>/dev/null
1494
+ # somewhere upstream.
1495
+ if [ "$rc" = 0 ]; then
1496
+ "$script_dir/plot-push-main.sh" "$bookbr" "$MAIN" 2>&1 | sed 's/^/ /'
1497
+ # The pipeline's status is sed's, so ask the helper's directly.
1498
+ rc=${PIPESTATUS[0]}
1499
+ fi
1500
+
1501
+ git worktree remove --force "$tmpwt" 2>/dev/null || true
1502
+ git branch -D "$bookbr" >/dev/null 2>&1 || true
1503
+ return "$rc"
1504
+ }
1505
+
1506
+ # The plan path relative to the repo root, with the index symlink resolved.
1507
+ # Called from within $repo_root.
1508
+ real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relative)
1509
+ local p="$1" d b t
1510
+ d=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1
1511
+ b=$(basename "$p")
1512
+ t=$(readlink "$d/$b" 2>/dev/null || true)
1513
+ if [ -n "$t" ]; then
1514
+ case "$t" in
1515
+ /*) d=$(cd "$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
1516
+ *) d=$(cd "$d/$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
1517
+ esac
1518
+ b=$(basename "$t")
1519
+ fi
1520
+ case "$d" in
1521
+ "$repo_root") printf '%s' "$b" ;;
1522
+ "$repo_root"/*) printf '%s/%s' "${d#$repo_root/}" "$b" ;;
1523
+ *) return 1 ;;
1524
+ esac
1525
+ }
1526
+
1527
+ # Insert one `- **Started:** ...` line into the plan's `## Status` section, in
1528
+ # /plot-implement's exact shape so nothing downstream learns a second format.
1529
+ #
1530
+ # Placed after the LAST list item of `## Status`, never appended to the end of
1531
+ # the file: plot-plan-meta.sh reads these records out of that section, so a
1532
+ # line below it would parse as nothing at all — a record that exists on disk
1533
+ # and not in the data is worse than no record, because it looks written.
1534
+ #
1535
+ # A plan with no `## Status` heading is therefore a REFUSAL, not a best-effort
1536
+ # append. Exit 1 and let the caller report the record as unwritten; the plan is
1537
+ # malformed and guessing where the field belongs would hide that.
1538
+ append_started_line() { # $1=file $2=date $3=who $4=branch
1539
+ local f="$1" line
1540
+ line="- **Started:** $2, $3, \`$4\`"
1541
+ awk -v line="$line" '
1542
+ { lines[++n] = $0 }
1543
+ END {
1544
+ # The first `## Status` heading, then where the record belongs under it.
1545
+ for (i = 1; i <= n; i++) {
1546
+ if (lines[i] ~ /^##[ \t]*[Ss]tatus[ \t]*$/) { start = i; break }
1547
+ }
1548
+ if (!start) exit 1
1549
+
1550
+ # The template ships an EMPTY `- **Started:**` placeholder, and the record
1551
+ # belongs there. Appending after the last list item instead put it below
1552
+ # `- **Delivered:**` — the parser still read it, so nothing failed loudly,
1553
+ # but the block listed a start after a delivery and both plans dispatched
1554
+ # on 2026-08-16 had to be tidied by hand.
1555
+ #
1556
+ # Filling the placeholder is preferred; appending after the last list item
1557
+ # remains the fallback for plans that never had one (pre-Plot-2 files).
1558
+ insert = start
1559
+ for (i = start + 1; i <= n; i++) {
1560
+ if (lines[i] ~ /^##[ \t]/) break
1561
+ if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Started:\*\*[ \t]*$/) { slot = i; break }
1562
+ if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
1563
+ }
1564
+
1565
+ for (i = 1; i <= n; i++) {
1566
+ if (i == slot) { print line; continue } # replaces the empty placeholder
1567
+ print lines[i]
1568
+ if (!slot && i == insert) print line
1569
+ }
1570
+ }
1571
+ ' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
1572
+ mv "$f.plot-tmp" "$f"
1573
+ }
1574
+
1575
+
1576
+ # ---------------------------------------------------------------------------
1577
+ # What is already in flight
1578
+ # ---------------------------------------------------------------------------
1579
+ #
1580
+ # Before fanning out, say which branches already hold which files. Waves are a
1581
+ # WITHIN-PLAN ordering; a correctly eligible branch can still name a file an
1582
+ # agent has open on a different plan's branch, and nothing in the wave model
1583
+ # represents that. Assembling the answer by hand took five commands and had to
1584
+ # be done twice on 2026-08-16 — this turns it into a line of output.
1585
+ #
1586
+ # IT REPORTS AND REFUSES NOTHING. Every branch is dispatched exactly as before;
1587
+ # the operator reads the report and decides. Two designs that would have judged
1588
+ # instead were tried on paper and killed by measurement:
1589
+ #
1590
+ # - `git merge-tree` compares two EXISTING commits, and dispatch CREATES the
1591
+ # candidate branch. At check time it is identical to the default branch, so
1592
+ # the comparison reports clean for every candidate, forever. A check that
1593
+ # always passes is worse than none: it turns a known gap into a false
1594
+ # assurance. (merge-tree still earns its place where both commits exist —
1595
+ # re-dispatch and plot-merge-queue.)
1596
+ # - A `Touches:` field per branch, intersected with the measured side. The
1597
+ # scope guards in real briefs are `packages/board/**`,
1598
+ # `packages/board/src/app/**` and `plot-fleet-scan.sh` — the first CONTAINS
1599
+ # the second, so two branches that ran in parallel without touching each
1600
+ # other would read as colliding. Three of four briefs use `**` globs, so the
1601
+ # false positive is the normal case. It would also rest on an unverified
1602
+ # self-declaration, and a comparison is only as good as its weaker half.
1603
+ #
1604
+ # So only the MEASURED side is read, and nothing on the candidate side is
1605
+ # consulted at all — not the plan text, not a declaration, nothing.
1606
+ #
1607
+ # LOCAL REFS AND WORKTREES, NOT THE REMOTE. This is where refs-are-truth bends,
1608
+ # for a measured reason: the collision that blocked a dispatch on 2026-08-16
1609
+ # lived in an UNPUSHED commit — committed, clean worktree, remote ref holding
1610
+ # only the claim, invisible to any remote-based check. Uncommitted work is
1611
+ # invisible to refs entirely. Both are readable here and only here, and that is
1612
+ # sound rather than a violation because dispatch is inherently machine-specific:
1613
+ # it creates worktrees on THIS machine. A check that ignored what this machine
1614
+ # knows would be blind precisely where it acts.
1615
+
1616
+ # Files a branch holds in commits of its own, against ITS OWN MERGE-BASE.
1617
+ #
1618
+ # Not against origin/<main>. A branch rebased onto a newer main is not "behind"
1619
+ # it, and diffing against the tip would attribute every commit the branch picked
1620
+ # up from main to the branch itself — on a busy day that is the whole repo, and
1621
+ # the report becomes noise on its first use.
1622
+ committed_files() { # $1=branch → paths, one per line
1623
+ local br="$1" base
1624
+ base=$(git merge-base "$br" "origin/$MAIN" </dev/null 2>/dev/null) || return 0
1625
+ [ -n "$base" ] || return 0
1626
+ git diff --name-only "$base" "$br" </dev/null 2>/dev/null
1627
+ }
1628
+
1629
+ # Files a branch holds only in its worktree — no ref carries these, so they are
1630
+ # invisible to every ref-based check including this script's own.
1631
+ uncommitted_files() { # $1=worktree → paths, one per line
1632
+ local wt="$1"
1633
+ [ -d "$wt" ] || return 0
1634
+ git -C "$wt" status --porcelain </dev/null 2>/dev/null | awk '
1635
+ {
1636
+ # Porcelain v1: XY then a space then the path. A rename prints
1637
+ # "old -> new"; the new path is the one on disk.
1638
+ line = substr($0, 4)
1639
+ i = index(line, " -> ")
1640
+ if (i > 0) line = substr(line, i + 4)
1641
+ gsub(/^"|"$/, "", line)
1642
+ if (line != "") print line
1643
+ }'
1644
+ }
1645
+
1646
+ # The generated board bundle is excluded from every report. Every board branch
1647
+ # rebuilds it, so including it would make every board pair look like a
1648
+ # collision — which is precisely the noise `.gitattributes -merge` exists to
1649
+ # remove. Its conflicts are settled by rebuilding, never by reading.
1650
+ ARTIFACT_PATH="skills/plot/scripts/board/board-server.mjs"
1651
+
1652
+ # The worktree for a branch, by the same name-flattening rule dispatch uses.
1653
+ #
1654
+ # This COMPOSES a path and is used only where composition is right: the
1655
+ # in-flight file report below, which asks "if this branch WERE dispatched, where
1656
+ # would its worktree be", for a branch this run has not itself created. It is the
1657
+ # creation rule read forward, not a "which worktree holds this branch" query —
1658
+ # those ask git (held_worktree). The prefix follows the root, empty under a
1659
+ # dedicated `Worktree root:`.
1660
+ worktree_for() { # $1=branch
1661
+ printf '%s/%s%s' "$wt_root" "$wt_prefix" "$(printf '%s' "$1" | tr '/' '-')"
1662
+ }
1663
+
1664
+ # ---------------------------------------------------------------------------
1665
+ # THE HELD-BRANCH GATE
1666
+ # ---------------------------------------------------------------------------
1667
+ #
1668
+ # Is somebody already holding this branch — a worktree on this disk with work
1669
+ # in it that has not landed?
1670
+ #
1671
+ # THE MEASUREMENT. On 2026-08-20 `--dry-run` reported `claimed=0` across a fleet
1672
+ # with four live agents and offered `feature/the-row-carries-its-verdict` and
1673
+ # `feature/reconcile-calls-the-index-advisory` — both implemented, tested and
1674
+ # green in worktrees beside this repo — as dispatchable. Acting on that output
1675
+ # puts a second agent on finished work.
1676
+ #
1677
+ # WHY THE SCAN CANNOT SEE IT. `plot-fleet-scan.sh` derives every state from
1678
+ # `origin/<branch>`, and both branches had NO REMOTE REF: one local commit
1679
+ # each, never pushed. No remote ref means no claim, and no claim means
1680
+ # `eligible`. The scan is right about what it reads; it is reading the wrong
1681
+ # side of the machine.
1682
+ #
1683
+ # WHY A RULE CANNOT FIX IT. "Always dispatch through plot-dispatch.sh so the
1684
+ # claim ref exists" is answerable without doing it, and it was violated four
1685
+ # times in one evening by an operator who had read it that evening. See
1686
+ # CLAUDE.md § Gates Over Rules.
1687
+ #
1688
+ # WHY THIS SCRIPT CAN. It already enumerates worktrees and local refs for the
1689
+ # in-flight collision report, for the reason documented above that report:
1690
+ # dispatch is inherently machine-specific — it creates worktrees on THIS
1691
+ # machine, so a check blind to this machine is blind precisely where it acts.
1692
+ # The evidence was already being collected; it was simply never asked this
1693
+ # question.
1694
+ #
1695
+ # WHAT COUNTS AS HELD needs both halves, because either alone is wrong:
1696
+ #
1697
+ # * A WORKTREE MUST EXIST — found by ASKING GIT which one holds the branch,
1698
+ # never by rebuilding the path from the branch name. Without one there is no
1699
+ # desk and nobody at it, and a local branch on its own is not a hold: plenty
1700
+ # exist for other reasons.
1701
+ #
1702
+ # * IT MUST HOLD WORK THAT HAS NOT LANDED — in a commit or in the working
1703
+ # tree, and the working tree is checked first because no commit carries it.
1704
+ # Several leftover worktrees on merged branches sit on this disk (6 of 36
1705
+ # when this was written); their work landed and the directory was never
1706
+ # removed. Refusing those would make the gate fire on exactly the branches
1707
+ # that are safe, which is the fastest way to teach an operator to route
1708
+ # around it.
1709
+ #
1710
+ # THE TWO SHAPES ARE NOT ONE QUESTION, and treating them as one is how the first
1711
+ # version of this gate shipped a hole. `--is-ancestor` against `origin/<main>`
1712
+ # answers for the merged leftover AND — identically — for a worktree cut minutes
1713
+ # ago: its branch points at whatever main was then, so it is an ancestor
1714
+ # trivially. Both read `ahead=0, behind=N`, and no walk of the history separates
1715
+ # them. Only the FILES do, which is why `uncommitted_files` is consulted before
1716
+ # the ancestry test rather than instead of it.
1717
+ #
1718
+ # NOT MERGE-BASE, ANCESTRY. A branch rebased onto a newer main is not behind
1719
+ # it, and the merged question is only ever "is this tip already in main".
1720
+ #
1721
+ # LOCAL REF, not `origin/<branch>`. Reading the remote here would reproduce the
1722
+ # scan's blind spot inside the fix.
1723
+ #
1724
+ # `--allow-local` DOES NOT REACH HERE, and must never be wired to. That flag is
1725
+ # the named escape for a repo whose `origin/<main>` cannot be resolved, and it
1726
+ # says something about reading a PHASE — nothing whatever about whether a human
1727
+ # is mid-edit in a worktree. It is absent from this function by design, not by
1728
+ # oversight; a test pins that the refusal survives it.
1729
+ held_worktree() { # $1=branch → prints the worktree path when held, else nothing
1730
+ local br="$1" wt
1731
+ # ASK GIT WHICH WORKTREE HOLDS THE BRANCH. Do not reconstruct the path from
1732
+ # the branch name.
1733
+ #
1734
+ # MEASURED, after a first version did exactly that via `worktree_for`. Every
1735
+ # hand-made worktree on this machine is named `plot-wt-<last-segments>` with
1736
+ # the branch TYPE dropped — `plot-wt-a-branch-row-carries-its-link` for
1737
+ # `bug/a-branch-row-carries-its-link`, where dispatch's own rule would say
1738
+ # `plot-wt-bug-a-branch-row-carries-its-link`. A path-guessing gate therefore
1739
+ # missed a worktree with six modified files in it.
1740
+ #
1741
+ # And it missed it in the WORST POSSIBLE POPULATION: worktrees dispatch did
1742
+ # not create are precisely the ones carrying no claim ref, which is the entire
1743
+ # reason this gate exists. A check that only recognises its own naming
1744
+ # convention can only catch the branches that were already claimed.
1745
+ #
1746
+ # `git worktree list --porcelain` emits `worktree <path>` then `branch
1747
+ # refs/heads/<name>` per entry, so the branch line is matched and the path
1748
+ # remembered from the preceding line. A detached worktree has no branch line
1749
+ # and never matches, which is right: it holds no branch to hold.
1750
+ wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$br" '
1751
+ /^worktree / { path = substr($0, 10) }
1752
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
1753
+ [ -n "$wt" ] || return 1
1754
+ # A registered worktree whose directory is gone (removed by hand, not via
1755
+ # `git worktree remove`) holds nobody. `status` cannot be read there anyway.
1756
+ [ -d "$wt" ] || return 1
1757
+
1758
+ # UNCOMMITTED WORK IS UNLANDED WORK, and it is asked FIRST because the commit
1759
+ # history cannot see it at all.
1760
+ #
1761
+ # MEASURED ON THIS REPO, after the tip check below was already written and
1762
+ # green. `plot-wt-a-branch-row-carries-its-link` held six modified files for a
1763
+ # live agent and carried NO COMMIT YET: its branch sat at the main tip of the
1764
+ # moment the worktree was cut, so `--is-ancestor` answered "already landed"
1765
+ # and the gate offered the branch. Three sibling worktrees were in the same
1766
+ # shape. That is the plan's own failure — a second agent onto occupied work —
1767
+ # re-entering through the one shape a tip-based check cannot see.
1768
+ #
1769
+ # A freshly cut worktree is `ahead=0, behind=N`: indistinguishable by history
1770
+ # from the merged leftover the gate must NOT refuse. The file state is what
1771
+ # separates them, and `uncommitted_files` was already collecting it for the
1772
+ # in-flight report a few lines up.
1773
+ [ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
1774
+
1775
+ # Its tip landed already — a leftover desk, not a held one.
1776
+ git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
1777
+ printf '%s' "$wt"
1778
+ }
1779
+
1780
+ # The refusal, printed identically by --dry-run and the real run.
1781
+ #
1782
+ # IDENTICAL BY CONSTRUCTION, via one function called from both loops rather
1783
+ # than two messages that agree today. A dry run that offers what a real run
1784
+ # would refuse is worse than no dry run: it is the same wrong answer with a
1785
+ # reassurance attached.
1786
+ #
1787
+ # It NEVER CLAIMS on the operator's behalf. Writing a claim ref for a worktree
1788
+ # this script did not create puts a record in git nobody asked for, and a stale
1789
+ # ref is worse than an absent one — the reaper cannot tell it from a real claim.
1790
+ # So the gate reports and stops, and the operator decides.
1791
+ report_held() { # $1=branch $2=worktree
1792
+ echo "skipped $1 (held — worktree exists with unlanded work)"
1793
+ echo " worktree: $2"
1794
+ echo " nobody claimed it, so nothing here can tell a live agent from an"
1795
+ echo " abandoned desk. Check it, then remove the worktree or let it finish."
1796
+ }
1797
+
1798
+ # Every local branch that holds files, with what it holds.
1799
+ #
1800
+ # LOCAL branches, because worktrees share one ref database: `git rev-parse`
1801
+ # answers from the main repo for a branch checked out elsewhere, so a sibling
1802
+ # agent's unpushed commits are readable from here without visiting its worktree.
1803
+ #
1804
+ # Emits "branch<TAB>file,file,…" per branch that holds at least one file.
1805
+ # A branch holding nothing emits nothing — a bare claim marker is an empty
1806
+ # commit, and reporting "holds " with no files would be worse than silence.
1807
+ work_in_flight() { # $1=branch to exclude (the candidate)
1808
+ local exclude="${1:-}" br files
1809
+ # bash 3.2 on macOS: no `declare -A`, so this accumulates into a plain string
1810
+ # rather than a map. Sorted output keeps the report stable between runs.
1811
+ git for-each-ref --format='%(refname:short)' refs/heads/ </dev/null 2>/dev/null \
1812
+ | while read -r br; do
1813
+ [ -n "$br" ] || continue
1814
+ [ "$br" = "$exclude" ] && continue
1815
+ [ "$br" = "$MAIN" ] && continue
1816
+ files=$( { committed_files "$br"; uncommitted_files "$(worktree_for "$br")"; } \
1817
+ | grep -v -x -F "$ARTIFACT_PATH" \
1818
+ | sort -u \
1819
+ | tr '\n' ',' | sed 's/,$//' )
1820
+ [ -n "$files" ] || continue
1821
+ printf '%s\t%s\n' "$br" "$files"
1822
+ done
1823
+ }
1824
+
1825
+ # Print the in-flight report for one candidate, indented under its line.
1826
+ #
1827
+ # Silent when nothing is held. A report that always prints something teaches
1828
+ # the reader to skip it, and then it is worth nothing on the day it matters.
1829
+ #
1830
+ # BOUNDED, because measured on this repo it was not. The first run against real
1831
+ # state printed 13 branches under a single candidate, one of them naming 18
1832
+ # paths — the same "ignored by the third time" failure the design warns about,
1833
+ # arriving as volume rather than as false positives. Both caps are plain
1834
+ # truncation with the remainder counted, never a judgment about which branch or
1835
+ # file matters: nothing here can know that, and pretending to would be the
1836
+ # candidate-side prediction this design refuses.
1837
+ #
1838
+ # The full list stays one command away, and the line says which.
1839
+ IN_FLIGHT_MAX_FILES=6
1840
+ IN_FLIGHT_MAX_BRANCHES=8
1841
+
1842
+ report_in_flight() { # $1=candidate branch
1843
+ local br files shown extra n=0 total
1844
+ total=$(work_in_flight "$1" | wc -l | tr -d ' ')
1845
+ [ "${total:-0}" -gt 0 ] || return 0
1846
+
1847
+ work_in_flight "$1" | while IFS=$'\t' read -r br files; do
1848
+ n=$((n + 1))
1849
+ if [ "$n" -gt "$IN_FLIGHT_MAX_BRANCHES" ]; then
1850
+ # Said once, on the last line, rather than per branch.
1851
+ [ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
1852
+ echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
1853
+ "— plot-fleet for the full picture"
1854
+ continue
1855
+ fi
1856
+ # Commas to ", " for reading; the machine-countable summary is the footer,
1857
+ # so this line is allowed to be prose.
1858
+ shown=$(printf '%s' "$files" | tr ',' '\n' | head -"$IN_FLIGHT_MAX_FILES" \
1859
+ | tr '\n' ',' | sed -e 's/,$//' -e 's/,/, /g')
1860
+ # `printf '%s'` writes no trailing newline, so `wc -l` counts SEPARATORS
1861
+ # and undercounts the last field by one. Terminating the stream with
1862
+ # printf '\n' is what makes the remainder exact — it reported "+2 more"
1863
+ # for nine files with six shown until a test pinned the arithmetic.
1864
+ extra=$(( $(printf '%s\n' "$files" | tr ',' '\n' | wc -l) - IN_FLIGHT_MAX_FILES ))
1865
+ [ "$extra" -gt 0 ] && shown="$shown (+$extra more)"
1866
+ echo " in flight: $br holds $shown"
1867
+ done
1868
+ }
1869
+
1870
+ # A dry run changes nothing, so nothing can go stale — read the whole eligible
1871
+ # set once. (`--next` would loop forever here: without a claim it keeps
1872
+ # returning the same branch.)
1873
+ if [ "$dry_run" = 1 ]; then
1874
+ while read -r br; do
1875
+ [ -n "$br" ] || continue
1876
+ # The gate, BEFORE the "would dispatch" line — this loop's whole output is
1877
+ # a prediction, and predicting a dispatch the real run refuses is the
1878
+ # failure the gate exists to stop.
1879
+ if held=$(held_worktree "$br"); then
1880
+ report_held "$br" "$held"
1881
+ n_skipped=$((n_skipped + 1))
1882
+ continue
1883
+ fi
1884
+ echo "would dispatch $br → $(worktree_for "$br")"
1885
+ report_in_flight "$br"
1886
+ n_dispatched=$((n_dispatched + 1))
1887
+ done < <("$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null)
1888
+ # A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
1889
+ # information about the config — reporting "no workers started" here would be
1890
+ # true and useless, and would train the reader to skip the line on the real
1891
+ # run where it matters. Only the machine field travels.
1892
+ # `skipped` is REAL here, not a constant. A dry run refuses held branches
1893
+ # exactly as the real run does, so its count is a fact about this fleet — and
1894
+ # it was hardcoded to 0 until the gate gave it something to count.
1895
+ echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field)"
1896
+ exit 0
1897
+ fi
1898
+
1899
+ # Ask the fleet scan for eligible-and-unclaimed branches, one at a time.
1900
+ # Re-asking after each claim is deliberate (pull, not push): the answer changes
1901
+ # as we claim, and a list computed up front would go stale mid-fan-out.
1902
+ while :; do
1903
+ [ "$max" -gt 0 ] && [ "$n_dispatched" -ge "$max" ] && break
1904
+
1905
+ branch=$("$script_dir/plot-fleet-scan.sh" $offline --next "$slug" 2>/dev/null) || break
1906
+ [ -n "$branch" ] || break
1907
+ # --next has no memory; if it offers something we already failed on, the
1908
+ # eligible set is exhausted for this run.
1909
+ is_exhausted "$branch" && break
1910
+
1911
+ # Flatten the whole branch name, not just its last segment: feature/api and
1912
+ # bug/api are different work and must not share a worktree (a shared path
1913
+ # also makes --stop act on whichever claimed it first).
1914
+ suffix=$(printf '%s' "$branch" | tr '/' '-')
1915
+ wt="$wt_root/$wt_prefix$suffix"
1916
+
1917
+ if [ "$dry_run" = 1 ]; then
1918
+ echo "would dispatch $branch → $wt"
1919
+ report_in_flight "$branch"
1920
+ n_dispatched=$((n_dispatched + 1))
1921
+ continue
1922
+ fi
1923
+
1924
+ # THE HELD-BRANCH GATE, ahead of every write this loop makes.
1925
+ #
1926
+ # Ahead of the adoption path below in particular: `reusing existing worktree`
1927
+ # is right for a desk THIS script laid out and a worker has since finished
1928
+ # with, and wrong for one an operator opened by hand and is still using — and
1929
+ # by tip alone those two are the same directory. Unlanded work is what
1930
+ # separates them, so the gate asks first and adoption only sees what is left.
1931
+ #
1932
+ # `exhausted` is what makes the refusal terminal: --next has no memory and
1933
+ # would keep offering this same branch until the loop's own break fired.
1934
+ if held=$(held_worktree "$branch"); then
1935
+ report_held "$branch" "$held"
1936
+ n_skipped=$((n_skipped + 1))
1937
+ exhausted+=("$branch")
1938
+ continue
1939
+ fi
1940
+
1941
+ # BEFORE the worktree exists. Once dispatch has created this candidate's
1942
+ # worktree and claim, the candidate is itself work in flight, and a report
1943
+ # taken afterwards would describe the fan-out rather than what preceded it.
1944
+ in_flight=$(report_in_flight "$branch")
1945
+
1946
+ # Adopt an existing worktree rather than duplicating it.
1947
+ if git worktree list --porcelain | grep -qx "worktree $wt"; then
1948
+ echo "reusing existing worktree for $branch → $wt"
1949
+ [ -n "$in_flight" ] && printf '%s\n' "$in_flight"
1950
+ n_reused=$((n_reused + 1))
1951
+ else
1952
+ git worktree add -q -b "$branch" "$wt" "origin/$MAIN" 2>/dev/null || {
1953
+ # Branch exists locally already: attach the worktree to it instead.
1954
+ git worktree add -q "$wt" "$branch" 2>/dev/null || {
1955
+ echo "skipped $branch (cannot create worktree)"
1956
+ n_skipped=$((n_skipped + 1))
1957
+ exhausted+=("$branch")
1958
+ continue
1959
+ }
1960
+ }
1961
+ # THE CLAIM. Rejection means another session won the race; leave its
1962
+ # worktree alone and move on to the next branch.
1963
+ #
1964
+ # The claim carries an EMPTY COMMIT, and that is load-bearing. Pushing a
1965
+ # branch that merely points at origin/<main> is a no-op: the remote already
1966
+ # has that commit, so the push succeeds with "Everything up-to-date" and
1967
+ # BOTH dispatchers believe they own the branch. Mutual exclusion requires
1968
+ # the refs to diverge — two independent claim commits are not fast-forwards
1969
+ # of each other, so the second push is rejected as non-fast-forward.
1970
+ #
1971
+ # Never add --force or --force-with-lease here: forcing is precisely what
1972
+ # would let a second dispatcher take a branch someone is working on.
1973
+ git -C "$wt" -c "user.name=${PLOT_CLAIM_WHO:-$(git config user.name || echo plot)}" \
1974
+ commit -q --allow-empty -m "plot: claim $branch" 2>/dev/null
1975
+ if git -C "$wt" push -q -u origin "$branch" 2>/dev/null; then
1976
+ echo "dispatched $branch → $wt"
1977
+ # Reported AFTER the claim, never before: a branch another dispatcher won
1978
+ # is not this run's to describe. The facts themselves were read before the
1979
+ # worktree existed, so the claim cannot have polluted them.
1980
+ [ -n "$in_flight" ] && printf '%s\n' "$in_flight"
1981
+ n_dispatched=$((n_dispatched + 1))
1982
+ # AFTER the claim push, never before. A Started: record for a branch
1983
+ # another dispatcher won would be a lie in the file, and the claim is the
1984
+ # only thing that decides who holds a branch.
1985
+ claimed_now+=("$branch")
1986
+ else
1987
+ echo "skipped $branch (claimed by another session)"
1988
+ git worktree remove --force "$wt" 2>/dev/null || true
1989
+ n_skipped=$((n_skipped + 1))
1990
+ exhausted+=("$branch")
1991
+ continue
1992
+ fi
1993
+ fi
1994
+
1995
+ if [ "$no_start" = 0 ]; then
1996
+ # THE BRIEF GATE, between preparing and starting. Prepared work above stands;
1997
+ # only the launch is conditional. A missing brief refuses, naming the file
1998
+ # and the two ways forward — write it, or pass --no-brief. --no-brief starts
1999
+ # anyway and SAYS SO, so the override is on the record rather than silent.
2000
+ #
2001
+ # The refusal names the REF it looked at, not a bare path. A brief sitting
2002
+ # unpushed in the operator's checkout is the likeliest reason to see this
2003
+ # message, and "no brief at .plot/briefs/x.md" would send them to look at a
2004
+ # file that is right there — the ref says where the WORKER will look.
2005
+ if brief_present "$branch"; then
2006
+ start_worker "$branch" "$wt" && n_started=$((n_started + 1))
2007
+ elif [ "$no_brief" = 1 ]; then
2008
+ echo " no brief at $(brief_ref "$branch") — starting anyway (--no-brief)"
2009
+ start_worker "$branch" "$wt" && n_started=$((n_started + 1))
2010
+ else
2011
+ echo " prepared, not started — no brief at $(brief_ref "$branch")"
2012
+ echo " write one: /plot-implement $slug (then push it, or pass --no-brief to start without it)"
2013
+ fi
2014
+ fi
2015
+ done
2016
+
2017
+ # Book AFTER the fan-out, in one commit, so a booking that fails cannot leave
2018
+ # the plan claiming starts the run did not achieve. Its failure is reported and
2019
+ # then ignored: the summary below reports what was dispatched either way.
2020
+ book_started ${claimed_now[@]+"${claimed_now[@]}"} || true
2021
+
2022
+ # Check if we exceeded the cap and raise it if so — see "THE CAP GATES
2023
+ # AUTO-DISPATCH AND WARNS A PERSON" above. Done AFTER workers are started so
2024
+ # the count reflects the true state, and BEFORE the summary so the warning
2025
+ # appears before the footer.
2026
+ check_and_update_cap "$n_started"
2027
+
2028
+ print_summary "$n_dispatched" "$n_reused" "$n_skipped" "$n_started"