@plot-pm/board 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
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",
@@ -24,7 +24,6 @@
24
24
  "dist/board-server.mjs",
25
25
  "plot-agent-monitor.sh",
26
26
  "plot-agent-manifest.sh",
27
- "plot-build-monitor.sh",
28
27
  "plot-monitor-subject.sh",
29
28
  "plot-approve.sh",
30
29
  "plot-budget.sh",
@@ -39,11 +38,8 @@
39
38
  "plot-pr-merged.sh",
40
39
  "plot-reap.sh",
41
40
  "plot-release-refs.sh",
42
- "plot-resolve-artifact.sh",
43
41
  "plot-state-receipt.sh",
44
42
  "plot-tmp.sh",
45
- "plot-transcript-quiet.sh",
46
- "plot-worker-monitor.sh",
47
43
  "plot-worker-state.sh"
48
44
  ],
49
45
  "scripts": {
@@ -65,6 +61,7 @@
65
61
  "@types/react": "^19.2.0",
66
62
  "@types/react-dom": "^19.2.0",
67
63
  "@vitejs/plugin-react": "^6.0.0",
64
+ "@vitest/coverage-v8": "4.1.11",
68
65
  "class-variance-authority": "^0.7.1",
69
66
  "clsx": "^2.1.1",
70
67
  "esbuild": "^0.28.0",
@@ -77,7 +74,7 @@
77
74
  "typescript": "^5.9.0",
78
75
  "vite": "^8.1.0",
79
76
  "vite-plugin-singlefile": "^2.3.0",
80
- "vitest": "^4.1.10",
77
+ "vitest": "4.1.11",
81
78
  "zod": "^4.4.0"
82
79
  },
