@plot-pm/board 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-dispatch.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # Plot helper: fan out one worktree + one worker per eligible branch.
2
+ # Plot helper: hand one slice + its brief to the registry per eligible branch.
3
3
  # Usage: plot-dispatch.sh [--dry-run] [--no-start] [--no-brief] [--offline]
4
4
  # [--max N] [--allow-local] <slug>
5
5
  # plot-dispatch.sh --migrate [--yes] [--max N]
@@ -22,29 +22,42 @@
22
22
  # config — without one there is no destination. --dry-run by
23
23
  # default; --yes to actually move.
24
24
  # --dry-run print what would happen; create nothing, push nothing
25
+ # --monitors with --dry-run, also name which monitors would be attached to
26
+ # which worktree. Opt-in so the default --dry-run output stays
27
+ # byte-identical, which is what lets it be diffed against a run
28
+ # from before a change to this script.
25
29
  # --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.
30
+ # --no-start record that no worker was wanted: the run still hands its slices
31
+ # over, and reports `worker=suppressed` rather than a missing
32
+ # `Worker command`. Dispatch starts no worker either way the
33
+ # registry spawns agents so this now says WHY the zero was
34
+ # chosen and no longer changes what the run does.
35
+ # --no-brief hand a slice over even when its branch has no brief. The named
36
+ # escape for the brief gate: a missing brief is not handed over,
37
+ # because the agent's first instruction is to read
38
+ # `.plot/briefs/<branch>.md` and it has nothing to read. A refused
39
+ # slice leaves no desk and no claim and stays in the queue.
40
+ # --no-brief overrides that and says so.
32
41
  # --offline skip `git fetch`
33
42
  # --max N dispatch at most N branches this run (default: all eligible)
34
43
  # --allow-local read the plan's phase from the working tree when
35
44
  # origin/<main> cannot be resolved (no remote, fresh clone).
36
45
  # The explicit escape for a remote-less repo — never a default,
37
46
  # because a working-tree read is what this gate exists to avoid.
47
+ # --allow-waiting dispatch a branch whose `waits:` prerequisite has not
48
+ # merged. The named escape for the prerequisite gate, in the
49
+ # tradition of --allow-local: a gate with no exit is one people
50
+ # route around by never annotating at all. It says so on the
51
+ # line it overrides, so the override is on the record.
38
52
  # <slug> the plan to fan out
39
53
  # Output: one line per branch, each optionally followed by an indented
40
54
  # `in flight:` line naming a branch that already holds files, then the
41
- # summary block — an optional prose consequence line, then a
42
55
  # machine-countable footer.
43
56
  # A branch whose worktree exists with UNMERGED work is refused rather
44
- # than dispatched — counted `skipped`, with the worktree path named,
57
+ # than handed over — counted `skipped`, with the worktree path named,
45
58
  # 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
59
+ # handed over feature/one the registry
60
+ # summary: dispatched=2 reused=0 skipped=1 started=0 brief=missing worker=unconfigured brief_asked=0
48
61
  #
49
62
  # THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
50
63
  # always said "no 'Worker command' configured" beside the branch it could not
@@ -77,6 +90,23 @@
77
90
  # over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
78
91
  # and a gate that blocks looking-before-leaping is a gate in the wrong place.
79
92
  #
93
+ # `brief_asked=N` counts what the script did about it, and the distinction from
94
+ # `brief=missing` is exact: this script still writes no brief, it ASKS one to be
95
+ # written. `Brief command` names how to run an agent headless for one prompt,
96
+ # and the prompt is `/plot-implement <slug>` — the skill that already owns brief
97
+ # authorship. Absent key, or `none`: nothing is asked, the branch is refused as
98
+ # before, and the per-branch line names `no-brief-command` so the log says which
99
+ # arm ran. A project that never sets the key sees `brief_asked=0` and today's
100
+ # behaviour exactly.
101
+ #
102
+ # `brief_asked=N` COUNTS COMMANDS STARTED, NEVER BRIEFS WRITTEN. The command is
103
+ # detached by design and this script never waits on it, so a command that dies
104
+ # in its first millisecond is counted the same as one that writes and pushes a
105
+ # brief. Measured 2026-09-02: a `Brief command` that could not reach
106
+ # `/plot-implement` wrote a 33-byte log and the summary reported
107
+ # `brief_asked=1`. The per-branch line names the log for exactly this reason —
108
+ # the log is the evidence, the count is only that an attempt was made.
109
+ #
80
110
  # THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
81
111
  # (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
82
112
  # write here is either idempotent or refused:
@@ -113,6 +143,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
113
143
  # shellcheck source=plot-worker-state.sh
114
144
  . "$script_dir/plot-worker-state.sh"
115
145
 
146
+ # The ONE answer to "did the host merge ANY PR for this branch?" — `pr_merged`,
147
+ # read by `held_worktree` rather than derived from ancestry. Sourced for the
148
+ # same reason `plot-reap.sh` and `plot-release-refs.sh` source it: three callers
149
+ # gate on one fact and must never disagree about it. The helper defines two
150
+ # functions and does nothing else on load.
151
+ # shellcheck source=plot-pr-merged.sh
152
+ . "$script_dir/plot-pr-merged.sh"
153
+
116
154
  # ---------------------------------------------------------------------------
117
155
  # WHERE THE WORKTREES LIVE, and by what name
118
156
  # ---------------------------------------------------------------------------
@@ -155,6 +193,7 @@ resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
155
193
  }
156
194
 
157
195
  dry_run=0
196
+ show_monitors=0
158
197
  no_start=0
159
198
  no_brief=0
160
199
  mode=dispatch
@@ -162,12 +201,23 @@ stop_branch=""
162
201
  restart_branch=""
163
202
  offline=""
164
203
  allow_local=0
204
+ allow_waiting=0
165
205
  max=0
166
206
  slug=""
167
207
  migrate_yes=0
168
208
  while [ $# -gt 0 ]; do
169
209
  case "$1" in
170
210
  --dry-run) dry_run=1 ;;
211
+ # --monitors NAMES what would be attached, and it is OPT-IN for a reason
212
+ # that is a protection rather than a preference. `plot-dispatch.sh` is the
213
+ # largest script here and a mistake in start_worker starts no workers at
214
+ # all, so this slice pins `--dry-run` output as BYTE-IDENTICAL before and
215
+ # after on the same estate — the dry run exercises every refusal against
216
+ # real worktrees and real pids without starting or removing anything, which
217
+ # is the same protection the reap and dispatch domain work used. A line
218
+ # added to the default output would forfeit exactly that check. So the
219
+ # naming lives behind its own flag: `--dry-run --monitors`.
220
+ --monitors) show_monitors=1 ;;
171
221
  --status) mode=status ;;
172
222
  --migrate) mode=migrate ;;
173
223
  --yes) migrate_yes=1 ;;
@@ -184,12 +234,13 @@ while [ $# -gt 0 ]; do
184
234
  --no-brief) no_brief=1 ;;
185
235
  --offline|--no-fetch) offline="--offline" ;;
186
236
  --allow-local) allow_local=1 ;;
237
+ --allow-waiting) allow_waiting=1 ;;
187
238
  --max) max="${2:?--max needs a value}"
188
239
  case "$max" in
189
240
  ''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
190
241
  esac
191
242
  shift ;;
192
- -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
243
+ -h|--help) sed -n '2,46p' "$0"; exit 0 ;;
193
244
  *) slug="$1" ;;
194
245
  esac
195
246
  shift
