@plot-pm/board 0.16.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
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",
@@ -31,6 +31,7 @@
31
31
  "plot-config.sh",
32
32
  "plot-default-branch.sh",
33
33
  "plot-deliver.sh",
34
+ "plot-desk-root.sh",
34
35
  "plot-dispatch.sh",
35
36
  "plot-fleet-scan.sh",
36
37
  "plot-host.sh",
package/plot-approve.sh CHANGED
@@ -128,7 +128,13 @@ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run thi
128
128
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
129
129
 
130
130
  repo_root=$(git rev-parse --show-toplevel)
131
- wt_root=$(cd "$repo_root/.." && pwd)
131
+ # The booking worktree goes under the desk root of the MAIN checkout, never of
132
+ # this tree: inside a desk `--show-toplevel` answers the desk, and the default
133
+ # `.worktrees` would resolve beneath it. No fallback — `plot-desk-root.sh`.
134
+ # shellcheck source=plot-desk-root.sh
135
+ . "$script_dir/plot-desk-root.sh"
136
+ main_root=$(plot_repo_root)
137
+ wt_root=$(plot_desk_root "$main_root") || die "cannot resolve where the booking worktree goes (see above)"
132
138
 
133
139
  PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
134
140
  ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
@@ -735,6 +741,9 @@ else
735
741
 
736
742
  bookbr="plot/approve-$slug"
737
743
  tmpwt="$wt_root/.plot-approve-$slug.$$"
744
+ # `git worktree add` creates the desk root when it is missing, so exclude it
745
+ # first: a booking run must not leave `.worktrees/` untracked in `git status`.
746
+ plot_exclude_desk_root "$main_root"
738
747
  # -B: a leftover branch from an earlier failed run must not block this one.
739
748
  # It is disposable by construction — created here, pushed, deleted.
740
749
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
package/plot-config.sh CHANGED
@@ -27,12 +27,7 @@
27
27
  # Project board | Branch prefixes | Plan directory | Active index |
28
28
  # Delivered index | Sprint directory | Story directory | Story index |
29
29
  # Plan template | Worker prompt template | Main branch | Board command
30
- # Worktree root where /plot-dispatch puts its worktrees. Read by
31
- # plot-dispatch.sh; default is the repo's PARENT, which
32
- # scatters `plot-wt-*` beside the checkout. An absolute
33
- # path is taken as given; a relative one resolves against
34
- # the repo root, so `.worktrees` gathers them inside it.
35
- # The default is kept for repos that never set it.
30
+ # Worktree root the desk root; see its entry under the agent keys below.
36
31
  # Worker bound seconds a single prompt run may take in the worker loop
37
32
  # before it is ended and the worker exits (no hop). Read by
38
33
  # plot-worker-loop.sh; default 3600 (~1h), `0` disables it.
@@ -99,24 +94,26 @@
99
94
  # project-owned and reviewed like this one.
100
95
  # Absent or empty = no change, so an adopting project that
101
96
  # sets nothing behaves exactly as today.
102
- # Worktree root where /plot-dispatch creates fleet worktrees. A relative
103
- # value resolves against the repo root, an absolute one is
104
- # taken as given. Absent = the default `repo_root/..` with
105
- # the `plot-wt-` prefix — today's behaviour, so no existing
106
- # checkout moves. Under a dedicated root the prefix is
107
- # dropped: the directory already says these are Plot's. Read
108
- # only by the CREATION path; every "which worktree holds this
109
- # branch" read asks `git worktree list` instead.
97
+ # Worktree root the desk root: where /plot-dispatch creates fleet
98
+ # worktrees and the board writes its action records. A
99
+ # relative value resolves against the MAIN checkout, an
100
+ # absolute one is taken as given. Absent or empty =
101
+ # `<repo>/.worktrees`. The rule is `deskRoot`, asked through
102
+ # `plot-desk-root.sh`; no script resolves it itself. Desks
103
+ # carry no prefix; `plot-wt-*` desks an older dispatch made
104
+ # beside the repo stay there. Every "which worktree holds
105
+ # this branch" read asks `git worktree list` instead.
110
106
  # Agent-runner keys (optional; Plot hardcodes no agent tooling, Principle 5):
111
107
  # Worker command how /plot-dispatch runs an agent headless on a worktree.
112
- # `none` = asked, and this repo starts workers by hand —
113
- # a DELIBERATE absence, distinct from a missing key so
114
- # /plot-dispatch stops asking at every fan-out. Never run
115
- # as a command. Absent = nobody has been asked yet, and
116
- # the first dispatch asks (never /plot-init: at adoption
117
- # the question meets a need the answerer does not have,
118
- # gets a shrug, and an answered-and-wrong key is harder
119
- # to fix than a missing one).
108
+ # /plot-init writes `PLOT_UNATTENDED=1 plot-worker-loop.sh`;
109
+ # the bare name resolves to the loop beside
110
+ # plot-dispatch.sh, which puts its own directory first on
111
+ # PATH. A free agent (`--start`) refuses a command that
112
+ # does not run the loop, because only the loop waits for a
113
+ # slice. Absent = not set up: nothing starts, and
114
+ # /plot-dispatch offers to write the loop. `none` = asked,
115
+ # and this repo starts workers by hand — a DELIBERATE
116
+ # absence, never run as a command.
120
117
  # Approve command how the board runs `/plot-approve <slug>`; the prompt is
