@plot-pm/board 0.11.0 → 0.13.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,14 +269,18 @@ 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
247
276
  done
248
277
 
249
278
  git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
250
- [ -n "$slug" ] || [ "$mode" != dispatch ] || { echo "plot-dispatch: need a plan slug" >&2; exit 1; }
279
+ [ -n "$slug" ] || [ "$mode" != dispatch ] || {
280
+ echo "plot-dispatch: need a plan slug (usage: plot-dispatch.sh [--dry-run] <slug>)" >&2
281
+ echo " Which plans could be dispatched: /plot-pulse" >&2
282
+ exit 1
283
+ }
251
284
 
252
285
  # ---------------------------------------------------------------------------
253
286
  # Worker launch, and the identity it records
@@ -455,13 +488,202 @@ brief_prompt() { # $1 = branch, $2 = slug
455
488
  "$2" "$1" "$(brief_path "$1")" "$MAIN" "$MAIN"
456
489
  }
457
490
 
491
+ # WHETHER THE AGENT THIS COMMAND SPAWNS CAN REACH `plot-implement` AT ALL.
492
+ #
493
+ # `Brief command` runs an agent headless and asks it for `/plot-implement
494
+ # <slug>`. `plot-implement` is a SKILL, and a skill the running agent does not
495
+ # have resolves to nothing. Measured 2026-09-06: Plot is installed as a plugin
496
+ # at 1.2.0 against this repository's 2.13.0, and the installed `skills/` holds
497
+ # eight — `plot-implement` is not among them, nor are `plot-dispatch`,
498
+ # `plot-fleet`, `plot-pulse`, `plot-reconcile`, `plot-board`, `plot-init`,
499
+ # `plot-merge-queue` or `plot-reslice`. Twelve are missing.
500
+ #
501
+ # So `Unknown command: /plot-implement` was LITERALLY TRUE in both 33-byte logs
502
+ # (2026-09-02, 2026-09-04), and the board's *"Run /plot-implement `<slug>` and
503
+ # follow it."* would have failed identically. The prompt's wording was never the
504
+ # defect, and rewriting it fixes nothing.
505
+ #
506
+ # THE INSTALL BEING SCOPED TO ANOTHER PROJECT IS WHY FOUR MONTHS PASSED
507
+ # UNNOTICED. The registry entry carries `scope: project` and `projectPath:
508
+ # /Users/jwloka/Quatico/CDS/cpq-cds-develop` — a DIFFERENT repository, installed
509
+ # 2026-05-08. A plugin installed elsewhere is not absent, it is somewhere else,
510
+ # and nothing on this estate named that.
511
+ #
512
+ # IT ASKS WHETHER THE SKILL DIRECTORY EXISTS, NOT WHETHER THE VERSION MATCHES.
513
+ # A lagging install that still carries the skill works, and a version comparison
514
+ # would refuse it and demand an update nobody needs. One missing directory is
515
+ # what breaks a brief session, so that is what is read.
516
+ #
517
+ # IT FAILS TOWARD ALLOWING. An unreadable or absent `installed_plugins.json`
518
+ # means CANNOT VERIFY, not broken: a project running Plot from a checkout with
519
+ # no plugin install at all is a supported shape, and refusing it would break
520
+ # dispatch for everyone who never installed the plugin. It says the check could
521
+ # not be made, and proceeds.
522
+ #
523
+ # `PLOT_PLUGIN_ROOT` is the override, the same one `plot-board-probe.sh` uses so
524
+ # tests need not depend on `$HOME`.
525
+ plugin_registry() { printf '%s/installed_plugins.json' "${PLOT_PLUGIN_ROOT:-${HOME:-}/.claude/plugins}"; }
526
+
527
+ # Every `installPath` recorded for a plugin whose key starts `plot@`, one per
528
+ # line. The marketplace half of the key is not fixed — `plot@plot-marketplace`
529
+ # here, but a repository may serve Plot from a marketplace under any name — so
530
+ # the prefix is what is matched rather than the whole key.
531
+ #
532
+ # `awk` rather than `node` or `jq`: this script shells to neither anywhere else,
533
+ # and a check that fails toward allowing must not acquire a dependency whose
534
+ # absence it would then have to read as "cannot verify" on every machine that
535
+ # lacks it.
536
+ #
537
+ # IT DOES NOT DEPEND ON THE FILE'S LINE BREAKS. The registry Claude Code writes
538
+ # is pretty-printed, one field per line, and a line-oriented read of it works —
539
+ # but only because of how it happens to be formatted, and this check's wrong
540
+ # answer is silent. Measured 2026-09-07 against a compact registry written by
541
+ # hand: the whole object on two lines, and the reader found no install at all,
542
+ # reported "could not verify" and allowed. Safe, and still wrong about a file
543
+ # that was perfectly readable.
544
+ #
545
+ # So the text is flattened to one token per line FIRST — every `"…"` string and
546
+ # every `[` `]` `{` `}` on its own line — and the scan then walks tokens rather
547
+ # than lines. Formatting stops being an input.
548
+ #
549
+ # The bracket depth is what scopes a key: a plugin's entry runs from the `[`
550
+ # after its key to the `]` that closes it, so an `installPath` is attributed to
551
+ # the key whose array still encloses it. Tracking the key alone would let the
552
+ # LAST `plot@…` key claim every install after it in the file.
553
+ plugin_install_paths() { # → one installPath per line, for keys matching plot@*
554
+ awk '
555
+ {
556
+ line = $0
557
+ while (length(line) > 0) {
558
+ c = substr(line, 1, 1)
559
+ if (c == "\"") {
560
+ # A JSON string, taken whole: scan to the closing quote, honouring
561
+ # backslash escapes so a quote inside a value does not end it early.
562
+ v = ""; i = 2
563
+ while (i <= length(line)) {
564
+ ch = substr(line, i, 1)
565
+ if (ch == "\\") { v = v substr(line, i + 1, 1); i += 2; continue }
566
+ if (ch == "\"") break
567
+ v = v ch; i++
568
+ }
569
+ print "S" v
570
+ line = substr(line, i + 1)
571
+ } else if (c == "[" || c == "]" || c == "{" || c == "}") {
572
+ print "P" c
573
+ line = substr(line, 2)
574
+ } else {
575
+ line = substr(line, 2)
576
+ }
577
+ }
578
+ }
579
+ ' "$1" 2>/dev/null | awk '
580
+ # `pending` is the string most recently seen, which is a KEY when the next
581
+ # token opens a container. `depth` is where this plugin"s array closes.
582
+ /^P\[/ {
583
+ depth++
584
+ if (pending ~ /^plot@/ && inplot == 0) { inplot = 1; plotdepth = depth }
585
+ pending = ""; next
586
+ }
587
+ /^P\]/ {
588
+ if (inplot && depth == plotdepth) inplot = 0
589
+ depth--; pending = ""; next
590
+ }
591
+ /^P/ { pending = ""; next }
592
+ /^S/ {
593
+ v = substr($0, 2)
594
+ if (want) { if (inplot && v != "") print v; want = 0; pending = ""; next }
595
+ if (v == "installPath") { want = 1; next }
596
+ pending = v
597
+ }
598
+ '
599
+ }
600
+
601
+ # Can a spawned agent reach `plot-implement`?
602
+ #
603
+ # 0 — yes: some recorded install carries `skills/plot-implement/`
604
+ # 1 — no: the registry was read, Plot is installed, and none of its installs
605
+ # carry the skill. This is the refusal.
606
+ # 2 — cannot verify: no registry, unreadable, or no `plot@*` entry at all.
607
+ # Allowed, and said.
608
+ #
609
+ # The three-way answer is the whole point. Collapsing "cannot verify" into
610
+ # either of the others is how this check would break a repository running Plot
611
+ # from a checkout, or wave through the exact install that produced two empty
612
+ # logs.
613
+ implement_skill_reach() { # → 0 reachable, 1 unreachable, 2 unverifiable
614
+ local reg paths p
615
+ reg=$(plugin_registry)
616
+ [ -n "$reg" ] && [ -r "$reg" ] || return 2
617
+ paths=$(plugin_install_paths "$reg")
618
+ [ -n "$paths" ] || return 2
619
+ while IFS= read -r p; do
620
+ [ -n "$p" ] || continue
621
+ case "$p" in "~/"*) p="${HOME:-}/${p#\~/}" ;; esac
622
+ [ -d "$p/skills/plot-implement" ] && return 0
623
+ done <<< "$paths"
624
+ return 1
625
+ }
626
+
627
+ # What the refusal SAYS, and it is the shape `plot-fleetctl.sh:306` uses for a
628
+ # wrong `node` major — the reading first, then the repair.
629
+ #
630
+ # IT NAMES A REPAIR AN OPERATOR CAN PERFORM. Plot cannot install its own plugin;
631
+ # updating an install is an action on a machine. So the message reports what was
632
+ # read — which installs, and that none carries the skill — and names the command
633
+ # that fixes it. Nothing here installs or updates anything.
634
+ implement_unreachable_report() { # prints the reading and the repair, indented
635
+ local reg p
636
+ reg=$(plugin_registry)
637
+ echo " no-implement-skill — the agent this would spawn cannot reach \`/plot-implement\`"
638
+ echo " read: $reg"
639
+ while IFS= read -r p; do
640
+ [ -n "$p" ] || continue
641
+ echo " install: $p (no skills/plot-implement/)"
642
+ done <<< "$(plugin_install_paths "$reg")"
643
+ echo " A prompt naming a skill the agent does not have resolves to nothing,"
644
+ echo " whatever its wording — measured twice as a 33-byte log reading"
645
+ echo " \`Unknown command: /plot-implement\`. Refusing before spawning is what"
646
+ echo " puts that in front of you instead of in a log nobody reads."
647
+ echo " Fix it: update the Plot plugin so its skills/ carries plot-implement"
648
+ echo " (/plugin, or reinstall from the marketplace), then dispatch again."
649
+ echo " Or write the brief yourself: /plot-implement $1"
650
+ }
651
+
652
+ # THE THIRD ANSWER, and it allows. A registry that cannot be read says nothing
653
+ # about whether the skill is there — a project running Plot from a checkout with
654
+ # no plugin install at all is a supported shape, and refusing it would break
655
+ # dispatch for everyone who never installed the plugin. So the run says the
656
+ # check could not be made and proceeds, which is what keeps a silent skip from
657
+ # reading as a verified pass.
658
+ implement_unverifiable_note() {
659
+ echo " could not verify that the agent can reach the implement skill —"
660
+ echo " no readable plugin registry at $(plugin_registry); proceeding"
661
+ }
662
+
458
663
  request_brief() { # $1 = branch, $2 = slug → 0 if a command was started
459
- local branch="$1" bslug="$2" cmd log
664
+ local branch="$1" bslug="$2" cmd log reach
460
665
  cmd=$(brief_command)
461
666
  if [ -z "$cmd" ]; then
462
667
  echo " no-brief-command — no \`Brief command\` in Plot Config, so nothing was asked to write it"
463
668
  return 1
464
669
  fi
670
+ # REFUSES BEFORE SPAWNING, NEVER AFTER. Failing late cost two logs nobody
671
+ # read and a session writing nine briefs by hand without asking why the arm
672
+ # was silent. The refusal is read by whoever ran the dispatch.
673
+ #
674
+ # A DIFFERENT REFUSAL FROM `no-brief-command`, and it gets its own word rather
675
+ # than overloading that one: there the key is absent and nothing was asked to
676
+ # write the brief; here the key is SET and the skill it names is unreachable.
677
+ # One word for two states is a word an operator cannot act on.
678
+ implement_skill_reach
679
+ reach=$?
680
+ if [ "$reach" = 1 ]; then
681
+ implement_unreachable_report "$bslug"
682
+ return 1
683
+ fi
684
+ if [ "$reach" = 2 ]; then
685
+ implement_unverifiable_note
686
+ fi
465
687
  log="$repo_root/.plot/brief-$(printf '%s' "${branch##*/}").log"
466
688
  mkdir -p "$(dirname "$log")" 2>/dev/null || true
467
689
  # `nohup ... &` inside a subshell, the same detachment `start_worker` uses:
@@ -950,14 +1172,38 @@ if [ "$mode" = "stop" ]; then
950
1172
  echo " Refusing to guess — stopping the wrong worker discards its work." >&2
951
1173
  exit 1
952
1174
  fi
953
- wt="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
954
- [ -d "$wt" ] || { echo "plot-dispatch: no worktree for '$stop_branch' at $wt" >&2; exit 1; }
1175
+ # ASK GIT WHICH WORKTREE HOLDS THE BRANCH, then fall back to the dispatch
1176
+ # path. `--restart` below already asks, and this did not: it rebuilt one path
1177
+ # from the branch name and reported that single path as though it were the
1178
+ # only place a desk could be. On 2026-09-07 the desk existed elsewhere — a
1179
+ # worktree made by hand, which is the population that never follows
1180
+ # dispatch's naming — and the refusal sent a reader to `kill`.
1181
+ #
1182
+ # A refusal that is confidently wrong is worse than one that is terse, so the
1183
+ # path-guess survives only as the LAST candidate and the refusal below says
1184
+ # which places were looked in.
1185
+ wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$stop_branch" '
1186
+ /^worktree / { path = substr($0, 10) }
1187
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
1188
+ wt_guess="$wt_root_early/$wt_prefix_early$(printf '%s' "$stop_branch" | tr '/' '-')"
1189
+ [ -n "$wt" ] && [ -d "$wt" ] || wt="$wt_guess"
1190
+ if [ ! -d "$wt" ]; then
1191
+ echo "plot-dispatch: no worktree holds '$stop_branch' — nothing to stop." >&2
1192
+ echo " Asked git for every worktree, and looked at $wt_guess." >&2
1193
+ echo " If a worker is running somewhere this cannot see, that machine is" >&2
1194
+ echo " where to stop it: /plot-dispatch --status names the desks here." >&2
1195
+ echo " Nothing was killed." >&2
1196
+ exit 1
1197
+ fi
955
1198
  st=$(worker_state "$wt" "$stop_branch")
956
1199
  case "$st" in
957
1200
  running*)
958
1201
  pid=${st#running }
959
1202
  kill "$pid" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
960
- || { echo "plot-dispatch: could not stop pid $pid" >&2; exit 1; }
1203
+ || { echo "plot-dispatch: could not stop pid $pid — it may have exited between the read and the signal, or belong to another user." >&2
1204
+ echo " Check it: ps -p $pid -o pid=,stat=,command=" >&2
1205
+ echo " Nothing else was written; the worktree and the claim stand." >&2
1206
+ exit 1; }
961
1207
  # The worktree and its claim are left in place: the branch is still taken,
962
1208
  # and deleting either would be the kind of write this design avoids.
963
1209
  echo " worktree kept at $wt — the claim stands until you release it"
@@ -1100,6 +1346,284 @@ if [ "$mode" = "restart" ]; then
1100
1346
  exit 0
1101
1347
  fi
1102
1348
 
1349
+ if [ "$mode" = "start" ]; then
1350
+ # THE LAST LINK IN THE CHAIN. `plot-dispatch.sh <slug>` queues slices and the
1351
+ # registry matches them to free agents, but until this verb existed nothing
1352
+ # brought a free agent into being. Measured 2026-09-05: a dispatch reported
1353
+ # `handed over feature/... → the registry` and `started=0`, the supervisor
1354
+ # ticked `queued=456 idle=0 agents registered: 0`, and `.plot/agents/` was
1355
+ # empty. The slice was queued, the registry was willing, and there was nobody
1356
+ # to hand it to.
1357
+ #
1358
+ # AN AGENT IS STARTED WITH NO SLICE, and that is the whole shape. It gets a
1359
+ # desk, a manifest naming no branch, and a loop that waits — `isAgentFree`
1360
+ # already reports exactly that state as free (`rules/free.ts:64`: alive, and
1361
+ # `branch === ""`), so the supervisor's next tick can hand each one a queued
1362
+ # slice with nobody touching a desk.
1363
+ #
1364
+ # HERE, BESIDE --stop AND --restart AND BEFORE THE PHASE GATE, for the reason
1365
+ # that block already gives from the other side: starting a free agent is not
1366
+ # about any plan, so there is no plan whose phase could refuse it. A gate on a
1367
+ # slug this verb never takes would refuse every call.
1368
+ repo_root="$repo_root_early"
1369
+ resolve_wt_root "$repo_root"
1370
+
1371
+ # THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
1372
+ # config key, then origin's own HEAD, then `main`. Resolved here because the
1373
+ # fan-out's `MAIN` is set past the phase gate this path exits before.
1374
+ start_main=$(bash "$script_dir/plot-config.sh" get "Main branch")
1375
+ [ -n "$start_main" ] || start_main=$(default_branch)
1376
+ [ -n "$start_main" ] || start_main="main"
1377
+
1378
+ # HOW MANY WORKERS ARE ALREADY UP, counted from the desks on this disk rather
1379
+ # than from the registry. A manifest records a launch; a live pid records a
1380
+ # worker, and the question `--start` asks is about the machine's load. The
1381
+ # count is what `fleetSize` subtracts, so asking for three twice gives three
1382
+ # agents rather than six.
1383
+ #
1384
+ # A CALLER THAT HAS ALREADY DECIDED THE COUNT SUBTRACTS NOTHING, and
1385
+ # `PLOT_START_ONE` is how it says so. The supervisor's tick ran `fleetSize`
1386
+ # against the WHOLE fleet, decided N, and hands this script one write at a
1387
+ # time; re-counting per call subtracts the agent the PREVIOUS call just
1388
+ # started. Measured 2026-09-05 in a sandbox: a tick that decided `started=3`
1389
+ # produced ONE agent, because calls two and three each saw a fleet already
1390
+ # the size they were asked for.
1391
+ #
1392
+ # SO IT MEANS *START THIS ONE*: the count is one and the subtraction is
1393
+ # skipped. The machine keeps its veto — a starved one still answers with
1394
+ # fewer, which is the reading that must not be skipped, because it is about
1395
+ # the load this very call would add.
1396
+ #
1397
+ # A SEPARATE VARIABLE FROM `PLOT_START_DESK`, and not a side effect of it.
1398
+ # The two answer different questions — *where does this desk go* and *has the
1399
+ # count already been decided* — and the caller that has decided the count is
1400
+ # not always the caller that can name a path. The supervisor is exactly that
1401
+ # caller: the domain names no worktree, so it sets this and leaves the path
1402
+ # to the script's own `Worktree root` convention.
1403
+ start_running=0
1404
+ if [ -n "${PLOT_START_ONE:-}" ]; then
1405
+ start_count=1
1406
+ else
1407
+ while IFS= read -r wt; do
1408
+ [ -n "$wt" ] || continue
1409
+ [ -f "$wt/.plot-worker.pid" ] || continue
1410
+ p=$(cat "$wt/.plot-worker.pid" 2>/dev/null) || continue
1411
+ [ -n "$p" ] && ps -p "$p" >/dev/null 2>&1 && start_running=$((start_running + 1))
1412
+ done <<EOF
1413
+ $(git worktree list --porcelain </dev/null 2>/dev/null | awk '/^worktree /{print $2}')
1414
+ EOF
1415
+ fi
1416
+
1417
+ # THE MACHINE'S OWN READING, one timed fork. `machine-system.ts` samples five
1418
+ # and divides; this takes ONE, because the decision it feeds is coarse — three
1419
+ # bands — and a start that spent 250 ms sampling before deciding whether the
1420
+ # machine is busy would be the story's own complaint reproduced by its fix.
1421
+ #
1422
+ # UNMEASURABLE IS NOT STARVED. A `date` that cannot answer in milliseconds
1423
+ # (BSD `date` without `%N`) leaves the cost empty, the rule reads `unmeasured`,
1424
+ # and an absent veto is not a refusal. The machine vetoes what it can measure.
1425
+ #
1426
+ # THE CLOCK IS BASH'S OWN `EPOCHREALTIME`, not `date` and not `python3`. Both
1427
+ # of those are a FORK, which is precisely the thing being timed — the
1428
+ # measurement would cost two of what it measures and report the sum. Bash 5
1429
+ # expands `EPOCHREALTIME` in-process; where it is empty (bash 4, still the
1430
+ # system shell on macOS) the cost stays unmeasured, which the rule reads as
1431
+ # `unmeasured` rather than as clear.
1432
+ start_cost=""
1433
+ if [ -n "${EPOCHREALTIME:-}" ]; then
1434
+ start_t0=${EPOCHREALTIME/[.,]/}
1435
+ git -C "$repo_root" rev-parse --git-dir >/dev/null 2>&1
1436
+ start_t1=${EPOCHREALTIME/[.,]/}
1437
+ # Microseconds to milliseconds. Integer division, so a fork faster than a
1438
+ # millisecond reports 0 — which is honest about a machine this clear and is
1439
+ # the same band `clear` covers anyway.
1440
+ case "$start_t0$start_t1" in
1441
+ *[!0-9]*) ;;
1442
+ *) start_cost=$(( (start_t1 - start_t0) / 1000 )) ;;
1443
+ esac
1444
+ fi
1445
+
1446
+ # THE COUNT IS THE RULE'S, and the rule is `packages/domain/src/rules/
1447
+ # fleet-size.ts` — imported directly, the same shape `plot-reap.sh` uses for
1448
+ # `reapable.ts`. Node 24 strips the types, so there is no build step between
1449
+ # this script and the decision it asks for, and there is no second copy of the
1450
+ # default, the subtraction or the machine's veto living in shell.
1451
+ #
1452
+ # A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. Missing node, a
1453
+ # failed import, a module that throws all leave the answer empty. The
1454
+ # direction is the reaper's: silence is never permission, and here permission
1455
+ # would spawn detached processes.
1456
+ #
1457
+ # TWO MODULES, BECAUSE THE VERDICT AND THE COUNT ARE TWO RULES. `headroomFor`
1458
+ # owns what a fork cost MEANS and `fleetSize` owns what to do about it; the
1459
+ # count rule takes the verdict as a reading rather than deriving it, so the
1460
+ # thresholds have exactly one home and this block is the join.
1461
+ #
1462
+ # IMPORTED AS `.ts` WITH NO `.js` REWRITING. Node 24 strips types but does not
1463
+ # remap a relative specifier, so `fleet-size.ts` may only `import type` from
1464
+ # its neighbours — which is why the verdict arrives as a value here rather
1465
+ # than being computed inside the rule.
1466
+ start_domain="$(cd "$script_dir/../../.." 2>/dev/null && pwd)/packages/domain/src"
1467
+ start_rule="file://$start_domain/rules/fleet-size.ts"
1468
+ start_answer=$(PLOT_REQUESTED="$start_count" PLOT_RUNNING="$start_running" \
1469
+ PLOT_COST="$start_cost" PLOT_RULE="$start_rule" \
1470
+ PLOT_MACHINE="file://$start_domain/entities/machine.ts" \
1471
+ node --input-type=module - <<'NODE_EOF' 2>/dev/null
1472
+ const { fleetSize, DEFAULT_FLEET_SIZE } = await import(process.env.PLOT_RULE);
1473
+ const { headroomFor } = await import(process.env.PLOT_MACHINE);
1474
+
1475
+ // AN ABSENT COUNT IS THE RULE'S DEFAULT, resolved here rather than in the
1476
+ // shell: the number and the argument for it have one home.
1477
+ const requested =
1478
+ process.env.PLOT_REQUESTED === "" ? DEFAULT_FLEET_SIZE : Number(process.env.PLOT_REQUESTED);
1479
+
1480
+ // An UNMEASURED cost is null, never zero: zero is the fastest fork there is and
1481
+ // would read as the clearest possible machine.
1482
+ const spawnCostMs = process.env.PLOT_COST === "" ? null : Number(process.env.PLOT_COST);
1483
+
1484
+ const answer = fleetSize({
1485
+ requested,
1486
+ running: Number(process.env.PLOT_RUNNING),
1487
+ spawnCostMs,
1488
+ headroom: headroomFor(spawnCostMs),
1489
+ });
1490
+
1491
+ process.stdout.write(`${answer.start}\t${answer.headroom}\t${answer.shortfall}`);
1492
+ NODE_EOF
1493
+ )
1494
+
1495
+ if [ -z "$start_answer" ]; then
1496
+ echo "plot-dispatch: --start could not ask how many agents to start — starting none." >&2
1497
+ echo " The rule is $start_rule" >&2
1498
+ echo " It needs node 24 and a readable checkout of packages/domain." >&2
1499
+ exit 1
1500
+ fi
1501
+
1502
+ start_n=${start_answer%%$'\t'*}
1503
+ start_rest=${start_answer#*$'\t'}
1504
+ start_headroom=${start_rest%%$'\t'*}
1505
+ start_why=${start_rest#*$'\t'}
1506
+
1507
+ echo "starting $start_n agent(s) — machine $start_headroom, $start_running already running"
1508
+
1509
+ # THE WORKER COMMAND IS ASKED ONCE, BEFORE THE LOOP. `start_worker` prints its
1510
+ # own "start it yourself" line per agent when the key is absent, and N
1511
+ # identical copies of it is a wall rather than a message. Asked here, the
1512
+ # refusal is one sentence naming what to configure.
1513
+ #
1514
+ # THE THREE-WAY ANSWER IS THE FAN-OUT'S OWN `worker=` FIELD, in the footer for
1515
+ # the same reason it is there: `agents=0` with no reason beside it is what was
1516
+ # printed and missed five times on 2026-08-17. A caller reading only the
1517
+ # summary — which is now a performer as well as a person — must be able to
1518
+ # tell *the machine bounded it* from *nobody has configured how to start one*.
1519
+ worker_cmd_declined=0
1520
+ start_worker_state=configured
1521
+ case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
1522
+ none|NONE|None) worker_cmd_declined=1; start_worker_state=declined ;;
1523
+ '') start_worker_state=unconfigured ;;
1524
+ esac
1525
+ if [ "$start_worker_state" != configured ]; then
1526
+ echo " no worker will start — 'Worker command' is $start_worker_state in this repo's Plot Config."
1527
+ echo " The desks below are cut and registered; start them by hand, or set the key."
1528
+ fi
1529
+
1530
+ # `slug` STAYS EMPTY, and the loop reads it. A free agent belongs to no plan
1531
+ # — the registry sends the slug WITH the assignment, which is the same reason
1532
+ # `wait_for_work` skips the outlook scan for an agent that holds none.
1533
+ slug=""
1534
+
1535
+ start_made=0
1536
+ start_i=0
1537
+ while [ "$start_i" -lt "$start_n" ]; do
1538
+ start_i=$((start_i + 1))
1539
+
1540
+ # THE DESK IS DETACHED AT `origin/<main>`, AND NEITHER HALF IS INCIDENTAL.
1541
+ #
1542
+ # A free agent still needs a desk: the loop reads `${PLOT_WORKTREE:-$PWD}`
1543
+ # throughout and the transcript directory is derived from that path. It has
1544
+ # no branch to cut one from, so the base is the only thing left — which is
1545
+ # what the loop's own hop already does. `reset_desk` step 1 checks out
1546
+ # `origin/$main_branch` DETACHED before attaching the slice's branch, so a
1547
+ # desk that starts detached at the base is where every reset passes through
1548
+ # anyway, and the first hand-over is a plain `checkout -b` from it.
1549
+ #
1550
+ # DETACHED RATHER THAN ON THE DEFAULT BRANCH, and that is the guard. A tree
1551
+ # sitting on the default branch is one of `plot-reap.sh`'s five refusals
1552
+ # (`on-default-branch`) — so it is never reaped, but it is also never
1553
+ # measured: the refusal exists because that tree's dispatched branch was
1554
+ # never checked out. A detached desk reads `branch: ''`, so the refusal that
1555
+ # keeps it is `no-merged-pr` — unlanded work, the honest reading of a desk
1556
+ # holding nothing yet — and it is kept for a reason that describes it.
1557
+ #
1558
+ # Git refuses to check out one branch in two worktrees, and the main
1559
+ # checkout usually holds the default branch, so an attached desk could not
1560
+ # be cut here at all.
1561
+ # THE DESK PATH IS THE SCRIPT'S UNLESS A CALLER NAMED ONE.
1562
+ #
1563
+ # `PLOT_START_DESK` is how the supervisor's performer passes the desk the
1564
+ # DECISION named. It is an environment variable rather than a flag because
1565
+ # it takes exactly one path and is set by exactly one caller: a flag would
1566
+ # put a machine-only interface in the help text an operator reads, beside
1567
+ # the count they actually type. A person typing `--start 3` never names
1568
+ # three paths.
1569
+ #
1570
+ # It applies to ONE desk, so a run that was handed it starts one agent —
1571
+ # `fleetSize` is what decides the count and the performer applies its writes
1572
+ # one at a time, which is what keeps the decision and the writes in step.
1573
+ if [ -n "${PLOT_START_DESK:-}" ]; then
1574
+ start_wt="$PLOT_START_DESK"
1575
+ else
1576
+ start_wt="$wt_root/${wt_prefix}free-$(plot_session_id | cut -c1-8)"
1577
+ fi
1578
+ if [ -e "$start_wt" ]; then
1579
+ echo " skipped $start_wt — a desk of that name already exists"
1580
+ continue
1581
+ fi
1582
+ if [ "$dry_run" = 1 ]; then
1583
+ echo " would create $start_wt (detached at origin/$start_main) and start a free agent"
1584
+ start_made=$((start_made + 1))
1585
+ continue
1586
+ fi
1587
+ mkdir -p "$wt_root" 2>/dev/null || true
1588
+ if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
1589
+ # NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
1590
+ # with no remote has no `origin/<main>`; the local one is the same commit
1591
+ # in every case that matters, and a desk on it is still detached.
1592
+ if ! git worktree add -q --detach "$start_wt" "$start_main" 2>/dev/null; then
1593
+ echo " could not create a desk at $start_wt — skipping" >&2
1594
+ continue
1595
+ fi
1596
+ fi
1597
+
1598
+ # SPOTLIGHT IS TOLD NOT TO INDEX THE DESK, exactly as the loop's hop does.
1599
+ # A desk is a full checkout and the fleet makes and unmakes them all day;
1600
+ # the marker is ignored via `info/exclude` rather than `.gitignore`, because
1601
+ # an untracked file in a desk reads as unlanded work to
1602
+ # `plot-worker-state.sh` and would make every free agent look stalled.
1603
+ _excl="$(git -C "$start_wt" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
1604
+ if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
1605
+ printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
1606
+ fi
1607
+ : > "$start_wt/.metadata_never_index" 2>/dev/null || true
1608
+
1609
+ echo " desk $start_wt (detached at origin/$start_main)"
1610
+ # THE EMPTY BRANCH IS THE WHOLE POINT, and `start_worker` already takes it
1611
+ # as a parameter: `write_agent_manifest` writes `"branch": ""`, the loop
1612
+ # reads `PLOT_BRANCH` empty and enters its wait rather than its prompt, and
1613
+ # `isAgentFree` reports the agent free with no change to `rules/free.ts`.
1614
+ if start_worker "" "$start_wt"; then
1615
+ start_made=$((start_made + 1))
1616
+ fi
1617
+ done
1618
+
1619
+ # THE SHORTFALL IS SAID HERE AND STORED NOWHERE. An operator reads it and runs
1620
+ # the command again; a remembered target would be the first piece of state in
1621
+ # a fleet whose statelessness is measured rather than assumed.
1622
+ [ -n "$start_why" ] && echo " $start_why"
1623
+ echo "summary: agents=$start_made requested=${start_count:-default} running=$start_running headroom=$start_headroom worker=$start_worker_state"
1624
+ exit 0
1625
+ fi
1626
+
1103
1627
  # ---------------------------------------------------------------------------