@@ -248,6 +299,22 @@ json_escape() {
248
299
  #
249
300
  # Model and context are still absent on purpose: they belong to the runtime and
250
301
  # are read from the transcript, so a manifest that named them would be a guess.
302
+ #
303
+ # `resumeId` AND `session` ARE TWO FIELDS THAT HOLD ONE VALUE AT LAUNCH, and
304
+ # they are written separately on purpose. `session` is the transcript join key
305
+ # and STAYS FIXED across a branch hop, by design — `plot-worker-loop.sh` rewrites
306
+ # `branch` and `worktree` on each hop and leaves `session` alone. The resume
307
+ # handle is a different identity with a different lifetime, and whether it should
308
+ # follow a hop cannot even be ASKED while one field carries both meanings. They
309
+ # will usually agree; nothing may assume they always do.
310
+ #
311
+ # `attempts` IS THE SUPERVISOR'S OWN COUNTER, DISTINCT FROM `relaunches`.
312
+ # `relaunches` counts operator-initiated restarts — a human's record, written by
313
+ # the launch stamp. `attempts` counts a supervisor's own retries and is what a
314
+ # bound would read. Merging them would let a person's three manual restarts
315
+ # exhaust an automatic budget, or the reverse. It is written 0 here and by
316
+ # nothing else in this script: no component in Plot raises it yet, and a launch
317
+ # that guessed at one would be recording a retry nobody made.
251
318
  # The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
252
319
  # its own child — see `stamp_manifest_pid`. The dispatcher does not know the
253
320
  # agent pid at this line (only the wrapper does, from its `$!`), so it writes the
@@ -260,10 +327,12 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
260
327
  {
261
328
  printf '{\n'
262
329
  printf ' "session": "%s",\n' "$(json_escape "$2")"
330
+ printf ' "resumeId": "%s",\n' "$(json_escape "$2")"
263
331
  printf ' "branch": "%s",\n' "$(json_escape "$3")"
264
332
  printf ' "worktree": "%s",\n' "$(json_escape "$4")"
265
333
  printf ' "command": "%s",\n' "$(json_escape "$5")"
266
334
  printf ' "pid": "",\n'
335
+ printf ' "attempts": 0,\n'
267
336
  printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
268
337
  printf '}\n'
269
338
  } > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
@@ -302,11 +371,16 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
302
371
  #
303
372
  # READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
304
373
  # 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.
374
+ # but "will the AGENT find one?". The agent cuts or resets its desk from
375
+ # `origin/$MAIN`, so a brief committed nowhere — or committed locally and never
376
+ # pushed — is invisible to it. Checking the working tree passes the gate and
377
+ # hands over a slice whose specification is empty, which is the exact failure
378
+ # this gate exists to prevent.
379
+ #
380
+ # THAT HOLDS ACROSS THE GATE'S MOVE, and is why the move cost this function
381
+ # nothing: the desk was cut from `origin/$MAIN` when dispatch cut it and is cut
382
+ # from `origin/$MAIN` now that the agent does. The ref the brief must be on did
383
+ # not change, only who reads it there.
310
384
  #
311
385
  # Both directions were measured 2026-08-27. Running the filesystem check from a
312
386
  # checkout 8 commits behind main reported three branches' briefs missing while
@@ -326,6 +400,121 @@ brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
326
400
  [ "${sz:-0}" -gt 0 ]
327
401
  }
328
402
 