83
80
  "engines": {
@@ -17,7 +17,31 @@
17
17
  # `plot_session_id`, which `plot-dispatch.sh` calls to launch an agent and
18
18
  # `plot-worker-loop.sh` calls when a hop moves the agent to a new branch; and
19
19
  # `manifest_resume_id` with `session_handle`, the conversation handle that the
20
- # loop passes to the prompt and `plot-worker-monitor.sh` probes for a transcript.
20
+ # loop passes to the prompt and the loop's own watcher probes for a transcript.
21
+
22
+ # ONE READER FOR EVERY STRING FIELD a manifest carries, and one for every
23
+ # counter. Six callers read one field each — `branch`, `session`, `worktree`,
24
+ # `resumeId`, `attempts`, `correctionAttempts` — and each carried its own
25
+ # fourteen-line copy of the same read-parse-default dance. The copies answered
26
+ # identically and drifted only in the field name, so the name is the argument.
27
+ #
28
+ # ABSENT, UNREADABLE AND WRONG-TYPED ARE ONE ANSWER, which is the contract
29
+ # every caller already documents: nothing for a string (status 1), `0` for a
30
+ # counter. A manifest that cannot be read is not permission to do anything, and
31
+ # a counter that cannot be read also cannot be raised.
32
+ manifest_string() { # $1=manifest $2=field → prints the value, or nothing
33
+ local manifest="$1" value
34
+ [ -n "$manifest" ] && [ -f "$manifest" ] || return 1
35
+ value=$(node -e '
36
+ const fs = require("fs");
37
+ try {
38
+ const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
39
+ const v = manifest[process.argv[2]];
40
+ process.stdout.write(typeof v === "string" ? v : "");
41
+ } catch { process.stdout.write(""); }
42
+ ' "$manifest" "$2" 2>/dev/null) && [ -n "$value" ] || return 1
43
+ printf '%s' "$value"
44
+ }
21
45
 
22
46
  # Clear `branch` when a slice finishes, so the window before the next one is
23
47
  # observable.
@@ -98,18 +122,7 @@ plot_session_id() {
98
122
  # `assigned_branch` in `plot-worker-loop.sh` already takes: no handle. A hand-started loop has no
99
123
  # manifest, and a manifest nobody can read is not a handle.
100
124
  manifest_resume_id() { # $1=manifest → prints the handle, or nothing
101
- local manifest="$1"
102
- [ -n "$manifest" ] && [ -f "$manifest" ] || return 1
103
- local id
104
- id=$(node -e '
105
- const fs = require("fs");
106
- try {
107
- const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
108
- process.stdout.write(typeof manifest.resumeId === "string" ? manifest.resumeId : "");
109
- } catch { process.stdout.write(""); }
110
- ' "$manifest" 2>/dev/null) || return 1
111
- [ -n "$id" ] || return 1
112
- printf '%s' "$id"
125
+ manifest_string "$1" resumeId
113
126
  }
114
127
 
115
128
  # THE HANDLE THE PROMPT CARRIES — the manifest's `resumeId`, or the launch id.
@@ -119,8 +132,8 @@ manifest_resume_id() { # $1=manifest → prints the handle, or nothing
119
132
  # the two answers are the same string and this reads as a no-op. It stops being
120
133
  # one the moment the handle diverges from the join key — which is what the two
121
134
  # fields exist to allow, and what a later `--fork-session` would do. The loop
122
- # and `plot-worker-monitor.sh` both call this, so the prompt and the idle
123
- # verdict ask about one conversation.
135
+ # calls this for the prompt and its own watcher calls it for the idle verdict,
136
+ # so both ask about one conversation.
124
137
  #
125
138
  # `$PLOT_SESSION_ID` IS THE FALLBACK, NOT THE SOURCE. A hand-started loop has no
126
139
  # manifest and a pre-`resumeId` manifest carries no handle; both are the launch
@@ -135,3 +148,51 @@ session_handle() { # → the handle, or nothing
135
148
  [ -n "${PLOT_SESSION_ID:-}" ] || return 1
136
149
  printf '%s' "$PLOT_SESSION_ID"
137
150
  }
151
+
152
+ # The live agents whose manifests name a branch, for `claimAnswer`'s
153
+ # `holders` — one session id per line, in the directory's own order.
154
+ #
155
+ # ASKS EACH MANIFEST'S OWN DESK, never the branch's checkout: an agent just
156
+ # handed the branch has not checked it out, so a check against the branch's
157
+ # worktree would answer nothing even while that agent's own desk is alive and
158
+ # genuinely holds the slice. `queue-reading.ts:302` takes the same `running` or
159
+ # `waiting` bar; a dead agent's manifest is not a holder (#1039's negative
160
+ # control).
161
+ #
162
+ # `$2` AND `$3` EXCLUDE DIFFERENT THINGS, and a caller supplies at most one.
163
+ # `plot-dispatch.sh --release` has no asker and excludes `release_wt` instead —
164
+ # the desk its OWN live-worker refusal already checks, so this must not
165
+ # duplicate that refusal's case with a different message for the same desk.
166
+ # `plot-worker-loop.sh`'s rejection excludes its OWN manifest file instead —
167
+ # the supervisor wrote the rejected branch into it before the push, so
168
+ # counting it would answer `held-by-agent` on every rejection.
169
+ live_holders_of_branch() { # $1=registry_dir $2=branch $3=exclude_manifest $4=exclude_worktree → "session\tworktree" lines
170
+ local dir="$1" branch="$2" exclude_manifest="${3:-}" exclude_worktree="${4:-}" m m_wt
171
+ [ -d "$dir" ] || return 0
172
+ for m in "$dir"/*.json; do
173
+ [ -f "$m" ] && [ "$m" != "$exclude_manifest" ] || continue
174
+ [ "$(manifest_string "$m" branch)" = "$branch" ] || continue
175
+ m_wt=$(manifest_string "$m" worktree) || continue
176
+ [ -d "$m_wt" ] && [ "$m_wt" != "$exclude_worktree" ] || continue
177
+ case "$(plot_worker_state "$m_wt" "" | cut -f1)" in
178
+ running|waiting) printf '%s\t%s\n' "$(manifest_string "$m" session)" "$m_wt" ;;
179
+ esac
180
+ done
181
+ }
182
+
183
+ # WHAT A CLAIM MEANS, ASKED OF THE DOMAIN. One of `claimAnswer`'s five words,
184
+ # or nothing when the bundle is absent — a checkout that vendored the skills
185
+ # without building the board answers no question rather than a wrong one.
186
+ #
187
+ # ONE ASKER FOR BOTH CALLERS. `plot-dispatch.sh --release` asks with `ref:
188
+ # unknown` and no log, because `held-by-agent` follows from `holders` alone;
189
+ # `plot-worker-loop.sh`'s rejection path supplies all three. The JSON shape is
190
+ # the bundle's contract, and two hand-built copies of it would drift.
191
+ claim_answer() { # $1=bundle $2=ref $3=log $4=holders, newline-separated → the word, or nothing
192
+ [ -f "$1" ] || return 1
193
+ printf '%s\n' "$4" | node -e '
194
+ const holders = require("fs").readFileSync(0, "utf8").split("\n").filter(Boolean);
195
+ const [ref, log] = process.argv.slice(1);
196
+ process.stdout.write(JSON.stringify({ ref, log: ref === "present" ? log : null, holders }));
197
+ ' "$2" "$3" | node "$1" 2>/dev/null | cut -f1
198
+ }
@@ -93,9 +93,13 @@ Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
93
93
  the environment, exactly as the wrapper's other children do:
94
94
 
95
95
  PLOT_BRANCH the branch whose debts this monitor will report
96
- PLOT_WORKTREE the desk it reads
96
+ PLOT_WORKTREE the desk it STARTS on; it follows the manifest's
97
+ `worktree` from then on (PLOT_MANIFEST_FILE below)
98
+ PLOT_MANIFEST_FILE the manifest this agent is named in, re-read each pass;
99
+ absent or unreadable keeps watching PLOT_WORKTREE
97
100
  PLOT_MONITOR_FILE where findings are published (default:
98
- $PLOT_WORKTREE/.plot-worker.monitor.agent.jsonl)
101
+ $PLOT_WORKTREE/.plot-worker.monitor.agent.jsonl). Set
102
+ explicitly, it WINS and does not follow a hop.
99
103
  PLOT_MONITOR_INTERVAL seconds between passes (default 300)
100
104
 
101
105
  --once take one sample and exit, rather than looping. A test
@@ -116,7 +120,14 @@ done
116
120
  monitor='AgentMonitor'
117
121
 
118
122
  branch="${PLOT_BRANCH:-}"
119
- worktree="${PLOT_WORKTREE:-}"
123
+ # THE DESK THIS MONITOR WAS LAUNCHED ON, FIXED FOR ITS WHOLE LIFE. `worktree`
124
+ # itself is no longer fixed: `monitor_pass` reassigns it every pass by asking
125
+ # `plot_watched_desk`, which follows a hop to the manifest's new `worktree`
126
+ # field. This is the launch desk `plot_watched_desk` falls back to when the
127
+ # manifest carries no override — never read directly after startup.
128
+ launched_worktree="${PLOT_WORKTREE:-}"
129
+ worktree="$launched_worktree"
130
+ manifest_file="${PLOT_MANIFEST_FILE:-}"
120
131
  # FIVE MINUTES IS A HOST BUDGET, NOT CAUTION. Its findings need a PR lookup, and
121
132
  # this repository has already measured what happens when host questions ride a
122
133
  # fast loop. Against a stall that lasted 50 minutes, five makes it visible 45
@@ -126,7 +137,11 @@ worktree="${PLOT_WORKTREE:-}"
126
137
  # prevent.
127
138
  interval="${PLOT_MONITOR_INTERVAL:-300}"
128
139
 
129
- findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
140
+ # IF `PLOT_MONITOR_FILE` IS SET IT WINS AND DOES NOT FOLLOW THE DESK — the
141
+ # existing contract this usage text already states. Otherwise `findings`
142
+ # follows `worktree`, reassigned alongside it at the top of `monitor_pass`.
143
+ monitor_file_override="${PLOT_MONITOR_FILE:-}"
144
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
130
145
 
131
146
  # THE SUBJECT, read the same way the WorkerMonitor reads it: `.plot-worker.pid`
132
147
  # names the AGENT, and the wrapper passes its path in `PLOT_PID_FILE`.
@@ -478,7 +493,30 @@ sample_finding() { # → prints "finding\tevidence", or nothing
478
493
 
479
494
  # One full pass: sample, publish only on a change.
480
495
  monitor_pass() {
481
- local row finding evidence
496
+ local row finding evidence new_worktree
497
+ # REASSIGNED ONCE, HERE, RATHER THAN THREADED THROUGH EACH READER.
498
+ # `worktree` feeds `plot_worker_dirty`, `plot_worker_blocked` and this
499
+ # monitor's transcript probe, so a hop is picked up in one place rather than
500
+ # three. `PLOT_MONITOR_FILE`, when set, WINS and does not follow the desk —
501
+ # the existing contract the usage text states.
502
+ new_worktree=$(plot_watched_desk "$manifest_file" "$launched_worktree")
503
+
504
+ # A DESK CHANGE RESETS `published`/`since` DELIBERATELY. The debt they
505
+ # remember is the OLD desk's: a finding like `holds unlanded work` is about
506
+ # commits and files on that specific tree, and after a hop the new desk has
507
+ # never been measured. Carrying the old `published` forward would compare
508
+ # the new desk's first reading against the old one's last and could publish
509
+ # `clear` for a debt that was never the new desk's to begin with — the exact
510
+ # trap the brief names. Resetting to blank treats the new desk as a fresh
511
+ # subject: a real finding there publishes as new, and a clean desk publishes
512
+ # nothing at all, which is silence rather than a false `clear`.
513
+ if [ "$new_worktree" != "$worktree" ]; then
514
+ published=''
515
+ since=''
516
+ fi
517
+ worktree="$new_worktree"
518
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
519
+
482
520
  row=$(sample_finding)
483
521
  finding="${row%% *}"
484
522
  evidence=''
package/plot-approve.sh CHANGED
@@ -107,12 +107,13 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
107
107
  . "$script_dir/plot-state-receipt.sh"
108
108
 
109
109
  dry_run=0
110
- who_override=""
110
+ who_flag_given=0
111
+ who_flag=""
111
112
  slug=""
112
113
  while [ $# -gt 0 ]; do
113
114
  case "$1" in
114
115
  --dry-run) dry_run=1 ;;
115
- --who) who_override="${2:?--who needs a value}"; shift ;;
116
+ --who) who_flag_given=1; who_flag="${2:-}"; shift ;;
116
117
  -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
117
118
  -*) echo "plot-approve: unknown flag '$1'" >&2; exit 1 ;;
118
119
  *) slug="$1" ;;
@@ -195,11 +196,40 @@ esac
195
196
  # NONE means a pre-Plot-2 plan on an idea branch, which the skill documents as
196
197
  # `pr` by default. An unrecognised value is refused rather than defaulted:
197
198
  # "carry on" is the shape of stale assumption this whole story keeps finding.
199
+ #
200
+ # `in-session` IS NO LONGER A REFUSAL HERE, which is this slice's whole change.
201
+ # `plot-approve.sh --who <handle> <slug>` performs the seven mechanical steps
202
+ # for it exactly as it does for `pr`, because #1185 made the domain able to
203
+ # decide the write (`approveTransition` takes `who` and `people`). What stays a
204
+ # refusal is the absence of a usable reviewer: no `--who` at all, an empty one,
205
+ # or one `People` does not declare — the domain's `review-human` and
206
+ # `reviewer-undeclared` gates, asked through `decide_transition` below rather
207
+ # than re-implemented here. There is no `--reviewer`: a round-1 panel measured
208
+ # that spelling exiting 2 at the controller gate, and `--who` is the one flag
209
+ # this script already declares.
210
+ #
211
+ # UNDER `PLOT_UNATTENDED=1` THIS STILL REFUSES, `--who` or not. The reviewer is
212
+ # a human in a session that, by definition, has nobody in it under an
213
+ # unattended run — the same argument `plot-approve/SKILL.md` already makes for
214
+ # the skill's own in-session walkthrough. A default here would let the machine
215
+ # name the reviewer, which is exactly what `--who`'s absence of a fallback (no
216
+ # `PLOT_APPROVE_WHO`, no `git config user.name`) refuses for the same channel.
217
+ in_session=0
198
218
  case "$review" in
199
219
  pr|NONE) ;;
200
220
  in-session)
201
- die "plan '$slug' declares 'Review: in-session' — the reviewer is a human in the room.
202
- A script cannot stand in for one. Approve it with /plot-approve $slug." ;;
221
+ if [ "${PLOT_UNATTENDED:-}" = "1" ]; then
222
+ die "plan '$slug' declares 'Review: in-session' — the reviewer is a human in the room.
223
+ Refusing under PLOT_UNATTENDED=1: there is nobody here to name. Approve it from a session: /plot-approve $slug"
224
+ fi
225
+ # No reviewer named: refuse before any git work. The domain's `review-human`
226
+ # gate says the same thing later, after a booking worktree exists; asking
227
+ # here keeps a refusal local and cheap. `--who` has no default.
228
+ if [ -z "$(printf '%s' "$who_flag" | tr -d '[:space:]')" ]; then
229
+ die "plan '$slug' declares 'Review: in-session' — name the reviewer with --who."
230
+ fi
231
+ in_session=1
232
+ ;;
203
233
  ballot)
204
234
  die "plan '$slug' declares 'Review: ballot' — the tally is the approval.
205
235
  A script cannot read a ballot. Approve it with /plot-approve $slug." ;;
@@ -208,71 +238,134 @@ case "$review" in
208
238
  Refusing rather than treating it as 'pr' — that would approve a plan nobody discussed." ;;
209
239
  esac
210
240
 
241
+ # The declared handles, for the domain's `reviewer-undeclared` gate. Read
242
+ # unconditionally — cheap, and a `pr` or `ballot` plan never reaches the branch
243
+ # that uses it — from `People`'s `handle = Spelling; handle = Spelling` form:
244
+ # only the text before each `=` is a handle, and `People` may be absent.
245
+ people_raw=$(cfg "People" "")
246
+ people_csv=$(printf '%s' "$people_raw" | tr ';' '\n' | while IFS= read -r entry; do
247
+ h="${entry%%=*}"
248
+ h="$(printf '%s' "$h" | sed -e 's/^[ \t]*//' -e 's/[ \t]*$//' | tr '[:upper:]' '[:lower:]')"
249
+ [ -n "$h" ] && printf '%s\n' "$h"
250
+ done | paste -sd, -)
251
+ [ -n "$people_csv" ] || people_csv=""
252
+
253
+ # Read even for in-session: the booking worktree below (every non-same-branch
254
+ # flow, in-session included) fetches and books against it.
211
255
  MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
212
256
  [ -n "$MAIN" ] || MAIN=$(bash "$script_dir/plot-host.sh" default-branch 2>/dev/null) || MAIN=""
213
257
  [ -n "$MAIN" ] || MAIN="main"
214
258
 
215
- # The PR that carries the plan. `Impl: same branch` puts plan and code on the
216
- # work branch, so its PR is the WORK branch's — and it must not be merged here
217
- # (it merges once, at the end, carrying the implementation with it).
218
- same_branch=0
219
- [ "$impl" = "same-branch" ] && same_branch=1
220
-
221
- if [ "$same_branch" = 1 ]; then
222
- pr_branch="$slug"
223
- for p in $(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" | tr ',' ' '); do
224
- p="${p%/}"; p="${p# }"
225
- [ -z "$p" ] && continue
226
- [ "$p" = "idea" ] && continue
227
- if git show-ref --verify --quiet "refs/heads/$p/$slug" \
228
- || git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
229
- pr_branch="$p/$slug"
230
- break
231
- fi
232
- done
259
+ if [ "$in_session" = 1 ]; then
260
+ # No plan PR to read or merge: the approval IS the reviewer's go, not a host
261
+ # state. `same_branch` stays 0 — in-session records through the same booking
262
+ # worktree the `pr` flow uses, since an in-session plan has no work branch of
263
+ # its own to record on.
264
+ same_branch=0
265
+ pr_number=0
266
+ pr_state="NONE"
267
+ pr_draft="false"
268
+ echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} (in-session, no plan PR)"
233
269
  else
234
- pr_branch="idea/$slug"
235
- fi
270
+ # The PR that carries the plan. `Impl: same branch` puts plan and code on the
271
+ # work branch, so its PR is the WORK branch's — and it must not be merged here
272
+ # (it merges once, at the end, carrying the implementation with it).
273
+ same_branch=0
274
+ [ "$impl" = "same-branch" ] && same_branch=1
275
+
276
+ if [ "$same_branch" = 1 ]; then
277
+ pr_branch="$slug"
278
+ for p in $(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" | tr ',' ' '); do
279
+ p="${p%/}"; p="${p# }"
280
+ [ -z "$p" ] && continue
281
+ [ "$p" = "idea" ] && continue
282
+ if git show-ref --verify --quiet "refs/heads/$p/$slug" \
283
+ || git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
284
+ pr_branch="$p/$slug"
285
+ break
286
+ fi
287
+ done
288
+ else
289
+ pr_branch="idea/$slug"
290
+ fi
236
291
 
237
- # THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
238
- # with `state: NONE` when the host answered that the branch has no PR, and
239
- # non-zero when the host could not be asked: 3 for a refused or failed call
240
- # (a rate limit included), 4 for a backend with no answer at all. Only the
241
- # first is an absence. The other two stop here with the host's own words and
242
- # name no repair to the branch, because nothing about the branch was read.
243
- pr_err_file=""
244
- plot_tmpfile pr_err_file approve-pr
245
- pr_rc=0
246
- pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
247
- pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
248
- if [ "$pr_rc" = 4 ]; then
249
- die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
292
+ # THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
293
+ # with `state: NONE` when the host answered that the branch has no PR, and
294
+ # non-zero when the host could not be asked: 3 for a refused or failed call
295
+ # (a rate limit included), 4 for a backend with no answer at all. Only the
296
+ # first is an absence. The other two stop here with the host's own words and
297
+ # name no repair to the branch, because nothing about the branch was read.
298
+ pr_err_file=""
299
+ plot_tmpfile pr_err_file approve-pr
300
+ pr_rc=0
301
+ pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
302
+ pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
303
+ if [ "$pr_rc" = 4 ]; then
304
+ die "the host backend has no answer for the PR state of '$pr_branch' (plot-host.sh pr-state exited 4).
250
305
  ${pr_err:-The host adapter gave no reason.}
251
306
  This backend cannot report a PR's state, so the approval cannot read its gate. The plan was not approved and its phase is unchanged."
252
- elif [ "$pr_rc" != 0 ]; then
253
- die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
307
+ elif [ "$pr_rc" != 0 ]; then
308
+ die "the host could not be asked for the PR of '$pr_branch' (plot-host.sh pr-state exited $pr_rc).
254
309
  ${pr_err:-The host adapter gave no reason.}
255
310
  The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
256
- fi
257
- [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
258
- pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
259
- pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
260
- pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
261
-
262
- # --- refusal 3: the PR ------------------------------------------------------
263
- case "$pr_state" in
264
- MERGED) ;;
265
- OPEN) ;;
266
- CLOSED)
267
- die "the plan PR for '$slug' (#$pr_number) is closed.
311
+ fi
312
+ [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
313
+ pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
314
+ pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
315
+ pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
316
+
317
+ # --- refusal 3: the PR ------------------------------------------------------
318
+ case "$pr_state" in
319
+ MERGED) ;;
320
+ OPEN) ;;
321
+ CLOSED)
322
+ die "the plan PR for '$slug' (#$pr_number) is closed.
268
323
  Reopen it on the host, or push '$pr_branch' again and open a new one." ;;
269
- NONE|*)
270
- die "no PR found for branch '$pr_branch'.
324
+ NONE|*)
325
+ die "no PR found for branch '$pr_branch'.
271
326
  Push the branch: git push -u origin $pr_branch