1104
1628
  # Migration mode: move legacy worktrees into the configured root
1105
1629
  # ---------------------------------------------------------------------------
@@ -1312,8 +1836,7 @@ fi
1312
1836
  # exactly where nothing can catch it. --allow-local is the explicit escape, and
1313
1837
  # it is named in the refusal so an operator learns it exists when they need it.
1314
1838
  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"
1839
+ [ -n "$MAIN" ] || MAIN=$(default_branch)
1317
1840
  [ -n "$offline" ] || git fetch -q origin "$MAIN" 2>/dev/null
1318
1841
 
1319
1842
  PLAN_DIR_CFG=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
@@ -1389,6 +1912,7 @@ fi
1389
1912
  if [ -z "$plan_path" ]; then
1390
1913
  if [ "$allow_local" = 1 ]; then
1391
1914
  echo "plot-dispatch: no plan found for '$slug' — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
1915
+ echo " Check the slug: ls $PLAN_DIR_CFG | grep -i '$slug'" >&2
1392
1916
  else
1393
1917
  echo "plot-dispatch: no plan for '$slug' on $gate_ref — looked in $ACTIVE_DIR_CFG and $PLAN_DIR_CFG" >&2
1394
1918
  echo " A plan that exists only in this working tree has not been shared yet: push it first." >&2
