@plot-pm/board 0.14.0 → 0.14.2

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
@@ -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,23 +290,70 @@ 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
- -h|--help) sed -n '2,59p' "$0"; exit 0 ;;
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
 
284
- git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
285
- [ -n "$slug" ] || [ "$mode" != dispatch ] || {
286
- echo "plot-dispatch: need a plan slug (usage: plot-dispatch.sh [--dry-run] <slug>)" >&2
287
- echo " Which plans could be dispatched: /plot-pulse" >&2
288
- exit 1
289
- }
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
+
343
+ # THE TWO PRECONDITIONS OF A DISPATCH, and a sourcing test has neither.
344
+ # `PLOT_DISPATCH_SOURCED=1` is taking the definitions below rather than running
345
+ # a dispatch, so it has no slug and often no git repository; the guard that
346
+ # stops it sits further down, after the function it exists to expose, and these
347
+ # two would exit before it is reached. See that guard for why it cannot simply
348
+ # move up here.
349
+ if [ -z "${PLOT_DISPATCH_SOURCED:-}" ]; then
350
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
351
+ [ -n "$slug" ] || [ "$mode" != dispatch ] || {
352
+ echo "plot-dispatch: need a plan slug (usage: plot-dispatch.sh [--dry-run] <slug>)" >&2
353
+ echo " Which plans could be dispatched: /plot-pulse" >&2
354
+ exit 1
355
+ }
356
+ fi
290
357
 
291
358
  # ---------------------------------------------------------------------------
292
359
  # Worker launch, and the identity it records
@@ -743,9 +810,243 @@ brief_staleness_note() { # $1 = branch → prints a hint, or nothing
743
810
  echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
744
811
  }
745
812
 
