@plot-pm/board 0.10.0 → 0.12.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,11 +1,18 @@
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
+ # plot-dispatch.sh --start [N] [--dry-run]
5
6
  # plot-dispatch.sh --migrate [--yes] [--max N]
6
7
  # --status list fleet worktrees with worker pid, liveness, and last log
7
8
  # line; then exit. Works regardless of plan phase.
8
9
  # --stop <br> stop the worker on <br> (branch required — never "all").
10
+ # --start [N] start N free agents — registered, waiting, holding no slice.
11
+ # Defaults to three. Each gets a desk detached at origin/<main>
12
+ # and a manifest naming no branch, so the registry's next tick can
13
+ # hand each a queued slice with nobody touching a desk. The count
14
+ # is a REQUEST: a machine at its bound answers with fewer and says
15
+ # so, and the shortfall is reported rather than remembered.
9
16
  # --restart <br>
10
17
  # start a worker on <br>, which already holds a claim — the
11
18
  # counterpart to --stop, and the only way to hand a stopped
@@ -22,29 +29,42 @@
22
29
  # config — without one there is no destination. --dry-run by
23
30
  # default; --yes to actually move.
24
31
  # --dry-run print what would happen; create nothing, push nothing
32
+ # --monitors with --dry-run, also name which monitors would be attached to
33
+ # which worktree. Opt-in so the default --dry-run output stays
34
+ # byte-identical, which is what lets it be diffed against a run
35
+ # from before a change to this script.
25
36
  # --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.
37
+ # --no-start record that no worker was wanted: the run still hands its slices
38
+ # over, and reports `worker=suppressed` rather than a missing
39
+ # `Worker command`. Dispatch starts no worker either way the
40
+ # registry spawns agents so this now says WHY the zero was
41
+ # chosen and no longer changes what the run does.
42
+ # --no-brief hand a slice over even when its branch has no brief. The named
43
+ # escape for the brief gate: a missing brief is not handed over,
44
+ # because the agent's first instruction is to read
45
+ # `.plot/briefs/<branch>.md` and it has nothing to read. A refused
46
+ # slice leaves no desk and no claim and stays in the queue.
47
+ # --no-brief overrides that and says so.
32
48
  # --offline skip `git fetch`
33
49
  # --max N dispatch at most N branches this run (default: all eligible)
34
50
  # --allow-local read the plan's phase from the working tree when
35
51
  # origin/<main> cannot be resolved (no remote, fresh clone).
36
52
  # The explicit escape for a remote-less repo — never a default,
37
53
  # because a working-tree read is what this gate exists to avoid.
54
+ # --allow-waiting dispatch a branch whose `waits:` prerequisite has not
55
+ # merged. The named escape for the prerequisite gate, in the
56
+ # tradition of --allow-local: a gate with no exit is one people
57
+ # route around by never annotating at all. It says so on the
58
+ # line it overrides, so the override is on the record.
38
59
  # <slug> the plan to fan out
39
60
  # Output: one line per branch, each optionally followed by an indented
40
61
  # `in flight:` line naming a branch that already holds files, then the
41
- # summary block — an optional prose consequence line, then a
42
62
  # machine-countable footer.
43
63
  # A branch whose worktree exists with UNMERGED work is refused rather
44
- # than dispatched — counted `skipped`, with the worktree path named,
64
+ # than handed over — counted `skipped`, with the worktree path named,
45
65
  # 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
66
+ # handed over feature/one the registry
67
+ # summary: dispatched=2 reused=0 skipped=1 started=0 brief=missing worker=unconfigured brief_asked=0
48
68
  #
49
69
  # THE CONSEQUENCE IS STATED IN THE SUMMARY, NOT PER BRANCH. start_worker has
50
70
  # always said "no 'Worker command' configured" beside the branch it could not
@@ -77,6 +97,23 @@
77
97
  # over. It does NOT refuse: --dry-run and --status are legitimate direct calls,
78
98
  # and a gate that blocks looking-before-leaping is a gate in the wrong place.
79
99
  #
100
+ # `brief_asked=N` counts what the script did about it, and the distinction from
101
+ # `brief=missing` is exact: this script still writes no brief, it ASKS one to be
102
+ # written. `Brief command` names how to run an agent headless for one prompt,
103
+ # and the prompt is `/plot-implement <slug>` — the skill that already owns brief
104
+ # authorship. Absent key, or `none`: nothing is asked, the branch is refused as
105
+ # before, and the per-branch line names `no-brief-command` so the log says which
106
+ # arm ran. A project that never sets the key sees `brief_asked=0` and today's
107
+ # behaviour exactly.
108
+ #
109
+ # `brief_asked=N` COUNTS COMMANDS STARTED, NEVER BRIEFS WRITTEN. The command is
110
+ # detached by design and this script never waits on it, so a command that dies
111
+ # in its first millisecond is counted the same as one that writes and pushes a
112
+ # brief. Measured 2026-09-02: a `Brief command` that could not reach
113
+ # `/plot-implement` wrote a 33-byte log and the summary reported
114
+ # `brief_asked=1`. The per-branch line names the log for exactly this reason —
115
+ # the log is the evidence, the count is only that an attempt was made.
116
+ #
80
117
  # THIS IS THE ONE SCRIPT IN THE FLEET THAT WRITES. Everything else
81
118
  # (plot-fleet-scan.sh, plot-reconcile-scan.sh) is read-only. Consequently every
82
119
  # write here is either idempotent or refused:
@@ -113,6 +150,22 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
113
150
  # shellcheck source=plot-worker-state.sh
114
151
  . "$script_dir/plot-worker-state.sh"
115
152
 
153
+ # The ONE answer to "did the host merge ANY PR for this branch?" — `pr_merged`,
154
+ # read by `held_worktree` rather than derived from ancestry. Sourced for the
155
+ # same reason `plot-reap.sh` and `plot-release-refs.sh` source it: three callers
156
+ # gate on one fact and must never disagree about it. The helper defines two
157
+ # functions and does nothing else on load.
158
+ # shellcheck source=plot-pr-merged.sh
159
+ . "$script_dir/plot-pr-merged.sh"
160
+
161
+ # The ONE answer to "what is the default branch?" — `default_branch`, which
162
+ # repairs an unresolvable `origin/HEAD` before it answers. This script is where
163
+ # that corruption was MEASURED: twice on 2026-09-04 the symref pointed at
164
+ # `origin/plot-corpus-pin`, and the refusal below fired on every dispatch. The
165
+ # refusal stays; it now has less to refuse over.
166
+ # shellcheck source=plot-default-branch.sh
167
+ . "$script_dir/plot-default-branch.sh"
168
+
116
169
  # ---------------------------------------------------------------------------
117
170
  # WHERE THE WORKTREES LIVE, and by what name
118
171
  # ---------------------------------------------------------------------------
@@ -155,19 +208,35 @@ resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
155
208
  }
156
209
 
157
210
  dry_run=0
211
+ show_monitors=0
158
212
  no_start=0
159
213
  no_brief=0
160
214
  mode=dispatch
161
215
  stop_branch=""
162
216
  restart_branch=""
217
+ # EMPTY MEANS "THE DEFAULT", AND THE DEFAULT IS THE RULE'S. `fleetSize` owns the
218
+ # number and the argument for it; a literal here would be a second copy of a
219
+ # decision that has one home. It is filled from the rule below.
220
+ start_count=""
163
221
  offline=""
164
222
  allow_local=0
223
+ allow_waiting=0
165
224
  max=0
166
225
  slug=""
167
226
  migrate_yes=0
168
227
  while [ $# -gt 0 ]; do
169
228
  case "$1" in
170
229
  --dry-run) dry_run=1 ;;
230
+ # --monitors NAMES what would be attached, and it is OPT-IN for a reason
231
+ # that is a protection rather than a preference. `plot-dispatch.sh` is the
232
+ # largest script here and a mistake in start_worker starts no workers at
233
+ # all, so this slice pins `--dry-run` output as BYTE-IDENTICAL before and
234
+ # after on the same estate — the dry run exercises every refusal against
235
+ # real worktrees and real pids without starting or removing anything, which
236
+ # is the same protection the reap and dispatch domain work used. A line
237
+ # added to the default output would forfeit exactly that check. So the
238
+ # naming lives behind its own flag: `--dry-run --monitors`.
239
+ --monitors) show_monitors=1 ;;
171
240
  --status) mode=status ;;
172
241
  --migrate) mode=migrate ;;
173
242
  --yes) migrate_yes=1 ;;