403
+ # WHAT HAPPENS AFTER THE GATE FIRES. The gate above is correct and stays: a
404
+ # missing brief still prepares and still refuses to start. What it never had is
405
+ # a next step — it named the file, and every brief on this estate was then
406
+ # written by hand.
407
+ #
408
+ # `Brief command` is that step, and it is a CONFIG KEY rather than a new script
409
+ # for one reason: `/plot-implement` step 4 already owns brief authorship. A
410
+ # script here would be a SECOND brief writer, and two writers drift. So the key
411
+ # names how to run an agent headless — the shape `Idea command`, `Story command`
412
+ # and `Approve command` already use — and the prompt it is handed asks for
413
+ # `/plot-implement <slug>` and nothing else.
414
+ #
415
+ # ABSENT IS NOT AN ERROR. A project with no `Brief command` behaves exactly as
416
+ # it does today: the gate refuses, and the refusal now names WHY nothing was
417
+ # called — `no-brief-command`, the shape `commission.ts` gives `no-idea-command`.
418
+ # `none` reads the same way as it does for `Worker command`: asked, and answered
419
+ # "we write them by hand".
420
+ brief_command() { # → the usable `Brief command`, or empty
421
+ local cmd
422
+ cmd=$("$script_dir/plot-config.sh" get "Brief command" "")
423
+ case "$cmd" in none|NONE|None) cmd="" ;; esac
424
+ printf '%s' "$cmd"
425
+ }
426
+
427
+ # Run the brief command for one branch, detached, and say what was done.
428
+ #
429
+ # DETACHED AND NOT WAITED ON, for the reason `commission.ts` gives: this is a
430
+ # `claude -p` session of unknown length, and a fan-out that blocks on one would
431
+ # hold every later branch behind it. The dispatch run reports that it asked; the
432
+ # brief lands in a later commit, and the NEXT dispatch of the same branch starts
433
+ # it. That is the whole loop.
434
+ #
435
+ # The prompt travels as ONE argument through `"$@"`, never interpolated into the
436
+ # command string — `Brief command` is a shell FRAGMENT run through `sh -c`, so
437
+ # anything spliced into it would be shell source. The slug is the only value
438
+ # that reaches it and it is a plan slug, but the rule holds regardless of the
439
+ # value: the safety is in the shape, not in the input.
440
+ # WHAT THE AGENT IS ASKED FOR, and the one thing it must not be asked for.
441
+ #
442
+ # It is asked to run `/plot-implement <slug>`, and it is NOT asked to write a
443
+ # brief in its own words. `/plot-implement` step 4 owns brief authorship; a
444
+ # prompt that described the brief here would be a second author, and two
445
+ # authors drift. The branch and the path are named because the skill writes for
446
+ # one branch and the gate reads one path — `brief_path` is the same function
447
+ # `brief_present` reads with, so writer and reader cannot disagree.
448
+ #
449
+ # It is told to COMMIT AND PUSH, because the gate reads `origin/<main>`. A brief
450
+ # written and left in a working tree is invisible to the gate that asked for it,
451
+ # and the next dispatch would ask again — a loop that writes a file every pass
452
+ # and never starts a worker.
453
+ brief_prompt() { # $1 = branch, $2 = slug
454
+ printf '/plot-implement %s — write the hand-off brief for branch `%s` at %s, then commit and push it to %s. The dispatch gate reads that path on origin/%s, so a brief left uncommitted is invisible to it.' \
455
+ "$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
456
+ }
457
+
458
+ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
459
+ local branch="$1" bslug="$2" cmd log
460
+ cmd=$(brief_command)
461
+ if [ -z "$cmd" ]; then
462
+ echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
463
+ return 1
464
+ fi
465
+ log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
466
+ mkdir -p "$(dirname "$log")" 2>/dev/null || true
467
+ # `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
468
+ # this outlives the dispatch run by design, because the fan-out must not block
469
+ # on a `claude -p` session of unknown length. `setsid` is not used — it does
470
+ # not exist on macOS, where most of this fleet runs.
471
+ ( cd "$repo_root" \
472
+ && PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
473
+ nohup sh -c "$cmd \"\$@\"" plot-brief \
474
+ "$(brief_prompt "$branch" "$bslug")" \
475
+ >"$log" 2>&1 </dev/null & ) 2>/dev/null
476
+ echo " asked the \`Brief command\` to write it — log: $log"
477
+ # SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
478
+ # is detached and never waited on, so this returns 0 the moment it is spawned
479
+ # and a command that fails in its first millisecond still counts. Measured
480
+ # 2026-09-02, first real use: the log held 33 bytes, `Unknown command:
481
+ # /plot-implement`, and the summary still reported `brief_asked=1`. Naming the
482
+ # log as the evidence is what keeps the count from reading as a promise.
483
+ echo " started, not awaited — read the log to see whether it wrote anything"
484
+ echo " dispatch $bslug again once it lands; the gate reads $(brief_ref "$branch")"
485
+ return 0
486
+ }
487
+
488
+ # STALENESS REPORTS AND NEVER REFUSES, and the measurement says why.
489
+ #
490
+ # Compared 2026-09-01, all three live briefs were older than their plans and all
491
+ # three were CORRECT — every plan edit between them was bookkeeping (a PR
492
+ # annotation, a measurement note, a re-measure before approval). A timestamp
493
+ # gate would have refused 3 of 3 on the day it shipped, and a gate that refuses
494
+ # everything is one people disable in its first week.
495
+ #
496
+ # It would also have missed the real case. The teardown brief was written AFTER
497
+ # its plan and was still wrong, citing 80 `fs.rmSync` sites where the tree held
498
+ # 76 — the CODE moved, not the plan. Freshness against the plan is the wrong
499
+ # input: a brief's claims are about the repository, and nothing here compares
500
+ # those to the repository. What would actually gate is judgement about which
501
+ # numbers in a paragraph are claims, which grep cannot reach.
502
+ #
503
+ # So this prints a hint and SAYS it is a hint, naming the plan commit it
504
+ # compared against so the reader can look at that commit rather than guess.
505
+ brief_staleness_note() { # $1 = branch → prints a hint, or nothing
506
+ local branch="$1" bc pc bs ps
507
+ [ -n "$gate_sha" ] || return 0 # nothing shared to compare against
508
+ [ -n "${plan_path:-}" ] || return 0
509
+ bc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$(brief_path "$branch")" 2>/dev/null) || return 0
510
+ pc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$plan_path" 2>/dev/null) || return 0
511
+ [ -n "$bc" ] && [ -n "$pc" ] || return 0
512
+ bs=${bc##* }; ps=${pc##* }
513
+ [ "$bs" -lt "$ps" ] 2>/dev/null || return 0
514
+ echo " brief older than the plan — a HINT, not a gate: the plan may have moved, or the edit may have been bookkeeping"
515
+ echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
516
+ }
517
+
329
518
  start_worker() {
330
519
  local branch="$1" wt="$2"
331
520
  local cmd
@@ -473,6 +662,23 @@ start_worker() {
473
662
  # single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
474
663
  # already does.
475
664
  #
665
+ # AND THE WRAPPER WRITES IT ITSELF, from `$$` inside the `sh -c`. Until
666
+ # 2026-08-31 the dispatcher wrote `echo $!` beside the spawn, which named an
667
+ # intermediate subshell rather than the wrapper: three of three live workers
668
+ # measured that day recorded a pid one process above the agent's real parent
669
+ # (7357 against 7358, 71953 against 71954, 92947 against 92949).
670
+ #
671
+ # The cause is that `$!` names the last job THIS shell backgrounded, and with
672
+ # an env-var prefix in front of `nohup` bash cannot collapse the AND-list into
673
+ # one child — it forks a subshell, and that subshell is what `$!` reports.
674
+ # (Without the prefix bash `exec`s the command in place and `$!` is correct,
675
+ # which is why the shape matters and a smaller repro does not show it.)
676
+ #
677
+ # So the same rule the agent pid already follows applies here: THE PROCESS
678
+ # THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
679
+ # ancestor can name it without guessing. It is written FIRST, before the
680
+ # monitors and the agent, so the file exists as early as it can.
681
+ #
476
682
  # The agent runs backgrounded inside the wrapper so the wrapper can capture its
477
683
  # `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
478
684
  # starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
@@ -522,6 +728,62 @@ start_worker() {
522
728
  # after it, then any stale copies of those lines are dropped and `startedAt` is
523
729
  # rewritten to the current run. This is exactly `stampManifest`, line for line,
524
730
  # which the parity test pins byte for byte.
731
+ #
732
+ # EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
733
+ #
734
+ # Three monitors start INSIDE the wrapper, as its children, immediately before
735
+ # the agent: one watches the process (`plot-worker-monitor.sh`), one watches
736
+ # the desk (`plot-agent-monitor.sh`), one watches the run
737
+ # (`plot-build-monitor.sh`). Each has a subject the others do not and a
738
+ # cadence it cannot share — seconds on the process table, minutes on the host,
739
+ # seconds again on a run but only while one is live.
740
+ #
741
+ # WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
742
+ # its agent by construction — it must, or there would be no exit code to
743
+ # write — so a child of it inherits that survival for free. Two processes
744
+ # started SIDE BY SIDE are independently mortal: the monitor could be killed
745
+ # or crash with nothing noticing, which is the failure being fixed one level
746
+ # up. `--stop` kills the agent; the monitors and the exit record survive it.
747
+ #
748
+ # WHY HERE RATHER THAN ANYWHERE ELSE. `start_worker` is the single path to a
749
+ # worker, which is what makes "every worker is born monitored" a gate rather
750
+ # than a rule: there is no other place to forget. Ask CLAUDE.md's test — *can
751
+ # you answer "did I attach a monitor?" without doing the work?* Here you
752
+ # cannot: no monitor start, no monitored worker, and a mutation test says so.
753
+ #
754
+ # ORDER, AND WHY IT IS THIS WAY ROUND. The monitors are backgrounded FIRST so
755
+ # they exist before their subject does; the agent is backgrounded next and
756
+ # `$!` is captured on the VERY NEXT command, because `$!` names the most
757
+ # recent background job and the pid file must name the AGENT. Starting a
758
+ # monitor between the agent and its `$!` would record a monitor's pid as the
759
+ # worker's — the panel bug the two-pid split already exists to prevent.
760
+ #
761
+ # THEY INHERIT THE STARTUP WINDOW RATHER THAN WIDENING IT. There is a
762
+ # sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
763
+ # is written, and a scan landing in it reads `none` — honest. The monitors
764
+ # start inside that same window; they must never turn an unwritten pid file
765
+ # into a `gone` finding, which is why the no-op reads no pid at all and the
766
+ # next slice treats an absent pid file as *not yet*.
767
+ #
768
+ # THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
769
+ # `sh -c` body is single-quoted and a path with spaces would not survive
770
+ # interpolation into it — the same reason the exit, pid and manifest paths are
771
+ # passed this way. An EMPTY value means "not attached", which is what keeps a
772
+ # missing script from turning into `command not found` in a detached shell
773
+ # nobody is reading.
774
+ #
775
+ # A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
776
+ # enforced: this is the only code that starts a wrapper, and a worktree with
777
+ # no wrapper has nothing for a monitor to be a child of.
778
+ local worker_monitor='' agent_monitor='' build_monitor=''
779
+ [ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
780
+ [ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
781
+ # THE THIRD MONITOR, born the same way and for the same reason. It watches the
782
+ # RUN — a Build is its own entity in the spec, so a monitor per entity is the
783
+ # pattern rather than an exception to it. Its cadence is the WorkerMonitor's
784
+ # 30 s rather than the AgentMonitor's 300 s, and it can afford that against a
785
+ # HOST because it asks nothing while no run is live.
786
+ [ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
525
787
  local stamp_now
526
788
  stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
527
789
  ( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
@@ -529,8 +791,12 @@ start_worker() {
529
791
  PLOT_SESSION_ID="$session" \
530
792
  PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
531
793
  PLOT_STAMP_STARTED="$stamp_now" \
794
+ PLOT_WORKER_MONITOR="$worker_monitor" \
795
+ PLOT_AGENT_MONITOR="$agent_monitor" \
796
+ PLOT_BUILD_MONITOR="$build_monitor" \
532
797
  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" '"'"'
798
+ PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
799
+ nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$PLOT_WORKER_MONITOR" ]; then "$PLOT_WORKER_MONITOR" & wmon=$!; fi; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; fi; if [ -n "$PLOT_BUILD_MONITOR" ]; then "$PLOT_BUILD_MONITOR" & bmon=$!; fi; ( '"$cmd"' ) & agent=$!; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" -v wrapper="$$" -v wmon="$wmon" -v amon="$amon" -v bmon="$bmon" '"'"'
534
800
  BEGIN { relaunch = 0; count = 1; stamped = 0 }
535
801
  FNR == NR {
536
802
  if ($0 ~ /^ "pid": "[^"]*",$/) {
@@ -545,18 +811,26 @@ start_worker() {
545
811
  !stamped && $0 ~ /^ "pid": "[^"]*",$/ {
546
812
  stamped = 1
547
813
  print " \"pid\": \"" pid "\","
814
+ print " \"wrapperPid\": \"" wrapper "\","
815
+ print " \"workerMonitorPid\": \"" wmon "\","
816
+ print " \"agentMonitorPid\": \"" amon "\","
817
+ print " \"buildMonitorPid\": \"" bmon "\","
548
818
  if (relaunch) {
549
819
  print " \"previousPid\": \"" displaced "\","
550
820
  print " \"relaunches\": " count ","
551
821
  }
552
822
  next
553
823
  }
824
+ $0 ~ /^ "wrapperPid": "[^"]*",$/ { next }
825
+ $0 ~ /^ "workerMonitorPid": "[^"]*",$/ { next }
826
+ $0 ~ /^ "agentMonitorPid": "[^"]*",$/ { next }
827
+ $0 ~ /^ "buildMonitorPid": "[^"]*",$/ { next }
554
828
  relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
555
829
  relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
556
830
  relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
557
831
  { print }
558
832
  '"'"' "$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" )
833
+ >"$log" 2>&1 </dev/null & )
560
834
  echo " started worker (log: $log)"
561
835
  return 0
562
836
  }
@@ -899,63 +1173,69 @@ if [ "$mode" = "migrate" ]; then
899
1173
  continue
900
1174
  fi
901
1175
 
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.
1176
+ # FOUR READINGS, GATHERED HERE AND DECIDED ELSEWHERE. This block holds no
1177
+ # `if` about whether a worktree may move; it collects what was measured and
1178
+ # `plot-movable.mjs` returns the refusal. The four were shell `if`s until
1179
+ # 2026-09-01, and nothing could trigger one in isolation least of all the
1180
+ # combinations this estate will not produce on demand, a live pid and a
1181
+ # dirty tree at once.
1182
+ #
1183
+ # LIVENESS AND UNLANDED WORK STAY TWO SEPARATE MEASUREMENTS, exactly as they
1184
+ # were: plot_worker_state answers "is a process running or waiting here" and
1185
+ # is keyed on the records a dispatch writes (`.plot-worker.pid`,
1186
+ # `.plot-worker.exit`). A hand-made worktree that never ran one reads `none`
1187
+ # however dirty its tree is and hand-made worktrees are precisely the
1188
+ # estate this mode exists to tidy. The rule reads them as two fields for
1189
+ # that reason.
1190
+ #
1191
+ # plot_worker_state is the ONE liveness answer, sourced by both this script
1192
+ # and the fleet scan. It carries what a bare `ps` cannot — pid-reuse
1193
+ # detection via the manifest's `startedAt`, and the `waiting` state a
1194
+ # PLOT-BLOCKED* marker produces.
918
1195
  wstate_row=$(plot_worker_state "$wt")
919
1196
  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
1197
+ pid=$(printf '%s' "$wstate_row" | cut -f2)
934
1198
 
935
- # REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
936
1199
  # `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.
1200
+ # own bookkeeping do not count), so this reads real work only.
938
1201
  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
1202
 
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".
1203
+ # Only the branch's OWN upstream answers "pushed?". An absent upstream
1204
+ # leaves the field EMPTY, which the rule reads as unanswerable rather than
1205
+ # as zero and an unanswered question is not a refusal, the principle
1206
+ # plot_worker_task_state reached the hard way when counting against
1207
+ # origin/main marked every clean branch stalled in a remote-less repo.
1208
+ ahead=""
951
1209
  if [ -n "$br" ]; then
952
1210
  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 ;;
1211
+ fi
1212
+
1213
+ # THE DECISION. One call per tree, and the answer is a named refusal.
1214
+ #
1215
+ # A rule that cannot be asked REFUSES: a missing `node`, a missing bundle or
1216
+ # a throwing module all leave `mv_verdict` empty, and an empty verdict keeps
1217
+ # the worktree and says the rule could not be asked. Silence is never
1218
+ # permission — and here the permissive direction moves a checkout an agent
1219
+ # may be writing to, which `git worktree move` breaks mid-run.
1220
+ mv_verdict=$(printf '%s\t%s\t%s\t%s' "$state" "$pid" "$dirty" "$ahead" \
1221
+ | node "$script_dir/board/plot-movable.mjs" 2>/dev/null || true)
1222
+ mv_refusal=${mv_verdict%%$'\t'*}
1223
+ mv_detail=${mv_verdict#*$'\t'}
1224
+
1225
+ # RENDERING, not deciding. The rule named the measurement; this names what
1226
+ # it means to someone reading the table, which is the caller's half because
1227
+ # only the caller knows it is printing one.
1228
+ if [ "$mv_refusal" != "move" ]; then
1229
+ case "$mv_refusal" in
1230
+ live-worker) reason="worker alive (pid $mv_detail)" ;;
1231
+ blocked-marker) reason="blocked marker — needs a person" ;;
1232
+ uncommitted-changes) reason="uncommitted: $mv_detail" ;;
1233
+ unpushed-commits) reason="unpushed commits ($mv_detail ahead)" ;;
1234
+ *) reason="rule could not be asked — keeping" ;;
958
1235
  esac
