@plot-pm/board 0.11.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
@@ -2,10 +2,17 @@
2
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
@@ -151,6 +158,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
151
158
  # shellcheck source=plot-pr-merged.sh
152
159
  . "$script_dir/plot-pr-merged.sh"
153
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
+
154
169
  # ---------------------------------------------------------------------------
155
170
  # WHERE THE WORKTREES LIVE, and by what name
156
171
  # ---------------------------------------------------------------------------
@@ -199,6 +214,10 @@ no_brief=0
199
214
  mode=dispatch
200
215
  stop_branch=""
201
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=""
202
221
  offline=""
203
222
  allow_local=0
204
223
  allow_waiting=0
@@ -230,6 +249,16 @@ while [ $# -gt 0 ]; do
230
249
  # plan for feature/x", which describes neither what was asked nor what
231
250
  # went wrong. The branch is consumed only when it looks like one.
232
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 ;;
233
262
  --no-start) no_start=1 ;;
234
263
  --no-brief) no_brief=1 ;;
235
264
  --offline|--no-fetch) offline="--offline" ;;
@@ -240,7 +269,7 @@ while [ $# -gt 0 ]; do
240
269
  ''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
241
270
  esac
242
271
  shift ;;
243
- -h|--help) sed -n '2,46p' "$0"; exit 0 ;;
272
+ -h|--help) sed -n '2,59p' "$0"; exit 0 ;;
244
273
  *) slug="$1" ;;
245
274
  esac
246
275
  shift
@@ -455,13 +484,202 @@ brief_prompt() { # $1 = branch, $2 = slug
455
484
  "$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
456
485
  }
457
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
+
458
659
  request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
459
- local branch="$1" bslug="$2" cmd log
660
+ local branch="$1" bslug="$2" cmd log reach
460
661
  cmd=$(brief_command)
461
662
  if [ -z "$cmd" ]; then
462
663
  echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
463
664
  return 1
464
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
465
683
  log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
466
684
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
467
685
  # `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
@@ -1100,6 +1318,284 @@ if [ "$mode" = "restart" ]; then
1100
1318
  exit 0
1101
1319
  fi
1102
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
+
1103
1599
  # ---------------------------------------------------------------------------
1104
1600
  # Migration mode: move legacy worktrees into the configured root
1105
1601
  # ---------------------------------------------------------------------------
@@ -1312,8 +1808,7 @@ fi
1312
1808
  # exactly where nothing can catch it. --allow-local is the explicit escape, and
1313
1809
  # it is named in the refusal so an operator learns it exists when they need it.
1314
1810
  MAIN=$(bash "$script_dir/plot-config.sh" get "Main branch")
1315
- [ -n "$MAIN" ] || MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
1316
- [ -n "$MAIN" ] || MAIN="main"
1811
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1317
1812
  [ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
1318
1813
 
1319
1814
  PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
@@ -2412,7 +2907,7 @@ report_in_flight() { # $1=candidate branch
2412
2907
  # Said once, on the last line, rather than per branch.
2413
2908
  [ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
2414
2909
  echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
2415
- "— plot-fleet for the full picture"
2910
+ "— plot-pulse for the full picture"
2416
2911
  continue
2417
2912
  fi
2418
2913
  # Commas to ", " for reading; the machine-countable summary is the footer,