272
327
  Then open its PR — or run /plot-idea, which does both." ;;
273
- esac
328
+ esac
329
+ fi
274
330
 
275
- echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
331
+ # --- refusal 4: a branch under no slice heading -----------------------------
332
+ #
333
+ # BEFORE THE MERGE, which is the whole point of its position. Step 2 merges the
334
+ # plan PR, and a refusal fired after that leaves the PR merged, the plan still
335
+ # Draft, and a refusal the operator can clear only by editing a merged plan. So
336
+ # it sits with refusals 1 to 3, reading the same `$meta` step 1 took, and it
337
+ # writes nothing.
338
+ #
339
+ # The heading is the slice's name on the board and the title of its PR
340
+ # (`openSlicePr` refuses `slice-unnamed`), so it is owed on the default branch
341
+ # before any agent starts. Without this an agent meets that refusal, writes the
342
+ # heading on its own branch, and the board reads `(unnamed)` until the PR
343
+ # merges (#1057).
344
+ #
345
+ # The rule is the domain's and it is asked, never re-implemented here:
346
+ # `decide_transition` asks the same one through the same bundle, so the two can
347
+ # never disagree about one plan.
348
+ transition_check_mjs="$script_dir/board/plot-transition.mjs"
349
+ if [ -f "$transition_check_mjs" ]; then
350
+ slice_err_file=""
351
+ plot_tmpfile slice_err_file approve-slices
352
+ slice_rc=0
353
+ printf '%s' "$meta" | node "$transition_check_mjs" --check-slices "$slug" \
354
+ 2>"$slice_err_file" >/dev/null || slice_rc=$?
355
+ slice_err=$(cat "$slice_err_file" 2>/dev/null); rm -f "$slice_err_file"
356
+ if [ "$slice_rc" = 1 ]; then
357
+ die "$(printf '%s' "$slice_err" | cut -f2-)
358
+ The plan was not approved, nothing was merged and its phase is unchanged."
359
+ elif [ "$slice_rc" != 0 ]; then
360
+ die "cannot read the slices of '$plan_file' (plot-transition.mjs --check-slices exited $slice_rc).
361
+ ${slice_err:-The bundle gave no reason.}
362
+ Refusing rather than approving a plan whose slice headings were never read."
363
+ fi
364
+ else
365
+ echo "plot-approve: cannot find $transition_check_mjs — slice headings went unchecked. Run 'pnpm build:board'." >&2
366
+ fi
367
+
368
+ [ "$in_session" = 1 ] || echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
276
369
 