1236
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "$reason"
1237
+ n_skipped=$((n_skipped + 1))
1238
+ continue
959
1239
  fi
960
1240
 
961
1241
  # This worktree is idle — it can be moved.
@@ -1205,6 +1485,194 @@ esac
1205
1485
  # MAIN was resolved and origin fetched above, before the phase gate — the gate
1206
1486
  # needs the shared ref to read the plan from it.
1207
1487
 
1488
+ # ---------------------------------------------------------------------------
1489
+ # THE PREREQUISITE GATE: a branch that waits on another plan's branch
1490
+ # ---------------------------------------------------------------------------
1491
+ #
1492
+ # A plan may annotate one of its branches `<!-- waits: <branch> -->`, naming ONE
1493
+ # branch — usually of another plan — that must merge before this one may start.
1494
+ # The parser exposes it as `waves[].branches[].waits_on`; the scan turns it into
1495
+ # the branch states `waiting` and `blocked`, so `--next` already stops OFFERING
1496
+ # such a branch.
1497
+ #
1498
+ # THAT IS ONE HALF, AND THIS IS THE OTHER. An empty offer has nothing to say
1499
+ # about what it filtered out: `plot-dispatch.sh <slug>` answered `dispatched=0`
1500
+ # with no reason attached, which is the same silence `--restart` was built to
1501
+ # break. So this names the branch AND the prerequisite, and it names them in
1502
+ # `--dry-run` identically to a real run.
1503
+ #
1504
+ # IT HAS COST TWO WORKERS. Measured 2026-09-02:
1505
+ # `feature/the-domain-forgets-the-vendor-list` was re-dispatched at 04:50 into a
1506
+ # prerequisite that had not merged, hit its own gate, and wrote a PLOT-BLOCKED
1507
+ # marker. Its report names the cause: *"plot-dispatch.sh gates on the plan's
1508
+ # phase, and this plan is Approved, so the slice read as eligible."*
1509
+ #
1510
+ # THE ANNOTATION IS READ FROM THE SHARED REF, out of the same `gate_meta` the
1511
+ # phase gate parsed. A `waits:` that exists only in this working tree is an
1512
+ # ordering constraint nobody else can see, exactly as a local approval is.
1513
+ #
1514
+ # THE PREREQUISITE IS ASKED OF THE HOST, NEVER OF THE REFS.
1515
+ # `plot-release-refs.sh` deletes the remote refs of a delivered plan's merged
1516
+ # branches, so a prerequisite that SUCCEEDED and was then reaped has no ref —
1517
+ # and a rule reading refs would hold its dependent forever BECAUSE its
1518
+ # dependency succeeded. That is the worst available failure: correct work
1519
+ # producing a permanent block. `pr-state` answers about PULL REQUESTS, and a
1520
+ # merged PR outlives the branch it was cut from.
1521
+ #
1522
+ # `NONE` AND SILENCE ARE DIFFERENT ANSWERS. `NONE` means the host was asked and
1523
+ # has never seen a PR for that branch — a typo, which is `blocked`. A host that
1524
+ # could not be asked is neither permission nor proof of a typo, so it HOLDS the
1525
+ # branch at `waiting`. Both refuse; only one tells the operator to fix the plan.
1526
+
1527
+ # What the host says about the prerequisite's pull requests.
1528
+ #
1529
+ # Four answers, and the last two must never be collapsed — see the header
1530
+ # above. `--offline` promises no network, so it answers `unreachable`: the
1531
+ # question was not put, and a flag that lied would be worse than a slower
1532
+ # answer. The same reasoning `reached_review` applies one screen up.
1533
+ prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
1534
+ local js st
1535
+ [ -z "$offline" ] || { echo unreachable; return; }
1536
+ [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || { echo unreachable; return; }
1537
+ # Exit code first: a non-zero is a transport failure and its stdout is not an
1538
+ # answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
1539
+ # trusted the payload on failure would have started every waiting branch.
1540
+ js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || { echo unreachable; return; }
1541
+ st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
1542
+ case "$st" in
1543
+ MERGED) echo merged ;;
1544
+ NONE) echo none ;;
1545
+ # OPEN and CLOSED both mean the host has SEEN the branch. A closed, unmerged
1546
+ # PR is `unmerged` rather than `none`: nothing is misspelled — somebody
1547
+ # withdrew the work, and that resolves by reopening it, not by editing the
1548
+ # plan.
1549
+ OPEN|CLOSED) echo unmerged ;;
1550
+ # A state word this adapter does not emit, or none at all. Unread is not
1551
+ # answered, and this gate's silence holds rather than permits.
1552
+ *) echo unreachable ;;
1553
+ esac
1554
+ }
1555
+
1556
+ # Every branch the plan annotates `waits:`, with what it waits on — read from
1557
+ # the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`.
1558
+ #
1559
+ # NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
1560
+ # and it outranks a wait for the same reason the scan lets it: a branch nobody
1561
+ # will start does not need to be told what it is waiting for. The two
1562
+ # annotations sit on one line and neither reads the other's value.
1563
+ waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
1564
+ printf '%s' "$gate_meta" | awk '
1565
+ {
1566
+ n = split($0, parts, /\{"branch":"/)
1567
+ for (i = 2; i <= n; i++) {
1568
+ rec = parts[i]
1569
+ br = rec; sub(/".*$/, "", br)
1570
+ if (rec ~ /"deferred":true/) continue
1571
+ if (match(rec, /"waits_on":"[^"]*"/)) {
1572
+ w = substr(rec, RSTART + 12, RLENGTH - 13)
1573
+ if (w != "") print br "\t" w
1574
+ }
1575
+ }
1576
+ }'
1577
+ }
1578
+
1579
+ # THE PREFLIGHT, run once before the fan-out, and it is where the REFUSAL lives.
1580
+ #
1581
+ # WHY IT CANNOT LIVE IN THE LOOP ALONE. The fan-out loop only ever sees what
1582
+ # `plot-fleet-scan.sh` offered, and the scan ALREADY reports a waiting branch as
1583
+ # `waiting` rather than `open` — so it is filtered out before this script hears
1584
+ # of it, and the run ends `dispatched=0 skipped=0` with nothing said about what
1585
+ # was withheld. That silence is the exact defect: an empty offer has nothing to
1586
+ # say about what it filtered out, and a worker was dispatched twice on 2026-09-02
1587
+ # by an operator reading it as "nothing to do here".
1588
+ #
1589
+ # So the plan is walked DIRECTLY. This script already holds the parsed plan from
1590
+ # the shared ref — the same blob its phase gate read — so naming what the fan-out
1591
+ # will not reach costs one host call per annotated branch, on a population of six
1592
+ # plans in 188.
1593
+ #
1594
+ # IT FILLS `waits_held`, WHICH THE LOOPS THEN CONSULT. Two mechanisms, one
1595
+ # decision: this states the refusal, and the loops refuse to write for a branch
1596
+ # it named — belt to that brace, because a scan that could not reach the host
1597
+ # still offers the branch as `open`.
1598
+ declare -a waits_held=()
1599
+ is_waits_held() {
1600
+ local x
1601
+ for x in ${waits_held[@]+"${waits_held[@]}"}; do [ "$x" = "$1" ] && return 0; done
1602
+ return 1
1603
+ }
1604
+
1605
+ # AND IT FILLS `waits_freed`, WHICH IS WHERE `--allow-waiting` GETS ITS
1606
+ # CANDIDATE FROM.
1607
+ #
1608
+ # The override cannot work by relaxing a test in this script, because the branch
1609
+ # never reaches a test here: `plot-fleet-scan.sh` reports a waiting branch as
1610
+ # `waiting` rather than `open`, so `--list-eligible` and `--next` both withhold
1611
+ # it and the loops are handed an empty set. Measured 2026-09-02 — the flag
1612
+ # printed its override line and the run still reported `dispatched=0 skipped=0`,
1613
+ # counting the branch neither way.
1614
+ #
1615
+ # So the flag ADDS a candidate rather than removing a filter. The preflight
1616
+ # already walked the plan from the shared ref and asked the host about the
1617
+ # prerequisite, so it holds the one fact the scan withheld, and naming it here
1618
+ # costs no further call.
1619
+ #
1620
+ # ONLY A BRANCH THE PREFLIGHT ITSELF HELD, and only under the flag. This adds
1621
+ # nothing the scan refused for any OTHER reason — a claimed branch, a `wip` one,
1622
+ # an incomplete prior wave — because those verdicts are not this flag's to
1623
+ # override and the scan remains the only thing that decides them. The branch
1624
+ # still passes every gate the loops apply after it: `held_worktree`, the claim
1625
+ # race, and the brief.
1626
+ declare -a waits_freed=()
1627
+ is_waits_freed() {
1628
+ local x
1629
+ for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
1630
+ return 1
1631
+ }
1632
+
1633
+ # Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
1634
+ # withheld to `n_skipped`.
1635
+ #
1636
+ # NOT A COMMAND SUBSTITUTION, and that is not a style choice. `$( … )` is a
1637
+ # SUBSHELL, so an array filled inside one is discarded on return — the loops
1638
+ # below would consult an empty `waits_held` and the refusal would be a message
1639
+ # with no effect behind it. So this writes its two results into globals and the
1640
+ # caller invokes it plainly.
1641
+ #
1642
+ # PRINTED IDENTICALLY BY --dry-run AND THE REAL RUN, the discipline `report_held`
1643
+ # already sets: a dry run that offers what a real run would refuse is worse than
1644
+ # no dry run — it is the same wrong answer with a reassurance attached.
1645
+ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipped
1646
+ local br prereq answer held
1647
+ while IFS=$'\t' read -r br prereq; do
1648
+ [ -n "$br" ] || continue
1649
+ answer=$(prereq_answer "$prereq")
1650
+ case "$answer" in
1651
+ merged) continue ;;
1652
+ none) held=blocked ;;
1653
+ *) held=waiting ;;
1654
+ esac
1655
+ # `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES. An override nobody can
1656
+ # see in the output is an override nobody can audit.
1657
+ if [ "$allow_waiting" = 1 ]; then
1658
+ echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
1659
+ waits_freed+=("$br")
1660
+ continue
1661
+ fi
1662
+ waits_held+=("$br")
1663
+ n_skipped=$((n_skipped + 1))
1664
+ if [ "$held" = "blocked" ]; then
1665
+ echo "skipped $br (blocked — no PR found for $prereq)"
1666
+ echo " the plan says this branch waits on $prereq, and the host has never"
1667
+ echo " seen a pull request for it. Check the branch name in the plan."
1668
+ else
1669
+ echo "skipped $br (waiting on $prereq)"
1670
+ echo " the plan says this branch waits on $prereq, which has not merged."
1671
+ echo " Dispatch it when that lands, or pass --allow-waiting to start anyway."
1672
+ fi
1673
+ done < <(waits_pairs)
1674
+ }
1675
+
1208
1676
  # Where the worktrees live and what their names carry — see resolve_wt_root.
