@plot-pm/board 0.14.1 → 0.14.3
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 +128 -124
- package/package.json +1 -1
- package/plot-deliver.sh +72 -0
- package/plot-dispatch.sh +221 -46
- package/plot-fleet-scan.sh +134 -7
- package/plot-host.sh +687 -34
- package/plot-plan-meta.sh +98 -7
package/package.json
CHANGED
package/plot-deliver.sh
CHANGED
|
@@ -419,6 +419,70 @@ decide_transition() { # $1=file → prints "<Phase>\t<record>\t<write|already>"
|
|
|
419
419
|
# writes — and appending a second is how one plan came to hold two `Delivered:`
|
|
420
420
|
# lines (2026-09-01). The domain returns the written record unchanged in that
|
|
421
421
|
# case, so the phase still flips and nothing is inserted.
|
|
422
|
+
# Would the PARSER read this phase out of the file we are about to land?
|
|
423
|
+
#
|
|
424
|
+
# THE WRITE SUCCEEDING IS NOT THE OUTCOME HOLDING, and that gap is #924. A plan
|
|
425
|
+
# carrying BOTH front matter and a `## Status` block was delivered in a project
|
|
426
|
+
# repo: `flip_phase` wrote `Delivered` into the block, `mv` landed it, the
|
|
427
|
+
# summary said `phase=flipped`, and `plot-plan-meta.sh` went on answering
|
|
428
|
+
# `approved` — because it preferred front matter wherever it existed. The write
|
|
429
|
+
# took effect on bytes nobody reads.
|
|
430
|
+
#
|
|
431
|
+
# THAT PRECEDENCE INVERTED IN #933, so the two-record plan now delivers rather
|
|
432
|
+
# than refusing here: the parser reads the `## Status` block, which is the field
|
|
433
|
+
# every lifecycle script writes. This gate is unchanged and is not softened —
|
|
434
|
+
# its condition simply stops holding for that shape. It still fires on a scratch
|
|
435
|
+
# copy the parser cannot read, and it still asks the parser rather than trusting
|
|
436
|
+
# that awk changed a line.
|
|
437
|
+
#
|
|
438
|
+
# `flip_phase`'s awk matches only inside `section == "status"`. That one guard
|
|
439
|
+
# IS the defect: on a front-matter plan it edits the block and leaves the front
|
|
440
|
+
# matter untouched, and returns 0 for having changed something.
|
|
441
|
+
#
|
|
442
|
+
# SO THE TEST IS WHAT THE PARSER ANSWERS, NEVER WHETHER AWK CHANGED A LINE.
|
|
443
|
+
# Refusing on `flipped=0` would break every re-run of a correct delivery — a
|
|
444
|
+
# plan already carrying `Delivered` flips nothing and is fine. This asks the one
|
|
445
|
+
# question that distinguishes them: read the scratch copy the way every later
|
|
446
|
+
# consumer will read the plan, and compare.
|
|
447
|
+
#
|
|
448
|
+
# IT RUNS ON THE SCRATCH COPY, BEFORE THE `mv`, and the caller passes whichever
|
|
449
|
+
# file that arm is about to land — `$a` on the `recorded=yes` arm, `$b` on the
|
|
450
|
+
# other. Parsing `$a` on the record arm would check content that never reaches
|
|
451
|
+
# the plan. After the `mv` is too late twice over: the script's own header
|
|
452
|
+
# documents exit 0 as *"the plan is Delivered on the default branch"*, so
|
|
453
|
+
# refusing there would exit 1 on a run meeting the documented success
|
|
454
|
+
# condition — and `runAutoDeliver` spawns this detached, logging a non-zero exit
|
|
455
|
+
# to nobody while the plan sits delivered on main.
|
|
456
|
+
#
|
|
457
|
+
# AN UNREADABLE SCRATCH COPY REFUSES. `decide_transition` already takes that
|
|
458
|
+
# line — *"refusing rather than guessing"* — and a file the parser cannot read
|
|
459
|
+
# is exactly the state this gate exists to keep off the plan.
|
|
460
|
+
phase_would_read() { # $1=scratch file $2=expected phase (lowercase) → 0 agrees, 1 refuses
|
|
461
|
+
local scratch="$1" want="$2" m got
|
|
462
|
+
m=$(bash "$script_dir/plot-plan-meta.sh" "$scratch" 2>/dev/null) || m=""
|
|
463
|
+
if [ -z "$m" ]; then
|
|
464
|
+
echo "plot-deliver: $rel — the written file does not parse, so the delivery was not landed." >&2
|
|
465
|
+
echo " Nothing was written. Re-run after fixing the file." >&2
|
|
466
|
+
return 1
|
|
467
|
+
fi
|
|
468
|
+
got=$(printf '%s' "$m" | jq -r '.phase // ""')
|
|
469
|
+
[ "$got" = "$want" ] && return 0
|
|
470
|
+
|
|
471
|
+
# THE REFUSAL NAMES BOTH VALUES AND THE FILE. A message saying only "delivery
|
|
472
|
+
# failed" throws away the half a person acts on: which phase was written, and
|
|
473
|
+
# which one the parser still reads. The cause is named too, because the file
|
|
474
|
+
# holding two records of one fact is the thing to fix — and which format ought
|
|
475
|
+
# to win is a decision this gate deliberately leaves to a person.
|
|
476
|
+
echo "plot-deliver: $rel — wrote phase '$want', but the parser still reads '$got'." >&2
|
|
477
|
+
echo " The write landed and the parser reads something else, so the delivery" >&2
|
|
478
|
+
echo " would have reported a success it did not achieve." >&2
|
|
479
|
+
echo " Nothing was written — the plan is unchanged. Check what the plan says" >&2
|
|
480
|
+
echo " its phase is, and where: a plan stating it in two places reports the" >&2
|
|
481
|
+
echo " '## Status' block, which is the field every lifecycle script writes." >&2
|
|
482
|
+
echo " See what the parser reads: $script_dir/plot-plan-meta.sh $rel" >&2
|
|
483
|
+
return 1
|
|
484
|
+
}
|
|
485
|
+
|
|
422
486
|
write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_report record_report
|
|
423
487
|
local f="$1" record="$2" recorded="$3" a="$1.plot-phase" b="$1.plot-record" flipped=0
|
|
424
488
|
|
|
@@ -428,6 +492,11 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
|
|
|
428
492
|
[ -s "$a" ] || { rm -f "$a"; echo "plot-deliver: could not read $rel" >&2; return 1; }
|
|
429
493
|
|
|
430
494
|
if [ "$recorded" = "yes" ]; then
|
|
495
|
+
# THE DRY RUN, on the file this arm is about to land. A refusal discards the
|
|
496
|
+
# scratch copy and leaves the plan byte-identical — and never reaches
|
|
497
|
+
# `record_state_receipt`, which would otherwise license a commit of a state
|
|
498
|
+
# that was refused.
|
|
499
|
+
phase_would_read "$a" delivered || { rm -f "$a"; return 1; }
|
|
431
500
|
mv "$a" "$f" || { rm -f "$a"; return 1; }
|
|
432
501
|
record_report="already"
|
|
433
502
|
else
|
|
@@ -438,6 +507,9 @@ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_repo
|
|
|
438
507
|
echo " with no record is invisible to the scan. Fix the section and re-run." >&2
|
|
439
508
|
return 1
|
|
440
509
|
fi
|
|
510
|
+
# `$b` AND NOT `$a`: this arm lands the file carrying the record, so `$a`
|
|
511
|
+
# is content that never reaches the plan.
|
|
512
|
+
phase_would_read "$b" delivered || { rm -f "$a" "$b"; return 1; }
|
|
441
513
|
mv "$b" "$f" || { rm -f "$a" "$b"; return 1; }
|
|
442
514
|
rm -f "$a"
|
|
443
515
|
record_report="written"
|
package/plot-dispatch.sh
CHANGED
|
@@ -56,6 +56,25 @@
|
|
|
56
56
|
# tradition of --allow-local: a gate with no exit is one people
|
|
57
57
|
# route around by never annotating at all. It says so on the
|
|
58
58
|
# line it overrides, so the override is on the record.
|
|
59
|
+
# --agent <name> dispatch this run's agents under the charter
|
|
60
|
+
# `.plot/charters/<name>.json`. It SETS `PLOT_AGENT`, which was
|
|
61
|
+
# the input all along and which nothing chose: the charter
|
|
62
|
+
# mechanism shipped in v2.17.0 with 16 readers for `harness` and
|
|
63
|
+
# no selector at all, so a kind could be declared and never
|
|
64
|
+
# asked for. The choice is EXPLICIT and never inferred — a
|
|
65
|
+
# matcher reading a plan could guess a kind, and a guess that is
|
|
66
|
+
# usually right produces a fleet whose wrong answers cannot be
|
|
67
|
+
# explained. An already-exported PLOT_AGENT is overridden, since
|
|
68
|
+
# a flag on this run is the more specific answer.
|
|
69
|
+
# AMENDED 2026-09-15: a slice may now DECLARE its kind, and
|
|
70
|
+
# `start_worker` reads it where this flag left `PLOT_AGENT`
|
|
71
|
+
# unset. That is not the matcher refused above — a declaration is
|
|
72
|
+
# a field a person wrote, and nothing here ranks candidates or
|
|
73
|
+
# infers a kind from a slice's contents. The flag still wins,
|
|
74
|
+
# because a flag typed on this run is more specific than a field
|
|
75
|
+
# written when the plan was drafted. A charter this clone does
|
|
76
|
+
# not hold is REPORTED and dispatched anyway; see
|
|
77
|
+
# `plan_declared_agent`.
|
|
59
78
|
# <slug> the plan to fan out
|
|
60
79
|
# Output: one line per branch, each optionally followed by an indented
|
|
61
80
|
# `in flight:` line naming a branch that already holds files, then the
|
|
@@ -230,6 +249,7 @@ allow_waiting=0
|
|
|
230
249
|
max=0
|
|
231
250
|
slug=""
|
|
232
251
|
migrate_yes=0
|
|
252
|
+
agent=""
|
|
233
253
|
while [ $# -gt 0 ]; do
|
|
234
254
|
case "$1" in
|
|
235
255
|
--dry-run) dry_run=1 ;;
|
|
@@ -270,17 +290,56 @@ while [ $# -gt 0 ]; do
|
|
|
270
290
|
--offline|--no-fetch) offline="--offline" ;;
|
|
271
291
|
--allow-local) allow_local=1 ;;
|
|
272
292
|
--allow-waiting) allow_waiting=1 ;;
|
|
293
|
+
# THE VALUE IS REQUIRED AND ALWAYS CONSUMED, which is the opposite of the
|
|
294
|
+
# rule `--stop`, `--restart` and `--start` follow — and the difference is
|
|
295
|
+
# the value's SHAPE rather than a change of mind. Those three take a branch
|
|
296
|
+
# (`*/*`) or a count (digits), each recognisable on sight, so an absent one
|
|
297
|
+
# can be left for the parser. An agent name is a bare word and so is a plan
|
|
298
|
+
# slug: nothing tells `--agent reviewer` from `--agent` followed by the
|
|
299
|
+
# slug. Leaving it unconsumed would let the `*)` arm below silently take
|
|
300
|
+
# the agent name as the plan to dispatch, which reads as "no such plan" and
|
|
301
|
+
# names neither what was asked nor what went wrong. So a missing value
|
|
302
|
+
# REFUSES rather than guessing.
|
|
303
|
+
--agent) agent="${2:?--agent needs a charter name, e.g. --agent reviewer}"
|
|
304
|
+
case "$agent" in
|
|
305
|
+
-*) echo "plot-dispatch: --agent needs a charter name, got '$agent'" >&2; exit 1 ;;
|
|
306
|
+
esac
|
|
307
|
+
shift ;;
|
|
273
308
|
--max) max="${2:?--max needs a value}"
|
|
274
309
|
case "$max" in
|
|
275
310
|
''|*[!0-9]*) echo "plot-dispatch: --max needs a number, got '$max'" >&2; exit 1 ;;
|
|
276
311
|
esac
|
|
277
312
|
shift ;;
|
|
278
|
-
|
|
313
|
+
# THE RANGE MOVED WITH THE HEADER IT PRINTS. It ended at `<slug>` and still
|
|
314
|
+
# does; adding `--agent` above pushed that line from 59 to 69, and
|
|
315
|
+
# documenting the plan-declared kind pushed it from 69 to 78. Two records
|
|
316
|
+
# of one fact, and nothing compares them — a stale number here silently
|
|
317
|
+
# truncates the help rather than failing, so it is checked by a test.
|
|
318
|
+
-h|--help) sed -n '2,78p' "$0"; exit 0 ;;
|
|
279
319
|
*) slug="$1" ;;
|
|
280
320
|
esac
|
|
281
321
|
shift
|
|
282
322
|
done
|
|
283
323
|
|
|
324
|
+
# `--agent` SETS THE INPUT THAT ALREADY EXISTED, and it sets it in ONE place.
|
|
325
|
+
#
|
|
326
|
+
# `PLOT_AGENT` was read in three places before this flag — `resolve_launch`'s
|
|
327
|
+
# call in `start_worker`, the `--capabilities` block, and the forwarded export
|
|
328
|
+
# into the worker's environment — and assigned in none: `plot-dispatch.sh` held
|
|
329
|
+
# `PLOT_AGENT="${PLOT_AGENT:-}"`, a pass-through of whatever the operator had
|
|
330
|
+
# already exported. So the selector existed as a variable nothing chose.
|
|
331
|
+
#
|
|
332
|
+
# ONE ASSIGNMENT RATHER THAN A THREADED ARGUMENT. Every reader already asks
|
|
333
|
+
# `${PLOT_AGENT:-}`; exporting here reaches all three with no further change and
|
|
334
|
+
# leaves no call site that could be missed when a fourth reader is added. The
|
|
335
|
+
# flag's whole meaning is "set PLOT_AGENT", which is also what keeps it from
|
|
336
|
+
# becoming a second selector competing with the variable.
|
|
337
|
+
#
|
|
338
|
+
# THE FLAG WINS OVER AN INHERITED VALUE. A dispatched worker runs with its own
|
|
339
|
+
# `PLOT_AGENT` in the environment, so a run launched from inside one would
|
|
340
|
+
# otherwise inherit a kind nobody asked for on this dispatch.
|
|
341
|
+
[ -n "$agent" ] && export PLOT_AGENT="$agent"
|
|
342
|
+
|
|
284
343
|
# THE TWO PRECONDITIONS OF A DISPATCH, and a sourcing test has neither.
|
|
285
344
|
# `PLOT_DISPATCH_SOURCED=1` is taking the definitions below rather than running
|
|
286
345
|
# a dispatch, so it has no slug and often no git repository; the guard that
|
|
@@ -930,6 +989,42 @@ start_worker() {
|
|
|
930
989
|
local branch="$1" wt="$2"
|
|
931
990
|
local cmd
|
|
932
991
|
|
|
992
|
+
# THE PLAN IS THE DEFAULT AND THE FLAG IS THE OVERRIDE.
|
|
993
|
+
#
|
|
994
|
+
# `[ -z "${PLOT_AGENT:-}" ]` is the whole precedence rule: `--agent` exported
|
|
995
|
+
# the variable before this line was reached, so a flag typed on this run stops
|
|
996
|
+
# the plan being read at all. A field written when the plan was drafted is the
|
|
997
|
+
# less specific answer — the same order `--agent` already applies to an
|
|
998
|
+
# inherited `PLOT_AGENT`.
|
|
999
|
+
#
|
|
1000
|
+
# ONE ASSIGNMENT, FOR THE REASON `--agent` GIVES ONE FIELD OVER. Four readers
|
|
1001
|
+
# ask `${PLOT_AGENT:-}` — `resolve_launch` below, the capabilities block, the
|
|
1002
|
+
# manifest, and the export into the worker environment — so setting the
|
|
1003
|
+
# variable reaches all four and leaves no call site to miss when a fifth
|
|
1004
|
+
# appears.
|
|
1005
|
+
#
|
|
1006
|
+
# A FREE AGENT HAS NO BRANCH and so has no plan to read: `--start` calls this
|
|
1007
|
+
# with `branch` empty, and `plan_declared_agent` returns 1 on it immediately.
|
|
1008
|
+
if [ -z "${PLOT_AGENT:-}" ] && [ -n "$branch" ]; then
|
|
1009
|
+
local declared charter
|
|
1010
|
+
if declared=$(plan_declared_agent "$branch"); then
|
|
1011
|
+
export PLOT_AGENT="$declared"
|
|
1012
|
+
# A MISSING CHARTER IS REPORTED AND DISPATCHED ANYWAY. Said HERE rather
|
|
1013
|
+
# than only in the manifest, because this is where a person watching the
|
|
1014
|
+
# run can still act on it: the launch below falls back to the repo default
|
|
1015
|
+
# and is otherwise silent about a declaration that reached nothing.
|
|
1016
|
+
charter=$(charter_file_for "$declared")
|
|
1017
|
+
if [ -n "$charter" ] && [ ! -f "$charter" ]; then
|
|
1018
|
+
echo " $branch declares agent '$declared', and no charter answers to that name"
|
|
1019
|
+
echo " looked for $charter"
|
|
1020
|
+
echo " starting it on the repo's 'Worker command' — a plan written where that"
|
|
1021
|
+
echo " charter exists must stay dispatchable on a clone that lacks it."
|
|
1022
|
+
else
|
|
1023
|
+
echo " $branch declares agent '$declared' — its plan selected the charter"
|
|
1024
|
+
fi
|
|
1025
|
+
fi
|
|
1026
|
+
fi
|
|
1027
|
+
|
|
933
1028
|
# RESOLVED BEFORE `Worker command`, because the charter is the more specific
|
|
934
1029
|
# answer and the config key is the fallback rather than the sole source.
|
|
935
1030
|
#
|
|
@@ -1359,6 +1454,78 @@ start_worker() {
|
|
|
1359
1454
|
return 0
|
|
1360
1455
|
}
|
|
1361
1456
|
|
|
1457
|
+
# WHICH KIND OF AGENT A BRANCH'S PLAN DECLARES — the selector nobody has to type.
|
|
1458
|
+
#
|
|
1459
|
+
# `--agent` was the only one, and an operator is the only thing that can type a
|
|
1460
|
+
# flag. The registry hands a queued slice to a free agent with no `--agent`
|
|
1461
|
+
# anywhere in the path, so an unattended fleet ran every slice as the same
|
|
1462
|
+
# undifferentiated worker. A plan that names the kind is what reaches a dispatch
|
|
1463
|
+
# nobody is watching.
|
|
1464
|
+
#
|
|
1465
|
+
# IT ANSWERS AND EXPORTS NOTHING. The caller decides, because the precedence is
|
|
1466
|
+
# the caller's: `--agent` already won over an inherited `PLOT_AGENT` and must win
|
|
1467
|
+
# over this too — a flag typed on this run is more specific than a field written
|
|
1468
|
+
# when the plan was drafted.
|
|
1469
|
+
#
|
|
1470
|
+
# THE PLAN DIRECTORY IS SEARCHED, NOT THE ACTIVE INDEX, and the candidates are
|
|
1471
|
+
# grepped before any is parsed — both `plot-open-pr.sh`'s rules, measured there:
|
|
1472
|
+
# a plan governing a branch may carry no symlink, and parsing 253 plans took
|
|
1473
|
+
# 103 s against 0.6 s for one `grep -lF` over the directory. A branch name is a
|
|
1474
|
+
# literal string, so a plan that does not contain it cannot name it.
|
|
1475
|
+
#
|
|
1476
|
+
# ABSENCE IS THE COMMON ANSWER AND NEVER AN ERROR. Every plan on the estate
|
|
1477
|
+
# names no kind, a `--restart` may run on a branch no plan names at all, and the
|
|
1478
|
+
# annotation is optional by design. All three print nothing and return 1.
|
|
1479
|
+
plan_declared_agent() { # $1 = branch → prints the declared kind, or nothing
|
|
1480
|
+
local branch="$1" plan_dir root cands f found
|
|
1481
|
+
[ -n "$branch" ] || return 1
|
|
1482
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
|
|
1483
|
+
plan_dir=$("$script_dir/plot-config.sh" get "Plan directory" "docs/plans/")
|
|
1484
|
+
case "$plan_dir" in /*) ;; *) plan_dir="$root/$plan_dir" ;; esac
|
|
1485
|
+
|
|
1486
|
+
cands=$(grep -lF "$branch" "$plan_dir"*.md 2>/dev/null || true)
|
|
1487
|
+
for f in $cands; do
|
|
1488
|
+
[ -e "$f" ] || continue
|
|
1489
|
+
# The PARSER answers, never a grep of the plan file. A `grep agent:` would
|
|
1490
|
+
# read the marker out of prose documenting it and out of a DIFFERENT branch
|
|
1491
|
+
# line in the same plan — the annotation binds to one branch, and only the
|
|
1492
|
+
# parser knows which.
|
|
1493
|
+
found=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null \
|
|
1494
|
+
| PLOT_WANT_BRANCH="$branch" node -e '
|
|
1495
|
+
let s = "";
|
|
1496
|
+
process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
|
1497
|
+
let meta;
|
|
1498
|
+
try { meta = JSON.parse(s); } catch { return; }
|
|
1499
|
+
const want = process.env.PLOT_WANT_BRANCH;
|
|
1500
|
+
for (const wave of meta.waves ?? []) {
|
|
1501
|
+
for (const b of wave.branches ?? []) {
|
|
1502
|
+
// PRESENCE, not truthiness. The parser emits no `agent` key where
|
|
1503
|
+
// the plan declares none, which is every plan on the estate.
|
|
1504
|
+
if (b.branch === want && "agent" in b) { console.log(b.agent); return; }
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
') || found=""
|
|
1509
|
+
if [ -n "$found" ]; then printf '%s\n' "$found"; return 0; fi
|
|
1510
|
+
done
|
|
1511
|
+
return 1
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
# DOES THIS CLONE HOLD THE CHARTER? Answered so a MISSING one can be REPORTED.
|
|
1515
|
+
#
|
|
1516
|
+
# A DELIBERATE ASYMMETRY, and the plan settles it: `resolve_launch` refuses a
|
|
1517
|
+
# charter it cannot BELIEVE (malformed) and a harness not on PATH, and both
|
|
1518
|
+
# stay. A charter that simply does not EXIST is the adoption case — a plan
|
|
1519
|
+
# written where `reviewer` is declared, dispatched on a clone that declares
|
|
1520
|
+
# nothing — and refusing it would make that plan undispatchable on every such
|
|
1521
|
+
# clone. So the kind still travels, the launch falls back to the repo default,
|
|
1522
|
+
# and the run SAYS which name it looked for.
|
|
1523
|
+
charter_file_for() { # $1 = agent name → prints the path it would read
|
|
1524
|
+
local name="$1" root
|
|
1525
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
|
|
1526
|
+
printf '%s/.plot/charters/%s.json\n' "$root" "$name"
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1362
1529
|
# `PLOT_DISPATCH_SOURCED=1` STOPS HERE, so a test can take `resolve_launch` and
|
|
1363
1530
|
# `start_worker` without dispatching anything — `plot-worker-loop.sh` states
|
|
1364
1531
|
# this idiom for `resolve_prompt_file`, and this is the same one applied to the
|
|
@@ -1737,62 +1904,70 @@ EOF
|
|
|
1737
1904
|
esac
|
|
1738
1905
|
fi
|
|
1739
1906
|
|
|
1740
|
-
# THE COUNT IS THE RULE'S, and the rule is
|
|
1741
|
-
# fleet-size.
|
|
1742
|
-
#
|
|
1743
|
-
#
|
|
1744
|
-
#
|
|
1907
|
+
# THE COUNT IS THE RULE'S, and the rule is asked through its BUNDLE —
|
|
1908
|
+
# `board/plot-fleet-size.mjs`, tracked in git beside the other 24. There is no
|
|
1909
|
+
# second copy of the default, the subtraction or the machine's veto living in
|
|
1910
|
+
# shell.
|
|
1911
|
+
#
|
|
1912
|
+
# A SOURCE IMPORT CANNOT REACH A PLUGIN INSTALL, and this block used to be
|
|
1913
|
+
# one. It imported `rules/fleet-size.ts` and `entities/machine.ts` as `file://`
|
|
1914
|
+
# sources. Node 24 strips types, so the TypeScript was never the obstacle — the
|
|
1915
|
+
# SECOND import is: `machine.ts` opens with `import { z } from 'zod'`, and an
|
|
1916
|
+
# install carrying no `node_modules` cannot resolve it. Measured 2026-09-17
|
|
1917
|
+
# against a copy with no `node_modules` on the path:
|
|
1745
1918
|
#
|
|
1746
|
-
#
|
|
1747
|
-
#
|
|
1748
|
-
#
|
|
1749
|
-
#
|
|
1919
|
+
# machine.ts FAILED: Cannot find package 'zod'
|
|
1920
|
+
# fleet-size.ts: imported
|
|
1921
|
+
#
|
|
1922
|
+
# So the bundle carries BOTH rules with `zod` bundled in. `a-shell-script-asks
|
|
1923
|
+
# -the-domain` settled the shape: a bundle under `skills/plot/scripts/board/`
|
|
1924
|
+
# is how a shell script reaches a rule, and a skill's own script directory is
|
|
1925
|
+
# what a plugin ships.
|
|
1750
1926
|
#
|
|
1751
1927
|
# TWO MODULES, BECAUSE THE VERDICT AND THE COUNT ARE TWO RULES. `headroomFor`
|
|
1752
1928
|
# owns what a fork cost MEANS and `fleetSize` owns what to do about it; the
|
|
1753
1929
|
# count rule takes the verdict as a reading rather than deriving it, so the
|
|
1754
|
-
# thresholds have exactly one home and
|
|
1755
|
-
#
|
|
1756
|
-
#
|
|
1757
|
-
#
|
|
1758
|
-
#
|
|
1759
|
-
#
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
PLOT_COST="$start_cost" PLOT_RULE="$start_rule" \
|
|
1764
|
-
PLOT_MACHINE="file://$start_domain/entities/machine.ts" \
|
|
1765
|
-
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
1766
|
-
const { fleetSize, DEFAULT_FLEET_SIZE } = await import(process.env.PLOT_RULE);
|
|
1767
|
-
const { headroomFor } = await import(process.env.PLOT_MACHINE);
|
|
1768
|
-
|
|
1769
|
-
// AN ABSENT COUNT IS THE RULE'S DEFAULT, resolved here rather than in the
|
|
1770
|
-
// shell: the number and the argument for it have one home.
|
|
1771
|
-
const requested =
|
|
1772
|
-
process.env.PLOT_REQUESTED === "" ? DEFAULT_FLEET_SIZE : Number(process.env.PLOT_REQUESTED);
|
|
1773
|
-
|
|
1774
|
-
// An UNMEASURED cost is null, never zero: zero is the fastest fork there is and
|
|
1775
|
-
// would read as the clearest possible machine.
|
|
1776
|
-
const spawnCostMs = process.env.PLOT_COST === "" ? null : Number(process.env.PLOT_COST);
|
|
1777
|
-
|
|
1778
|
-
const answer = fleetSize({
|
|
1779
|
-
requested,
|
|
1780
|
-
running: Number(process.env.PLOT_RUNNING),
|
|
1781
|
-
spawnCostMs,
|
|
1782
|
-
headroom: headroomFor(spawnCostMs),
|
|
1783
|
-
});
|
|
1784
|
-
|
|
1785
|
-
process.stdout.write(`${answer.start}\t${answer.headroom}\t${answer.shortfall}`);
|
|
1786
|
-
NODE_EOF
|
|
1787
|
-
)
|
|
1930
|
+
# thresholds have exactly one home and the bundle's entry is the join.
|
|
1931
|
+
#
|
|
1932
|
+
# A RULE THAT CANNOT BE ASKED STARTS NOTHING AND SAYS SO. A missing bundle, a
|
|
1933
|
+
# missing node, a module that throws all leave the answer empty. The direction
|
|
1934
|
+
# is the reaper's: silence is never permission, and here permission would spawn
|
|
1935
|
+
# detached processes.
|
|
1936
|
+
start_bundle="$script_dir/board/plot-fleet-size.mjs"
|
|
1937
|
+
start_answer=$(printf '%s\t%s\t%s' "$start_count" "$start_running" "$start_cost" \
|
|
1938
|
+
| node "$start_bundle" 2>/dev/null)
|
|
1788
1939
|
|
|
1789
1940
|
if [ -z "$start_answer" ]; then
|
|
1941
|
+
# THE REFUSAL NAMES THE CONDITION THAT FAILED, never two that hold. The
|
|
1942
|
+
# message this replaced said *"it needs node 24 and a readable checkout of
|
|
1943
|
+
# packages/domain"* to an operator whose node was 24.4.1 and whose checkout
|
|
1944
|
+
# was readable — the import it could not resolve was named nowhere. A
|
|
1945
|
+
# refusal that names the wrong condition costs more than one that says
|
|
1946
|
+
# nothing, because it looks actionable. So the two causes are separated and
|
|
1947
|
+
# tested in the order that distinguishes them: an absent bundle is a broken
|
|
1948
|
+
# or partial installation, and a present bundle that answered nothing is the
|
|
1949
|
+
# runtime underneath it.
|
|
1790
1950
|
echo "plot-dispatch: --start could not ask how many agents to start — starting none." >&2
|
|
1791
|
-
|
|
1792
|
-
|
|
1951
|
+
if [ ! -f "$start_bundle" ]; then
|
|
1952
|
+
echo " The rule's bundle is missing: $start_bundle" >&2
|
|
1953
|
+
echo " Every bundle is tracked in git, so this is a broken or partial installation." >&2
|
|
1954
|
+
echo " In a development checkout, run 'pnpm build:board'." >&2
|
|
1955
|
+
else
|
|
1956
|
+
start_node_v="$(node --version 2>/dev/null)" || start_node_v=""
|
|
1957
|
+
echo " The rule's bundle is $start_bundle" >&2
|
|
1958
|
+
if [ -z "$start_node_v" ]; then
|
|
1959
|
+
echo " No usable 'node' was found on PATH. The bundle needs node 20 or newer." >&2
|
|
1960
|
+
else
|
|
1961
|
+
echo " The bundle is present but answered nothing under node $start_node_v." >&2
|
|
1962
|
+
echo " Run it directly to see why: printf '%s\\t%s\\t%s' '$start_count' '$start_running' '$start_cost' | node '$start_bundle'" >&2
|
|
1963
|
+
fi
|
|
1964
|
+
fi
|
|
1793
1965
|
exit 1
|
|
1794
1966
|
fi
|
|
1795
1967
|
|
|
1968
|
+
# THREE FIELDS, AND THE SENTENCE IS LAST. `start_why` is printed to the
|
|
1969
|
+
# operator below, so it travels; taking it as the whole remainder means a
|
|
1970
|
+
# shortfall can never be truncated by its own punctuation.
|
|
1796
1971
|
start_n=${start_answer%%$'\t'*}
|
|
1797
1972
|
start_rest=${start_answer#*$'\t'}
|
|
1798
1973
|
start_headroom=${start_rest%%$'\t'*}
|
package/plot-fleet-scan.sh
CHANGED
|
@@ -89,6 +89,9 @@
|
|
|
89
89
|
# Output: per-plan wave report on stdout, terminated by a machine-countable
|
|
90
90
|
# summary line:
|
|
91
91
|
# summary: plans=1 waves=3 branches=5 claimed=1 eligible=2 blocked=1 deferred=1 waiting=1 prereq_missing=0 merge_detect=pr-merge host=ok main=main
|
|
92
|
+
# `host` is one of ok, partial, throttled, secondary, failed, unasked —
|
|
93
|
+
# `partial` means some of the host's states answered and some did not,
|
|
94
|
+
# so the PR readings below are incomplete rather than absent.
|
|
92
95
|
# `blocked` counts WAVES an earlier wave holds; `waiting` and
|
|
93
96
|
# `prereq_missing` count BRANCHES their `waits:` annotation holds.
|
|
94
97
|
# merge_detect names how merged-and-deleted branches were detected:
|
|
@@ -631,6 +634,19 @@ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
|
|
|
631
634
|
# secondary — a burst refusal (`plot-host.sh` exit 6). Nothing is broken
|
|
632
635
|
# either, and it clears in seconds rather than minutes.
|
|
633
636
|
# failed — any other failure (exit 3, or anything unclassified).
|
|
637
|
+
# partial — SOME of the host's states answered and some did not
|
|
638
|
+
# (`plot-host.sh` exit 7). The rows that arrived are real and
|
|
639
|
+
# are parsed; what is missing is a whole state, so the reading
|
|
640
|
+
# is incomplete rather than absent. Only Bitbucket can produce
|
|
641
|
+
# it: `bb pr list` has no `all` state, so the arm asks once per
|
|
642
|
+
# state, while GitHub takes `--state all` in one call.
|
|
643
|
+
#
|
|
644
|
+
# IT IS NOT `ok` AND IT IS NOT `failed`. Reporting `ok` would
|
|
645
|
+
# serve a page missing a state as a complete answer — #912, where
|
|
646
|
+
# nine branches read `commits, no PR ever opened` and two had
|
|
647
|
+
# live PRs. Reporting `failed` would throw away rows that
|
|
648
|
+
# arrived and make every branch `unknown`, which is not
|
|
649
|
+
# startable — the right refusal about the wrong thing.
|
|
634
650
|
# unasked — no host to ask, or --offline WITHOUT `--next`. Not a
|
|
635
651
|
# degradation: the scan was never going to ask, and saying
|
|
636
652
|
# `failed` would report a fault where there is a configuration.
|
|
@@ -644,6 +660,43 @@ PR_LIST_LIMIT="${PLOT_PR_LIST_LIMIT:-1000}"
|
|
|
644
660
|
# secondary limit waits minutes for a ceiling that cleared in seconds.
|
|
645
661
|
HOST_VERDICT=unasked
|
|
646
662
|
|
|
663
|
+
# The remote branches this scan tracks, read once for every question that asks.
|
|
664
|
+
#
|
|
665
|
+
# MOVED UP FROM ITS OLD POSITION (#333) so the host call below can be told which
|
|
666
|
+
# branches to ask about. It is the same single `for-each-ref` it always was —
|
|
667
|
+
# see the commentary at its old site — and the reasons it exists are unchanged:
|
|
668
|
+
# `git show-ref --verify` was asked once per branch from two places, and
|
|
669
|
+
# `%(objectname)` rides along free for the commit walk.
|
|
670
|
+
REMOTE_REFS=$(git for-each-ref --format='%(refname:strip=3)%09%(objectname)' \
|
|
671
|
+
"refs/remotes/origin" </dev/null 2>/dev/null)
|
|
672
|
+
|
|
673
|
+
# The branch names alone, space-separated — what the sweep asks the host about.
|
|
674
|
+
#
|
|
675
|
+
# THE JOIN'S OWN KEYS, AND NOTHING WIDER. `prefill_pr_states` indexes the host's
|
|
676
|
+
# reply by branch and every row it cannot key is discarded, so the set this asks
|
|
677
|
+
# about is exactly the set that could ever be used. Measured 2026-09-20 on
|
|
678
|
+
# `quatico/quaweb-website`: 11 remote branches against 902 pull requests, of
|
|
679
|
+
# which a listing hands over 50 — the sweep asks 11 questions and gets 11
|
|
680
|
+
# answers, where the listing asked one and answered for 5%.
|
|
681
|
+
#
|
|
682
|
+
# `HEAD` IS DROPPED. `refs/remotes/origin/HEAD` is a symbolic ref naming the
|
|
683
|
+
# default branch, not a branch of its own; asking the host about a branch called
|
|
684
|
+
# `HEAD` spends a query to learn that nothing is named that.
|
|
685
|
+
#
|
|
686
|
+
# SPACE-SEPARATED, AND GIT IS WHAT MAKES THAT SAFE. Both this list and the
|
|
687
|
+
# adapter's `PR_LIST_BRANCHES` are read by an unquoted `for`, so a name carrying
|
|
688
|
+
# whitespace would split into two branches that do not exist. `git
|
|
689
|
+
# check-ref-format` REFUSES a ref name containing a space or a tab — verified
|
|
690
|
+
# 2026-09-20, both exit non-zero — so no such branch can reach this, and the
|
|
691
|
+
# separator is git's guarantee rather than a hopeful convention.
|
|
692
|
+
#
|
|
693
|
+
# EMPTY IS A REAL ANSWER AND IT DISABLES THE SWEEP. A checkout with no remote
|
|
694
|
+
# refs has no branches to ask about, and a sweep of nothing would state that
|
|
695
|
+
# every tracked branch answered — a completeness claim over an empty set, which
|
|
696
|
+
# would license `NONE` for branches nobody asked about. `prefill_pr_states`
|
|
697
|
+
# falls back to the listing there, which is what it has always done.
|
|
698
|
+
TRACKED_BRANCHES=$(printf '%s\n' "$REMOTE_REFS" | cut -f1 | grep -v '^HEAD$' | grep -v '^$' | tr '\n' ' ')
|
|
699
|
+
|
|
647
700
|
prefill_pr_states() {
|
|
648
701
|
[ "$HOST_LOOKUP_OK" = 1 ] || return 0
|
|
649
702
|
[ -n "$HOST_STATE_CACHE" ] || return 0
|
|
@@ -672,10 +725,34 @@ prefill_pr_states() {
|
|
|
672
725
|
# and /dev/null keeps the call working with the text simply unavailable.
|
|
673
726
|
host_list_out="${HOST_STATE_CACHE:+$HOST_STATE_CACHE/pr-list.json}"
|
|
674
727
|
host_list_out="${host_list_out:-/dev/null}"
|
|
728
|
+
# THE BRANCHES THIS SCAN TRACKS, HANDED TO THE HOST (#333). The adapter uses
|
|
729
|
+
# them only where it can — the Bitbucket arm sweeps its REST endpoint once per
|
|
730
|
+
# branch per state — and ignores them everywhere else, so the GitHub arm makes
|
|
731
|
+
# the single call it always made. Passing them unconditionally keeps one call
|
|
732
|
+
# shape here rather than a backend test this script has no business making.
|
|
733
|
+
#
|
|
734
|
+
# AN EMPTY SET PASSES NOTHING and the adapter lists as before. See
|
|
735
|
+
# `TRACKED_BRANCHES`: a completeness claim over an empty set would license
|
|
736
|
+
# `NONE` for branches nobody asked about.
|
|
737
|
+
_branch_args=()
|
|
738
|
+
for _tb in $TRACKED_BRANCHES; do _branch_args+=(--branch "$_tb"); done
|
|
675
739
|
host_err=$("$script_dir/plot-host.sh" pr-list --state all --limit "$PR_LIST_LIMIT" --rich \
|
|
740
|
+
${_branch_args[@]+"${_branch_args[@]}"} \
|
|
676
741
|
</dev/null 2>&1 >"$host_list_out"); rc=$?
|
|
677
742
|
js=$(cat "$host_list_out" 2>/dev/null)
|
|
678
|
-
|
|
743
|
+
# A PARTIAL ANSWER TAKES THE PARSE PATH AND STILL DEGRADES THE VERDICT, which
|
|
744
|
+
# is a control-flow change rather than another `case` arm below: every other
|
|
745
|
+
# non-zero rc sets a verdict and returns BEFORE `$js` is read, because there
|
|
746
|
+
# is nothing to read. Here there is — the rows of the states that answered are
|
|
747
|
+
# already in `host_list_out`, since stdout is redirected to a file and stderr
|
|
748
|
+
# captured separately.
|
|
749
|
+
#
|
|
750
|
+
# BOTH HALVES ARE REQUIRED. Falling through without setting the verdict would
|
|
751
|
+
# report `ok` over a page missing a whole state, which is #912; returning
|
|
752
|
+
# early would throw away rows the host did answer with.
|
|
753
|
+
if [ "$rc" -eq 7 ]; then
|
|
754
|
+
HOST_VERDICT=partial
|
|
755
|
+
elif [ "$rc" -ne 0 ]; then
|
|
679
756
|
# THREE OUTCOMES, NOT TWO. `unasked` already means "the question was
|
|
680
757
|
# never put" (see HOST_VERDICT above: *not a degradation, the scan was
|
|
681
758
|
# never asking*), and a host that cannot be ASKED AT ALL belongs there
|
|
@@ -743,7 +820,12 @@ prefill_pr_states() {
|
|
|
743
820
|
return 0
|
|
744
821
|
fi
|
|
745
822
|
# The list arrived. An empty one arrived too — that is the whole distinction.
|
|
746
|
-
|
|
823
|
+
#
|
|
824
|
+
# A PARTIAL VERDICT IS NOT OVERWRITTEN HERE. Exit 7 reaches this line
|
|
825
|
+
# deliberately, because its rows must be parsed; an unguarded `ok` would
|
|
826
|
+
# undo the one thing that distinguishes an incomplete page from a whole one
|
|
827
|
+
# and report #912 as a healthy reading.
|
|
828
|
+
[ "$HOST_VERDICT" = partial ] || HOST_VERDICT=ok
|
|
747
829
|
# `pr-list` emits one compact JSON object per line. PARSED IN ONE PASS, and
|
|
748
830
|
# that is a correctness-of-cost property rather than a style preference:
|
|
749
831
|
# measured 2026-08-18 on this repo's 221 PRs, a `sed` per field per row —
|
|
@@ -864,9 +946,39 @@ EOF
|
|
|
864
946
|
# A repository genuinely holding zero PRs loses nothing by being asked: it has
|
|
865
947
|
# no branches with PRs for the join to serve either, so the cost is zero calls
|
|
866
948
|
# in both readings.
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
949
|
+
#
|
|
950
|
+
# A SWEEP STATES ITS COMPLETENESS; A PAGE ONLY EVER IMPLIED IT (#333). The
|
|
951
|
+
# test above reads completeness off one page's size, which is the only
|
|
952
|
+
# evidence a listing offers — and on Bitbucket it is evidence the listing
|
|
953
|
+
# cannot give at all, since `bb pr list` returns a fixed 50 whether or not
|
|
954
|
+
# more exist. Where the adapter swept per branch it says so on stderr, naming
|
|
955
|
+
# both counts, and that sentence is a stronger claim than any row count: every
|
|
956
|
+
# tracked branch was asked and each one answered.
|
|
957
|
+
#
|
|
958
|
+
# THE ROW COUNT IS NOT CONSULTED ON THAT PATH, and it must not be. A sweep
|
|
959
|
+
# over 11 branches of which 2 have pull requests emits 2 rows — a true and
|
|
960
|
+
# complete answer that `0 < rows < PR_LIST_LIMIT` would also accept, but for
|
|
961
|
+
# the wrong reason, and which a sweep of 0 matches would fail outright despite
|
|
962
|
+
# being equally complete. Reading the claim the adapter made is exact where
|
|
963
|
+
# re-deriving it from the output is a coincidence.
|
|
964
|
+
#
|
|
965
|
+
# THE WORDING IS A CONTRACT between this script and `plot-host.sh`'s
|
|
966
|
+
# `pr_sweep_report`, pinned on both sides. A partial sweep never prints it, so
|
|
967
|
+
# a match is licence and a miss is silence — never a guess.
|
|
968
|
+
#
|
|
969
|
+
# WHAT IS LOST BY GETTING THIS WRONG IS COST, NOT CORRECTNESS. Without the
|
|
970
|
+
# marker, `host_pr_state --ask` falls through to one `pr-state` call per
|
|
971
|
+
# unjoined branch and still answers correctly — the per-branch N+1 that #216
|
|
972
|
+
# removed. That is why withholding the marker is always the safe direction and
|
|
973
|
+
# is what every failure path here does.
|
|
974
|
+
case "$host_err" in
|
|
975
|
+
*"pr-list sweep complete"*)
|
|
976
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true ;;
|
|
977
|
+
*)
|
|
978
|
+
if [ "$_pr_rows" -gt 0 ] && [ "$_pr_rows" -lt "$PR_LIST_LIMIT" ] 2>/dev/null; then
|
|
979
|
+
printf '1' > "$HOST_STATE_CACHE/.list-complete" 2>/dev/null || true
|
|
980
|
+
fi ;;
|
|
981
|
+
esac
|
|
870
982
|
}
|
|
871
983
|
prefill_pr_states
|
|
872
984
|
|
|
@@ -1487,8 +1599,12 @@ worktree_locked() { # $1=worktree path → 0 when a lock is held there
|
|
|
1487
1599
|
# on the population that must stay free. The walk here is a subject/emptiness
|
|
1488
1600
|
# question rather than a timestamp read, but the guard is deliberately broad and
|
|
1489
1601
|
# loosening it to fit this change is how a guard rots.
|
|
1490
|
-
|
|
1491
|
-
|
|
1602
|
+
# READ ABOVE `prefill_pr_states`, not here. The per-branch sweep (#333) hands
|
|
1603
|
+
# the host the branches this scan tracks, and that list is exactly what this
|
|
1604
|
+
# batch already answers — so the assignment moved up rather than a second
|
|
1605
|
+
# `for-each-ref` being added beside it. Everything documented above still
|
|
1606
|
+
# describes it; only the line's position changed, and it depends on nothing but
|
|
1607
|
+
# git, so nothing between the two points can read a different answer.
|
|
1492
1608
|
|
|
1493
1609
|
# Whether `origin/$1` exists, answered from the batch rather than by spawning.
|
|
1494
1610
|
#
|
|
@@ -4265,6 +4381,17 @@ elif [ "$HOST_VERDICT" = failed ]; then
|
|
|
4265
4381
|
echo " branch below reads from local evidence alone, and a branch whose"
|
|
4266
4382
|
echo " PR is unknown reads 'unknown' rather than 'open'. This is not a"
|
|
4267
4383
|
echo " rate limit — waiting will not clear it; check the host and auth."
|
|
4384
|
+
# THE PAGE IS SHORT, NOT ABSENT, and that is a different instruction to a
|
|
4385
|
+
# reader. The three notes above all say *no PR could be read*; here some were,
|
|
4386
|
+
# so the branches below are a MIXTURE — a branch shown without a PR may have one
|
|
4387
|
+
# in the state that failed. Telling a reader to treat this as an outage would
|
|
4388
|
+
# discard the rows that arrived; telling them nothing is #912, where nine
|
|
4389
|
+
# branches read as having no PR and two had live ones.
|
|
4390
|
+
elif [ "$HOST_VERDICT" = partial ]; then
|
|
4391
|
+
echo " note: the git host answered for some states and not others, so the PR"
|
|
4392
|
+
echo " list below is INCOMPLETE. A branch shown without a PR may have one"
|
|
4393
|
+
echo " in the state that failed — do not read this as evidence that a"
|
|
4394
|
+
echo " branch is unreviewed. Re-run to get the whole list."
|
|
4268
4395
|
fi
|
|
4269
4396
|
# A STALE PULSE SAYS SO. The fetch used to fail silently, which made a scan of
|
|
4270
4397
|
# hour-old refs read exactly like a scan of current ones — the same
|