277
370
  # --- a DRAFT is taken out of draft, not refused ------------------------------
278
371
  #
@@ -290,16 +383,35 @@ echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$
290
383
  # mergeable on either host — and stated as its own `step:` line so a caller
291
384
  # reading the output can see which half happened if the second one fails.
292
385
 
293
- who="${who_override:-${PLOT_APPROVE_WHO:-$(git config user.name 2>/dev/null || echo plot)}}"
386
+ # `in-session` TAKES NO DEFAULT. A default here would let the machine name the
387
+ # reviewer, which is the one thing a script must never do for a channel that
388
+ # exists because a human is in the room — so `--who` alone answers, and an
389
+ # empty or absent one reaches the domain's `review-human` refusal below rather
390
+ # than silently becoming `git config user.name`. `pr` and the direct/same-branch
391
+ # flow keep the existing chain: `PLOT_APPROVE_WHO`, then the git identity.
392
+ if [ "$in_session" = 1 ]; then
393
+ who="$who_flag"
394
+ channel="in-session"
395
+ else
396
+ who="${who_flag:-${PLOT_APPROVE_WHO:-$(git config user.name 2>/dev/null || echo plot)}}"
397
+ fi
294
398
  today=$(date +%Y-%m-%d)
295
399
 
296
400
  if [ "$dry_run" = 1 ]; then