1209
1677
  # The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
1210
1678
  # key relocates them (and drops the prefix, which was only earning its keep
@@ -1215,6 +1683,7 @@ repo_root=$(git rev-parse --show-toplevel)
1215
1683
  resolve_wt_root "$repo_root"
1216
1684
 
1217
1685
  n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
1686
+ n_brief_asked=0
1218
1687
 
1219
1688
  # Whether this run COULD have started anything, read once and up front.
1220
1689
  #
@@ -1249,31 +1718,20 @@ worker_state_field() {
1249
1718
  fi
1250
1719
  }
1251
1720
 
1252
- # The summary block: an optional prose line, then the machine-countable footer.
1721
+ # The summary: the machine-countable footer, and nothing above it.
1722
+ #
1723
+ # THE PROSE LINE IS GONE, BY ITS OWN RULE. It existed to explain a zero that
1724
+ # had a cause worth naming — a `Worker command` nobody had configured, read off
1725
+ # a run that had prepared desks and staffed none. Dispatch starts no worker at
1726
+ # all now, so `started=0` is structural: the line would print on every run,
1727
+ # always true and never informative. `--dry-run` was held to exactly this rule
1728
+ # from the start — *"a dry run starts nothing BY CONSTRUCTION, so it explains
1729
+ # nothing"* — and the fan-out has become the same case.
1253
1730
  #
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.
1731
+ # `worker=` still travels in the footer. It says how this repo is configured,
1732
+ # which remains a fact about the repo even where it no longer explains a count.
1259
1733
  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"