@@ -1449,6 +1973,7 @@ case "$gate_phase" in
1449
1973
  exit 1 ;;
1450
1974
  delivered|released)
1451
1975
  echo "plot-dispatch: plan '$slug' is already $gate_phase — its work is done." >&2
1976
+ echo " Nothing to dispatch. To start new work: /plot-idea" >&2
1452
1977
  exit 1 ;;
1453
1978
  "")
1454
1979
  echo "plot-dispatch: cannot read the phase of '$slug' ($gate_source)." >&2
@@ -1456,6 +1981,8 @@ case "$gate_phase" in
1456
1981
  exit 1 ;;
1457
1982
  *)
1458
1983
  echo "plot-dispatch: plan '$slug' is in phase '$gate_phase', not Approved." >&2
1984
+ echo " Correct the 'State:' line in the plan and push it, or approve it:" >&2
1985
+ echo " /plot-approve $slug" >&2
1459
1986
  exit 1 ;;
1460
1987
  esac
1461
1988
 
@@ -1467,6 +1994,7 @@ case "$gate_impl" in
1467
1994
  same-branch)
1468
1995
  echo "plot-dispatch: plan '$slug' records 'Impl: same branch' — plan and code" >&2
1469
1996
  echo " travel on one branch, so there is nothing to fan out." >&2