121
118
  # appended as one argument. Absent = the board's Approve
122
119
  # button renders disabled, naming this key as the fix.
package/plot-deliver.sh CHANGED
@@ -81,7 +81,13 @@ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run thi
81
81
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
82
82
 
83
83
  repo_root=$(git rev-parse --show-toplevel)
84
- wt_root=$(cd "$repo_root/.." && pwd)
84
+ # The booking worktree goes under the desk root of the MAIN checkout, never of
85
+ # this tree: inside a desk `--show-toplevel` answers the desk, and the default
86
+ # `.worktrees` would resolve beneath it. No fallback — `plot-desk-root.sh`.
87
+ # shellcheck source=plot-desk-root.sh
88
+ . "$script_dir/plot-desk-root.sh"
89
+ main_root=$(plot_repo_root)
90
+ wt_root=$(plot_desk_root "$main_root") || die "cannot resolve where the booking worktree goes (see above)"
85
91
 
86
92
  PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
87
93
  ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
@@ -635,6 +641,9 @@ git fetch -q origin "$MAIN" 2>/dev/null
635
641
 
636
642
  bookbr="plot/deliver-$slug"
637
643
  tmpwt="$wt_root/.plot-deliver-$slug.$$"
644
+ # `git worktree add` creates the desk root when it is missing, so exclude it
645
+ # first: a booking run must not leave `.worktrees/` untracked in `git status`.
646
+ plot_exclude_desk_root "$main_root"
638
647
  # -B: a leftover branch from an earlier failed run must not block this one.
639
648
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
640
649
  || die "could not prepare a booking worktree at $tmpwt.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bash