1734
+ echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
1277
1735
  }
1278
1736
 
1279
1737
  # ---------------------------------------------------------------------------
@@ -1345,7 +1803,7 @@ update_parallel_agents_cap() { # $1 = new cap
1345
1803
  if [ -f "$FLEET_CONTROLS_FILE" ]; then
1346
1804
  # Preserve the existing autoDispatch setting
1347
1805
  local existing
1348
- existing=$(sed -n 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
1806
+ existing=$(sed -nE 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*(true|false).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
1349
1807
  [ -n "$existing" ] && auto_dispatch="$existing"
1350
1808
  fi
1351
1809
 
@@ -1470,14 +1928,39 @@ write_started_record() { # $@ = branches
1470
1928
  fi
1471
1929
 
1472
1930
  if [ -f "$tmpwt/$rel" ]; then
1473
- local br
1931
+ local br wrote=0
1474
1932
  for br in "$@"; do
1933
+ # A BRANCH ALREADY RECORDED IS NOT RECORDED AGAIN, and this check is what
1934
+ # the CLAIM used to do. Dispatch pushed a claim, a claimed branch was
1935
+ # never offered again, and a second run therefore booked nothing — the
1936
+ # idempotence was a side effect of the lock rather than a property of the
1937
+ # record. The claim went with the fan-out's writes, so the record owns its
1938
+ # own idempotence now: a plan dispatched three times must not read as
1939
+ # started three times, or the count drifts from the refs it describes.
1940
+ #
1941
+ # MATCHED ON THE BRANCH IN BACKTICKS, the shape `append_started_line`
1942
+ # writes and `plot-plan-meta.sh` parses. A bare substring match would let
1943
+ # `feature/api` find itself inside `feature/api-v2`.
1944
+ if grep -qF -- "\`$br\`" "$tmpwt/$rel" 2>/dev/null \
1945
+ && grep -q -- "Started:.*\`$br\`" "$tmpwt/$rel" 2>/dev/null; then
1946
+ continue
1947
+ fi
1475
1948
  append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
1476
1949
  echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
1477
1950
  rc=1
1478
1951
  break
1479
1952
  }
1953
+ wrote=1
1480
1954
  done
1955
+ # NOTHING NEW TO SAY IS NOT A FAILURE. Every branch this run handed over was
1956
+ # already on the record, so there is no commit to make and no push to
1957
+ # attempt — and a run that pushed an empty commit would leave one per
1958
+ # re-dispatch on the default branch.
1959
+ if [ "$rc" = 0 ] && [ "$wrote" = 0 ]; then
1960
+ git worktree remove --force "$tmpwt" 2>/dev/null || true
1961
+ git branch -D "$bookbr" >/dev/null 2>&1 || true
1962
+ return 0
1963
+ fi
1481
1964
  if [ "$rc" = 0 ]; then
1482
1965
  git -C "$tmpwt" add -- "$rel" 2>/dev/null
1483
1966
  git -C "$tmpwt" -c "user.name=$who" commit -q \
@@ -1538,6 +2021,23 @@ real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relativ
1538
2021
  append_started_line() { # $1=file $2=date $3=who $4=branch
1539
2022
  local f="$1" line
1540
2023
  line="- **Started:** $2, $3, \`$4\`"
2024
+
2025
+ # ALREADY RECORDED IS NOT AN ERROR — it is a second dispatch of the same
2026
+ # slice, and this returns 0 having written nothing.
2027
+ #
2028
+ # THE CLAIM USED TO BE THIS GUARD. A dispatched branch was claimed by a ref
2029
+ # push, so `--next` never offered it twice and a re-run booked nothing. The
2030
+ # hand-over pushes no claim, so the branch stays `open` and every re-run
2031
+ # reaches this line: measured 2026-09-04, two runs left two identical
2032
+ # `Started:` records in one plan.
2033
+ #
2034
+ # The DATE is deliberately not matched. A slice handed over again tomorrow is
2035
+ # the same start, and a per-day record would drift from the refs it describes
2036
+ # exactly as a per-run one does.
2037
+ if grep -qF -- "\`$4\`" "$f" 2>/dev/null \
2038
+ && grep -q -- "^[ \t]*[-*][ \t]*\*\*Started:\*\*.*\`$4\`" "$f" 2>/dev/null; then
2039
+ return 0
2040
+ fi
1541
2041
  awk -v line="$line" '
1542
2042
  { lines[++n] = $0 }
1543
2043
  END {
@@ -1772,7 +2272,38 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
1772
2272
  # in-flight report a few lines up.
1773
2273
  [ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
1774
2274
 
1775
- # Its tip landed already a leftover desk, not a held one.
2275
+ # DID ITS WORK LAND? THE HOST ANSWERS, NOT ANCESTRY.
2276
+ #
2277
+ # This asked `git merge-base --is-ancestor "$br" "origin/$MAIN"` until
2278
+ # 2026-09-04. Measured that day on this estate: ten merged branches still
2279
+ # carried a remote ref and ancestry disagreed with the host on TEN OF TEN.
2280
+ # Squash-merge is not occasionally wrong here — the squashed commit is not the
2281
+ # branch's commit, so the branch stays ahead of main forever and ancestry
2282
+ # answers "not landed" about every squash-merged branch there is.
2283
+ #
2284
+ # The failure direction is throughput, not safety: ancestry called a landed
2285
+ # leftover HELD, so dispatch refused a branch that was free. That is the
2286
+ # cheap half of the plan's measurement and it is still a refusal an operator
2287
+ # has to route around.
2288
+ #
2289
+ # `pr_merged` is the ONE answer, sourced rather than re-derived — the same
2290
+ # gate `plot-reap.sh` and `plot-release-refs.sh` read, for the reason that
2291
+ # file states: two implementations of one question drift, and one of them
2292
+ # drifts permissive.
2293
+ #
2294
+ # ANCESTRY REMAINS AS A SECOND CHANCE, and only toward "landed". A
2295
+ # fast-forward or rebase merge leaves the tip genuinely in main while the host
2296
+ # may hold no PR at all — a branch pushed straight to main, which this repo's
2297
+ # own fixtures do. It can only ever release a worktree the host already
2298
+ # declined to release, so it adds no way to refuse and no way to hide work.
2299
+ #
2300
+ # AN UNREACHABLE HOST ANSWERS "NOT MERGED", which keeps the worktree held.
2301
+ # That is `pr_merged`'s documented direction and the right one here too:
2302
+ # silence is never permission to hand somebody's desk to a second agent.
2303
+ # plot-ancestry: prefilter — second only to `pr_merged` above, and it can
2304
+ # only RELEASE a worktree the host already declined to release. It adds no
2305
+ # refusal, so a squash merge it misreads changes nothing.
2306
+ pr_merged "$br" && return 1
1776
2307
  git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
1777
2308
  printf '%s' "$wt"
1778
2309
  }
@@ -1839,6 +2370,37 @@ work_in_flight() { # $1=branch to exclude (the candidate)
1839
2370
  IN_FLIGHT_MAX_FILES=6
1840
2371
  IN_FLIGHT_MAX_BRANCHES=8
1841
2372
 
2373
+ # What a real run would attach, named per worktree — behind `--monitors`.
2374
+ #
2375
+ # SILENT UNLESS ASKED, which is what keeps the default `--dry-run` output
2376
+ # byte-identical to a run from before the monitors existed. That diff is this
2377
+ # slice's protection against the one failure that matters here: a mistake in
2378
+ # `start_worker` starts no workers at all, and the dry run exercises every
2379
+ # refusal against real worktrees and real pids without starting anything.
2380
+ #
2381
+ # IT NAMES THE SCRIPT PATH, not just the monitor. The question a reader has at
2382
+ # a dry run is *which code would run against my worktree* — a bare "2 monitors"
2383
+ # would send them into this script to find out, and a path they can `cat` is
2384
+ # the same courtesy the manifest refusal above pays by naming its directory.
2385
+ #
2386
+ # IT REPORTS ABSENCE TOO. A monitor script that is missing or non-executable
2387
+ # means an unmonitored worker, and the empty env var that produces is invisible
2388
+ # at launch by design (a detached `sh -c` nobody reads must not spew `command
2389
+ # not found`). The dry run is the one place that silence can be made audible
2390
+ # before it matters.
2391
+ report_monitors() { # $1=worktree
2392
+ [ "$show_monitors" = 1 ] || return 0
2393
+ local wt="$1" m
2394
+ for m in worker agent; do
2395
+ local script="$script_dir/plot-$m-monitor.sh"
2396
+ if [ -x "$script" ]; then
2397
+ echo " would attach: $script → $wt"
2398
+ else
2399
+ echo " would attach NOTHING for the $m monitor — $script is missing or not executable"
2400
+ fi
2401
+ done
2402
+ }
2403
+
1842
2404
  report_in_flight() { # $1=candidate branch
1843
2405
  local br files shown extra n=0 total
1844
2406
  total=$(work_in_flight "$1" | wc -l | tr -d ' ')
@@ -1867,6 +2429,13 @@ report_in_flight() { # $1=candidate branch
1867
2429
  done
1868
2430
  }
1869
2431
 
2432
+ # THE PREREQUISITE PREFLIGHT, before either fan-out path. It names every branch
2433
+ # this plan will not start and why, and fills `waits_held` so neither loop can
2434
+ # write for one. Run here, once, rather than inside the loops: the scan filters a
2435
+ # waiting branch out before a loop ever hears of it, so a refusal that only fires
2436
+ # on an offered branch would never fire at all.
2437
+ run_waits_preflight
2438
+
1870
2439
  # A dry run changes nothing, so nothing can go stale — read the whole eligible
1871
2440
  # set once. (`--next` would loop forever here: without a claim it keeps
1872
2441
  # returning the same branch.)
@@ -1881,10 +2450,21 @@ if [ "$dry_run" = 1 ]; then
1881
2450
  n_skipped=$((n_skipped + 1))
1882
2451
  continue
1883
2452
  fi
2453
+ # ALREADY REFUSED BY THE PREFLIGHT, which named it and counted it. The
2454
+ # scan does not normally offer a waiting branch at all; this arm catches
2455
+ # the one that reached here because the scan could not ask the host.
2456
+ is_waits_held "$br" && continue
1884
2457
  echo "would dispatch $br → $(worktree_for "$br")"
2458
+ report_monitors "$(worktree_for "$br")"
1885
2459
  report_in_flight "$br"
1886
2460
  n_dispatched=$((n_dispatched + 1))
1887
- done < <("$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null)
2461
+ # The scan's eligible set, plus whatever `--allow-waiting` freed. The scan
2462
+ # reports a waiting branch as `waiting`, so it is absent from the first and
2463
+ # only the preflight can supply it — see `waits_freed`. `sort -u` because a
2464
+ # branch the scan DID offer (its host call failed where the preflight's
2465
+ # succeeded) must be dispatched once, not twice.
2466
+ done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
2467
+ printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}; } | grep -v '^$' | sort -u)
1888
2468
  # A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
1889
2469
  # information about the config — reporting "no workers started" here would be
1890
2470
  # true and useless, and would train the reader to skip the line on the real
@@ -1892,42 +2472,82 @@ if [ "$dry_run" = 1 ]; then
1892
2472
  # `skipped` is REAL here, not a constant. A dry run refuses held branches
1893
2473
  # exactly as the real run does, so its count is a fact about this fleet — and
1894
2474
  # 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)"
2475
+ # `brief_asked=0` is a CONSTANT here and not a prediction. A dry run changes
2476
+ # nothing, and asking the `Brief command` to write a brief spawns an agent
2477
+ # that commits — the loudest write in this script. The field travels so the
2478
+ # footer's shape does not depend on the mode a machine reader happened to
2479
+ # call in.
2480
+ echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field) brief_asked=0"
1896
2481
  exit 0
1897
2482
  fi
1898
2483
 
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
2484
+ # THE LIST IS READ ONCE, AND THAT FOLLOWS FROM THE CLAIM GOING.
2485
+ #
2486
+ # This was a PULL: `--next` was asked again after every claim, because claiming
2487
+ # a branch changed what the next ask would offer and a list computed up front
2488
+ # would have gone stale mid-fan-out. Dispatch claims nothing now — it hands a
2489
+ # slice to the registry and returns — so nothing this loop does changes the
2490
+ # scan's answer, and re-asking would return the same branch until a gate marked
2491
+ # it exhausted and the loop broke on it. Measured on the first run after the
2492
+ # claim was removed: `feature/one` handed over, `feature/two` never reached.
2493
+ #
2494
+ # ONE SCAN RATHER THAN N. The scan is 18.3 s here, so the pull cost one of those
2495
+ # per branch to re-derive an answer that could not have moved.
2496
+ #
2497
+ # `--allow-waiting`'s CANDIDATES COME LAST, after every branch the scan was
2498
+ # willing to name. `--list-eligible` reports a waiting branch as `waiting` and
2499
+ # never offers it, so the flag's candidates can only come from the preflight; a
2500
+ # held branch is one the operator chose to start early and must not displace one
2501
+ # that was ready. `sort -u` because a branch the scan DID offer — its host call
2502
+ # failed where the preflight's succeeded — must be handed over once, not twice.
2503
+ #
2504
+ # A `while read` LOOP, BECAUSE macOS SHIPS BASH 3.2. The bash 4 builtin that
2505
+ # reads a stream into an array does not exist there, and
2506
+ # `test/reconcile/mergequeue.test.mjs` gates on it — by grepping these scripts,
2507
+ # so naming the builtin in a comment fails the gate too.
2508
+ #
2509
+ # The single scan this block exists for is unaffected: the subshell still runs
2510
+ # once, and its output is still read once.
2511
+ fan_out=()
2512
+ while IFS= read -r _line; do
2513
+ fan_out+=("$_line")
2514
+ done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
2515
+ [ "$allow_waiting" = 1 ] && printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}
2516
+ :; } | grep -v '^$' | sort -u)
2517
+
2518
+ for branch in ${fan_out[@]+"${fan_out[@]}"}; do
1903
2519
  [ "$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).
2520
+ # `exhausted` SURVIVES THE PULL IT WAS WRITTEN FOR. It no longer has to stop
2521
+ # the loop re-offering a branch a list cannot — but the gates below still
2522
+ # mark what they refused, and `sort -u` cannot merge a preflight candidate
2523
+ # with a scan-offered one where the two spellings differ.
2524
+ is_exhausted "$branch" && continue
2525
+
2526
+ # THE PATH DISPATCH NO LONGER CREATES, still composed for the two readers that
2527
+ # need it: the dry run, which names where a desk WOULD go, and the monitor
2528
+ # report. The agent decides its own desk now, so this is a prediction rather
2529
+ # than a destination and it stays flattened whole, because `feature/api` and
2530
+ # `bug/api` are different work and must not name one directory.
1914
2531
  suffix=$(printf '%s' "$branch" | tr '/' '-')
1915
2532
  wt="$wt_root/$wt_prefix$suffix"
1916
2533
 
1917
2534
  if [ "$dry_run" = 1 ]; then
1918
- echo "would dispatch $branch → $wt"
2535
+ echo "would hand over $branch → the registry"
2536
+ report_monitors "$wt"
1919
2537
  report_in_flight "$branch"
1920
2538
  n_dispatched=$((n_dispatched + 1))
1921
2539
  continue
1922
2540
  fi
1923
2541
 
1924
- # THE HELD-BRANCH GATE, ahead of every write this loop makes.
2542
+ # THE HELD-BRANCH GATE, and it survives the fan-out losing its writes.
1925
2543
  #
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.
2544
+ # It refuses a branch whose own desk holds work that has not landed, and that
2545
+ # is a MEASUREMENT of somebody sitting at it not a prediction about a file.
2546
+ # Dispatch creates no desk any more, so this no longer protects an adoption
2547
+ # path; it protects the hand-over itself. Handing a slice to the registry
2548
+ # while an agent is mid-edit on that branch is how two agents end up on one,
2549
+ # and the desk is the only place that work is visible: it is unpushed by
2550
+ # definition, so no ref and no PR reports it.
1931
2551
  #
1932
2552
  # `exhausted` is what makes the refusal terminal: --next has no memory and
1933
2553
  # would keep offering this same branch until the loop's own break fired.
@@ -1938,80 +2558,104 @@ while :; do
1938
2558
  continue
1939
2559
  fi
1940
2560
 
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.
2561
+ # ALREADY REFUSED BY THE PREFLIGHT, and still asked here. A slice handed over
2562
+ # while its prerequisite is unmerged is an agent started on work that cannot
2563
+ # build, and `feature/the-domain-forgets-the-vendor-list` is the measured
2564
+ # case — claimed, and holding nothing but its claim commit.
2565
+ #
2566
+ # `exhausted` is what makes the refusal terminal — `--next` has no memory and
2567
+ # would keep offering this branch until the loop's own break fired. It should
2568
+ # not be offering it at all (the scan reads `waiting`), so this arm is the
2569
+ # belt to the preflight's brace: a branch offered by a scan that could not
2570
+ # reach the host still stops here, and it is NOT counted again — the preflight
2571
+ # already reported it.
2572
+ if is_waits_held "$branch"; then
2573
+ exhausted+=("$branch")
2574
+ continue
2575
+ fi
2576
+
2577
+ # BEFORE anything is handed over. The candidate is not yet work in flight —
2578
+ # dispatch creates no desk and pushes no claim — so this describes what stood
2579
+ # before this run rather than what this run made.
1944
2580
  in_flight=$(report_in_flight "$branch")
1945
2581
 
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))
2582
+ # THE BRIEF GATE, AT THE HAND-OVER RATHER THAN AT THE LAUNCH.
2583
+ #
2584
+ # ITS RULE IS UNCHANGED a slice with no brief is not handed over — AND ONLY
2585
+ # ITS POSITION MOVED. It used to sit between a prepared desk and a started
2586
+ # worker, so a missing brief left a worktree and a claim nobody was sat at:
2587
+ # correct at the time, because preparing was the only thing dispatch could do
2588
+ # first. Dispatch now prepares nothing, so a refused slice leaves nothing at
2589
+ # all and simply stays in the queue.
2590
+ #
2591
+ # THE REFUSAL STILL NAMES THE REF IT LOOKED AT, not a bare path. A brief
2592
+ # sitting unpushed in the operator's checkout is the likeliest reason to see
2593
+ # this message, and `no brief at .plot/briefs/x.md` would send them to look at
2594
+ # a file that is right there — the ref says where the AGENT will look.
2595
+ #
2596
+ # `--no-brief` KEEPS ITS MEANING: it hands over without one and SAYS SO, so
2597
+ # the override stays on the record rather than being silent.
2598
+ if brief_present "$branch"; then
2599
+ # A brief that is present is never refused for age — see
2600
+ # `brief_staleness_note`. The note is printed before the hand-over so it
2601
+ # sits with the branch it describes, and the hand-over happens either way.
2602
+ brief_staleness_note "$branch"
2603
+ elif [ "$no_brief" = 1 ]; then
2604
+ echo " no brief at $(brief_ref "$branch") — handing it over anyway (--no-brief)"
1951
2605
  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