813
+ # ---------------------------------------------------------------------------
814
+ # WHAT THIS AGENT RUNS
815
+ # ---------------------------------------------------------------------------
816
+ #
817
+ # THE HARNESS IS RESOLVED, NOT ASSUMED. `Worker command` is one key and every
818
+ # dispatched agent got the identical command line, so a fleet could not hold a
819
+ # reviewer on one model beside an implementer on another. A charter
820
+ # (`.plot/charters/<name>.json`) names its own `harness`, `model` and `effort`,
821
+ # and `plot-prompt.mjs --launch` decides which apply.
822
+ #
823
+ # A CHARTER NAMES A HARNESS; IT NEVER CARRIES A COMMAND LINE. Plot exports the
824
+ # three names and the PROMPT FILE holds the invocation — the contract
825
+ # `.plot/worker-prompt.sh` already states: *"Plot exports the variables and
826
+ # cannot write the invocation."* So this sets three variables and never builds
827
+ # a command out of them.
828
+ #
829
+ # NOTHING ON THE ESTATE CHANGES UNTIL A CHARTER EXISTS. `$PLOT_AGENT` unset —
830
+ # which is every worker today, since the estate holds zero charters — exports
831
+ # none of the three, and the command line is byte-identical to what it was.
832
+ # So does a named agent with no charter file on this clone.
833
+ #
834
+ # A REFUSAL IS NOT A FALLBACK, for `resolve_prompt_file`'s reason one field
835
+ # over: a charter that cannot be believed would otherwise RUN, successfully,
836
+ # under an invocation the operator did not ask for, and nothing in
837
+ # `.plot-worker.log` would say so.
838
+ #
839
+ # A HARNESS THIS MACHINE CANNOT RUN IS THE SECOND REFUSAL, and it is a MACHINE
840
+ # fact rather than a charter one. `plot-prompt.mjs` answers what a charter
841
+ # DECLARES and reaches no machine; whether the name is on PATH is the adapter's
842
+ # question, asked here, in the shell that is about to spawn — beside the `node`
843
+ # and `launchd` probes `plot-fleetctl.sh` performs. Without it a typo in a hand
844
+ # written field (`agnet` for `agent`) exports a name the prompt file then
845
+ # ignores, and the work is done by the repo default with nothing saying so.
846
+ #
847
+ # THE BUNDLE MISSING IS ALSO NOT A REFUSAL. `plot-prompt.mjs` is vendored beside
848
+ # this script, and a checkout without it is a Plot installation problem rather
849
+ # than a statement about this agent — so it falls back and SAYS it could not
850
+ # ask, the shape `plot-dispatch.sh:1502` already uses for an unaskable rule.
851
+ #
852
+ # It sets `launch_harness`, `launch_model`, `launch_effort` and `launch_agent`,
853
+ # and returns 1 on a refusal; the caller decides what a refusal costs.
854
+ resolve_launch() { # $1 = repo root, $2 = agent name ('' when none)
855
+ local root="$1" agent="$2" resolution="" status=0 verb rest why
856
+ launch_harness=""
857
+ launch_model=""
858
+ launch_effort=""
859
+ launch_agent=""
860
+
861
+ if [ -f "$script_dir/board/plot-prompt.mjs" ]; then
862
+ resolution=$(node "$script_dir/board/plot-prompt.mjs" --launch "$root" "$agent" 2>/dev/null)
863
+ status=$?
864
+ else
865
+ echo "plot-dispatch: no plot-prompt.mjs beside this script — starting ${agent:-this agent} on the repo's 'Worker command' without asking what it declared" >&2
866
+ return 0
867
+ fi
868
+
869
+ verb=${resolution%%$'\t'*}
870
+ rest=${resolution#*$'\t'}
871
+ launch_harness=${rest%%$'\t'*}
872
+ rest=${rest#*$'\t'}
873
+ launch_model=${rest%%$'\t'*}
874
+ rest=${rest#*$'\t'}
875
+ launch_effort=${rest%%$'\t'*}
876
+ why=${rest#*$'\t'}
877
+
878
+ if [ "$status" -eq 3 ] || [ "$verb" = "refused" ]; then
879
+ launch_harness=""
880
+ launch_model=""
881
+ launch_effort=""
882
+ launch_why="$why"
883
+ return 1
884
+ fi
885
+
886
+ case "$verb" in
887
+ declared)
888
+ launch_agent="$why"
889
+ # A HARNESS THIS MACHINE CANNOT RUN REFUSES. `command -v` is the reading
890
+ # because it is the question the prompt file asks when it interpolates the
891
+ # name: a check that asked anything else would refuse launches that work
892
+ # and pass launches that will not. It sits on this arm because only
893
+ # `declared` carries a harness the launch exports — the refusal above and
894
+ # the `*)` arm below have both already blanked the field.
895
+ #
896
+ # AN UNNAMED HARNESS IS NOT AN UNRUNNABLE ONE. A charter naming none
897
+ # resolves to '' and launches exactly as it does today, which is every
898
+ # dispatch on the estate; the guard on a non-empty name is what keeps it
899
+ # that way.
900
+ if [ -n "$launch_harness" ] && ! command -v "$launch_harness" >/dev/null 2>&1; then
901
+ # The name is read BEFORE the fields are blanked: the refusal's whole
902
+ # job is to name what it looked for, and clearing first loses it.
903
+ why="charter '$launch_agent' names harness '$launch_harness', which is not on PATH"
904
+ launch_harness=""
905
+ launch_model=""
906
+ launch_effort=""
907
+ launch_why="$why"
908
+ return 1
909
+ fi
910
+ ;;
911
+ *)
912
+ # A fallback, an unrecognised verb, or an empty answer from a bundle that
913
+ # could not run. Three empty names, which is what every worker exports
914
+ # today.
915
+ launch_harness=""
916
+ launch_model=""
917
+ launch_effort=""
918
+ ;;
919
+ esac
920
+ return 0
921
+ }
922
+
923
+ # WHETHER A DESK MAY BE HANDED TO A NEW WORKER — the ONE gate, two callers.
924
+ #
925
+ # EXTRACTED RATHER THAN COPIED, and that is the point. `--restart` asked these
926
+ # three questions inline; `--absent` asks the same three over every orphaned
927
+ # desk. A second, laxer set written for the sweep is the failure
928
+ # `plot-dispatch --stop` already argues against for stop rules, and it matters
929
+ # more here because the sweep runs with nobody watching.
930
+ #
931
+ # IT RETURNS RATHER THAN EXITS, which is the only change the extraction makes.
932
+ # A verb acting on one branch exits on a refusal; a sweep over several must
933
+ # refuse one desk and carry on to the next. The caller decides which.
934
+ #
935
+ # THE ORDER IS LOAD-BEARING AND IT IS THE MEASURED ONE:
936
+ #
937
+ # 1. THE PR, BEFORE THE STATE WORD. Five of five `failed` worktrees in this
938
+ # estate held a PR — four open, one already merged. `plot-worker-state.sh`
939
+ # refines `finished` by the tree but deliberately does NOT refine `failed`,
940
+ # `ended` or `none`, because a recorded non-zero exit is already a specific
941
+ # answer about the PROCESS — and silent about the WORK. A gate written on
942
+ # the state word alone would have restarted all five and discarded exactly
943
+ # what the `finished` refusal exists to protect.
944
+ # 2. A LIVE WORKER. There is no --force: a flag overriding this is the flag
945
+ # typed reflexively, and what it would override is another agent's work in
946
+ # progress.
947
+ # 3. A `PLOT-BLOCKED` MARKER. A person owes this branch an answer, and a new
948
+ # worker meets the same question and writes the same marker. Asked through
949
+ # `plot_worker_blocked_file`, never re-globbed: the marker's spelling lives
950
+ # with the classification in `plot-worker-state.sh` and only there.
951
+ #
952
+ # `stalled`, `failed`, `ended` and `no worker` all pass. The PR question is what
953
+ # makes `failed` safe to include — and including it is the point: a gate that
954
+ # simply refused `failed` would pass every refusal test and leave the verb
955
+ # unable to do the one thing it exists for.
956
+ handover_refusal() { # $1=branch $2=worktree $3=state → 0 may hand over, 1 refused
957
+ local branch="$1" wt="$2" state="$3" pr_json pr_num pr_state marker
958
+
959
+ if reached_review "$branch"; then
960
+ pr_json=$("$script_dir/plot-host.sh" pr-state "$branch" </dev/null 2>/dev/null || true)
961
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
962
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
963
+ echo "plot-dispatch: $branch has a pull request (#${pr_num:-?}, ${pr_state:-OPEN}) — refusing." >&2
964
+ echo " The work reached review, whatever the worker's exit code says. A" >&2
965
+ echo " restart here redoes work someone is already looking at." >&2
966
+ echo " Review it, or reap the worktree once it merges." >&2
967
+ return 1
968
+ fi
969
+
970
+ case "$state" in
971
+ running*)
972
+ echo "plot-dispatch: a worker is alive on $branch (pid ${state#running }) — refusing." >&2
973
+ echo " Stop it first if you mean to replace it:" >&2
974
+ echo " plot-dispatch.sh --stop $branch" >&2
975
+ return 1
976
+ ;;
977
+ waiting*)
978
+ marker=$(plot_worker_blocked_file "$wt" || true)
979
+ echo "plot-dispatch: $branch is blocked on a question — refusing." >&2
980
+ echo " the question is in $wt/${marker:-the marker file}" >&2
981
+ echo " Answer it and delete the marker, then restart." >&2
982
+ return 1
983
+ ;;
984
+ esac
985
+ return 0
986
+ }
987
+
746
988
  start_worker() {
747
989
  local branch="$1" wt="$2"
748
990
  local cmd
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
+
1028
+ # RESOLVED BEFORE `Worker command`, because the charter is the more specific
1029
+ # answer and the config key is the fallback rather than the sole source.
1030
+ #
1031
+ # THE REFUSAL FIRES HERE, WHICH IS BEFORE ANYTHING THIS FUNCTION WRITES. The
1032
+ # plan asks for a refusal "before the desk is touched"; on this codebase the
1033
+ # desk already exists by the time `start_worker` is called — both call sites
1034
+ # (`--restart`, and `--start`'s `git worktree add`) hand it a worktree path.
1035
+ # What this position does guarantee is that NO WORKER IS LAUNCHED, no manifest
1036
+ # is written and no `.plot-worker.exit` is removed, and `start_worker` pushes
1037
+ # no claim at all — so the slice stays claimable by an agent that can run it,
1038
+ # which is the property the plan's sentence is protecting. A desk with no
1039
+ # worker is what `--start` already creates for a free agent, and the reaper
1040
+ # handles it. See the PR body.
1041
+ if ! resolve_launch "$repo_root" "${PLOT_AGENT:-}"; then
1042
+ echo " refusing to start ${branch:-a free agent} — $launch_why" >&2
1043
+ echo " A charter that cannot be read is a person's typo, and the repo's 'Worker command'" >&2
1044
+ echo " would run successfully under an invocation nobody asked for." >&2
1045
+ echo " Fix $repo_root/.plot/charters/${PLOT_AGENT:-?}.json, or unset PLOT_AGENT to start it" >&2
1046
+ echo " on the repo's configured command deliberately." >&2
1047
+ return 1
1048
+ fi
1049
+
749
1050
  cmd=$("$script_dir/plot-config.sh" get "Worker command" "")
750
1051
  # `none` means "asked, and this repo starts them by hand". Running it would
751
1052
  # spawn a worker per branch that fails with `none: command not found` — a
@@ -1003,6 +1304,67 @@ start_worker() {
1003
1304
  # A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
1004
1305
  # enforced: this is the only code that starts a wrapper, and a worktree with
1005
1306
  # no wrapper has nothing for a monitor to be a child of.
1307
+ # WHAT THIS AGENT MAY TOUCH — `PLOT_CAPABILITIES`, AND WHY IT IS RESOLVED HERE.
1308
+ #
1309
+ # A charter's `capabilities` list is the one differentiation Plot can express
1310
+ # that is not a rule. Everything else an agent is told is PROSE — text it reads
1311
+ # and can reason past — and CLAUDE.md's own test settles what that makes it:
1312
+ # *can you answer "did I complete this?" without doing the work?* An agent
1313
+ # asked in prose not to edit the code it reviews can answer yes without it
1314
+ # being true. A tool the harness never offers cannot be called.
1315
+ #
1316
+ # RESOLUTION, NEVER MATCHING, the rule `plot-worker-loop.sh:916` states for the
1317
+ # prompt. `$PLOT_AGENT` is what the operator or the registry set; nothing here
1318
+ # reads a plan, ranks a candidate or chooses an agent. This slice BOUNDS an
1319
+ # agent and does not route to one — `matchQueue` is the assignment lock and is
1320
+ # untouched.
1321
+ #
1322
+ # THE LIST TRAVELS, THE SPELLING DOES NOT. Plot carries capability NAMES and
1323
+ # has no opinion about what they mean: Principle 5, *"Plot contains zero
1324
+ # hardcoded project names, paths, or configuration"*. Different harnesses spell
1325
+ # a tool restriction differently, so the prompt file turns a name into a flag —
1326
+ # the same division the prompt itself draws. There is no capability enum here,
1327
+ # no built-in `read-only`, and no tool table in the domain.
1328
+ #
1329
+ # ASKED OF THE BUNDLE, NOT OF THE FILE. `plot-prompt.mjs` already reads this
1330
+ # charter through `readCharter`, which refuses an unknown key, a run fact and
1331
+ # bytes that are not JSON. A `grep` for the field would happily read
1332
+ # capabilities out of a charter the domain refuses outright.
1333
+ #
1334
+ # AN EXIT CODE, NOT AN EMPTINESS. Three readings print nothing on stdout and
1335
+ # only one of them is a fault: no agent named, and a named agent with no
1336
+ # charter on this clone, are the estate today; a charter that EXISTS and cannot
1337
+ # be believed is a person's typo, and exits 3. Launching unbounded on it would
1338
+ # run — successfully — under a scope nobody asked for.
1339
+ #
1340
+ # NOTHING IS EXPORTED WHERE NOTHING WAS DECLARED. An agent with no charter, or
1341
+ # one naming no capabilities, exports no `PLOT_CAPABILITIES` at all — not an
1342
+ # empty string. A prompt file probes `[ -n "$PLOT_CAPABILITIES" ]`, and a
1343
+ # variable that is always set makes that probe meaningless.
1344
+ #
1345
+ # THE BUNDLE MISSING IS NOT A REFUSAL, `resolve_prompt_file`'s fourth arm: a
1346
+ # checkout without it is a Plot installation problem rather than a statement
1347
+ # about this agent. It says it could not ask and launches unbounded, which is
1348
+ # what it did before this existed.
1349
+ local capabilities='' cap_status=0
1350
+ if [ -n "${PLOT_AGENT:-}" ]; then
1351
+ if [ -f "$script_dir/board/plot-prompt.mjs" ]; then
1352
+ capabilities=$(node "$script_dir/board/plot-prompt.mjs" --capabilities "$repo_root" "$PLOT_AGENT" 2>/dev/null)
1353
+ cap_status=$?
1354
+ if [ "$cap_status" -eq 3 ]; then
1355
+ echo " refusing to start $branch — the charter for agent '$PLOT_AGENT' cannot be read:"
1356
+ node "$script_dir/board/plot-prompt.mjs" --capabilities "$repo_root" "$PLOT_AGENT" 2>&1 >/dev/null \
1357
+ | sed 's/^/ /'
1358
+ echo " A charter that cannot be believed must not launch an agent, because the"
1359
+ echo " launch would succeed under a scope nobody asked for. Fix"
1360
+ echo " $repo_root/.plot/charters/$PLOT_AGENT.json or unset PLOT_AGENT."
1361
+ return 1
1362
+ fi
1363
+ else
1364
+ echo " no plot-prompt.mjs beside this script — starting $branch without asking what agent '$PLOT_AGENT' may touch"
1365
+ fi
1366
+ fi
1367
+
1006
1368
  local worker_monitor='' agent_monitor='' build_monitor=''
1007
1369
  [ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
1008
1370
  [ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
@@ -1014,9 +1376,38 @@ start_worker() {
1014
1376
  [ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
1015
1377
  local stamp_now
1016
1378
  stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1017
- ( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
1379
+ # THE THREE NAMES THE CHARTER DECLARED, and they travel as env vars for the
1380
+ # reason every other path here does: the `sh -c` body is single-quoted, so a
1381
+ # value with spaces would not survive interpolation into it.
1382
+ #
1383
+ # EMPTY IS THE ESTATE TODAY and must stay indistinguishable from before. An
1384
+ # agent with no charter exports three empty strings, a prompt file
1385
+ # interpolating `${PLOT_MODEL:-}` gets nothing, and the command line is
1386
+ # byte-identical to what it was — which is the 100% case, since zero charters
1387
+ # exist. `PLOT_AGENT` is forwarded too, so the loop's own `resolve_prompt_file`
1388
+ # asks about the same agent this launch resolved.
1389
+ ( cd "$wt" && \
1390
+ # AN `export`, NOT AN ENV PREFIX, AND THE REASON IS A MEASUREMENT. Bash
1391
+ # recognises an assignment prefix BEFORE it expands parameters, so a
1392
+ # `${caps:+PLOT_CAPABILITIES="$caps"}` in the prefix below is not an
1393
+ # assignment at all — it expands to a WORD, and bash then looks for a
1394
+ # command by that name. Measured 2026-09-12: the empty case passed and
1395
+ # every non-empty one failed with `PLOT_CAPABILITIES=read-only: command
1396
+ # not found`, which would have broken every dispatch that had a charter
1397
+ # while the case with no charter went on working.
1398
+ #
1399
+ # The conditional is what keeps an undeclared agent's variable UNSET
1400
+ # rather than empty, so a prompt file's `[ -n "$PLOT_CAPABILITIES" ]`
1401
+ # probe means what it says. This subshell is already the launch's own, so
1402
+ # the export reaches the wrapper and nothing else.
1403
+ { [ -n "$capabilities" ] && export PLOT_CAPABILITIES="$capabilities"; true; } && \
1404
+ PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
1018
1405
  PLOT_SLUG="$slug" \
1019
1406
  PLOT_SESSION_ID="$session" \
1407
+ PLOT_AGENT="${PLOT_AGENT:-}" \
1408
+ PLOT_HARNESS="$launch_harness" \
1409
+ PLOT_MODEL="$launch_model" \
1410
+ PLOT_EFFORT="$launch_effort" \
1020
1411
  PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
1021
1412
  PLOT_STAMP_STARTED="$stamp_now" \
1022
1413
  PLOT_WORKER_MONITOR="$worker_monitor" \
@@ -1063,6 +1454,102 @@ start_worker() {
1063
1454
  return 0
1064
1455
  }
1065
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
+
1529
+ # `PLOT_DISPATCH_SOURCED=1` STOPS HERE, so a test can take `resolve_launch` and
1530
+ # `start_worker` without dispatching anything — `plot-worker-loop.sh` states
1531
+ # this idiom for `resolve_prompt_file`, and this is the same one applied to the
1532
+ # functions that answer the same question one script over.
1533
+ #
1534
+ # AFTER BOTH DEFINITIONS IT EXISTS TO EXPOSE, and that placement is the whole
1535
+ # subtlety. This file is not `plot-worker-loop.sh`, where every definition
1536
+ # precedes every executing line: here the argument parsing runs at the TOP and
1537
+ # the functions are defined below it. Measured while writing this: a guard at
1538
+ # the top of the file returned before `resolve_launch` existed
1539
+ # (`resolve_launch: command not found`), and a guard between the two functions
1540
+ # hid `start_worker` the same way — which is the one a test must call to prove
1541
+ # a refusal touches no desk.
1542
+ #
1543
+ # `script_dir` IS ALREADY RESOLVED at this point, which is what a sourcing test
1544
+ # needs and what slicing the file cannot give it: `script_dir` is derived from
1545
+ # `BASH_SOURCE`, so a copy written to /tmp resolves every helper to /tmp.
1546
+ #
1547
+ # THE FLAG IS OPT-IN AND NAMED FOR THIS FILE. An unset variable leaves the
1548
+ # script exactly as it was — no caller changes, and a dispatched worker cannot
1549
+ # reach this return by accident. `return` rather than `exit` because a sourced
1550
+ # script returns to its sourcer.
1551
+ [ -n "${PLOT_DISPATCH_SOURCED:-}" ] && return 0
1552
+
1066
1553
  # ---------------------------------------------------------------------------
1067
1554
  # Inspection and shutdown
1068
1555
  # ---------------------------------------------------------------------------
@@ -1278,44 +1765,12 @@ if [ "$mode" = "restart" ]; then
1278
1765
  # Same lesson plot-reap.sh learned from the other side: it reads `mergedAt`
1279
1766
  # and never `state`, because a merged PR reports CLOSED. There the state word
1280
1767
  # lies about merging; here the exit code lies about completion.
1281
- if reached_review "$restart_branch"; then
1282
- pr_json=$("$script_dir/plot-host.sh" pr-state "$restart_branch" </dev/null 2>/dev/null || true)
1283
- pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
1284
- pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
1285
- echo "plot-dispatch: $restart_branch has a pull request (#${pr_num:-?}, ${pr_state:-OPEN}) — refusing." >&2
1286
- echo " The work reached review, whatever the worker's exit code says. A" >&2
1287
- echo " restart here redoes work someone is already looking at." >&2
1288
- echo " Review it, or reap the worktree once it merges." >&2
1289
- exit 1
1290
- fi
1291
1768
 
1292
1769
  # Only now the process. `worker_state` is the ONE answer to "is a worker
1293
1770
  # running here" — asked rather than re-derived, so this cannot drift from
1294
1771
  # `--status` and the scan the way five of six states already did once.
1295
1772
  restart_state=$(worker_state "$restart_wt" "$restart_branch")
1296
- case "$restart_state" in
1297
- running*)
1298
- # THE REFUSAL THAT PREVENTS TWO WORKERS ON ONE BRANCH. There is no
1299
- # --force: a flag overriding this is the flag typed reflexively, and what
1300
- # it would override is another agent's work in progress.
1301
- echo "plot-dispatch: a worker is alive on $restart_branch (pid ${restart_state#running }) — refusing." >&2
1302
- echo " Stop it first if you mean to replace it:" >&2
1303
- echo " plot-dispatch.sh --stop $restart_branch" >&2
1304
- exit 1
1305
- ;;
1306
- waiting*)
1307
- # A person owes this branch an answer. A new worker meets the same
1308
- # question and writes the same marker.
1309
- # ASKED, NOT RE-GLOBBED. The marker's spelling lives with the
1310
- # classification in plot-worker-state.sh and only there; a copy of the
1311
- # glob here is the drift `workerstate.test.mjs` pins against.
1312
- marker=$(plot_worker_blocked_file "$restart_wt" || true)
1313
- echo "plot-dispatch: $restart_branch is blocked on a question — refusing." >&2
1314
- echo " the question is in $restart_wt/${marker:-the marker file}" >&2
1315
- echo " Answer it and delete the marker, then restart." >&2
1316
- exit 1
1317
- ;;
1318
- esac
1773
+ handover_refusal "$restart_branch" "$restart_wt" "$restart_state" || exit 1
1319
1774
  # `stalled`, `failed`, `ended` and `no worker` all restart. The PR question
1320
1775
  # above is what makes `failed` safe to include — and including it is the
1321
1776
  # point: a gate that simply refused `failed` would pass every refusal test