297
- [ "$pr_draft" = "true" ] && echo "step: would mark PR #$pr_number ready for review"
298
- echo "step: would merge PR #$pr_number"
299
- echo "step: would flip Phase → Approved and fill Approved: $today, $who, plan-PR #$pr_number merged"
300
- echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
301
- echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
302
- echo "summary: merged=would phase=would record=would holds=would sprint=would push=would"
401
+ if [ "$in_session" = 1 ]; then
402
+ echo "step: would flip Phase → Approved and fill Approved: $today, $who, in-session"
403
+ echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
404
+ echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
405
+ echo "step: would append a row to .plot/state/in-session-approvals.tsv"
406
+ echo "summary: merged=skipped-in-session phase=would record=would holds=would sprint=would push=would"
407
+ else
408
+ [ "$pr_draft" = "true" ] && echo "step: would mark PR #$pr_number ready for review"
409
+ echo "step: would merge PR #$pr_number"
410
+ echo "step: would flip Phase → Approved and fill Approved: $today, $who, plan-PR #$pr_number merged"
411
+ echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
412
+ echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
413
+ echo "summary: merged=would phase=would record=would holds=would sprint=would push=would"
414
+ fi
303
415
  exit 0
304
416
  fi
305
417
 
@@ -313,7 +425,11 @@ fi
313
425
  # Merge commits, not squash: plan refinement history is the context a later