@@ -180,16 +249,27 @@ while [ $# -gt 0 ]; do
180
249
  # plan for feature/x", which describes neither what was asked nor what
181
250
  # went wrong. The branch is consumed only when it looks like one.
182
251
  --restart) mode=restart; case "${2:-}" in */*) restart_branch="$2"; shift ;; esac ;;
252
+ # `--start [N]` brings FREE agents into existence — registered, waiting, and
253
+ # holding no slice. The count is OPTIONAL and only a bare number is consumed,
254
+ # the same rule `--stop` and `--restart` apply to a branch: a value that does
255
+ # not look like a count is left for the parser rather than swallowed, so
256
+ # `--start --dry-run` means what it reads as.
257
+ --start) mode=start
258
+ case "${2:-}" in
259
+ ''|*[!0-9]*) ;;
260
+ *) start_count="$2"; shift ;;
261
+ esac ;;
183
262
  --no-start) no_start=1 ;;
184
263
  --no-brief) no_brief=1 ;;
185
264
  --offline|--no-fetch) offline="--offline" ;;
186
265
  --allow-local) allow_local=1 ;;
266
+ --allow-waiting) allow_waiting=1 ;;
187
267
  --max) max="${2:?--max needs a value}"
188
268
  case "$max" in
189
269
  ''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
190
270
  esac
191
271
  shift ;;
192
- -h|--help) sed -n '2,38p' "$0"; exit 0 ;;
272
+ -h|--help) sed -n '2,59p' "$0"; exit 0 ;;
193
273
  *) slug="$1" ;;
194
274
  esac
195
275
  shift
@@ -248,6 +328,22 @@ json_escape() {
248
328
  #
249
329
  # Model and context are still absent on purpose: they belong to the runtime and
250
330
  # are read from the transcript, so a manifest that named them would be a guess.
331
+ #
332
+ # `resumeId` AND `session` ARE TWO FIELDS THAT HOLD ONE VALUE AT LAUNCH, and
333
+ # they are written separately on purpose. `session` is the transcript join key
334
+ # and STAYS FIXED across a branch hop, by design — `plot-worker-loop.sh` rewrites
335
+ # `branch` and `worktree` on each hop and leaves `session` alone. The resume
336
+ # handle is a different identity with a different lifetime, and whether it should
337
+ # follow a hop cannot even be ASKED while one field carries both meanings. They
338
+ # will usually agree; nothing may assume they always do.
339
+ #
340
+ # `attempts` IS THE SUPERVISOR'S OWN COUNTER, DISTINCT FROM `relaunches`.
341
+ # `relaunches` counts operator-initiated restarts — a human's record, written by
342
+ # the launch stamp. `attempts` counts a supervisor's own retries and is what a
343
+ # bound would read. Merging them would let a person's three manual restarts
344
+ # exhaust an automatic budget, or the reverse. It is written 0 here and by
345
+ # nothing else in this script: no component in Plot raises it yet, and a launch
346
+ # that guessed at one would be recording a retry nobody made.
251
347
  # The `pid` starts EMPTY here and is stamped by the wrapper the instant it learns
252
348
  # its own child — see `stamp_manifest_pid`. The dispatcher does not know the
253
349
  # agent pid at this line (only the wrapper does, from its `$!`), so it writes the
@@ -260,10 +356,12 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
260
356
  {
261
357
  printf '{\n'
262
358
  printf ' "session": "%s",\n' "$(json_escape "$2")"
359
+ printf ' "resumeId": "%s",\n' "$(json_escape "$2")"
263
360
  printf ' "branch": "%s",\n' "$(json_escape "$3")"
264
361
  printf ' "worktree": "%s",\n' "$(json_escape "$4")"
265
362
  printf ' "command": "%s",\n' "$(json_escape "$5")"
266
363
  printf ' "pid": "",\n'
364
+ printf ' "attempts": 0,\n'
267
365
  printf ' "startedAt": "%s"\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
268
366
  printf '}\n'
269
367
  } > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
@@ -302,11 +400,16 @@ write_agent_manifest() { # $1=path $2=session $3=branch $4=worktree $5=command
302
400
  #
303
401
  # READ FROM `origin/<main>`, NOT THE WORKING TREE, for the same reason the phase
304
402
  # 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.
403
+ # but "will the AGENT find one?". The agent cuts or resets its desk from
404
+ # `origin/$MAIN`, so a brief committed nowhere — or committed locally and never
405
+ # pushed — is invisible to it. Checking the working tree passes the gate and
406
+ # hands over a slice whose specification is empty, which is the exact failure
407
+ # this gate exists to prevent.
408
+ #
409
+ # THAT HOLDS ACROSS THE GATE'S MOVE, and is why the move cost this function
410
+ # nothing: the desk was cut from `origin/$MAIN` when dispatch cut it and is cut
411
+ # from `origin/$MAIN` now that the agent does. The ref the brief must be on did
412
+ # not change, only who reads it there.
310
413
  #
311
414
  # Both directions were measured 2026-08-27. Running the filesystem check from a
312
415
  # checkout 8 commits behind main reported three branches' briefs missing while
@@ -326,6 +429,310 @@ brief_present() { # $1 = branch → 0 if a usable brief exists on origin/<main>
326
429
  [ "${sz:-0}" -gt 0 ]
327
430
  }
328
431
 
432
+ # WHAT HAPPENS AFTER THE GATE FIRES. The gate above is correct and stays: a
433
+ # missing brief still prepares and still refuses to start. What it never had is
434
+ # a next step — it named the file, and every brief on this estate was then
435
+ # written by hand.
436
+ #
437
+ # `Brief command` is that step, and it is a CONFIG KEY rather than a new script
438
+ # for one reason: `/plot-implement` step 4 already owns brief authorship. A
439
+ # script here would be a SECOND brief writer, and two writers drift. So the key
440
+ # names how to run an agent headless — the shape `Idea command`, `Story command`
441
+ # and `Approve command` already use — and the prompt it is handed asks for
442
+ # `/plot-implement <slug>` and nothing else.
443
+ #
444
+ # ABSENT IS NOT AN ERROR. A project with no `Brief command` behaves exactly as
445
+ # it does today: the gate refuses, and the refusal now names WHY nothing was
446
+ # called — `no-brief-command`, the shape `commission.ts` gives `no-idea-command`.
447
+ # `none` reads the same way as it does for `Worker command`: asked, and answered
448
+ # "we write them by hand".
449
+ brief_command() { # → the usable `Brief command`, or empty
450
+ local cmd
451
+ cmd=$("$script_dir/plot-config.sh" get "Brief command" "")
452
+ case "$cmd" in none|NONE|None) cmd="" ;; esac
453
+ printf '%s' "$cmd"
454
+ }
455
+
456
+ # Run the brief command for one branch, detached, and say what was done.
457
+ #
458
+ # DETACHED AND NOT WAITED ON, for the reason `commission.ts` gives: this is a
459
+ # `claude -p` session of unknown length, and a fan-out that blocks on one would
460
+ # hold every later branch behind it. The dispatch run reports that it asked; the
461
+ # brief lands in a later commit, and the NEXT dispatch of the same branch starts
462
+ # it. That is the whole loop.
463
+ #
464
+ # The prompt travels as ONE argument through `"$@"`, never interpolated into the
465
+ # command string — `Brief command` is a shell FRAGMENT run through `sh -c`, so
466
+ # anything spliced into it would be shell source. The slug is the only value
467
+ # that reaches it and it is a plan slug, but the rule holds regardless of the
468
+ # value: the safety is in the shape, not in the input.
469
+ # WHAT THE AGENT IS ASKED FOR, and the one thing it must not be asked for.
470
+ #
471
+ # It is asked to run `/plot-implement <slug>`, and it is NOT asked to write a
472
+ # brief in its own words. `/plot-implement` step 4 owns brief authorship; a
473
+ # prompt that described the brief here would be a second author, and two
474
+ # authors drift. The branch and the path are named because the skill writes for
475
+ # one branch and the gate reads one path — `brief_path` is the same function
476
+ # `brief_present` reads with, so writer and reader cannot disagree.
477
+ #
478
+ # It is told to COMMIT AND PUSH, because the gate reads `origin/<main>`. A brief
479
+ # written and left in a working tree is invisible to the gate that asked for it,
480
+ # and the next dispatch would ask again — a loop that writes a file every pass
481
+ # and never starts a worker.
482
+ brief_prompt() { # $1 = branch, $2 = slug
483
+ 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.' \
484
+ "$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
485
+ }
486
+
487
+ # WHETHER THE AGENT THIS COMMAND SPAWNS CAN REACH `plot-implement` AT ALL.
488
+ #
489
+ # `Brief command` runs an agent headless and asks it for `/plot-implement
490
+ # <slug>`. `plot-implement` is a SKILL, and a skill the running agent does not
491
+ # have resolves to nothing. Measured 2026-09-06: Plot is installed as a plugin
492
+ # at 1.2.0 against this repository's 2.13.0, and the installed `skills/` holds
493
+ # eight — `plot-implement` is not among them, nor are `plot-dispatch`,
494
+ # `plot-fleet`, `plot-pulse`, `plot-reconcile`, `plot-board`, `plot-init`,
495
+ # `plot-merge-queue` or `plot-reslice`. Twelve are missing.
496
+ #
497
+ # So `Unknown command: /plot-implement` was LITERALLY TRUE in both 33-byte logs
498
+ # (2026-09-02, 2026-09-04), and the board's *"Run /plot-implement `<slug>` and
499
+ # follow it."* would have failed identically. The prompt's wording was never the
500
+ # defect, and rewriting it fixes nothing.
501
+ #
502
+ # THE INSTALL BEING SCOPED TO ANOTHER PROJECT IS WHY FOUR MONTHS PASSED
503
+ # UNNOTICED. The registry entry carries `scope: project` and `projectPath:
504
+ # /Users/jwloka/Quatico/CDS/cpq-cds-develop` — a DIFFERENT repository, installed
505
+ # 2026-05-08. A plugin installed elsewhere is not absent, it is somewhere else,
506
+ # and nothing on this estate named that.
507
+ #
508
+ # IT ASKS WHETHER THE SKILL DIRECTORY EXISTS, NOT WHETHER THE VERSION MATCHES.
509
+ # A lagging install that still carries the skill works, and a version comparison
510
+ # would refuse it and demand an update nobody needs. One missing directory is
511
+ # what breaks a brief session, so that is what is read.
512
+ #
513
+ # IT FAILS TOWARD ALLOWING. An unreadable or absent `installed_plugins.json`
514
+ # means CANNOT VERIFY, not broken: a project running Plot from a checkout with
515
+ # no plugin install at all is a supported shape, and refusing it would break
516
+ # dispatch for everyone who never installed the plugin. It says the check could
517
+ # not be made, and proceeds.
518
+ #
519
+ # `PLOT_PLUGIN_ROOT` is the override, the same one `plot-board-probe.sh` uses so
520
+ # tests need not depend on `$HOME`.
521
+ plugin_registry() { printf '%s/installed_plugins.json' "${PLOT_PLUGIN_ROOT:-${HOME:-}/.claude/plugins}"; }
522
+
523
+ # Every `installPath` recorded for a plugin whose key starts `plot@`, one per
524
+ # line. The marketplace half of the key is not fixed — `plot@plot-marketplace`
525
+ # here, but a repository may serve Plot from a marketplace under any name — so
526
+ # the prefix is what is matched rather than the whole key.
527
+ #
528
+ # `awk` rather than `node` or `jq`: this script shells to neither anywhere else,
529
+ # and a check that fails toward allowing must not acquire a dependency whose
530
+ # absence it would then have to read as "cannot verify" on every machine that
531
+ # lacks it.
532
+ #
533
+ # IT DOES NOT DEPEND ON THE FILE'S LINE BREAKS. The registry Claude Code writes
534
+ # is pretty-printed, one field per line, and a line-oriented read of it works —
535
+ # but only because of how it happens to be formatted, and this check's wrong
536
+ # answer is silent. Measured 2026-09-07 against a compact registry written by
537
+ # hand: the whole object on two lines, and the reader found no install at all,
538
+ # reported "could not verify" and allowed. Safe, and still wrong about a file
539
+ # that was perfectly readable.
540
+ #
541
+ # So the text is flattened to one token per line FIRST — every `"…"` string and
542
+ # every `[` `]` `{` `}` on its own line — and the scan then walks tokens rather
543
+ # than lines. Formatting stops being an input.
544
+ #
545
+ # The bracket depth is what scopes a key: a plugin's entry runs from the `[`
546
+ # after its key to the `]` that closes it, so an `installPath` is attributed to
547
+ # the key whose array still encloses it. Tracking the key alone would let the
548
+ # LAST `plot@…` key claim every install after it in the file.
549
+ plugin_install_paths() { # → one installPath per line, for keys matching plot@*
550
+ awk '
551
+ {
552
+ line = $0
553
+ while (length(line) > 0) {
554
+ c = substr(line, 1, 1)
555
+ if (c == "\"") {
556
+ # A JSON string, taken whole: scan to the closing quote, honouring
557
+ # backslash escapes so a quote inside a value does not end it early.
558
+ v = ""; i = 2
559
+ while (i <= length(line)) {
560
+ ch = substr(line, i, 1)
561
+ if (ch == "\\") { v = v substr(line, i + 1, 1); i += 2; continue }
562
+ if (ch == "\"") break
563
+ v = v ch; i++
564
+ }
565
+ print "S" v
566
+ line = substr(line, i + 1)
567
+ } else if (c == "[" || c == "]" || c == "{" || c == "}") {
568
+ print "P" c
569
+ line = substr(line, 2)
570
+ } else {
571
+ line = substr(line, 2)
572
+ }
573
+ }
574
+ }
575
+ ' "$1" 2>/dev/null | awk '
576
+ # `pending` is the string most recently seen, which is a KEY when the next
577
+ # token opens a container. `depth` is where this plugin"s array closes.
578
+ /^P\[/ {
579
+ depth++
580
+ if (pending ~ /^plot@/ && inplot == 0) { inplot = 1; plotdepth = depth }
581
+ pending = ""; next
582
+ }
583
+ /^P\]/ {
584
+ if (inplot && depth == plotdepth) inplot = 0
585
+ depth--; pending = ""; next
586
+ }
587
+ /^P/ { pending = ""; next }
588
+ /^S/ {
589
+ v = substr($0, 2)
590
+ if (want) { if (inplot && v != "") print v; want = 0; pending = ""; next }
591
+ if (v == "installPath") { want = 1; next }
592
+ pending = v
593
+ }
594
+ '
595
+ }
596
+
597
+ # Can a spawned agent reach `plot-implement`?
598
+ #
599
+ # 0 — yes: some recorded install carries `skills/plot-implement/`
600
+ # 1 — no: the registry was read, Plot is installed, and none of its installs
601
+ # carry the skill. This is the refusal.
602
+ # 2 — cannot verify: no registry, unreadable, or no `plot@*` entry at all.
603
+ # Allowed, and said.
604
+ #
605
+ # The three-way answer is the whole point. Collapsing "cannot verify" into
606
+ # either of the others is how this check would break a repository running Plot
607
+ # from a checkout, or wave through the exact install that produced two empty
608
+ # logs.
609
+ implement_skill_reach() { # → 0 reachable, 1 unreachable, 2 unverifiable
610
+ local reg paths p
611
+ reg=$(plugin_registry)
612
+ [ -n "$reg" ] && [ -r "$reg" ] || return 2
613
+ paths=$(plugin_install_paths "$reg")
614
+ [ -n "$paths" ] || return 2
615
+ while IFS= read -r p; do
616
+ [ -n "$p" ] || continue
617
+ case "$p" in "~/"*) p="${HOME:-}/${p#\~/}" ;; esac
618
+ [ -d "$p/skills/plot-implement" ] && return 0
619
+ done <<< "$paths"
620
+ return 1
621
+ }
622
+
623
+ # What the refusal SAYS, and it is the shape `plot-fleetctl.sh:306` uses for a
624
+ # wrong `node` major — the reading first, then the repair.
625
+ #
626
+ # IT NAMES A REPAIR AN OPERATOR CAN PERFORM. Plot cannot install its own plugin;
627
+ # updating an install is an action on a machine. So the message reports what was
628
+ # read — which installs, and that none carries the skill — and names the command
629
+ # that fixes it. Nothing here installs or updates anything.
630
+ implement_unreachable_report() { # prints the reading and the repair, indented
631
+ local reg p
632
+ reg=$(plugin_registry)
633
+ echo " no-implement-skill — the agent this would spawn cannot reach \`/plot-implement\`"
634
+ echo " read: $reg"
635
+ while IFS= read -r p; do
636
+ [ -n "$p" ] || continue
637
+ echo " install: $p (no skills/plot-implement/)"
638
+ done <<< "$(plugin_install_paths "$reg")"
639
+ echo " A prompt naming a skill the agent does not have resolves to nothing,"
640
+ echo " whatever its wording — measured twice as a 33-byte log reading"
641
+ echo " \`Unknown command: /plot-implement\`. Refusing before spawning is what"
642
+ echo " puts that in front of you instead of in a log nobody reads."
643
+ echo " Fix it: update the Plot plugin so its skills/ carries plot-implement"
644
+ echo " (/plugin, or reinstall from the marketplace), then dispatch again."
645
+ echo " Or write the brief yourself: /plot-implement $1"
646
+ }
647
+
648
+ # THE THIRD ANSWER, and it allows. A registry that cannot be read says nothing
649
+ # about whether the skill is there — a project running Plot from a checkout with
650
+ # no plugin install at all is a supported shape, and refusing it would break
651
+ # dispatch for everyone who never installed the plugin. So the run says the
652
+ # check could not be made and proceeds, which is what keeps a silent skip from
653
+ # reading as a verified pass.
654
+ implement_unverifiable_note() {
655
+ echo " could not verify that the agent can reach the implement skill —"
656
+ echo " no readable plugin registry at $(plugin_registry); proceeding"
657
+ }
658
+
659
+ request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
660
+ local branch="$1" bslug="$2" cmd log reach
661
+ cmd=$(brief_command)
662
+ if [ -z "$cmd" ]; then
663
+ echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
664
+ return 1
665
+ fi
666
+ # REFUSES BEFORE SPAWNING, NEVER AFTER. Failing late cost two logs nobody
667
+ # read and a session writing nine briefs by hand without asking why the arm
668
+ # was silent. The refusal is read by whoever ran the dispatch.
669
+ #
670
+ # A DIFFERENT REFUSAL FROM `no-brief-command`, and it gets its own word rather
671
+ # than overloading that one: there the key is absent and nothing was asked to
672
+ # write the brief; here the key is SET and the skill it names is unreachable.
673
+ # One word for two states is a word an operator cannot act on.
674
+ implement_skill_reach
675
+ reach=$?
676
+ if [ "$reach" = 1 ]; then
677
+ implement_unreachable_report "$bslug"
678
+ return 1
679
+ fi
680
+ if [ "$reach" = 2 ]; then
681
+ implement_unverifiable_note
682
+ fi
683
+ log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
684
+ mkdir -p "$(dirname "$log")" 2>/dev/null || true
685
+ # `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
686
+ # this outlives the dispatch run by design, because the fan-out must not block
687
+ # on a `claude -p` session of unknown length. `setsid` is not used — it does
688
+ # not exist on macOS, where most of this fleet runs.
689
+ ( cd "$repo_root" \
690
+ && PLOT_UNATTENDED=1 PLOT_PLAN_SLUG="$bslug" PLOT_BRIEF_BRANCH="$branch" \
691
+ nohup sh -c "$cmd \"\$@\"" plot-brief \
692
+ "$(brief_prompt "$branch" "$bslug")" \
693
+ >"$log" 2>&1 </dev/null & ) 2>/dev/null
694
+ echo " asked the \`Brief command\` to write it — log: $log"
695
+ # SAYS WHAT WAS MEASURED, WHICH IS THE START AND NOT THE RESULT. The command
696
+ # is detached and never waited on, so this returns 0 the moment it is spawned
697
+ # and a command that fails in its first millisecond still counts. Measured
698
+ # 2026-09-02, first real use: the log held 33 bytes, `Unknown command:
699
+ # /plot-implement`, and the summary still reported `brief_asked=1`. Naming the
700
+ # log as the evidence is what keeps the count from reading as a promise.
701
+ echo " started, not awaited — read the log to see whether it wrote anything"
702
+ echo " dispatch $bslug again once it lands; the gate reads $(brief_ref "$branch")"
703
+ return 0
704
+ }
705
+
706
+ # STALENESS REPORTS AND NEVER REFUSES, and the measurement says why.
707
+ #
708
+ # Compared 2026-09-01, all three live briefs were older than their plans and all
709
+ # three were CORRECT — every plan edit between them was bookkeeping (a PR
710
+ # annotation, a measurement note, a re-measure before approval). A timestamp
711
+ # gate would have refused 3 of 3 on the day it shipped, and a gate that refuses
712
+ # everything is one people disable in its first week.
713
+ #
714
+ # It would also have missed the real case. The teardown brief was written AFTER
715
+ # its plan and was still wrong, citing 80 `fs.rmSync` sites where the tree held
716
+ # 76 — the CODE moved, not the plan. Freshness against the plan is the wrong
717
+ # input: a brief's claims are about the repository, and nothing here compares
718
+ # those to the repository. What would actually gate is judgement about which
719
+ # numbers in a paragraph are claims, which grep cannot reach.
720
+ #
721
+ # So this prints a hint and SAYS it is a hint, naming the plan commit it
722
+ # compared against so the reader can look at that commit rather than guess.
723
+ brief_staleness_note() { # $1 = branch → prints a hint, or nothing
724
+ local branch="$1" bc pc bs ps
725
+ [ -n "$gate_sha" ] || return 0 # nothing shared to compare against
726
+ [ -n "${plan_path:-}" ] || return 0
727
+ bc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$(brief_path "$branch")" 2>/dev/null) || return 0
728
+ pc=$(git log -1 --format='%H %ct' "$gate_ref" -- "$plan_path" 2>/dev/null) || return 0
729
+ [ -n "$bc" ] && [ -n "$pc" ] || return 0
730
+ bs=${bc##* }; ps=${pc##* }
731
+ [ "$bs" -lt "$ps" ] 2>/dev/null || return 0
732
+ echo " brief older than the plan — a HINT, not a gate: the plan may have moved, or the edit may have been bookkeeping"
733
+ echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
734
+ }
735
+
329
736
  start_worker() {
330
737
  local branch="$1" wt="$2"
331
738
  local cmd
@@ -473,6 +880,23 @@ start_worker() {
473
880
  # single-quoted `sh -c` mangles a path with spaces, exactly as the exit file
474
881
  # already does.
475
882
  #
883
+ # AND THE WRAPPER WRITES IT ITSELF, from `$$` inside the `sh -c`. Until
884
+ # 2026-08-31 the dispatcher wrote `echo $!` beside the spawn, which named an
885
+ # intermediate subshell rather than the wrapper: three of three live workers
886
+ # measured that day recorded a pid one process above the agent's real parent
887
+ # (7357 against 7358, 71953 against 71954, 92947 against 92949).
888
+ #
889
+ # The cause is that `$!` names the last job THIS shell backgrounded, and with
890
+ # an env-var prefix in front of `nohup` bash cannot collapse the AND-list into
891
+ # one child — it forks a subshell, and that subshell is what `$!` reports.
892
+ # (Without the prefix bash `exec`s the command in place and `$!` is correct,
893
+ # which is why the shape matters and a smaller repro does not show it.)
894
+ #
895
+ # So the same rule the agent pid already follows applies here: THE PROCESS
896
+ # THAT KNOWS A PID IS THE ONE THAT WRITES IT. The wrapper knows `$$`; no
897
+ # ancestor can name it without guessing. It is written FIRST, before the
898
+ # monitors and the agent, so the file exists as early as it can.
899
+ #
476
900
  # The agent runs backgrounded inside the wrapper so the wrapper can capture its
477
901
  # `$!` and `wait` for it. There is a sub-millisecond window after the wrapper
478
902
  # starts and before it writes `.plot-worker.pid`; a scan landing in it reads an
@@ -522,6 +946,62 @@ start_worker() {
522
946
  # after it, then any stale copies of those lines are dropped and `startedAt` is
523
947
  # rewritten to the current run. This is exactly `stampManifest`, line for line,
524
948
  # which the parity test pins byte for byte.
949
+ #
950
+ # EVERY WORKER IS BORN MONITORED, AND THAT IS ENFORCED HERE OR NOWHERE.
951
+ #
952
+ # Three monitors start INSIDE the wrapper, as its children, immediately before
953
+ # the agent: one watches the process (`plot-worker-monitor.sh`), one watches
954
+ # the desk (`plot-agent-monitor.sh`), one watches the run
955
+ # (`plot-build-monitor.sh`). Each has a subject the others do not and a
956
+ # cadence it cannot share — seconds on the process table, minutes on the host,
957
+ # seconds again on a run but only while one is live.
958
+ #
959
+ # WHY INSIDE THE WRAPPER RATHER THAN BESIDE IT. The wrapper already outlives
960
+ # its agent by construction — it must, or there would be no exit code to
961
+ # write — so a child of it inherits that survival for free. Two processes
962
+ # started SIDE BY SIDE are independently mortal: the monitor could be killed
963
+ # or crash with nothing noticing, which is the failure being fixed one level
964
+ # up. `--stop` kills the agent; the monitors and the exit record survive it.
965
+ #
966
+ # WHY HERE RATHER THAN ANYWHERE ELSE. `start_worker` is the single path to a
967
+ # worker, which is what makes "every worker is born monitored" a gate rather
968
+ # than a rule: there is no other place to forget. Ask CLAUDE.md's test — *can
969
+ # you answer "did I attach a monitor?" without doing the work?* Here you
970
+ # cannot: no monitor start, no monitored worker, and a mutation test says so.
971
+ #
972
+ # ORDER, AND WHY IT IS THIS WAY ROUND. The monitors are backgrounded FIRST so
973
+ # they exist before their subject does; the agent is backgrounded next and
974
+ # `$!` is captured on the VERY NEXT command, because `$!` names the most
975
+ # recent background job and the pid file must name the AGENT. Starting a
976
+ # monitor between the agent and its `$!` would record a monitor's pid as the
977
+ # worker's — the panel bug the two-pid split already exists to prevent.
978
+ #
979
+ # THEY INHERIT THE STARTUP WINDOW RATHER THAN WIDENING IT. There is a
980
+ # sub-millisecond gap after the wrapper starts and before `.plot-worker.pid`
981
+ # is written, and a scan landing in it reads `none` — honest. The monitors
982
+ # start inside that same window; they must never turn an unwritten pid file
983
+ # into a `gone` finding, which is why the no-op reads no pid at all and the
984
+ # next slice treats an absent pid file as *not yet*.
985
+ #
986
+ # THE PATHS TRAVEL AS ENV VARS, like every other path the wrapper needs. The
987
+ # `sh -c` body is single-quoted and a path with spaces would not survive
988
+ # interpolation into it — the same reason the exit, pid and manifest paths are
989
+ # passed this way. An EMPTY value means "not attached", which is what keeps a
990
+ # missing script from turning into `command not found` in a detached shell
991
+ # nobody is reading.
992
+ #
993
+ # A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
994
+ # enforced: this is the only code that starts a wrapper, and a worktree with
995
+ # no wrapper has nothing for a monitor to be a child of.
996
+ local worker_monitor='' agent_monitor='' build_monitor=''
997
+ [ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
998
+ [ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
999
+ # THE THIRD MONITOR, born the same way and for the same reason. It watches the
1000
+ # RUN — a Build is its own entity in the spec, so a monitor per entity is the
1001
+ # pattern rather than an exception to it. Its cadence is the WorkerMonitor's
1002
+ # 30 s rather than the AgentMonitor's 300 s, and it can afford that against a
1003
+ # HOST because it asks nothing while no run is live.
1004
+ [ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
525
1005
  local stamp_now
526
1006
  stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
527
1007
  ( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
@@ -529,8 +1009,12 @@ start_worker() {
529
1009
  PLOT_SESSION_ID="$session" \
530
1010
  PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
531
1011
  PLOT_STAMP_STARTED="$stamp_now" \
1012
+ PLOT_WORKER_MONITOR="$worker_monitor" \
1013
+ PLOT_AGENT_MONITOR="$agent_monitor" \
1014
+ PLOT_BUILD_MONITOR="$build_monitor" \
532
1015
  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" '"'"'
1016
+ PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
1017
+ 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
1018
  BEGIN { relaunch = 0; count = 1; stamped = 0 }
535
1019
  FNR == NR {
536
1020
  if ($0 ~ /^ "pid": "[^"]*",$/) {
@@ -545,18 +1029,26 @@ start_worker() {
545
1029
  !stamped && $0 ~ /^ "pid": "[^"]*",$/ {
546
1030
  stamped = 1
547
1031
  print " \"pid\": \"" pid "\","
1032
+ print " \"wrapperPid\": \"" wrapper "\","
1033
+ print " \"workerMonitorPid\": \"" wmon "\","
1034
+ print " \"agentMonitorPid\": \"" amon "\","
1035
+ print " \"buildMonitorPid\": \"" bmon "\","
548
1036
  if (relaunch) {
549
1037
  print " \"previousPid\": \"" displaced "\","
550
1038
  print " \"relaunches\": " count ","
551
1039
  }
552
1040
  next
553
1041
  }
1042
+ $0 ~ /^ "wrapperPid": "[^"]*",$/ { next }
1043
+ $0 ~ /^ "workerMonitorPid": "[^"]*",$/ { next }
1044
+ $0 ~ /^ "agentMonitorPid": "[^"]*",$/ { next }
1045
+ $0 ~ /^ "buildMonitorPid": "[^"]*",$/ { next }
554
1046
  relaunch && $0 ~ /^ "previousPid": "[^"]*",$/ { next }
555
1047
  relaunch && $0 ~ /^ "relaunches": [0-9]+,$/ { next }
556
1048
  relaunch && $0 ~ /^ "startedAt": "[^"]*"$/ { print " \"startedAt\": \"" started "\""; next }
557
1049
  { print }
558
1050
  '"'"' "$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" )
1051
+ >"$log" 2>&1 </dev/null & )
560
1052
  echo " started worker (log: $log)"
561
1053
  return 0
562
1054
  }
@@ -826,6 +1318,284 @@ if [ "$mode" = "restart" ]; then
826
1318
  exit 0
827
1319
  fi
828
1320
 
1321
+ if [ "$mode" = "start" ]; then
1322
+ # THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
1323
+ # registry matches them to free agents, but until this verb existed nothing
1324
+ # brought a free agent into being. Measured 2026-09-05: a dispatch reported
1325
+ # `handed over feature/... → the registry` and `started=0`, the supervisor
1326
+ # ticked `queued=456 idle=0 agents registered: 0`, and `.plot/agents/` was
1327
+ # empty. The slice was queued, the registry was willing, and there was nobody
1328
+ # to hand it to.
1329
+ #
1330
+ # AN AGENT IS STARTED WITH NO SLICE, and that is the whole shape. It gets a
1331
+ # desk, a manifest naming no branch, and a loop that waits — `isAgentFree`
1332
+ # already reports exactly that state as free (`rules/free.ts:64`: alive, and
1333
+ # `branch === ""`), so the supervisor's next tick can hand each one a queued
1334
+ # slice with nobody touching a desk.
1335
+ #
1336
+ # HERE, BESIDE --stop AND --restart AND BEFORE THE PHASE GATE, for the reason
1337
+ # that block already gives from the other side: starting a free agent is not
1338
+ # about any plan, so there is no plan whose phase could refuse it. A gate on a
1339
+ # slug this verb never takes would refuse every call.
1340
+ repo_root="$repo_root_early"
1341
+ resolve_wt_root "$repo_root"
1342
+
1343
+ # THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
1344
+ # config key, then origin's own HEAD, then `main`. Resolved here because the
1345
+ # fan-out's `MAIN` is set past the phase gate this path exits before.
1346
+ start_main=$(bash "$script_dir/plot-config.sh" get "Main branch")
1347
+ [ -n "$start_main" ] || start_main=$(default_branch)
1348
+ [ -n "$start_main" ] || start_main="main"
1349
+
1350
+ # HOW MANY WORKERS ARE ALREADY UP, counted from the desks on this disk rather
1351
+ # than from the registry. A manifest records a launch; a live pid records a
1352
+ # worker, and the question `--start` asks is about the machine's load. The
1353
+ # count is what `fleetSize` subtracts, so asking for three twice gives three
1354
+ # agents rather than six.
1355
+ #
1356
+ # A CALLER THAT HAS ALREADY DECIDED THE COUNT SUBTRACTS NOTHING, and
1357
+ # `PLOT_START_ONE` is how it says so. The supervisor's tick ran `fleetSize`
1358
+ # against the WHOLE fleet, decided N, and hands this script one write at a
1359
+ # time; re-counting per call subtracts the agent the PREVIOUS call just
1360
+ # started. Measured 2026-09-05 in a sandbox: a tick that decided `started=3`
1361
+ # produced ONE agent, because calls two and three each saw a fleet already
1362
+ # the size they were asked for.
1363
+ #
1364
+ # SO IT MEANS *START THIS ONE*: the count is one and the subtraction is
1365
+ # skipped. The machine keeps its veto — a starved one still answers with
1366
+ # fewer, which is the reading that must not be skipped, because it is about
1367
+ # the load this very call would add.
1368
+ #
1369
+ # A SEPARATE VARIABLE FROM `PLOT_START_DESK`, and not a side effect of it.
1370
+ # The two answer different questions — *where does this desk go* and *has the
1371
+ # count already been decided* — and the caller that has decided the count is
1372
+ # not always the caller that can name a path. The supervisor is exactly that
1373
+ # caller: the domain names no worktree, so it sets this and leaves the path
1374
+ # to the script's own `Worktree root` convention.
1375
+ start_running=0
1376
+ if [ -n "${PLOT_START_ONE:-}" ]; then
1377
+ start_count=1
1378
+ else
1379
+ while IFS= read -r wt; do
1380
+ [ -n "$wt" ] || continue
1381
+ [ -f "$wt/.plot-worker.pid" ] || continue
1382
+ p=$(cat "$wt/.plot-worker.pid" 2>/dev/null) || continue
1383
+ [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1 && start_running=$((start_running + 1))
1384
+ done <<EOF
1385
+ $(git worktree list --porcelain </dev/null 2>/dev/null | awk '/^worktree /{print $2}')
1386
+ EOF
1387
+ fi
1388
+
1389
+ # THE MACHINE'S OWN READING, one timed fork. `machine-system.ts` samples five
1390
+ # and divides; this takes ONE, because the decision it feeds is coarse — three
1391
+ # bands — and a start that spent 250 ms sampling before deciding whether the
1392
+ # machine is busy would be the story's own complaint reproduced by its fix.
1393
+ #
1394
+ # UNMEASURABLE IS NOT STARVED. A `date` that cannot answer in milliseconds
1395
+ # (BSD `date` without `%N`) leaves the cost empty, the rule reads `unmeasured`,
1396
+ # and an absent veto is not a refusal. The machine vetoes what it can measure.
1397
+ #
1398
+ # THE CLOCK IS BASH'S OWN `EPOCHREALTIME`, not `date` and not `python3`. Both
1399
+ # of those are a FORK, which is precisely the thing being timed — the
1400
+ # measurement would cost two of what it measures and report the sum. Bash 5
1401
+ # expands `EPOCHREALTIME` in-process; where it is empty (bash 4, still the
1402
+ # system shell on macOS) the cost stays unmeasured, which the rule reads as
1403
+ # `unmeasured` rather than as clear.
1404
+ start_cost=""
1405
+ if [ -n "${EPOCHREALTIME:-}" ]; then
1406
+ start_t0=${EPOCHREALTIME/[.,]/}
1407
+ git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1
1408
+ start_t1=${EPOCHREALTIME/[.,]/}
1409
+ # Microseconds to milliseconds. Integer division, so a fork faster than a
1410
+ # millisecond reports 0 — which is honest about a machine this clear and is
1411
+ # the same band `clear` covers anyway.
1412
+ case "$start_t0$start_t1" in
1413
+ *[!0-9]*) ;;
1414
+ *) start_cost=$(( (start_t1 - start_t0) / 1000 )) ;;
1415
+ esac
1416
+ fi
1417
+
1418
+ # THE COUNT IS THE RULE'S, and the rule is `packages/domain/src/rules/
1419
+ # fleet-size.ts` — imported directly, the same shape `plot-reap.sh` uses for
1420
+ # `reapable.ts`. Node 24 strips the types, so there is no build step between
1421
+ # this script and the decision it asks for, and there is no second copy of the
1422
+ # default, the subtraction or the machine's veto living in shell.
1423
+ #
1424
+ # A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. Missing node, a
1425
+ # failed import, a module that throws all leave the answer empty. The
1426
+ # direction is the reaper's: silence is never permission, and here permission
1427
+ # would spawn detached processes.
1428
+ #
1429
+ # TWO MODULES, BECAUSE THE VERDICT AND THE COUNT ARE TWO RULES. `headroomFor`
1430
+ # owns what a fork cost MEANS and `fleetSize` owns what to do about it; the
1431
+ # count rule takes the verdict as a reading rather than deriving it, so the
1432
+ # thresholds have exactly one home and this block is the join.
1433
+ #
1434
+ # IMPORTED AS `.ts` WITH NO `.js` REWRITING. Node 24 strips types but does not
1435
+ # remap a relative specifier, so `fleet-size.ts` may only `import type` from
1436
+ # its neighbours — which is why the verdict arrives as a value here rather
1437
+ # than being computed inside the rule.
1438
+ start_domain="$(cd "$script_dir/../../.." 2>/dev/null && pwd)/packages/domain/src"
1439
+ start_rule="file://$start_domain/rules/fleet-size.ts"
1440
+ start_answer=$(PLOT_REQUESTED="$start_count" PLOT_RUNNING="$start_running" \
1441
+ PLOT_COST="$start_cost" PLOT_RULE="$start_rule" \
1442
+ PLOT_MACHINE="file://$start_domain/entities/machine.ts" \
1443
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
1444
+ const { fleetSize, DEFAULT_FLEET_SIZE } = await import(process.env.PLOT_RULE);
1445
+ const { headroomFor } = await import(process.env.PLOT_MACHINE);
1446
+
1447
+ // AN ABSENT COUNT IS THE RULE'S DEFAULT, resolved here rather than in the
1448
+ // shell: the number and the argument for it have one home.
1449
+ const requested =
1450
+ process.env.PLOT_REQUESTED === "" ? DEFAULT_FLEET_SIZE : Number(process.env.PLOT_REQUESTED);
1451
+
1452
+ // An UNMEASURED cost is null, never zero: zero is the fastest fork there is and
1453
+ // would read as the clearest possible machine.
1454
+ const spawnCostMs = process.env.PLOT_COST === "" ? null : Number(process.env.PLOT_COST);
1455
+
1456
+ const answer = fleetSize({
1457
+ requested,
1458
+ running: Number(process.env.PLOT_RUNNING),
1459
+ spawnCostMs,
1460
+ headroom: headroomFor(spawnCostMs),
1461
+ });
1462
+
1463
+ process.stdout.write(`${answer.start}\t${answer.headroom}\t${answer.shortfall}`);
1464
+ NODE_EOF
1465
+ )
1466
+
1467
+ if [ -z "$start_answer" ]; then
1468
+ echo "plot-dispatch: --start could not ask how many agents to start — starting none." >&2
1469
+ echo " The rule is $start_rule" >&2
1470
+ echo " It needs node 24 and a readable checkout of packages/domain." >&2
1471
+ exit 1
1472
+ fi
1473
+
1474
+ start_n=${start_answer%%$'\t'*}
1475
+ start_rest=${start_answer#*$'\t'}
1476
+ start_headroom=${start_rest%%$'\t'*}
1477
+ start_why=${start_rest#*$'\t'}
1478
+
1479
+ echo "starting $start_n agent(s) — machine $start_headroom, $start_running already running"
1480
+
1481
+ # THE WORKER COMMAND IS ASKED ONCE, BEFORE THE LOOP. `start_worker` prints its
1482
+ # own "start it yourself" line per agent when the key is absent, and N
1483
+ # identical copies of it is a wall rather than a message. Asked here, the
1484
+ # refusal is one sentence naming what to configure.
1485
+ #
1486
+ # THE THREE-WAY ANSWER IS THE FAN-OUT'S OWN `worker=` FIELD, in the footer for
1487
+ # the same reason it is there: `agents=0` with no reason beside it is what was
1488
+ # printed and missed five times on 2026-08-17. A caller reading only the
1489
+ # summary — which is now a performer as well as a person — must be able to
1490
+ # tell *the machine bounded it* from *nobody has configured how to start one*.
1491
+ worker_cmd_declined=0
1492
+ start_worker_state=configured
1493
+ case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
1494
+ none|NONE|None) worker_cmd_declined=1; start_worker_state=declined ;;
1495
+ '') start_worker_state=unconfigured ;;
1496
+ esac
1497
+ if [ "$start_worker_state" != configured ]; then
1498
+ echo " no worker will start — 'Worker command' is $start_worker_state in this repo's Plot Config."
1499
+ echo " The desks below are cut and registered; start them by hand, or set the key."
1500
+ fi
1501
+
1502
+ # `slug` STAYS EMPTY, and the loop reads it. A free agent belongs to no plan
1503
+ # — the registry sends the slug WITH the assignment, which is the same reason
1504
+ # `wait_for_work` skips the outlook scan for an agent that holds none.
1505
+ slug=""
1506
+
1507
+ start_made=0
1508
+ start_i=0
1509
+ while [ "$start_i" -lt "$start_n" ]; do
1510
+ start_i=$((start_i + 1))
1511
+
1512
+ # THE DESK IS DETACHED AT `origin/<main>`, AND NEITHER HALF IS INCIDENTAL.
1513
+ #
1514
+ # A free agent still needs a desk: the loop reads `${PLOT_WORKTREE:-$PWD}`
1515
+ # throughout and the transcript directory is derived from that path. It has
1516
+ # no branch to cut one from, so the base is the only thing left — which is
1517
+ # what the loop's own hop already does. `reset_desk` step 1 checks out
1518
+ # `origin/$main_branch` DETACHED before attaching the slice's branch, so a
1519
+ # desk that starts detached at the base is where every reset passes through
1520
+ # anyway, and the first hand-over is a plain `checkout -b` from it.
1521
+ #
1522
+ # DETACHED RATHER THAN ON THE DEFAULT BRANCH, and that is the guard. A tree
1523
+ # sitting on the default branch is one of `plot-reap.sh`'s five refusals
1524
+ # (`on-default-branch`) — so it is never reaped, but it is also never
1525
+ # measured: the refusal exists because that tree's dispatched branch was
1526
+ # never checked out. A detached desk reads `branch: ''`, so the refusal that
1527
+ # keeps it is `no-merged-pr` — unlanded work, the honest reading of a desk
1528
+ # holding nothing yet — and it is kept for a reason that describes it.
1529
+ #
1530
+ # Git refuses to check out one branch in two worktrees, and the main
1531
+ # checkout usually holds the default branch, so an attached desk could not
1532
+ # be cut here at all.
1533
+ # THE DESK PATH IS THE SCRIPT'S UNLESS A CALLER NAMED ONE.
1534
+ #
1535
+ # `PLOT_START_DESK` is how the supervisor's performer passes the desk the
1536
+ # DECISION named. It is an environment variable rather than a flag because
1537
+ # it takes exactly one path and is set by exactly one caller: a flag would
1538
+ # put a machine-only interface in the help text an operator reads, beside
1539
+ # the count they actually type. A person typing `--start 3` never names
1540
+ # three paths.
1541
+ #
1542
+ # It applies to ONE desk, so a run that was handed it starts one agent —
1543
+ # `fleetSize` is what decides the count and the performer applies its writes
1544
+ # one at a time, which is what keeps the decision and the writes in step.
1545
+ if [ -n "${PLOT_START_DESK:-}" ]; then
1546
+ start_wt="$PLOT_START_DESK"
1547
+ else
1548
+ start_wt="$wt_root/${wt_prefix}free-$(plot_session_id | cut -c1-8)"
1549
+ fi
1550
+ if [ -e "$start_wt" ]; then
1551
+ echo " skipped $start_wt — a desk of that name already exists"
1552
+ continue
1553
+ fi
1554
+ if [ "$dry_run" = 1 ]; then
1555
+ echo " would create $start_wt (detached at origin/$start_main) and start a free agent"
1556
+ start_made=$((start_made + 1))
1557
+ continue
1558
+ fi
1559
+ mkdir -p "$wt_root" 2>/dev/null || true
1560
+ if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
1561
+ # NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
1562
+ # with no remote has no `origin/<main>`; the local one is the same commit
1563
+ # in every case that matters, and a desk on it is still detached.
1564
+ if ! git worktree add -q --detach "$start_wt" "$start_main" 2>/dev/null; then
1565
+ echo " could not create a desk at $start_wt — skipping" >&2
1566
+ continue
1567
+ fi
1568
+ fi
1569
+
1570
+ # SPOTLIGHT IS TOLD NOT TO INDEX THE DESK, exactly as the loop's hop does.
1571
+ # A desk is a full checkout and the fleet makes and unmakes them all day;
1572
+ # the marker is ignored via `info/exclude` rather than `.gitignore`, because
1573
+ # an untracked file in a desk reads as unlanded work to
1574
+ # `plot-worker-state.sh` and would make every free agent look stalled.
1575
+ _excl="$(git -C "$start_wt" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
1576
+ if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
1577
+ printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
1578
+ fi
1579
+ : > "$start_wt/.metadata_never_index" 2>/dev/null || true
1580
+
1581
+ echo " desk $start_wt (detached at origin/$start_main)"
1582
+ # THE EMPTY BRANCH IS THE WHOLE POINT, and `start_worker` already takes it
1583
+ # as a parameter: `write_agent_manifest` writes `"branch": ""`, the loop
1584
+ # reads `PLOT_BRANCH` empty and enters its wait rather than its prompt, and
1585
+ # `isAgentFree` reports the agent free with no change to `rules/free.ts`.
1586
+ if start_worker "" "$start_wt"; then
1587
+ start_made=$((start_made + 1))
1588
+ fi
1589
+ done
1590
+
1591
+ # THE SHORTFALL IS SAID HERE AND STORED NOWHERE. An operator reads it and runs
1592
+ # the command again; a remembered target would be the first piece of state in
1593
+ # a fleet whose statelessness is measured rather than assumed.
1594
+ [ -n "$start_why" ] && echo " $start_why"
1595
+ echo "summary: agents=$start_made requested=${start_count:-default} running=$start_running headroom=$start_headroom worker=$start_worker_state"
1596
+ exit 0
1597
+ fi
1598
+
829
1599
  # ---------------------------------------------------------------------------
830
1600
  # Migration mode: move legacy worktrees into the configured root
831
1601
  # ---------------------------------------------------------------------------
@@ -899,63 +1669,69 @@ if [ "$mode" = "migrate" ]; then
899
1669
  continue
900
1670
  fi
901
1671
 
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.
1672
+ # FOUR READINGS, GATHERED HERE AND DECIDED ELSEWHERE. This block holds no
1673
+ # `if` about whether a worktree may move; it collects what was measured and
1674
+ # `plot-movable.mjs` returns the refusal. The four were shell `if`s until
1675
+ # 2026-09-01, and nothing could trigger one in isolation least of all the
1676
+ # combinations this estate will not produce on demand, a live pid and a
1677
+ # dirty tree at once.
1678
+ #
1679
+ # LIVENESS AND UNLANDED WORK STAY TWO SEPARATE MEASUREMENTS, exactly as they
1680
+ # were: plot_worker_state answers "is a process running or waiting here" and
1681
+ # is keyed on the records a dispatch writes (`.plot-worker.pid`,
1682
+ # `.plot-worker.exit`). A hand-made worktree that never ran one reads `none`
1683
+ # however dirty its tree is and hand-made worktrees are precisely the
1684
+ # estate this mode exists to tidy. The rule reads them as two fields for
1685
+ # that reason.
1686
+ #
1687
+ # plot_worker_state is the ONE liveness answer, sourced by both this script
1688
+ # and the fleet scan. It carries what a bare `ps` cannot — pid-reuse
1689
+ # detection via the manifest's `startedAt`, and the `waiting` state a
1690
+ # PLOT-BLOCKED* marker produces.
918
1691
  wstate_row=$(plot_worker_state "$wt")
919
1692
  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
1693
+ pid=$(printf '%s' "$wstate_row" | cut -f2)
934
1694
 
935
- # REFUSAL 3 — UNCOMMITTED WORK, measured independently of any worker record.
936
1695
  # `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.
1696
+ # own bookkeeping do not count), so this reads real work only.
938
1697
  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
1698
 
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".
1699
+ # Only the branch's OWN upstream answers "pushed?". An absent upstream
1700
+ # leaves the field EMPTY, which the rule reads as unanswerable rather than
1701
+ # as zero and an unanswered question is not a refusal, the principle
1702
+ # plot_worker_task_state reached the hard way when counting against
1703
+ # origin/main marked every clean branch stalled in a remote-less repo.
1704
+ ahead=""
951
1705
  if [ -n "$br" ]; then
952
1706
  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 ;;
1707
+ fi
1708
+
1709
+ # THE DECISION. One call per tree, and the answer is a named refusal.
1710
+ #
1711
+ # A rule that cannot be asked REFUSES: a missing `node`, a missing bundle or
1712
+ # a throwing module all leave `mv_verdict` empty, and an empty verdict keeps
1713
+ # the worktree and says the rule could not be asked. Silence is never
1714
+ # permission — and here the permissive direction moves a checkout an agent
1715
+ # may be writing to, which `git worktree move` breaks mid-run.
1716
+ mv_verdict=$(printf '%s\t%s\t%s\t%s' "$state" "$pid" "$dirty" "$ahead" \
1717
+ | node "$script_dir/board/plot-movable.mjs" 2>/dev/null || true)
1718
+ mv_refusal=${mv_verdict%%$'\t'*}
1719
+ mv_detail=${mv_verdict#*$'\t'}
1720
+
1721
+ # RENDERING, not deciding. The rule named the measurement; this names what
1722
+ # it means to someone reading the table, which is the caller's half because
1723
+ # only the caller knows it is printing one.
1724
+ if [ "$mv_refusal" != "move" ]; then
1725
+ case "$mv_refusal" in
1726
+ live-worker) reason="worker alive (pid $mv_detail)" ;;
1727
+ blocked-marker) reason="blocked marker — needs a person" ;;
1728
+ uncommitted-changes) reason="uncommitted: $mv_detail" ;;
1729
+ unpushed-commits) reason="unpushed commits ($mv_detail ahead)" ;;
1730
+ *) reason="rule could not be asked — keeping" ;;
958
1731
  esac
1732
+ printf '%-8s %-52s %s\n' "keep" "$(basename "$wt")" "$reason"
1733
+ n_skipped=$((n_skipped + 1))
1734
+ continue
959
1735
  fi
960
1736
 
961
1737
  # This worktree is idle — it can be moved.
@@ -1032,8 +1808,7 @@ fi
1032
1808
  # exactly where nothing can catch it. --allow-local is the explicit escape, and
1033
1809
  # it is named in the refusal so an operator learns it exists when they need it.
1034
1810
  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"
1811
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1037
1812
  [ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
1038
1813
 
1039
1814
  PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
@@ -1205,6 +1980,194 @@ esac
1205
1980
  # MAIN was resolved and origin fetched above, before the phase gate — the gate
1206
1981
  # needs the shared ref to read the plan from it.
1207
1982
 
1983
+ # ---------------------------------------------------------------------------
1984
+ # THE PREREQUISITE GATE: a branch that waits on another plan's branch
1985
+ # ---------------------------------------------------------------------------
1986
+ #
1987
+ # A plan may annotate one of its branches `<!-- waits: <branch> -->`, naming ONE
1988
+ # branch — usually of another plan — that must merge before this one may start.
1989
+ # The parser exposes it as `waves[].branches[].waits_on`; the scan turns it into
1990
+ # the branch states `waiting` and `blocked`, so `--next` already stops OFFERING
1991
+ # such a branch.
1992
+ #
1993
+ # THAT IS ONE HALF, AND THIS IS THE OTHER. An empty offer has nothing to say
1994
+ # about what it filtered out: `plot-dispatch.sh <slug>` answered `dispatched=0`
1995
+ # with no reason attached, which is the same silence `--restart` was built to
1996
+ # break. So this names the branch AND the prerequisite, and it names them in
1997
+ # `--dry-run` identically to a real run.
1998
+ #
1999
+ # IT HAS COST TWO WORKERS. Measured 2026-09-02:
2000
+ # `feature/the-domain-forgets-the-vendor-list` was re-dispatched at 04:50 into a
2001
+ # prerequisite that had not merged, hit its own gate, and wrote a PLOT-BLOCKED
2002
+ # marker. Its report names the cause: *"plot-dispatch.sh gates on the plan's
2003
+ # phase, and this plan is Approved, so the slice read as eligible."*
2004
+ #
2005
+ # THE ANNOTATION IS READ FROM THE SHARED REF, out of the same `gate_meta` the
2006
+ # phase gate parsed. A `waits:` that exists only in this working tree is an
2007
+ # ordering constraint nobody else can see, exactly as a local approval is.
2008
+ #
2009
+ # THE PREREQUISITE IS ASKED OF THE HOST, NEVER OF THE REFS.
2010
+ # `plot-release-refs.sh` deletes the remote refs of a delivered plan's merged
2011
+ # branches, so a prerequisite that SUCCEEDED and was then reaped has no ref —
2012
+ # and a rule reading refs would hold its dependent forever BECAUSE its
2013
+ # dependency succeeded. That is the worst available failure: correct work
2014
+ # producing a permanent block. `pr-state` answers about PULL REQUESTS, and a
2015
+ # merged PR outlives the branch it was cut from.
2016
+ #
2017
+ # `NONE` AND SILENCE ARE DIFFERENT ANSWERS. `NONE` means the host was asked and
2018
+ # has never seen a PR for that branch — a typo, which is `blocked`. A host that
2019
+ # could not be asked is neither permission nor proof of a typo, so it HOLDS the
2020
+ # branch at `waiting`. Both refuse; only one tells the operator to fix the plan.
2021
+
2022
+ # What the host says about the prerequisite's pull requests.
2023
+ #
2024
+ # Four answers, and the last two must never be collapsed — see the header
2025
+ # above. `--offline` promises no network, so it answers `unreachable`: the
2026
+ # question was not put, and a flag that lied would be worse than a slower
2027
+ # answer. The same reasoning `reached_review` applies one screen up.
2028
+ prereq_answer() { # $1=prerequisite branch → merged|unmerged|none|unreachable
2029
+ local js st
2030
+ [ -z "$offline" ] || { echo unreachable; return; }
2031
+ [ "$("$script_dir/plot-host.sh" backend 2>/dev/null)" != "none" ] || { echo unreachable; return; }
2032
+ # Exit code first: a non-zero is a transport failure and its stdout is not an
2033
+ # answer. GitHub returned 503 all afternoon on 2026-08-17, and a reader that
2034
+ # trusted the payload on failure would have started every waiting branch.
2035
+ js=$("$script_dir/plot-host.sh" pr-state "$1" </dev/null 2>/dev/null) || { echo unreachable; return; }
2036
+ st=$(printf '%s' "$js" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
2037
+ case "$st" in
2038
+ MERGED) echo merged ;;
2039
+ NONE) echo none ;;
2040
+ # OPEN and CLOSED both mean the host has SEEN the branch. A closed, unmerged
2041
+ # PR is `unmerged` rather than `none`: nothing is misspelled — somebody
2042
+ # withdrew the work, and that resolves by reopening it, not by editing the
2043
+ # plan.
2044
+ OPEN|CLOSED) echo unmerged ;;
2045
+ # A state word this adapter does not emit, or none at all. Unread is not
2046
+ # answered, and this gate's silence holds rather than permits.
2047
+ *) echo unreachable ;;
2048
+ esac
2049
+ }
2050
+
2051
+ # Every branch the plan annotates `waits:`, with what it waits on — read from
2052
+ # the same blob, in the plan's own order, one line of `branch<TAB>prerequisite`.
2053
+ #
2054
+ # NON-DEFERRED ONLY. `deferred:` is a JUDGEMENT — somebody gave the branch up —
2055
+ # and it outranks a wait for the same reason the scan lets it: a branch nobody
2056
+ # will start does not need to be told what it is waiting for. The two
2057
+ # annotations sit on one line and neither reads the other's value.
2058
+ waits_pairs() { # → branch<TAB>prerequisite, one per annotated branch
2059
+ printf '%s' "$gate_meta" | awk '
2060
+ {
2061
+ n = split($0, parts, /\{"branch":"/)
2062
+ for (i = 2; i <= n; i++) {
2063
+ rec = parts[i]
2064
+ br = rec; sub(/".*$/, "", br)
2065
+ if (rec ~ /"deferred":true/) continue
2066
+ if (match(rec, /"waits_on":"[^"]*"/)) {
2067
+ w = substr(rec, RSTART + 12, RLENGTH - 13)
2068
+ if (w != "") print br "\t" w
2069
+ }
2070
+ }
2071
+ }'
2072
+ }
2073
+
2074
+ # THE PREFLIGHT, run once before the fan-out, and it is where the REFUSAL lives.
2075
+ #
2076
+ # WHY IT CANNOT LIVE IN THE LOOP ALONE. The fan-out loop only ever sees what
2077
+ # `plot-fleet-scan.sh` offered, and the scan ALREADY reports a waiting branch as
2078
+ # `waiting` rather than `open` — so it is filtered out before this script hears
2079
+ # of it, and the run ends `dispatched=0 skipped=0` with nothing said about what
2080
+ # was withheld. That silence is the exact defect: an empty offer has nothing to
2081
+ # say about what it filtered out, and a worker was dispatched twice on 2026-09-02
2082
+ # by an operator reading it as "nothing to do here".
2083
+ #
2084
+ # So the plan is walked DIRECTLY. This script already holds the parsed plan from
2085
+ # the shared ref — the same blob its phase gate read — so naming what the fan-out
2086
+ # will not reach costs one host call per annotated branch, on a population of six
2087
+ # plans in 188.
2088
+ #
2089
+ # IT FILLS `waits_held`, WHICH THE LOOPS THEN CONSULT. Two mechanisms, one
2090
+ # decision: this states the refusal, and the loops refuse to write for a branch
2091
+ # it named — belt to that brace, because a scan that could not reach the host
2092
+ # still offers the branch as `open`.
2093
+ declare -a waits_held=()
2094
+ is_waits_held() {
2095
+ local x
2096
+ for x in ${waits_held[@]+"${waits_held[@]}"}; do [ "$x" = "$1" ] && return 0; done
2097
+ return 1
2098
+ }
2099
+
2100
+ # AND IT FILLS `waits_freed`, WHICH IS WHERE `--allow-waiting` GETS ITS
2101
+ # CANDIDATE FROM.
2102
+ #
2103
+ # The override cannot work by relaxing a test in this script, because the branch
2104
+ # never reaches a test here: `plot-fleet-scan.sh` reports a waiting branch as
2105
+ # `waiting` rather than `open`, so `--list-eligible` and `--next` both withhold
2106
+ # it and the loops are handed an empty set. Measured 2026-09-02 — the flag
2107
+ # printed its override line and the run still reported `dispatched=0 skipped=0`,
2108
+ # counting the branch neither way.
2109
+ #
2110
+ # So the flag ADDS a candidate rather than removing a filter. The preflight
2111
+ # already walked the plan from the shared ref and asked the host about the
2112
+ # prerequisite, so it holds the one fact the scan withheld, and naming it here
2113
+ # costs no further call.
2114
+ #
2115
+ # ONLY A BRANCH THE PREFLIGHT ITSELF HELD, and only under the flag. This adds
2116
+ # nothing the scan refused for any OTHER reason — a claimed branch, a `wip` one,
2117
+ # an incomplete prior wave — because those verdicts are not this flag's to
2118
+ # override and the scan remains the only thing that decides them. The branch
2119
+ # still passes every gate the loops apply after it: `held_worktree`, the claim
2120
+ # race, and the brief.
2121
+ declare -a waits_freed=()
2122
+ is_waits_freed() {
2123
+ local x
2124
+ for x in ${waits_freed[@]+"${waits_freed[@]}"}; do [ "$x" = "$1" ] && return 0; done
2125
+ return 1
2126
+ }
2127
+
2128
+ # Runs the preflight: prints its refusals, fills `waits_held`, and adds what it
2129
+ # withheld to `n_skipped`.
2130
+ #
2131
+ # NOT A COMMAND SUBSTITUTION, and that is not a style choice. `$( … )` is a
2132
+ # SUBSHELL, so an array filled inside one is discarded on return — the loops
2133
+ # below would consult an empty `waits_held` and the refusal would be a message
2134
+ # with no effect behind it. So this writes its two results into globals and the
2135
+ # caller invokes it plainly.
2136
+ #
2137
+ # PRINTED IDENTICALLY BY --dry-run AND THE REAL RUN, the discipline `report_held`
2138
+ # already sets: a dry run that offers what a real run would refuse is worse than
2139
+ # no dry run — it is the same wrong answer with a reassurance attached.
2140
+ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipped
2141
+ local br prereq answer held
2142
+ while IFS=$'\t' read -r br prereq; do
2143
+ [ -n "$br" ] || continue
2144
+ answer=$(prereq_answer "$prereq")
2145
+ case "$answer" in
2146
+ merged) continue ;;
2147
+ none) held=blocked ;;
2148
+ *) held=waiting ;;
2149
+ esac
2150
+ # `--allow-waiting` SAYS SO ON THE LINE IT OVERRIDES. An override nobody can
2151
+ # see in the output is an override nobody can audit.
2152
+ if [ "$allow_waiting" = 1 ]; then
2153
+ echo "$br waits on $prereq ($held) — dispatching anyway (--allow-waiting)"
2154
+ waits_freed+=("$br")
2155
+ continue
2156
+ fi
2157
+ waits_held+=("$br")
2158
+ n_skipped=$((n_skipped + 1))
2159
+ if [ "$held" = "blocked" ]; then
2160
+ echo "skipped $br (blocked — no PR found for $prereq)"
2161
+ echo " the plan says this branch waits on $prereq, and the host has never"
2162
+ echo " seen a pull request for it. Check the branch name in the plan."
2163
+ else
2164
+ echo "skipped $br (waiting on $prereq)"
2165
+ echo " the plan says this branch waits on $prereq, which has not merged."
2166
+ echo " Dispatch it when that lands, or pass --allow-waiting to start anyway."
2167
+ fi
2168
+ done < <(waits_pairs)
2169
+ }
2170
+
1208
2171
  # Where the worktrees live and what their names carry — see resolve_wt_root.
1209
2172
  # The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
1210
2173
  # key relocates them (and drops the prefix, which was only earning its keep
@@ -1215,6 +2178,7 @@ repo_root=$(git rev-parse --show-toplevel)
1215
2178
  resolve_wt_root "$repo_root"
1216
2179
 
1217
2180
  n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
2181
+ n_brief_asked=0
1218
2182
 
1219
2183
  # Whether this run COULD have started anything, read once and up front.
1220
2184
  #
@@ -1249,31 +2213,20 @@ worker_state_field() {
1249
2213
  fi
1250
2214
  }
1251
2215
 
1252
- # The summary block: an optional prose line, then the machine-countable footer.
2216
+ # The summary: the machine-countable footer, and nothing above it.
1253
2217
  #
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.
2218
+ # THE PROSE LINE IS GONE, BY ITS OWN RULE. It existed to explain a zero that
2219
+ # had a cause worth naming a `Worker command` nobody had configured, read off
2220
+ # a run that had prepared desks and staffed none. Dispatch starts no worker at
2221
+ # all now, so `started=0` is structural: the line would print on every run,
2222
+ # always true and never informative. `--dry-run` was held to exactly this rule
2223
+ # from the start — *"a dry run starts nothing BY CONSTRUCTION, so it explains
2224
+ # nothing"* — and the fan-out has become the same case.
2225
+ #
2226
+ # `worker=` still travels in the footer. It says how this repo is configured,
2227
+ # which remains a fact about the repo even where it no longer explains a count.
1259
2228
  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"
2229
+ echo "summary: dispatched=$1 reused=$2 skipped=$3 started=$4 brief=missing worker=$(worker_state_field) brief_asked=${n_brief_asked:-0}"
1277
2230
  }
1278
2231
 
1279
2232
  # ---------------------------------------------------------------------------
@@ -1345,7 +2298,7 @@ update_parallel_agents_cap() { # $1 = new cap
1345
2298
  if [ -f "$FLEET_CONTROLS_FILE" ]; then
1346
2299
  # Preserve the existing autoDispatch setting
1347
2300
  local existing
1348
- existing=$(sed -n 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*\(true\|false\).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
2301
+ existing=$(sed -nE 's/.*"autoDispatch"[[:space:]]*:[[:space:]]*(true|false).*/\1/p' "$FLEET_CONTROLS_FILE" | head -1)
1349
2302
  [ -n "$existing" ] && auto_dispatch="$existing"
