@plot-pm/board 0.14.0 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/plot-dispatch.sh CHANGED
@@ -281,12 +281,20 @@ while [ $# -gt 0 ]; do
281
281
  shift
282
282
  done
283
283
 
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
- }
284
+ # THE TWO PRECONDITIONS OF A DISPATCH, and a sourcing test has neither.
285
+ # `PLOT_DISPATCH_SOURCED=1` is taking the definitions below rather than running
286
+ # a dispatch, so it has no slug and often no git repository; the guard that
287
+ # stops it sits further down, after the function it exists to expose, and these
288
+ # two would exit before it is reached. See that guard for why it cannot simply
289
+ # move up here.
290
+ if [ -z "${PLOT_DISPATCH_SOURCED:-}" ]; then
291
+ git rev-parse --git-dir >/dev/null 2>&1 || { echo "not a git repository" >&2; exit 1; }
292
+ [ -n "$slug" ] || [ "$mode" != dispatch ] || {
293
+ echo "plot-dispatch: need a plan slug (usage: plot-dispatch.sh [--dry-run] <slug>)" >&2
294
+ echo " Which plans could be dispatched: /plot-pulse" >&2
295
+ exit 1
296
+ }
297
+ fi
290
298
 
291
299
  # ---------------------------------------------------------------------------
292
300
  # Worker launch, and the identity it records
@@ -743,9 +751,207 @@ brief_staleness_note() { # $1 = branch → prints a hint, or nothing
743
751
  echo " plan commit ${pc%% *} touched $plan_path after the brief's last change; read it before trusting the brief"
744
752
  }
745
753
 