2
+ # The ONE answer to "where do desks and action records go?" — sourced, not run.
3
+ #
4
+ # Every script that resolved this privately asks here instead. Nine sites
5
+ # computed the rule on 2026-10-01 and two of them already answered `.worktrees`
6
+ # while seven answered the checkout's parent, so one of the two populations was
7
+ # always writing somewhere the other did not read.
8
+ #
9
+ # The rule itself is `deskRoot` in the domain, asked through
10
+ # `board/plot-desk-root.mjs`, per *A Shell Script Asks The Domain*: every caller
11
+ # runs once per operator command, which is the cost rule's permitted case.
12
+ #
13
+ # NO CALLER KEEPS A FALLBACK DEFAULT. When the bundle cannot answer — no `node`,
14
+ # a missing file — the caller stops with the reason. A silent fallback is a
15
+ # second default, and two defaults are the defect this removes.
16
+ #
17
+ # plot_repo_root → the MAIN checkout, from anywhere
18
+ # plot_desk_root [repo [configured]] → the desk root; exit 3 when unaskable
19
+ # plot_exclude_desk_root [repo] → keep it out of `git status`; never fails
20
+ #
21
+ # shellcheck shell=bash
22
+
23
+ # The MAIN checkout, from a desk as readily as from the checkout itself.
24
+ #
25
+ # `--show-toplevel` answers the DESK inside a linked worktree, so with
26
+ # `.worktrees` as the default it would resolve `<desk>/.worktrees` and place
27
+ # every tree under a root nothing else reads. `plot-reap.sh:440-447` recorded
28
+ # that failure on 2026-09-10, when every tree read as unplaceable.
29
+ #
30
+ # The parent of `--git-common-dir` is the main checkout from anywhere, because
31
+ # every linked worktree shares that one directory. It is asked FIRST and
32
+ # `--show-toplevel` is the fallback, since the common dir is also correct in a
33
+ # non-worktree checkout: there `.git` is a real directory in the root.
34
+ #
35
+ # PHYSICAL, through `pwd -P`: `git worktree list` and `--show-toplevel` print
36
+ # resolved paths, and a desk root composed from a logical one (`/tmp` against
37
+ # `/private/tmp` on macOS) is a prefix no worktree path ever starts with.
38
+ plot_repo_root() {
39
+ local common root=''
40
+ common=$(git rev-parse --git-common-dir 2>/dev/null) && [ -n "$common" ] && {
41
+ common=$(cd -- "$common" 2>/dev/null && pwd -P) || common=''
42
+ [ -n "$common" ] && root=$(dirname -- "$common")
43
+ }
44
+ [ -n "$root" ] || root=$(git rev-parse --show-toplevel 2>/dev/null) || root=$(pwd)
45
+ printf '%s\n' "$root"
46
+ }
47
+
48
+ # Resolved ONCE at source time, the way `plot-pr-merged.sh:105` resolves its
49
+ # own bundle. Computing it inside a function reads `BASH_SOURCE[0]` at call
50
+ # time, which is the caller's file once the function has been exported.
51
+ _plot_desk_root_mjs="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/board/plot-desk-root.mjs"
52
+ _plot_desk_root_config="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/plot-config.sh"
53
+
54
+ # The desk root for a repository, or exit 3 with the reason on stderr.
55
+ #
56
+ # A second argument is a value the caller was handed in place of the key — a
57
+ # `--worktrees DIR` flag — and goes through the same rule; without one the
58
+ # `Worktree root` key is read.
59
+ #
60
+ # READ THE EXIT CODE. A caller that treats an empty answer as a location would
61
+ # compose paths against the filesystem root.
62
+ plot_desk_root() {
63
+ local repo="${1:-}" bundle="$_plot_desk_root_mjs" configured answer
64
+ [ -n "$repo" ] || repo=$(plot_repo_root)
65
+ if [ ! -f "$bundle" ]; then
66
+ printf 'plot: cannot resolve the desk root: %s is missing\n' "$bundle" >&2
67
+ return 3
68
+ fi
69
+ if [ "$#" -ge 2 ]; then
70
+ configured="$2"
71
+ else
72
+ # Read from the TARGET repository, never the ambient one: `plot-config.sh`
73
+ # walks up from its cwd, so a caller asking about another checkout would
74
+ # otherwise get this one's key.
75
+ # A read that fails is a refusal, never the absent row: reading it as empty
76
+ # would answer the default for a repository that configured another root.
77
+ configured=$(cd -- "$repo" 2>/dev/null && bash "$_plot_desk_root_config" get "Worktree root" "" 2>/dev/null) || {
78
+ printf 'plot: cannot resolve the desk root: the Worktree root key in %s could not be read\n' "$repo" >&2
79
+ return 3
80
+ }
81
+ fi
82
+ answer=$(node "$bundle" "$repo" "$configured" 2>&1) || {
83
+ printf 'plot: cannot resolve the desk root: %s\n' "$answer" >&2
84
+ return 3
85
+ }
86
+ [ -n "$answer" ] || {
87
+ printf 'plot: the desk root rule answered nothing for %s\n' "$repo" >&2
88
+ return 3
89
+ }
90
+ printf '%s\n' "$answer"
91
+ }
92
+
93
+ # Keep the desk root out of `git status`, when it lies inside the repository.
94
+ #
95
+ # The line goes into the COMMON git directory's `info/exclude`: git reads that
96
+ # file from the common directory only, so a linked worktree's private gitdir is
97
+ # the wrong place and a desk writing there would exclude nothing. The write is
98
+ # idempotent, and `info/exclude` is never committed.
99
+ #
100
+ # Called by whoever CREATES the directory. Best-effort and always exit 0: a
101
+ # failure leaves untracked files in a listing, which is untidy, while refusing
102
+ # to create a desk over it would stop the work.
103
+ plot_exclude_desk_root() {
104
+ local repo="${1:-}" bundle="$_plot_desk_root_mjs" line common exclude configured
105
+ [ -n "$repo" ] || repo=$(plot_repo_root)
106
+ [ -f "$bundle" ] || return 0
107
+ configured=$(cd -- "$repo" 2>/dev/null && bash "$_plot_desk_root_config" get "Worktree root" "" 2>/dev/null) || configured=''
108
+ line=$(node "$bundle" --exclude-line "$repo" "$configured" 2>/dev/null) || return 0
109
+ # Empty means the root lies outside the repository, which needs no line.
110
+ [ -n "$line" ] || return 0
111
+ # Already ignored, by a `.gitignore` line /plot-init wrote or by an earlier
112
+ # run. A second rule for a path already ignored is noise in a file people read.
113
+ ( cd -- "$repo" 2>/dev/null && git check-ignore -q ".$line" ) && return 0
114
+ common=$(cd -- "$repo" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null) || return 0
115
+ [ -n "$common" ] || return 0
116
+ case "$common" in /*) ;; *) common="$repo/$common" ;; esac
117
+ exclude="$common/info/exclude"
118
+ if [ -f "$exclude" ] && grep -qxF "$line" "$exclude" 2>/dev/null; then return 0; fi
119
+ mkdir -p "$common/info" 2>/dev/null || return 0
120
+ # A file not ending in a newline would otherwise join this line to its last.
121
+ if [ -s "$exclude" ] && [ "$(tail -c 1 "$exclude" 2>/dev/null)" != "" ]; then
122
+ printf '\n%s\n' "$line" >> "$exclude" 2>/dev/null || true
123
+ else
124
+ printf '%s\n' "$line" >> "$exclude" 2>/dev/null || true
125
+ fi
126
+ return 0
127
+ }
package/plot-dispatch.sh CHANGED
@@ -14,6 +14,8 @@
14
14
  # hand each a queued slice with nobody touching a desk. The count
15
15
  # is a REQUEST: a machine at its bound answers with fewer and says
16
16
  # so, and the shortfall is reported rather than remembered.
17
+ # A `Worker command` that does not run plot-worker-loop.sh
18
+ # refuses (exit 1, `worker=no-loop`): only the loop waits.
17
19
  # --restart <br>
18
20
  # start a worker on <br>, which already holds a claim — the
19
21
  # counterpart to --stop, and the only way to hand a stopped
@@ -217,40 +219,37 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
217
219
  # WHERE THE WORKTREES LIVE, and by what name
218
220
  # ---------------------------------------------------------------------------
219
221
  #
220
- # Two facts, resolved together because the second is a PROPERTY OF THE FIRST:
221
- # the root directory the worktrees sit in, and the prefix their directory names
222
- # carry. `plot-wt-` exists to make Plot's worktrees identifiable AMONG UNRELATED
223
- # directories — it is a workaround for sharing a parent with other projects.
224
- # Under a dedicated `Worktree root:` the directory already says what they are,
225
- # so the prefix answers a question nobody is asking and is dropped. The legacy
226
- # default keeps it, where it is still doing its job. Two conventions coexist
227
- # permanently, and that is the intended outcome, not a transition cost.
222
+ # Two facts: the root directory the worktrees sit in, and the prefix their
223
+ # directory names carry. The root is the desk root, `deskRoot`'s answer asked
224
+ # through `plot-desk-root.sh`:
228
225
  #
229
- # `Worktree root:` absent → repo_root/.. , prefix `plot-wt-` (today's behaviour)
230
- # relative value → repo_root/<value> , NO prefix
231
- # absolute value → <value> as given , NO prefix
226
+ # `Worktree root:` absent or empty → <main checkout>/.worktrees
227
+ # relative value → <main checkout>/<value>
228
+ # absolute value → <value> as given
229
+ #
230
+ # The prefix is always empty. `plot-wt-` made Plot's worktrees identifiable
231
+ # among unrelated directories in the checkout's parent; the desk root is a
232
+ # directory Plot owns, so the directory already says what they are. Desks a
233
+ # dispatch created under the old default keep their `plot-wt-` names and their
234
+ # place: every read of "which worktree holds this branch" asks `git worktree
235
+ # list`, and `plot-reap.sh` still recognises the legacy path.
236
+ #
237
+ # The root is the MAIN checkout's, wherever this runs: inside a desk,
238
+ # `--show-toplevel` answers the desk and `.worktrees` would resolve beneath it.
239
+ # There is NO FALLBACK: when the rule cannot be asked, dispatch stops with the
240
+ # reason rather than compute a second default.
232
241
  #
233
242
  # THIS FUNCTION ONLY COMPOSES A ROOT AND A PREFIX. It is the CREATION side. Every
234
243
  # read of "which worktree holds this branch" asks `git worktree list` instead —
235
244
  # see THE HELD-BRANCH GATE. A second naming convention gives path-guessing a
236
245
  # second way to be wrong, so path-guessing is confined to creation alone.
237
- resolve_wt_root() { # $1=repo_root → sets globals wt_root, wt_prefix
238
- local rr="$1" configured
239
- configured=$("$script_dir/plot-config.sh" get "Worktree root" "")
240
- if [ -z "$configured" ]; then
241
- # The legacy default: beside the repo, prefixed. No existing checkout moves.
242
- wt_root=$(cd "$rr/.." && pwd)
243
- wt_prefix="plot-wt-"
244
- return
245
- fi
246
- case "$configured" in
247
- /*) wt_root="$configured" ;; # absolute: taken as given
248
- *) wt_root="$rr/$configured" ;; # relative: against the repo root
249
- esac
250
- # Normalise away a trailing slash so composed paths never double it. The
251
- # directory may not exist yet (created on first dispatch), so this is pure
252
- # string work, not a `cd`.
253
- wt_root="${wt_root%/}"
246
+ # shellcheck source=plot-desk-root.sh
247
+ . "$script_dir/plot-desk-root.sh"
248
+ resolve_wt_root() { # sets globals wt_root, wt_prefix; exits 3 when unaskable
249
+ wt_root=$(plot_desk_root "$(plot_repo_root)") || {
250
+ echo "plot-dispatch: cannot resolve where the worktrees go — nothing was dispatched." >&2
251
+ exit 3
252
+ }
254
253
  wt_prefix=""
255
254
  }
256
255
 
@@ -342,7 +341,7 @@ while [ $# -gt 0 ]; do
342
341
  # `--release` pushed it from 78 to 91. Two records
343
342
  # of one fact, and nothing compares them — a stale number here silently
344
343
  # truncates the help rather than failing, so it is checked by a test.
345
- -h|--help) sed -n '2,91p' "$0"; exit 0 ;;
344
+ -h|--help) sed -n '2,93p' "$0"; exit 0 ;;
346
345
  *) slug="$1" ;;
347
346
  esac
348
347
  shift
@@ -1022,6 +1021,46 @@ handover_refusal() { # $1=branch $2=worktree $3=state → 0 may hand over, 1 ref
1022
1021
  return 0
1023
1022
  }
1024
1023
 
1024
+ # WHICH COMMAND STARTS THIS AGENT is the domain's answer: `startCommand`, asked
1025
+ # through `board/plot-start-command.mjs`. An absent `Worker command` is not set
1026
+ # up and starts nothing, `none` is declined, and a free agent refuses a command
1027
+ # that does not run the loop (#1124). The loop's name and the value that runs it
1028
+ # are this script's readings; the rule names no script.
1029
+ #
1030
+ # THE VALUE IS THE BARE NAME, `PLOT_UNATTENDED=1 plot-worker-loop.sh`, the one
1031
+ # `/plot-init` writes: a plugin path carries a version and an absolute path
1032
+ # carries a machine. The launch puts this script's directory first on PATH, so
1033
+ # the bare name resolves to the loop shipped beside it.
1034
+ #
1035
+ # It sets `start_cmd_verb` (`run`, `unconfigured`, `declined`, `refused` or
1036
+ # `unaskable`), `start_cmd_run`, and `start_cmd_why` / `start_cmd_repair` where
1037
+ # they apply. `unaskable` is a bundle that could not answer, which starts
1038
+ # nothing.
1039
+ ask_start_command() { # $1 = free | assigned
1040
+ local answer rc
1041
+ start_cmd_verb="" start_cmd_run="" start_cmd_why="" start_cmd_repair=""
1042
+ start_cmd_configured=$("$script_dir/plot-config.sh" get "Worker command" "")
1043
+ start_cmd_bundle="$script_dir/board/plot-start-command.mjs"
1044
+ answer=$(printf '%s' "$start_cmd_configured" | node "$start_cmd_bundle" "$1" \
1045
+ plot-worker-loop.sh "PLOT_UNATTENDED=1 plot-worker-loop.sh" 2>/dev/null)
1046
+ rc=$?
1047
+ case "$rc:$answer" in
1048
+ 0:run$'\t'*) start_cmd_verb=run; start_cmd_run=${answer#run$'\t'} ;;
1049
+ 0:unconfigured$'\t'*)
1050
+ start_cmd_verb=unconfigured
1051
+ start_cmd_why="no 'Worker command' is configured"
1052
+ start_cmd_repair=${answer#unconfigured$'\t'}
1053
+ ;;
1054
+ 0:declined) start_cmd_verb=declined ;;
1055
+ 3:*)
1056
+ start_cmd_verb=refused
1057
+ start_cmd_why=${answer%%$'\n'*}
1058
+ start_cmd_repair=${answer#*$'\n'}
1059
+ ;;
1060
+ *) start_cmd_verb=unaskable; start_cmd_why="the rule's bundle $start_cmd_bundle answered exit $rc" ;;
1061
+ esac
1062
+ }
1063
+
1025
1064
  start_worker() {
1026
1065
  local branch="$1" wt="$2"
1027
1066
  local cmd
@@ -1084,11 +1123,26 @@ start_worker() {
1084
1123
  return 1
1085
1124
  fi
1086
1125
 
1087
- cmd=$("$script_dir/plot-config.sh" get "Worker command" "")
1088
1126
  # `none` means "asked, and this repo starts them by hand". Running it would
1089
1127
  # spawn a worker per branch that fails with `none: command not found` — a
1090
- # deliberate answer turned into N crashed workers.
1091
- case "$cmd" in none|NONE|None) cmd="" ;; esac
1128
+ # deliberate answer turned into N crashed workers. An absent key starts
1129
+ # nothing either, and its line names the value to set.
1130
+ ask_start_command "$([ -n "$branch" ] && echo assigned || echo free)"
1131
+ case "$start_cmd_verb" in
1132
+ run) cmd="$start_cmd_run" ;;
1133
+ declined) cmd="" ;;
1134
+ unconfigured)
1135
+ echo " worktree ready — ${start_cmd_why}, so nothing started:"
1136
+ echo " $start_cmd_repair."
1137
+ echo " cd $wt # ${branch:+branch $branch is claimed and waiting}"
1138
+ return 1
1139
+ ;;
1140
+ *)
1141
+ echo " refusing to start ${branch:-a free agent} — ${start_cmd_why}" >&2
1142
+ [ -n "$start_cmd_repair" ] && echo " Repair: $start_cmd_repair." >&2
1143
+ return 1
1144
+ ;;
1145
+ esac
1092
1146
  if [ -z "$cmd" ]; then
1093
1147
  # Not an error: Plot deliberately hardcodes no agent tooling (Principle 5).
1094
1148
  # Word it as the next step rather than a failure, or a first run reads as
@@ -1099,11 +1153,7 @@ start_worker() {
1099
1153
  # in the summary, because per-branch output is exactly where it was missed
1100
1154
  # five times on 2026-08-17. Saying it in both places would train the reader
1101
1155
  # to skip both.
1102
- if [ "$worker_cmd_declined" = 1 ]; then
1103
- echo " worktree ready — start it yourself:"
1104
- else
1105
- echo " worktree ready — no 'Worker command' configured, so start it yourself:"
1106
- fi
1156
+ echo " worktree ready — start it yourself:"
1107
1157
  echo " cd $wt # branch $branch is claimed and waiting"
1108
1158
  return 1
1109
1159
  fi
@@ -1448,7 +1498,8 @@ start_worker() {
1448
1498
  PLOT_BUILD_MONITOR="$build_monitor" \
1449
1499
  PLOT_EXIT_FILE="$wt/.plot-worker.exit" PLOT_PID_FILE="$wt/.plot-worker.pid" \
1450
1500
  PLOT_WRAPPER_PID_FILE="$wt/.plot-worker.wrapper.pid" \
1451
- nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$PLOT_WORKER_MONITOR" ]; then "$PLOT_WORKER_MONITOR" & wmon=$!; fi; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; fi; if [ -n "$PLOT_BUILD_MONITOR" ]; then "$PLOT_BUILD_MONITOR" & bmon=$!; fi; ( '"$cmd"' ) & agent=$!; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" -v wrapper="$$" -v wmon="$wmon" -v amon="$amon" -v bmon="$bmon" '"'"'
1501
+ PLOT_SCRIPT_DIR="$script_dir" \
1502
+ nohup sh -c 'printf "%s" "$$" > "$PLOT_WRAPPER_PID_FILE"; wmon=""; amon=""; bmon=""; if [ -n "$PLOT_WORKER_MONITOR" ]; then "$PLOT_WORKER_MONITOR" & wmon=$!; fi; if [ -n "$PLOT_AGENT_MONITOR" ]; then "$PLOT_AGENT_MONITOR" & amon=$!; fi; if [ -n "$PLOT_BUILD_MONITOR" ]; then "$PLOT_BUILD_MONITOR" & bmon=$!; fi; PATH="$PLOT_SCRIPT_DIR:$PATH"; export PATH; ( '"$cmd"' ) & agent=$!; printf "%s" "$agent" > "$PLOT_PID_FILE"; if [ -f "$PLOT_MANIFEST_FILE" ]; then awk -v pid="$agent" -v started="$PLOT_STAMP_STARTED" -v wrapper="$$" -v wmon="$wmon" -v amon="$amon" -v bmon="$bmon" '"'"'
1452
1503
  BEGIN { relaunch = 0; count = 1; stamped = 0 }
1453
1504
  FNR == NR {
1454
1505
  if ($0 ~ /^ "pid": "[^"]*",$/) {
@@ -1591,9 +1642,13 @@ charter_file_for() { # $1 = agent name → prints the path it would read
1591
1642
  # inspectable and stoppable even if the plan was since delivered or rejected.
1592
1643
  # Refusing to show a running worker because of a phase change would strand it.
1593
1644
  repo_root_early=$(git rev-parse --show-toplevel)
1594
- resolve_wt_root "$repo_root_early"
1595
- wt_root_early="$wt_root"
1596
- wt_prefix_early="$wt_prefix"
1645
+ # Only the two verbs that enumerate desks ask for the root here; every other
1646
+ # path asks where it composes one, so its own refusals answer first.
1647
+ if [ "$mode" = status ] || [ "$mode" = stop ]; then
1648
+ resolve_wt_root
1649
+ wt_root_early="$wt_root"
1650
+ wt_prefix_early="$wt_prefix"
1651
+ fi
1597
1652
 
1598
1653
  # States: "running <pid>" | "finished <pid>" | "waiting <pid> (answer it)"
1599
1654
  # | "stalled <pid> (work unfinished)" | "failed <pid> (exit N)"
@@ -1725,11 +1780,30 @@ if [ "$mode" = "stop" ]; then
1725
1780
  case "$st" in
1726
1781
  running*)
1727
1782
  pid=${st#running }
1728
- kill "$pid" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
1783
+ # THE WHOLE PROCESS GROUP, NOT ONE PID. Measured 2026-09-30 (#1084):
1784
+ # `--stop` signalled the wrapper, the group leader, and the loop, the
1785
+ # prompt shell, `claude` and its children survived reparented to pid 1;
1786
+ # `kill -TERM -<pgid>` ended all of them. The pid is the fallback when the
1787
+ # group cannot be read, or is this script's own group: a worker started
1788
+ # without job control shares its starter's group, and signalling that
1789
+ # group would stop the caller with it.
1790
+ stop_pgid=$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d ' ')
1791
+ stop_own_pgid=$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')
1792
+ stop_target="$pid"
1793
+ case "$stop_pgid" in
1794
+ ''|*[!0-9]*|0|1) ;;
1795
+ *) [ "$stop_pgid" != "$stop_own_pgid" ] && stop_target="-$stop_pgid" ;;
1796
+ esac
1797
+ kill -TERM -- "$stop_target" 2>/dev/null && echo "stopped $stop_branch (pid $pid)" \
1729
1798
  || { echo "plot-dispatch: could not stop pid $pid — it may have exited between the read and the signal, or belong to another user." >&2
1730
1799
  echo " Check it: ps -p $pid -o pid=,stat=,command=" >&2
1731
1800
  echo " Nothing else was written; the worktree and the claim stand." >&2
1732
1801
  exit 1; }
1802
+ if [ "$stop_target" = "$pid" ]; then
1803
+ echo " signalled pid $pid alone — its process group is unreadable or is this run's own"
1804
+ else
1805
+ echo " signalled its whole process group $stop_pgid"
1806
+ fi
1733
1807
  # The worktree and its claim are left in place: the branch is still taken,
1734
1808
  # and deleting either would be the kind of write this design avoids.
1735
1809
  echo " worktree kept at $wt — the claim stands until you release it:"
@@ -2087,7 +2161,6 @@ if [ "$mode" = "start" ]; then
2087
2161
  # about any plan, so there is no plan whose phase could refuse it. A gate on a
2088
2162
  # slug this verb never takes would refuse every call.
2089
2163
  repo_root="$repo_root_early"
2090
- resolve_wt_root "$repo_root"
2091
2164
 
2092
2165
  # THE DEFAULT BRANCH, by the same three steps the fan-out takes below — the
2093
2166
  # config key, then origin's own HEAD, then `main`. Resolved here because the
@@ -2233,6 +2306,38 @@ EOF
2233
2306
  start_headroom=${start_rest%%$'\t'*}
2234
2307
  start_why=${start_rest#*$'\t'}
2235
2308
 
2309
+ # A FREE AGENT NEEDS THE LOOP, so a configured command that does not run it
2310
+ # refuses here, before any desk is cut. A free agent starts with an empty
2311
+ # `PLOT_BRANCH`, and only `plot-worker-loop.sh` waits for the registry to hand
2312
+ # it a slice; any other command runs its prompt at once and exits. Measured
2313
+ # 2026-10-01 (#1124): a plain `claude -p "… $PLOT_BRANCH …"` received
2314
+ # "Implementiere den Branch in nach dem Plan", exited within 30 s, and
2315
+ # `--start` reported `agents=1`. A branch dispatch is not affected: a worker
2316
+ # given a branch works on any command.
2317
+ #
2318
+ # THE DECISION IS THE DOMAIN'S: `ask_start_command` asks `startCommand`
2319
+ # through its bundle. A refusal names the defect and the repair; a bundle that
2320
+ # cannot answer starts nothing, as the fleet-size ask above does.
2321
+ # `worker=no-loop` is the footer word the performer reads, the way it reads
2322
+ # `unconfigured` and `declined`.
2323
+ ask_start_command free
2324
+ case "$start_cmd_verb" in
2325
+ run|declined|unconfigured) ;;
2326
+ refused)
2327
+ echo "plot-dispatch: --start refuses — ${start_cmd_why}." >&2
2328
+ echo " configured: $start_cmd_configured" >&2
2329
+ echo " Repair: ${start_cmd_repair}." >&2
2330
+ echo " A branch dispatch (--restart <branch>) starts a worker on any command." >&2
2331
+ echo "summary: agents=0 requested=${start_count:-default} running=$start_running headroom=$start_headroom worker=no-loop"
2332
+ exit 1
2333
+ ;;
2334
+ *)
2335
+ echo "plot-dispatch: --start could not ask which command starts an agent — starting none." >&2
2336
+ echo " ${start_cmd_why}." >&2
2337
+ exit 1
2338
+ ;;
2339
+ esac
2340
+
2236
2341
  echo "starting $start_n agent(s) — machine $start_headroom, $start_running already running"
2237
2342
 
2238
2343
  # THE WORKER COMMAND IS ASKED ONCE, BEFORE THE LOOP. `start_worker` prints its
@@ -2246,14 +2351,18 @@ EOF
2246
2351
  # summary — which is now a performer as well as a person — must be able to
2247
2352
  # tell *the machine bounded it* from *nobody has configured how to start one*.
2248
2353
  worker_cmd_declined=0
2249
- start_worker_state=configured
2250
- case "$("$script_dir/plot-config.sh" get "Worker command" "")" in
2251
- none|NONE|None) worker_cmd_declined=1; start_worker_state=declined ;;
2252
- '') start_worker_state=unconfigured ;;
2354
+ case "$start_cmd_verb" in
2355
+ declined) worker_cmd_declined=1; start_worker_state=declined ;;
2356
+ unconfigured) start_worker_state=unconfigured ;;
2357
+ *) start_worker_state=configured ;;
2253
2358
  esac
2254
2359
  if [ "$start_worker_state" != configured ]; then
2255
2360
  echo " no worker will start — 'Worker command' is $start_worker_state in this repo's Plot Config."
2256
- echo " The desks below are cut and registered; start them by hand, or set the key."
2361
+ if [ "$start_worker_state" = unconfigured ]; then
2362
+ echo " The desks below are cut and registered. Repair: $start_cmd_repair."
2363
+ else
2364
+ echo " The desks below are cut and registered; start them by hand, or set the key."
2365
+ fi
2257
2366
  fi
2258
2367
 
2259
2368
  # `slug` STAYS EMPTY, and the loop reads it. A free agent belongs to no plan
@@ -2261,6 +2370,9 @@ EOF
2261
2370
  # `wait_for_work` skips the outlook scan for an agent that holds none.
2262
2371
  slug=""
2263
2372
 
2373
+ # Where the desks go, asked only now: every refusal above answers first, and
2374
+ # a broken runtime is named by the start-command check rather than here.
2375
+ resolve_wt_root
2264
2376
  start_made=0
2265
2377
  start_i=0
2266
2378
  while [ "$start_i" -lt "$start_n" ]; do
@@ -2314,6 +2426,9 @@ EOF
2314
2426
  continue
2315
2427
  fi
2316
2428
  mkdir -p "$wt_root" 2>/dev/null || true
2429
+ # The desk root lies inside the repository by default, so keep it out of
2430
+ # `git status` the moment it exists.
2431
+ plot_exclude_desk_root "$(plot_repo_root)"
2317
2432
  if ! git worktree add -q --detach "$start_wt" "origin/$start_main" 2>/dev/null; then
2318
2433
  # NO REMOTE REF IS NOT A FAILURE OF THIS VERB. A fresh clone or a repo
2319
2434
  # with no remote has no `origin/<main>`; the local one is the same commit
@@ -2329,10 +2444,7 @@ EOF
2329
2444
  # the marker is ignored via `info/exclude` rather than `.gitignore`, because
2330
2445
  # an untracked file in a desk reads as unlanded work to
2331
2446
  # `plot-worker-state.sh` and would make every free agent look stalled.
2332
- _excl="$(git -C "$start_wt" rev-parse --git-common-dir 2>/dev/null)/info/exclude"
2333
- if [ -f "$_excl" ] && ! grep -qxF '.metadata_never_index' "$_excl" 2>/dev/null; then
2334
- printf '%s\n' '.metadata_never_index' >> "$_excl" 2>/dev/null || true
2335
- fi
2447
+ plot_desk_exclude "$start_wt" '.metadata_never_index'
2336
2448
  : > "$start_wt/.metadata_never_index" 2>/dev/null || true
2337
2449
 
2338
2450
  echo " desk $start_wt (detached at origin/$start_main)"
@@ -2375,8 +2487,9 @@ if [ "$mode" = "migrate" ]; then
2375
2487
  configured_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
2376
2488
  if [ -z "$configured_root" ]; then
2377
2489
  echo "plot-dispatch --migrate: no 'Worktree root:' configured — nothing to migrate."
2378
- echo " When Worktree root is absent, worktrees live beside the repo (plot-wt-*)."
2379
- echo " To migrate, first add a 'Worktree root:' key to ## Plot Config."
2490
+ echo " When Worktree root is absent, new worktrees go to <repo>/.worktrees;"
2491
+ echo " desks an older dispatch made beside the repo (plot-wt-*) stay there."
2492
+ echo " To move those, first add a 'Worktree root:' key to ## Plot Config."
2380
2493
  exit 0
2381
2494
  fi
2382
2495
 
@@ -2933,13 +3046,12 @@ run_waits_preflight() { # → prints refusals; fills waits_held, adds to n_skipp
2933
3046
  }
2934
3047
 
2935
3048
  # Where the worktrees live and what their names carry — see resolve_wt_root.
2936
- # The default is beside the repo with the `plot-wt-` prefix; a `Worktree root:`
2937
- # key relocates them (and drops the prefix, which was only earning its keep
2938
- # among unrelated sibling directories). A nested root is made invisible to
2939
- # `git status` and the marker grep by a `.gitignore` line, not by living
2940
- # outside the repo.
3049
+ # The default is `<main checkout>/.worktrees` with no prefix; a `Worktree root:`
3050
+ # key relocates them. A root inside the repository is kept out of `git status`
3051
+ # by the `info/exclude` line `plot_exclude_desk_root` writes when a desk is
3052
+ # created.
2941
3053
  repo_root=$(git rev-parse --show-toplevel)
2942
- resolve_wt_root "$repo_root"
3054
+ resolve_wt_root
2943
3055
 
2944
3056
  n_dispatched=0 n_reused=0 n_skipped=0 n_started=0
2945
3057
  n_brief_asked=0
@@ -3180,6 +3292,9 @@ write_started_record() { # $@ = branches
3180
3292
  # stale tip and the push would be a guaranteed non-fast-forward.
3181
3293
  git fetch -q origin "$MAIN" 2>/dev/null
3182
3294
 
3295
+ # `git worktree add` creates the desk root when it is missing; exclude it
3296
+ # first so the booking leaves no untracked `.worktrees/` behind.
3297
+ plot_exclude_desk_root "$(plot_repo_root)"
3183
3298
  # -B: a leftover branch from an earlier failed booking must not block this
3184
3299
  # one. It is disposable by construction — created here, pushed, deleted.
3185
3300
  if ! git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null; then