1350
2303
  fi
1351
2304
 
@@ -1470,14 +2423,39 @@ write_started_record() { # $@ = branches
1470
2423
  fi
1471
2424
 
1472
2425
  if [ -f "$tmpwt/$rel" ]; then
1473
- local br
2426
+ local br wrote=0
1474
2427
  for br in "$@"; do
2428
+ # A BRANCH ALREADY RECORDED IS NOT RECORDED AGAIN, and this check is what
2429
+ # the CLAIM used to do. Dispatch pushed a claim, a claimed branch was
2430
+ # never offered again, and a second run therefore booked nothing — the
2431
+ # idempotence was a side effect of the lock rather than a property of the
2432
+ # record. The claim went with the fan-out's writes, so the record owns its
2433
+ # own idempotence now: a plan dispatched three times must not read as
2434
+ # started three times, or the count drifts from the refs it describes.
2435
+ #
2436
+ # MATCHED ON THE BRANCH IN BACKTICKS, the shape `append_started_line`
2437
+ # writes and `plot-plan-meta.sh` parses. A bare substring match would let
2438
+ # `feature/api` find itself inside `feature/api-v2`.
2439
+ if grep -qF -- "\`$br\`" "$tmpwt/$rel" 2>/dev/null \
2440
+ && grep -q -- "Started:.*\`$br\`" "$tmpwt/$rel" 2>/dev/null; then
2441
+ continue
2442
+ fi
1475
2443
  append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
1476
2444
  echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
1477
2445
  rc=1
1478
2446
  break
1479
2447
  }