754
+ # ---------------------------------------------------------------------------
755
+ # WHAT THIS AGENT RUNS
756
+ # ---------------------------------------------------------------------------
757
+ #
758
+ # THE HARNESS IS RESOLVED, NOT ASSUMED. `Worker command` is one key and every
759
+ # dispatched agent got the identical command line, so a fleet could not hold a
760
+ # reviewer on one model beside an implementer on another. A charter
761
+ # (`.plot/charters/<name>.json`) names its own `harness`, `model` and `effort`,
762
+ # and `plot-prompt.mjs --launch` decides which apply.
763
+ #
764
+ # A CHARTER NAMES A HARNESS; IT NEVER CARRIES A COMMAND LINE. Plot exports the
765
+ # three names and the PROMPT FILE holds the invocation — the contract
766
+ # `.plot/worker-prompt.sh` already states: *"Plot exports the variables and
767
+ # cannot write the invocation."* So this sets three variables and never builds
768
+ # a command out of them.
769
+ #
770
+ # NOTHING ON THE ESTATE CHANGES UNTIL A CHARTER EXISTS. `$PLOT_AGENT` unset —
771
+ # which is every worker today, since the estate holds zero charters — exports
772
+ # none of the three, and the command line is byte-identical to what it was.
773
+ # So does a named agent with no charter file on this clone.
774
+ #
775
+ # A REFUSAL IS NOT A FALLBACK, for `resolve_prompt_file`'s reason one field
776
+ # over: a charter that cannot be believed would otherwise RUN, successfully,
777
+ # under an invocation the operator did not ask for, and nothing in
778
+ # `.plot-worker.log` would say so.
779
+ #
780
+ # A HARNESS THIS MACHINE CANNOT RUN IS THE SECOND REFUSAL, and it is a MACHINE
781
+ # fact rather than a charter one. `plot-prompt.mjs` answers what a charter
782
+ # DECLARES and reaches no machine; whether the name is on PATH is the adapter's
783
+ # question, asked here, in the shell that is about to spawn — beside the `node`
784
+ # and `launchd` probes `plot-fleetctl.sh` performs. Without it a typo in a hand
785
+ # written field (`agnet` for `agent`) exports a name the prompt file then
786
+ # ignores, and the work is done by the repo default with nothing saying so.
787
+ #
788
+ # THE BUNDLE MISSING IS ALSO NOT A REFUSAL. `plot-prompt.mjs` is vendored beside
789
+ # this script, and a checkout without it is a Plot installation problem rather
790
+ # than a statement about this agent — so it falls back and SAYS it could not
791
+ # ask, the shape `plot-dispatch.sh:1502` already uses for an unaskable rule.
792
+ #
793
+ # It sets `launch_harness`, `launch_model`, `launch_effort` and `launch_agent`,
794
+ # and returns 1 on a refusal; the caller decides what a refusal costs.
795
+ resolve_launch() { # $1 = repo root, $2 = agent name ('' when none)
796
+ local root="$1" agent="$2" resolution="" status=0 verb rest why
797
+ launch_harness=""
798
+ launch_model=""
799
+ launch_effort=""
800
+ launch_agent=""
801
+
802
+ if [ -f "$script_dir/board/plot-prompt.mjs" ]; then
803
+ resolution=$(node "$script_dir/board/plot-prompt.mjs" --launch "$root" "$agent" 2>/dev/null)
804
+ status=$?
805
+ else
806
+ 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
807
+ return 0
808
+ fi
809
+
810
+ verb=${resolution%%$'\t'*}
811
+ rest=${resolution#*$'\t'}
812
+ launch_harness=${rest%%$'\t'*}
813
+ rest=${rest#*$'\t'}
814
+ launch_model=${rest%%$'\t'*}
815
+ rest=${rest#*$'\t'}
816
+ launch_effort=${rest%%$'\t'*}
817
+ why=${rest#*$'\t'}
818
+
819
+ if [ "$status" -eq 3 ] || [ "$verb" = "refused" ]; then
820
+ launch_harness=""
821
+ launch_model=""
822
+ launch_effort=""
823
+ launch_why="$why"
824
+ return 1
825
+ fi
826
+
827
+ case "$verb" in
828
+ declared)
829
+ launch_agent="$why"
830
+ # A HARNESS THIS MACHINE CANNOT RUN REFUSES. `command -v` is the reading
831
+ # because it is the question the prompt file asks when it interpolates the
832
+ # name: a check that asked anything else would refuse launches that work
833
+ # and pass launches that will not. It sits on this arm because only
834
+ # `declared` carries a harness the launch exports — the refusal above and
835
+ # the `*)` arm below have both already blanked the field.
836
+ #
837
+ # AN UNNAMED HARNESS IS NOT AN UNRUNNABLE ONE. A charter naming none
838
+ # resolves to '' and launches exactly as it does today, which is every
839
+ # dispatch on the estate; the guard on a non-empty name is what keeps it
840
+ # that way.
841
+ if [ -n "$launch_harness" ] && ! command -v "$launch_harness" >/dev/null 2>&1; then
842
+ # The name is read BEFORE the fields are blanked: the refusal's whole
843
+ # job is to name what it looked for, and clearing first loses it.
844
+ why="charter '$launch_agent' names harness '$launch_harness', which is not on PATH"
845
+ launch_harness=""
846
+ launch_model=""
847
+ launch_effort=""
848
+ launch_why="$why"
849
+ return 1
850
+ fi
851
+ ;;
852
+ *)
853
+ # A fallback, an unrecognised verb, or an empty answer from a bundle that
854
+ # could not run. Three empty names, which is what every worker exports
855
+ # today.
856
+ launch_harness=""
857
+ launch_model=""
858
+ launch_effort=""
859
+ ;;
860
+ esac
861
+ return 0
862
+ }
863
+
864
+ # WHETHER A DESK MAY BE HANDED TO A NEW WORKER — the ONE gate, two callers.
865
+ #
866
+ # EXTRACTED RATHER THAN COPIED, and that is the point. `--restart` asked these
867
+ # three questions inline; `--absent` asks the same three over every orphaned
868
+ # desk. A second, laxer set written for the sweep is the failure
869
+ # `plot-dispatch --stop` already argues against for stop rules, and it matters
870
+ # more here because the sweep runs with nobody watching.
871
+ #
872
+ # IT RETURNS RATHER THAN EXITS, which is the only change the extraction makes.
873
+ # A verb acting on one branch exits on a refusal; a sweep over several must
874
+ # refuse one desk and carry on to the next. The caller decides which.
875
+ #
876
+ # THE ORDER IS LOAD-BEARING AND IT IS THE MEASURED ONE:
877
+ #
878
+ # 1. THE PR, BEFORE THE STATE WORD. Five of five `failed` worktrees in this
879
+ # estate held a PR — four open, one already merged. `plot-worker-state.sh`
880
+ # refines `finished` by the tree but deliberately does NOT refine `failed`,
881
+ # `ended` or `none`, because a recorded non-zero exit is already a specific
882
+ # answer about the PROCESS — and silent about the WORK. A gate written on
883
+ # the state word alone would have restarted all five and discarded exactly
884
+ # what the `finished` refusal exists to protect.
885
+ # 2. A LIVE WORKER. There is no --force: a flag overriding this is the flag
886
+ # typed reflexively, and what it would override is another agent's work in
887
+ # progress.
888
+ # 3. A `PLOT-BLOCKED` MARKER. A person owes this branch an answer, and a new
889
+ # worker meets the same question and writes the same marker. Asked through
890
+ # `plot_worker_blocked_file`, never re-globbed: the marker's spelling lives
891
+ # with the classification in `plot-worker-state.sh` and only there.
892
+ #
893
+ # `stalled`, `failed`, `ended` and `no worker` all pass. The PR question is what
894
+ # makes `failed` safe to include — and including it is the point: a gate that
895
+ # simply refused `failed` would pass every refusal test and leave the verb
896
+ # unable to do the one thing it exists for.
897
+ handover_refusal() { # $1=branch $2=worktree $3=state → 0 may hand over, 1 refused
898
+ local branch="$1" wt="$2" state="$3" pr_json pr_num pr_state marker
899
+
900
+ if reached_review "$branch"; then
901
+ pr_json=$("$script_dir/plot-host.sh" pr-state "$branch" </dev/null 2>/dev/null || true)
902
+ pr_num=$(printf '%s' "$pr_json" | sed -n 's/.*"number":\([0-9]*\).*/\1/p')
903
+ pr_state=$(printf '%s' "$pr_json" | sed -n 's/.*"state":"\([A-Z]*\)".*/\1/p')
904
+ echo "plot-dispatch: $branch has a pull request (#${pr_num:-?}, ${pr_state:-OPEN}) — refusing." >&2
905
+ echo " The work reached review, whatever the worker's exit code says. A" >&2
906
+ echo " restart here redoes work someone is already looking at." >&2
907
+ echo " Review it, or reap the worktree once it merges." >&2
908
+ return 1
909
+ fi
910
+
911
+ case "$state" in
912
+ running*)
913
+ echo "plot-dispatch: a worker is alive on $branch (pid ${state#running }) — refusing." >&2
914
+ echo " Stop it first if you mean to replace it:" >&2
915
+ echo " plot-dispatch.sh --stop $branch" >&2
916
+ return 1
917
+ ;;
918
+ waiting*)
919
+ marker=$(plot_worker_blocked_file "$wt" || true)
920
+ echo "plot-dispatch: $branch is blocked on a question — refusing." >&2
921
+ echo " the question is in $wt/${marker:-the marker file}" >&2
922
+ echo " Answer it and delete the marker, then restart." >&2
923
+ return 1
924
+ ;;
925
+ esac
926
+ return 0
927
+ }
928
+
746
929
  start_worker() {
747
930
  local branch="$1" wt="$2"
748
931
  local cmd
932
+
933
+ # RESOLVED BEFORE `Worker command`, because the charter is the more specific
934
+ # answer and the config key is the fallback rather than the sole source.
935
+ #
936
+ # THE REFUSAL FIRES HERE, WHICH IS BEFORE ANYTHING THIS FUNCTION WRITES. The
937
+ # plan asks for a refusal "before the desk is touched"; on this codebase the
938
+ # desk already exists by the time `start_worker` is called — both call sites
939
+ # (`--restart`, and `--start`'s `git worktree add`) hand it a worktree path.
940
+ # What this position does guarantee is that NO WORKER IS LAUNCHED, no manifest
941
+ # is written and no `.plot-worker.exit` is removed, and `start_worker` pushes
942
+ # no claim at all — so the slice stays claimable by an agent that can run it,
943
+ # which is the property the plan's sentence is protecting. A desk with no
944
+ # worker is what `--start` already creates for a free agent, and the reaper
945
+ # handles it. See the PR body.
946
+ if ! resolve_launch "$repo_root" "${PLOT_AGENT:-}"; then
947
+ echo " refusing to start ${branch:-a free agent} — $launch_why" >&2
948
+ echo " A charter that cannot be read is a person's typo, and the repo's 'Worker command'" >&2
949
+ echo " would run successfully under an invocation nobody asked for." >&2
950
+ echo " Fix $repo_root/.plot/charters/${PLOT_AGENT:-?}.json, or unset PLOT_AGENT to start it" >&2
951
+ echo " on the repo's configured command deliberately." >&2
952
+ return 1
953
+ fi
954
+
749
955
  cmd=$("$script_dir/plot-config.sh" get "Worker command" "")
750
956
  # `none` means "asked, and this repo starts them by hand". Running it would
751
957
  # spawn a worker per branch that fails with `none: command not found` — a
@@ -1003,6 +1209,67 @@ start_worker() {
1003
1209
  # A HAND-MADE WORKTREE GETS NEITHER, and that falls out rather than being
1004
1210
  # enforced: this is the only code that starts a wrapper, and a worktree with
1005
1211
  # no wrapper has nothing for a monitor to be a child of.
1212
+ # WHAT THIS AGENT MAY TOUCH — `PLOT_CAPABILITIES`, AND WHY IT IS RESOLVED HERE.
1213
+ #
1214
+ # A charter's `capabilities` list is the one differentiation Plot can express
1215
+ # that is not a rule. Everything else an agent is told is PROSE — text it reads
1216
+ # and can reason past — and CLAUDE.md's own test settles what that makes it:
1217
+ # *can you answer "did I complete this?" without doing the work?* An agent
1218
+ # asked in prose not to edit the code it reviews can answer yes without it
1219
+ # being true. A tool the harness never offers cannot be called.
1220
+ #
1221
+ # RESOLUTION, NEVER MATCHING, the rule `plot-worker-loop.sh:916` states for the
1222
+ # prompt. `$PLOT_AGENT` is what the operator or the registry set; nothing here
1223
+ # reads a plan, ranks a candidate or chooses an agent. This slice BOUNDS an
1224
+ # agent and does not route to one — `matchQueue` is the assignment lock and is
1225
+ # untouched.
1226
+ #
1227
+ # THE LIST TRAVELS, THE SPELLING DOES NOT. Plot carries capability NAMES and
1228
+ # has no opinion about what they mean: Principle 5, *"Plot contains zero
1229
+ # hardcoded project names, paths, or configuration"*. Different harnesses spell
1230
+ # a tool restriction differently, so the prompt file turns a name into a flag —
1231
+ # the same division the prompt itself draws. There is no capability enum here,
1232
+ # no built-in `read-only`, and no tool table in the domain.
1233
+ #
1234
+ # ASKED OF THE BUNDLE, NOT OF THE FILE. `plot-prompt.mjs` already reads this
1235
+ # charter through `readCharter`, which refuses an unknown key, a run fact and
1236
+ # bytes that are not JSON. A `grep` for the field would happily read
1237
+ # capabilities out of a charter the domain refuses outright.
1238
+ #
1239
+ # AN EXIT CODE, NOT AN EMPTINESS. Three readings print nothing on stdout and
1240
+ # only one of them is a fault: no agent named, and a named agent with no
1241
+ # charter on this clone, are the estate today; a charter that EXISTS and cannot
1242
+ # be believed is a person's typo, and exits 3. Launching unbounded on it would
1243
+ # run — successfully — under a scope nobody asked for.
1244
+ #
1245
+ # NOTHING IS EXPORTED WHERE NOTHING WAS DECLARED. An agent with no charter, or
1246
+ # one naming no capabilities, exports no `PLOT_CAPABILITIES` at all — not an
1247
+ # empty string. A prompt file probes `[ -n "$PLOT_CAPABILITIES" ]`, and a
1248
+ # variable that is always set makes that probe meaningless.
1249
+ #
1250
+ # THE BUNDLE MISSING IS NOT A REFUSAL, `resolve_prompt_file`'s fourth arm: a
1251
+ # checkout without it is a Plot installation problem rather than a statement
1252
+ # about this agent. It says it could not ask and launches unbounded, which is
1253
+ # what it did before this existed.
1254
+ local capabilities='' cap_status=0
1255
+ if [ -n "${PLOT_AGENT:-}" ]; then
1256
+ if [ -f "$script_dir/board/plot-prompt.mjs" ]; then
1257
+ capabilities=$(node "$script_dir/board/plot-prompt.mjs" --capabilities "$repo_root" "$PLOT_AGENT" 2>/dev/null)
1258
+ cap_status=$?
1259
+ if [ "$cap_status" -eq 3 ]; then
1260
+ echo " refusing to start $branch — the charter for agent '$PLOT_AGENT' cannot be read:"
1261
+ node "$script_dir/board/plot-prompt.mjs" --capabilities "$repo_root" "$PLOT_AGENT" 2>&1 >/dev/null \
1262
+ | sed 's/^/ /'
1263
+ echo " A charter that cannot be believed must not launch an agent, because the"
1264
+ echo " launch would succeed under a scope nobody asked for. Fix"
1265
+ echo " $repo_root/.plot/charters/$PLOT_AGENT.json or unset PLOT_AGENT."
1266
+ return 1
1267
+ fi
1268
+ else
1269
+ echo " no plot-prompt.mjs beside this script — starting $branch without asking what agent '$PLOT_AGENT' may touch"
1270
+ fi
1271
+ fi
1272
+
1006
1273
  local worker_monitor='' agent_monitor='' build_monitor=''
1007
1274
  [ -x "$script_dir/plot-worker-monitor.sh" ] && worker_monitor="$script_dir/plot-worker-monitor.sh"
1008
1275
  [ -x "$script_dir/plot-agent-monitor.sh" ] && agent_monitor="$script_dir/plot-agent-monitor.sh"
@@ -1014,9 +1281,38 @@ start_worker() {
1014
1281
  [ -x "$script_dir/plot-build-monitor.sh" ] && build_monitor="$script_dir/plot-build-monitor.sh"
1015
1282
  local stamp_now
1016
1283
  stamp_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
1017
- ( cd "$wt" && PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
1284
+ # THE THREE NAMES THE CHARTER DECLARED, and they travel as env vars for the
1285
+ # reason every other path here does: the `sh -c` body is single-quoted, so a
1286
+ # value with spaces would not survive interpolation into it.
1287
+ #
1288
+ # EMPTY IS THE ESTATE TODAY and must stay indistinguishable from before. An
1289
+ # agent with no charter exports three empty strings, a prompt file
1290
+ # interpolating `${PLOT_MODEL:-}` gets nothing, and the command line is
1291
+ # byte-identical to what it was — which is the 100% case, since zero charters
1292
+ # exist. `PLOT_AGENT` is forwarded too, so the loop's own `resolve_prompt_file`
1293
+ # asks about the same agent this launch resolved.
1294
+ ( cd "$wt" && \
1295
+ # AN `export`, NOT AN ENV PREFIX, AND THE REASON IS A MEASUREMENT. Bash
1296
+ # recognises an assignment prefix BEFORE it expands parameters, so a
1297
+ # `${caps:+PLOT_CAPABILITIES="$caps"}` in the prefix below is not an
1298
+ # assignment at all — it expands to a WORD, and bash then looks for a
1299
+ # command by that name. Measured 2026-09-12: the empty case passed and
1300
+ # every non-empty one failed with `PLOT_CAPABILITIES=read-only: command
1301
+ # not found`, which would have broken every dispatch that had a charter
1302
+ # while the case with no charter went on working.
1303
+ #
1304
+ # The conditional is what keeps an undeclared agent's variable UNSET
1305
+ # rather than empty, so a prompt file's `[ -n "$PLOT_CAPABILITIES" ]`
1306
+ # probe means what it says. This subshell is already the launch's own, so
1307
+ # the export reaches the wrapper and nothing else.
1308
+ { [ -n "$capabilities" ] && export PLOT_CAPABILITIES="$capabilities"; true; } && \
1309
+ PLOT_BRANCH="$branch" PLOT_WORKTREE="$wt" \
1018
1310
  PLOT_SLUG="$slug" \
1019
1311
  PLOT_SESSION_ID="$session" \
1312
+ PLOT_AGENT="${PLOT_AGENT:-}" \
1313
+ PLOT_HARNESS="$launch_harness" \
1314
+ PLOT_MODEL="$launch_model" \
1315
+ PLOT_EFFORT="$launch_effort" \
1020
1316
  PLOT_MANIFEST_FILE="$manifest_dir/$session.json" \
1021
1317
  PLOT_STAMP_STARTED="$stamp_now" \
1022
1318
  PLOT_WORKER_MONITOR="$worker_monitor" \
@@ -1063,6 +1359,30 @@ start_worker() {
1063
1359
  return 0
1064
1360
  }
1065
1361
 
1362
+ # `PLOT_DISPATCH_SOURCED=1` STOPS HERE, so a test can take `resolve_launch` and
1363
+ # `start_worker` without dispatching anything — `plot-worker-loop.sh` states
1364
+ # this idiom for `resolve_prompt_file`, and this is the same one applied to the
1365
+ # functions that answer the same question one script over.
1366
+ #
1367
+ # AFTER BOTH DEFINITIONS IT EXISTS TO EXPOSE, and that placement is the whole
1368
+ # subtlety. This file is not `plot-worker-loop.sh`, where every definition
1369
+ # precedes every executing line: here the argument parsing runs at the TOP and
1370
+ # the functions are defined below it. Measured while writing this: a guard at
1371
+ # the top of the file returned before `resolve_launch` existed
1372
+ # (`resolve_launch: command not found`), and a guard between the two functions
1373
+ # hid `start_worker` the same way — which is the one a test must call to prove
1374
+ # a refusal touches no desk.
1375
+ #
1376
+ # `script_dir` IS ALREADY RESOLVED at this point, which is what a sourcing test
1377
+ # needs and what slicing the file cannot give it: `script_dir` is derived from
1378
+ # `BASH_SOURCE`, so a copy written to /tmp resolves every helper to /tmp.
1379
+ #
1380
+ # THE FLAG IS OPT-IN AND NAMED FOR THIS FILE. An unset variable leaves the
1381
+ # script exactly as it was — no caller changes, and a dispatched worker cannot
1382
+ # reach this return by accident. `return` rather than `exit` because a sourced
1383
+ # script returns to its sourcer.
1384
+ [ -n "${PLOT_DISPATCH_SOURCED:-}" ] && return 0
1385
+
1066
1386
  # ---------------------------------------------------------------------------
1067
1387
  # Inspection and shutdown
1068
1388
  # ---------------------------------------------------------------------------
@@ -1278,44 +1598,12 @@ if [ "$mode" = "restart" ]; then
1278
1598
  # Same lesson plot-reap.sh learned from the other side: it reads `mergedAt`
1279
1599
  # and never `state`, because a merged PR reports CLOSED. There the state word
1280
1600
  # 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
1601
 
1292
1602
  # Only now the process. `worker_state` is the ONE answer to "is a worker
1293
1603
  # running here" — asked rather than re-derived, so this cannot drift from
1294
1604
  # `--status` and the scan the way five of six states already did once.
1295
1605
  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
1606
+ handover_refusal "$restart_branch" "$restart_wt" "$restart_state" || exit 1
1319
1607
  # `stalled`, `failed`, `ended` and `no worker` all restart. The PR question
1320
1608
  # above is what makes `failed` safe to include — and including it is the
1321
1609
  # point: a gate that simply refused `failed` would pass every refusal test