1997
+ echo " Implement on that branch instead: /plot-implement $slug" >&2
1470
1998
  exit 1 ;;
1471
1999
  other-repo)
1472
2000
  echo "plot-dispatch: plan '$slug' records 'Impl: other repo' — implementation" >&2
@@ -1479,6 +2007,8 @@ case "$gate_impl" in
1479
2007
  *)
1480
2008
  echo "plot-dispatch: plan '$slug' records an unrecognised 'Impl:' answer" >&2
1481
2009
  echo " ('$gate_impl'). Refusing rather than guessing." >&2
2010
+ echo " Set the plan's 'Impl:' line to one of: own branches, same branch," >&2
2011
+ echo " other repo, none — then push it." >&2
1482
2012
  exit 1 ;;
1483
2013
  esac
1484
2014
 
@@ -1908,7 +2438,8 @@ write_started_record() { # $@ = branches
1908
2438
  # would carry the symlink and leave the record behind.
1909
2439
  rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
1910
2440
  if [ -z "$rel" ]; then
1911
- echo "plot-dispatch: $plan_file is outside the repository root" >&2
2441
+ echo "plot-dispatch: $plan_file is outside the repository root ($repo_root)." >&2
2442
+ echo " Move the plan under $PLAN_DIR_CFG inside this checkout and re-run." >&2
1912
2443
  return 1
1913
2444
  fi
1914
2445
 
@@ -1924,6 +2455,9 @@ write_started_record() { # $@ = branches
1924
2455
  # one. It is disposable by construction — created here, pushed, deleted.
1925
2456
  if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then
1926
2457
  echo "plot-dispatch: could not prepare a booking worktree at $tmpwt" >&2
2458
+ echo " Most often origin/$MAIN is not fetched, or '$bookbr' is checked out in" >&2
2459
+ echo " another worktree. Check both: git fetch origin $MAIN && git worktree list" >&2
2460
+ echo " The branches were dispatched; only the plan's Started record is missing." >&2
1927
2461
  return 1
1928
2462
  fi
1929
2463
 
@@ -1947,6 +2481,7 @@ write_started_record() { # $@ = branches
1947
2481
  fi
1948
2482
  append_started_line "$tmpwt/$rel" "$date" "$who" "$br" || {
1949
2483
  echo "plot-dispatch: $rel has no '## Status' section — nowhere to record" >&2
2484
+ echo " Add one to the plan (see .plot/templates/plan.md) and push it." >&2
1950
2485
  rc=1
1951
2486
  break
1952
2487
  }
@@ -1968,6 +2503,7 @@ write_started_record() { # $@ = branches
1968
2503
  fi
1969
2504
  else
1970
2505
  echo "plot-dispatch: $rel is not on origin/$MAIN" >&2
2506
+ echo " Push the plan to $MAIN first; the fleet reads plans from the shared ref." >&2
1971
2507
  rc=1
1972
2508
  fi
1973
2509
 
@@ -2412,7 +2948,7 @@ report_in_flight() { # $1=candidate branch
2412
2948
  # Said once, on the last line, rather than per branch.
2413
2949
  [ "$n" = "$((IN_FLIGHT_MAX_BRANCHES + 1))" ] && \
2414
2950
  echo " in flight: …and $((total - IN_FLIGHT_MAX_BRANCHES)) more branches" \
2415
- "— plot-fleet for the full picture"
2951
+ "— plot-pulse for the full picture"
2416
2952
  continue
2417
2953
  fi
2418
2954
  # Commas to ", " for reading; the machine-countable summary is the footer,