2448
+ wrote=1
1480
2449
  done
2450
+ # NOTHING NEW TO SAY IS NOT A FAILURE. Every branch this run handed over was
2451
+ # already on the record, so there is no commit to make and no push to
2452
+ # attempt — and a run that pushed an empty commit would leave one per
2453
+ # re-dispatch on the default branch.
2454
+ if [ "$rc" = 0 ] && [ "$wrote" = 0 ]; then
2455
+ git worktree remove --force "$tmpwt" 2>/dev/null || true
2456
+ git branch -D "$bookbr" >/dev/null 2>&1 || true
2457
+ return 0
2458
+ fi
1481
2459
  if [ "$rc" = 0 ]; then
1482
2460
  git -C "$tmpwt" add -- "$rel" 2>/dev/null
1483
2461
  git -C "$tmpwt" -c "user.name=$who" commit -q \
@@ -1538,6 +2516,23 @@ real_plan_path() { # $1=plan file as found (possibly a symlink, possibly relativ
1538
2516
  append_started_line() { # $1=file $2=date $3=who $4=branch
1539
2517
  local f="$1" line
1540
2518
  line="- **Started:** $2, $3, \`$4\`"
2519
+
2520
+ # ALREADY RECORDED IS NOT AN ERROR — it is a second dispatch of the same
2521
+ # slice, and this returns 0 having written nothing.
2522
+ #
2523
+ # THE CLAIM USED TO BE THIS GUARD. A dispatched branch was claimed by a ref
2524
+ # push, so `--next` never offered it twice and a re-run booked nothing. The
2525
+ # hand-over pushes no claim, so the branch stays `open` and every re-run
2526
+ # reaches this line: measured 2026-09-04, two runs left two identical
2527
+ # `Started:` records in one plan.
2528
+ #
2529
+ # The DATE is deliberately not matched. A slice handed over again tomorrow is
2530
+ # the same start, and a per-day record would drift from the refs it describes
2531
+ # exactly as a per-run one does.
2532
+ if grep -qF -- "\`$4\`" "$f" 2>/dev/null \
2533
+ && grep -q -- "^[ \t]*[-*][ \t]*\*\*Started:\*\*.*\`$4\`" "$f" 2>/dev/null; then
2534
+ return 0
2535
+ fi
1541
2536
  awk -v line="$line" '
1542
2537
  { lines[++n] = $0 }
1543
2538
  END {
@@ -1772,7 +2767,38 @@ held_worktree() { # $1=branch → prints the worktree path when held, else nothi
1772
2767
  # in-flight report a few lines up.
1773
2768
  [ -z "$(uncommitted_files "$wt")" ] || { printf '%s' "$wt"; return 0; }
1774
2769
 
1775
- # Its tip landed already a leftover desk, not a held one.
2770
+ # DID ITS WORK LAND? THE HOST ANSWERS, NOT ANCESTRY.
2771
+ #
2772
+ # This asked `git merge-base --is-ancestor "$br" "origin/$MAIN"` until
2773
+ # 2026-09-04. Measured that day on this estate: ten merged branches still
2774
+ # carried a remote ref and ancestry disagreed with the host on TEN OF TEN.
2775
+ # Squash-merge is not occasionally wrong here — the squashed commit is not the
2776
+ # branch's commit, so the branch stays ahead of main forever and ancestry
2777
+ # answers "not landed" about every squash-merged branch there is.
2778
+ #
2779
+ # The failure direction is throughput, not safety: ancestry called a landed
2780
+ # leftover HELD, so dispatch refused a branch that was free. That is the
2781
+ # cheap half of the plan's measurement and it is still a refusal an operator
2782
+ # has to route around.
2783
+ #
2784
+ # `pr_merged` is the ONE answer, sourced rather than re-derived — the same
2785
+ # gate `plot-reap.sh` and `plot-release-refs.sh` read, for the reason that
2786
+ # file states: two implementations of one question drift, and one of them
2787
+ # drifts permissive.
2788
+ #
2789
+ # ANCESTRY REMAINS AS A SECOND CHANCE, and only toward "landed". A
2790
+ # fast-forward or rebase merge leaves the tip genuinely in main while the host
2791
+ # may hold no PR at all — a branch pushed straight to main, which this repo's
2792
+ # own fixtures do. It can only ever release a worktree the host already
2793
+ # declined to release, so it adds no way to refuse and no way to hide work.
2794
+ #
2795
+ # AN UNREACHABLE HOST ANSWERS "NOT MERGED", which keeps the worktree held.
2796
+ # That is `pr_merged`'s documented direction and the right one here too:
2797
+ # silence is never permission to hand somebody's desk to a second agent.
2798
+ # plot-ancestry: prefilter — second only to `pr_merged` above, and it can
2799
+ # only RELEASE a worktree the host already declined to release. It adds no
2800
+ # refusal, so a squash merge it misreads changes nothing.
2801
+ pr_merged "$br" && return 1
1776
2802
  git merge-base --is-ancestor "$br" "origin/$MAIN" </dev/null 2>/dev/null && return 1
1777
2803
  printf '%s' "$wt"
1778
2804
  }
@@ -1839,6 +2865,37 @@ work_in_flight() { # $1=branch to exclude (the candidate)
1839
2865
  IN_FLIGHT_MAX_FILES=6
1840
2866
  IN_FLIGHT_MAX_BRANCHES=8
1841
2867
 
2868
+ # What a real run would attach, named per worktree — behind `--monitors`.
2869
+ #
2870
+ # SILENT UNLESS ASKED, which is what keeps the default `--dry-run` output
2871
+ # byte-identical to a run from before the monitors existed. That diff is this
2872
+ # slice's protection against the one failure that matters here: a mistake in
2873
+ # `start_worker` starts no workers at all, and the dry run exercises every
2874
+ # refusal against real worktrees and real pids without starting anything.
2875
+ #
2876
+ # IT NAMES THE SCRIPT PATH, not just the monitor. The question a reader has at
2877
+ # a dry run is *which code would run against my worktree* — a bare "2 monitors"
2878
+ # would send them into this script to find out, and a path they can `cat` is
2879
+ # the same courtesy the manifest refusal above pays by naming its directory.
2880
+ #
2881
+ # IT REPORTS ABSENCE TOO. A monitor script that is missing or non-executable
2882
+ # means an unmonitored worker, and the empty env var that produces is invisible
2883
+ # at launch by design (a detached `sh -c` nobody reads must not spew `command
2884
+ # not found`). The dry run is the one place that silence can be made audible
2885
+ # before it matters.
2886
+ report_monitors() { # $1=worktree
2887
+ [ "$show_monitors" = 1 ] || return 0
2888
+ local wt="$1" m
2889
+ for m in worker agent; do
2890
+ local script="$script_dir/plot-$m-monitor.sh"
2891
+ if [ -x "$script" ]; then
2892
+ echo " would attach: $script → $wt"
2893
+ else
2894
+ echo " would attach NOTHING for the $m monitor — $script is missing or not executable"
2895
+ fi
2896
+ done
2897
+ }
2898
+
1842
2899
  report_in_flight() { # $1=candidate branch
1843
2900
  local br files shown extra n=0 total
1844
2901
  total=$(work_in_flight "$1" | wc -l | tr -d ' ')
@@ -1850,7 +2907,7 @@ report_in_flight() { # $1=candidate branch
1850
2907
  # Said once, on the last line, rather than per branch.
1851
2908
  [ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
1852
2909
  echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
1853
- "— plot-fleet for the full picture"
2910
+ "— plot-pulse for the full picture"
1854
2911
  continue
1855
2912
  fi
1856
2913
  # Commas to ", " for reading; the machine-countable summary is the footer,
@@ -1867,6 +2924,13 @@ report_in_flight() { # $1=candidate branch
1867
2924
  done
1868
2925
  }
1869
2926
 
2927
+ # THE PREREQUISITE PREFLIGHT, before either fan-out path. It names every branch
2928
+ # this plan will not start and why, and fills `waits_held` so neither loop can
2929
+ # write for one. Run here, once, rather than inside the loops: the scan filters a
2930
+ # waiting branch out before a loop ever hears of it, so a refusal that only fires
2931
+ # on an offered branch would never fire at all.
2932
+ run_waits_preflight
2933
+
1870
2934
  # A dry run changes nothing, so nothing can go stale — read the whole eligible
1871
2935
  # set once. (`--next` would loop forever here: without a claim it keeps
1872
2936
  # returning the same branch.)
@@ -1881,10 +2945,21 @@ if [ "$dry_run" = 1 ]; then
1881
2945
  n_skipped=$((n_skipped + 1))
1882
2946
  continue
1883
2947
  fi
2948
+ # ALREADY REFUSED BY THE PREFLIGHT, which named it and counted it. The
2949
+ # scan does not normally offer a waiting branch at all; this arm catches
2950
+ # the one that reached here because the scan could not ask the host.
2951
+ is_waits_held "$br" && continue
1884
2952
  echo "would dispatch $br → $(worktree_for "$br")"
2953
+ report_monitors "$(worktree_for "$br")"
1885
2954
  report_in_flight "$br"
1886
2955
  n_dispatched=$((n_dispatched + 1))
1887
- done < <("$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null)
2956
+ # The scan's eligible set, plus whatever `--allow-waiting` freed. The scan
2957
+ # reports a waiting branch as `waiting`, so it is absent from the first and
2958
+ # only the preflight can supply it — see `waits_freed`. `sort -u` because a
2959
+ # branch the scan DID offer (its host call failed where the preflight's
2960
+ # succeeded) must be dispatched once, not twice.
2961
+ done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
2962
+ printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}; } | grep -v '^$' | sort -u)
1888
2963
  # A dry run starts nothing BY CONSTRUCTION, so its `started=0` carries no
1889
2964
  # information about the config — reporting "no workers started" here would be
1890
2965
  # true and useless, and would train the reader to skip the line on the real
@@ -1892,42 +2967,82 @@ if [ "$dry_run" = 1 ]; then
1892
2967
  # `skipped` is REAL here, not a constant. A dry run refuses held branches
1893
2968
  # exactly as the real run does, so its count is a fact about this fleet — and
1894
2969
  # 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)"
2970
+ # `brief_asked=0` is a CONSTANT here and not a prediction. A dry run changes
2971
+ # nothing, and asking the `Brief command` to write a brief spawns an agent
2972
+ # that commits — the loudest write in this script. The field travels so the
2973
+ # footer's shape does not depend on the mode a machine reader happened to
2974
+ # call in.
2975
+ echo "summary: dispatched=$n_dispatched reused=0 skipped=$n_skipped started=0 brief=missing worker=$(worker_state_field) brief_asked=0"
1896
2976
  exit 0
1897
2977
  fi
1898
2978
 
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
2979
+ # THE LIST IS READ ONCE, AND THAT FOLLOWS FROM THE CLAIM GOING.
2980
+ #
2981
+ # This was a PULL: `--next` was asked again after every claim, because claiming
2982
+ # a branch changed what the next ask would offer and a list computed up front
2983
+ # would have gone stale mid-fan-out. Dispatch claims nothing now — it hands a
2984
+ # slice to the registry and returns — so nothing this loop does changes the
2985
+ # scan's answer, and re-asking would return the same branch until a gate marked
2986
+ # it exhausted and the loop broke on it. Measured on the first run after the
2987
+ # claim was removed: `feature/one` handed over, `feature/two` never reached.
2988
+ #
2989
+ # ONE SCAN RATHER THAN N. The scan is 18.3 s here, so the pull cost one of those
2990
+ # per branch to re-derive an answer that could not have moved.
2991
+ #
2992
+ # `--allow-waiting`'s CANDIDATES COME LAST, after every branch the scan was
2993
+ # willing to name. `--list-eligible` reports a waiting branch as `waiting` and
2994
+ # never offers it, so the flag's candidates can only come from the preflight; a
2995
+ # held branch is one the operator chose to start early and must not displace one
2996
+ # that was ready. `sort -u` because a branch the scan DID offer — its host call
2997
+ # failed where the preflight's succeeded — must be handed over once, not twice.
2998
+ #
2999
+ # A `while read` LOOP, BECAUSE macOS SHIPS BASH 3.2. The bash 4 builtin that
3000
+ # reads a stream into an array does not exist there, and
3001
+ # `test/reconcile/mergequeue.test.mjs` gates on it — by grepping these scripts,
3002
+ # so naming the builtin in a comment fails the gate too.
3003
+ #
3004
+ # The single scan this block exists for is unaffected: the subshell still runs
3005
+ # once, and its output is still read once.
3006
+ fan_out=()
3007
+ while IFS= read -r _line; do
3008
+ fan_out+=("$_line")
3009
+ done < <({ "$script_dir/plot-fleet-scan.sh" $offline --list-eligible "$slug" 2>/dev/null
3010
+ [ "$allow_waiting" = 1 ] && printf '%s\n' ${waits_freed[@]+"${waits_freed[@]}"}
3011
+ :; } | grep -v '^$' | sort -u)
3012
+
3013
+ for branch in ${fan_out[@]+"${fan_out[@]}"}; do
1903
3014
  [ "$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).
3015
+ # `exhausted` SURVIVES THE PULL IT WAS WRITTEN FOR. It no longer has to stop
3016
+ # the loop re-offering a branch a list cannot — but the gates below still
3017
+ # mark what they refused, and `sort -u` cannot merge a preflight candidate
3018
+ # with a scan-offered one where the two spellings differ.
3019
+ is_exhausted "$branch" && continue
3020
+
3021
+ # THE PATH DISPATCH NO LONGER CREATES, still composed for the two readers that
3022
+ # need it: the dry run, which names where a desk WOULD go, and the monitor
3023
+ # report. The agent decides its own desk now, so this is a prediction rather
3024
+ # than a destination and it stays flattened whole, because `feature/api` and
3025
+ # `bug/api` are different work and must not name one directory.
1914
3026
  suffix=$(printf '%s' "$branch" | tr '/' '-')
1915
3027
  wt="$wt_root/$wt_prefix$suffix"
1916
3028
 
1917
3029
  if [ "$dry_run" = 1 ]; then
1918
- echo "would dispatch $branch → $wt"
3030
+ echo "would hand over $branch → the registry"
3031
+ report_monitors "$wt"
1919
3032
  report_in_flight "$branch"
1920
3033
  n_dispatched=$((n_dispatched + 1))
1921
3034
  continue
1922
3035
  fi
1923
3036
 
1924
- # THE HELD-BRANCH GATE, ahead of every write this loop makes.
3037
+ # THE HELD-BRANCH GATE, and it survives the fan-out losing its writes.
1925
3038
  #
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.
3039
+ # It refuses a branch whose own desk holds work that has not landed, and that
3040
+ # is a MEASUREMENT of somebody sitting at it not a prediction about a file.
3041
+ # Dispatch creates no desk any more, so this no longer protects an adoption
3042
+ # path; it protects the hand-over itself. Handing a slice to the registry
3043
+ # while an agent is mid-edit on that branch is how two agents end up on one,
3044
+ # and the desk is the only place that work is visible: it is unpushed by
3045
+ # definition, so no ref and no PR reports it.
1931
3046
  #
1932
3047
  # `exhausted` is what makes the refusal terminal: --next has no memory and
1933
3048
  # would keep offering this same branch until the loop's own break fired.
@@ -1938,80 +3053,104 @@ while :; do
1938
3053
  continue
1939
3054
  fi
1940
3055
 
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.
3056
+ # ALREADY REFUSED BY THE PREFLIGHT, and still asked here. A slice handed over
3057
+ # while its prerequisite is unmerged is an agent started on work that cannot
3058
+ # build, and `feature/the-domain-forgets-the-vendor-list` is the measured
3059
+ # case — claimed, and holding nothing but its claim commit.
3060
+ #
3061
+ # `exhausted` is what makes the refusal terminal — `--next` has no memory and
3062
+ # would keep offering this branch until the loop's own break fired. It should
3063
+ # not be offering it at all (the scan reads `waiting`), so this arm is the
3064
+ # belt to the preflight's brace: a branch offered by a scan that could not
3065
+ # reach the host still stops here, and it is NOT counted again — the preflight
3066
+ # already reported it.
3067
+ if is_waits_held "$branch"; then
3068
+ exhausted+=("$branch")
3069
+ continue
3070
+ fi
3071
+
3072
+ # BEFORE anything is handed over. The candidate is not yet work in flight —
3073
+ # dispatch creates no desk and pushes no claim — so this describes what stood
3074
+ # before this run rather than what this run made.
1944
3075
  in_flight=$(report_in_flight "$branch")
1945
3076
 
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))
3077
+ # THE BRIEF GATE, AT THE HAND-OVER RATHER THAN AT THE LAUNCH.
3078
+ #
3079
+ # ITS RULE IS UNCHANGED a slice with no brief is not handed over — AND ONLY
3080
+ # ITS POSITION MOVED. It used to sit between a prepared desk and a started
3081
+ # worker, so a missing brief left a worktree and a claim nobody was sat at:
3082
+ # correct at the time, because preparing was the only thing dispatch could do
3083
+ # first. Dispatch now prepares nothing, so a refused slice leaves nothing at
3084
+ # all and simply stays in the queue.
3085
+ #
3086
+ # THE REFUSAL STILL NAMES THE REF IT LOOKED AT, not a bare path. A brief
3087
+ # sitting unpushed in the operator's checkout is the likeliest reason to see
3088
+ # this message, and `no brief at .plot/briefs/x.md` would send them to look at
3089
+ # a file that is right there — the ref says where the AGENT will look.
3090
+ #
3091
+ # `--no-brief` KEEPS ITS MEANING: it hands over without one and SAYS SO, so
3092
+ # the override stays on the record rather than being silent.
3093
+ if brief_present "$branch"; then
3094
+ # A brief that is present is never refused for age — see
3095
+ # `brief_staleness_note`. The note is printed before the hand-over so it
3096
+ # sits with the branch it describes, and the hand-over happens either way.
3097
+ brief_staleness_note "$branch"
3098
+ elif [ "$no_brief" = 1 ]; then
3099
+ echo " no brief at $(brief_ref "$branch") — handing it over anyway (--no-brief)"
1951
3100
  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
3101
+ echo " not handed over no brief at $(brief_ref "$branch")"
3102
+ echo " write one: /plot-implement $slug (then push it, or pass --no-brief to hand it over without one)"
3103
+ # AND NOW SOMETHING IS DONE ABOUT IT. The refusal above stands unchanged;
3104
+ # this line says what happened NEXT. Either arm names itself, so the log
3105
+ # always records which one ran.
3106
+ request_brief "$branch" "$slug" && n_brief_asked=$((n_brief_asked + 1))
3107
+ n_skipped=$((n_skipped + 1))
3108
+ # `exhausted` is what makes the refusal terminal — `--next` has no memory
3109
+ # and would keep offering this branch until the loop's own break fired.
3110
+ exhausted+=("$branch")
3111
+ continue
1993
3112
  fi
1994
3113
 
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
3114
+ # THE HAND-OVER, AND IT IS THE WHOLE OF WHAT DISPATCH DOES WITH A SLICE.
3115
+ #
3116
+ # `git worktree add` USED TO BE HERE, with a claim push behind it and a worker
3117
+ # start behind that. All three are gone, and each for its own reason:
3118
+ #
3119
+ # THE DESK. `DESIGN-agent.md:65` — *"agent ──owns──► a worktree (its desk,
3120
+ # while it lives)"*. One desk per agent, not one per slice. The agent decides
3121
+ # create-or-reset when it takes the brief, because it is the only party that
3122
+ # can see its own tree; a desk cut here would be cut before anybody knows
3123
+ # which agent will sit at it. Measured 2026-09-02: 2 manifests against 11
3124
+ # worktrees, 5 of them on branches that had already merged.
3125
+ #
3126
+ # THE CLAIM. A pushed claim makes the branch read `claimed` rather than
3127
+ # `open`, and the queue is DERIVEDan eligible slice with a brief and no
3128
+ # claim IS queued. Claiming here would take the slice straight back out of
3129
+ # the queue it was being put into.
3130
+ #
3131
+ # THE WORKER. `DESIGN-agent.md:157` *"nothing starts a worker"*. The
3132
+ # registry spawns an agent, and spawning it IS starting its process. This
3133
+ # script hands work to the fleet; it does not staff it.
3134
+ #
3135
+ # SO THE HAND-OVER IS A REPORT AND NOT A WRITE. The queue derives from the
3136
+ # plan, the briefs and the refs, all of which are already on the host, so
3137
+ # there is nothing for this line to store — which is what keeps the daemon
3138
+ # stateless across restarts.
3139
+ #
3140
+ # IT REFUSES NOTHING FOR WANT OF A FREE AGENT, and never asks. An earlier
3141
+ # draft of the plan proposed refusing on `0 free` and it is wrong: it makes
3142
+ # dispatch synchronous with fleet capacity, the coupling `DESIGN-machine.md`
3143
+ # §10 spent two revisions rejecting, and `DESIGN-agent.md:173` states it from
3144
+ # the other side — *"a dispatch never asks the machine for capacity"*. **The
3145
+ # queue absorbs the timing.** A queue longer than the pool is the normal case.
3146
+ echo "handed over $branch → the registry"
3147
+ [ -n "$in_flight" ] && printf '%s\n' "$in_flight"
3148
+ n_dispatched=$((n_dispatched + 1))
3149
+ # AFTER the hand-over rather than after a claim. A `Started:` record now
3150
+ # states that the slice was handed to the fleet, which is what this run did;
3151
+ # who takes it is the registry's to decide and its own to record.
3152
+ claimed_now+=("$branch")
3153
+
2015
3154
  done
2016
3155
 
2017
3156
  # Book AFTER the fan-out, in one commit, so a booking that fails cannot leave