314
426
  # reader wants. `--delete-branch` retires idea/<slug>, which has no further job.
315
427
  merged_report="already"
316
- if [ "$same_branch" = 1 ]; then
428
+ if [ "$in_session" = 1 ]; then
429
+ # No plan PR exists for this channel: the approval is the reviewer's go,
430
+ # read nowhere on a host.
431
+ merged_report="skipped-in-session"
432
+ elif [ "$same_branch" = 1 ]; then
317
433
  # Plan and code ride one branch; the PR merges once, at the end, and merging
318
434
  # it here would land an unfinished implementation on the default branch.
319
435
  merged_report="skipped-same-branch"
@@ -513,16 +629,30 @@ decide_transition() { # $1=file $2=channel → prints "<Phase>\t<record>\t<writ
513
629
  || { echo "plot-approve: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
514
630
  m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
515
631
  [ -n "$m" ] || { echo "plot-approve: cannot parse $f — refusing rather than guessing." >&2; return 1; }
516
- answer=$(printf 'approve\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\n' \
632
+ # THE SLICES TRAVEL WITH THE TRANSITION, not only with the early check above.
633
+ # Measured 2026-10-02 by disabling that check: this call approved an unnamed
634
+ # plan outright — merged, flipped and recorded — because the field line
635
+ # carries no slices and the domain reads an absent reading as unmeasured. So
636
+ # the rule is asked twice, the second time from the file this re-parses, which
637
+ # on the `pr` flow is the plan on the default branch.
638
+ #
639
+ # Out of band rather than a field of its own, which is why `People` widening
640
+ # the line to twelve did not touch this: the slices are a nested list, and
641
+ # `requestFrom` pads nothing.
642
+ local slices_file=""
643
+ plot_tmpfile slices_file approve-transition-slices
644
+ printf '%s' "$m" > "$slices_file"
645
+ answer=$(printf 'approve\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\t%s\n' \
517
646
  "$slug" \
518
647
  "$(printf '%s' "$m" | jq -r '.phase // ""')" \
519
648
  "$(printf '%s' "$m" | jq -r '.review // ""')" \
520
649
  "$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
521
650
  "$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
522
651
  "$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
523
- "$today" "$who" "$channel" \
524
- | node "$transition_mjs" 2>&1)
652
+ "$today" "$who" "$channel" "$people_csv" \
653
+ | node "$transition_mjs" --slices "$slices_file" 2>&1)
525
654
  rc=$?
655
+ rm -f "$slices_file"
526
656
  # Exit 1 is the domain's refusal and its sentence, tab-separated after the
527
657
  # rule that fired. Exit 2 is this script handing it something unreadable,
528
658
  # which no operator can act on — so it reports as the bug it is.
@@ -690,10 +820,11 @@ apply_local_writes() { # $1=root → sets phase_report record_report holds_repo
690
820
  # here rather than trusted from the caller's copy, because on the `pr` flow
691
821
  # those are different files and the plan on the default branch is the one that
692
822
  # counts.
693
- local channel="plan-PR #$pr_number merged"
694
- [ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
823
+ local write_channel="plan-PR #$pr_number merged"
824
+ [ "$same_branch" = 1 ] && write_channel="plan-PR #$pr_number reviewed"
825
+ [ "$in_session" = 1 ] && write_channel="$channel"
695
826
  local decided record action recorded
696
- decided=$(decide_transition "$f" "$channel") || return 1
827
+ decided=$(decide_transition "$f" "$write_channel") || return 1
697
828
  record=$(printf '%s' "$decided" | cut -f2)
698
829
  action=$(printf '%s' "$decided" | cut -f3)
699
830
  recorded=$(printf '%s' "$decided" | cut -f4)
@@ -791,13 +922,16 @@ else
791
922
  cleanup
792
923
  else
793
924
  # BRANCH PROTECTION FALLBACK — the only path where a micro-PR is right.
794
- # Never leave the merged plan stranded at `Phase: Draft`: the merge is
795
- # done and irreversible, so the recorded phase must follow it.
925
+ # Never leave the approved plan stranded at `Phase: Draft`: the approval
926
+ # is decided and irreversible once committed, so the recorded phase must
927
+ # follow it.
928
+ micro_body="Records the approval of \`$slug\` (plan-PR #$pr_number merged)."
929
+ [ "$in_session" = 1 ] && micro_body="Records the in-session approval of \`$slug\` by $who."
796
930
  echo "step: push rejected — opening a micro-PR instead"
797
931
  if git push -q origin "$bookbr" 2>/dev/null \
798
932
  && micro_url=$(bash "$script_dir/plot-host.sh" pr-create \
799
933
  --title "plot: approve $slug" \
800
- --body "Records the approval of \`$slug\` (plan-PR #$pr_number merged)." \
934
+ --body "$micro_body" \
801
935
  --base "$MAIN" --head "$bookbr" 2>/dev/null) \
802
936
  && micro_num=$(printf '%s' "$micro_url" | sed 's#.*/##') \
803
937
  && bash "$script_dir/plot-host.sh" pr-merge "$micro_num" --delete-branch >/dev/null 2>&1
@@ -807,8 +941,10 @@ else
807
941
  cleanup
808
942
  else
809
943
  push_report="rejected"
944
+ stranded_reason="PR #$pr_number IS MERGED"
945
+ [ "$in_session" = 1 ] && stranded_reason="the in-session approval IS DECIDED"
810
946
  echo "plot-approve: the approval is committed on '$bookbr' but could not reach $MAIN." >&2
811
- echo " PR #$pr_number IS MERGED — the plan must not stay at Phase: Draft." >&2
947
+ echo " $stranded_reason — the plan must not stay at Phase: Draft." >&2
812
948
  echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
813
949
  git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
814
950
  echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
@@ -818,6 +954,21 @@ else
818
954
  fi
819
955
  fi
820
956
 
957
+ # THE LOG REPLACES A COUNT, NEVER A RECEIPT. Appended ONLY after the push
958
+ # landed this run — `nothing-to-commit` means an earlier run already recorded
959
+ # this approval (and, with it, this row), so appending again would double a
960
+ # count that exists to say how many in-session approvals happened, not how
961
+ # many times this script ran. A refused or interrupted run reaches neither
962
+ # this line nor a row, which is `plot-agent-settings.sh`'s rule applied here:
963
+ # read the exit code, not the emptiness.
964
+ if [ "$in_session" = 1 ] && [ "$push_report" != "nothing-to-commit" ] && [ "$push_report" != "n/a" ]; then
965
+ entry="script"
966
+ [ "${PLOT_APPROVE_ENTRY:-}" = "board" ] && entry="board"
967
+ log_dir="$main_root/.plot/state"
968
+ mkdir -p "$log_dir" 2>/dev/null \
969
+ && printf '%s\t%s\t%s\t%s\n' "$today" "$slug" "$who" "$entry" >> "$log_dir/in-session-approvals.tsv" 2>/dev/null
970
+ fi
971
+
821
972
  echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
822
973
  # THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
823
974
  # `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an