2606
+ echo " not handed over no brief at $(brief_ref "$branch")"
2607
+ echo " write one: /plot-implement $slug (then push it, or pass --no-brief to hand it over without one)"
2608
+ # AND NOW SOMETHING IS DONE ABOUT IT. The refusal above stands unchanged;
2609
+ # this line says what happened NEXT. Either arm names itself, so the log
2610
+ # always records which one ran.
2611
+ request_brief "$branch" "$slug" && n_brief_asked=$((n_brief_asked + 1))
2612
+ n_skipped=$((n_skipped + 1))
2613
+ # `exhausted` is what makes the refusal terminal — `--next` has no memory
2614
+ # and would keep offering this branch until the loop's own break fired.
2615
+ exhausted+=("$branch")
2616
+ continue
1993
2617
  fi
1994
2618
 
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
2619
+ # THE HAND-OVER, AND IT IS THE WHOLE OF WHAT DISPATCH DOES WITH A SLICE.
2620
+ #
2621
+ # `git worktree add` USED TO BE HERE, with a claim push behind it and a worker
2622
+ # start behind that. All three are gone, and each for its own reason:
2623
+ #
2624
+ # THE DESK. `DESIGN-agent.md:65` — *"agent ──owns──► a worktree (its desk,
2625
+ # while it lives)"*. One desk per agent, not one per slice. The agent decides
2626
+ # create-or-reset when it takes the brief, because it is the only party that
2627
+ # can see its own tree; a desk cut here would be cut before anybody knows
2628
+ # which agent will sit at it. Measured 2026-09-02: 2 manifests against 11
2629
+ # worktrees, 5 of them on branches that had already merged.
2630
+ #
2631
+ # THE CLAIM. A pushed claim makes the branch read `claimed` rather than
2632
+ # `open`, and the queue is DERIVEDan eligible slice with a brief and no
2633
+ # claim IS queued. Claiming here would take the slice straight back out of
2634
+ # the queue it was being put into.
2635
+ #
2636
+ # THE WORKER. `DESIGN-agent.md:157` *"nothing starts a worker"*. The
2637
+ # registry spawns an agent, and spawning it IS starting its process. This
2638
+ # script hands work to the fleet; it does not staff it.
2639
+ #
2640
+ # SO THE HAND-OVER IS A REPORT AND NOT A WRITE. The queue derives from the
2641
+ # plan, the briefs and the refs, all of which are already on the host, so
2642
+ # there is nothing for this line to store — which is what keeps the daemon
2643
+ # stateless across restarts.
2644
+ #
2645
+ # IT REFUSES NOTHING FOR WANT OF A FREE AGENT, and never asks. An earlier
2646
+ # draft of the plan proposed refusing on `0 free` and it is wrong: it makes
2647
+ # dispatch synchronous with fleet capacity, the coupling `DESIGN-machine.md`
2648
+ # §10 spent two revisions rejecting, and `DESIGN-agent.md:173` states it from
2649
+ # the other side — *"a dispatch never asks the machine for capacity"*. **The
2650
+ # queue absorbs the timing.** A queue longer than the pool is the normal case.
2651
+ echo "handed over $branch → the registry"
2652
+ [ -n "$in_flight" ] && printf '%s\n' "$in_flight"
2653
+ n_dispatched=$((n_dispatched + 1))
2654
+ # AFTER the hand-over rather than after a claim. A `Started:` record now
2655
+ # states that the slice was handed to the fleet, which is what this run did;
2656
+ # who takes it is the registry's to decide and its own to record.
2657
+ claimed_now+=("$branch")
2658
+
2015
2659
  done
2016
2660
 
2017
2661
  # Book AFTER the fan-out, in one commit, so a booking that fails cannot leave