@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/dist/board-server.mjs +124 -124
- package/package.json +5 -1
- package/plot-agent-monitor.sh +37 -13
- package/plot-approve.sh +6 -3
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +8 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +4 -2
- package/plot-dispatch.sh +500 -5
- package/plot-fleet-scan.sh +498 -175
- package/plot-host.sh +116 -13
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +111 -7
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +45 -5
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
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,
|
|
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=$(
|
|
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-
|
|
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,
|