@plot-pm/board 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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" ;;
@@ -128,7 +129,13 @@ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository — run thi
128
129
  cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
129
130
 
130
131
  repo_root=$(git rev-parse --show-toplevel)
131
- wt_root=$(cd "$repo_root/.." && pwd)
132
+ # The booking worktree goes under the desk root of the MAIN checkout, never of
133
+ # this tree: inside a desk `--show-toplevel` answers the desk, and the default
134
+ # `.worktrees` would resolve beneath it. No fallback — `plot-desk-root.sh`.
135
+ # shellcheck source=plot-desk-root.sh
136
+ . "$script_dir/plot-desk-root.sh"
137
+ main_root=$(plot_repo_root)
138
+ wt_root=$(plot_desk_root "$main_root") || die "cannot resolve where the booking worktree goes (see above)"
132
139
 
133
140
  PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
134
141
  ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
@@ -189,11 +196,40 @@ esac
189
196
  # NONE means a pre-Plot-2 plan on an idea branch, which the skill documents as
190
197
  # `pr` by default. An unrecognised value is refused rather than defaulted:
191
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
192
218
  case "$review" in
193
219
  pr|NONE) ;;
194
220
  in-session)
195
- die "plan '$slug' declares 'Review: in-session' — the reviewer is a human in the room.
196
- 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
+ ;;
197
233
  ballot)
198
234
  die "plan '$slug' declares 'Review: ballot' — the tally is the approval.
199
235
  A script cannot read a ballot. Approve it with /plot-approve $slug." ;;
@@ -202,71 +238,134 @@ case "$review" in
202
238
  Refusing rather than treating it as 'pr' — that would approve a plan nobody discussed." ;;
203
239
  esac
204
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.
205
255
  MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
206
256
  [ -n "$MAIN" ] || MAIN=$(bash "$script_dir/plot-host.sh" default-branch 2>/dev/null) || MAIN=""
207
257
  [ -n "$MAIN" ] || MAIN="main"
208
258
 
209
- # The PR that carries the plan. `Impl: same branch` puts plan and code on the
210
- # work branch, so its PR is the WORK branch's — and it must not be merged here
211
- # (it merges once, at the end, carrying the implementation with it).
212
- same_branch=0
213
- [ "$impl" = "same-branch" ] && same_branch=1
214
-
215
- if [ "$same_branch" = 1 ]; then
216
- pr_branch="$slug"
217
- for p in $(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" | tr ',' ' '); do
218
- p="${p%/}"; p="${p# }"
219
- [ -z "$p" ] && continue
220
- [ "$p" = "idea" ] && continue
221
- if git show-ref --verify --quiet "refs/heads/$p/$slug" \
222
- || git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
223
- pr_branch="$p/$slug"
224
- break
225
- fi
226
- 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)"
227
269
  else
228
- pr_branch="idea/$slug"
229
- 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
230
291
 
231
- # THE EXIT CODE IS THE READING, not the emptiness of stdout. `pr-state` exits 0
232
- # with `state: NONE` when the host answered that the branch has no PR, and
233
- # non-zero when the host could not be asked: 3 for a refused or failed call
234
- # (a rate limit included), 4 for a backend with no answer at all. Only the
235
- # first is an absence. The other two stop here with the host's own words and
236
- # name no repair to the branch, because nothing about the branch was read.
237
- pr_err_file=""
238
- plot_tmpfile pr_err_file approve-pr
239
- pr_rc=0
240
- pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>"$pr_err_file") || pr_rc=$?
241
- pr_err=$(cat "$pr_err_file" 2>/dev/null); rm -f "$pr_err_file"
242
- if [ "$pr_rc" = 4 ]; then
243
- 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).
244
305
  ${pr_err:-The host adapter gave no reason.}
245
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."
246
- elif [ "$pr_rc" != 0 ]; then
247
- 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).
248
309
  ${pr_err:-The host adapter gave no reason.}
249
310
  The plan was not approved and its phase is unchanged. Wait for the host to answer again, then re-run the approval."
250
- fi
251
- [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
252
- pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
253
- pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
254
- pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
255
-
256
- # --- refusal 3: the PR ------------------------------------------------------
257
- case "$pr_state" in
258
- MERGED) ;;
259
- OPEN) ;;
260
- CLOSED)
261
- 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.
262
323
  Reopen it on the host, or push '$pr_branch' again and open a new one." ;;
263
- NONE|*)
264
- die "no PR found for branch '$pr_branch'.
324
+ NONE|*)
325
+ die "no PR found for branch '$pr_branch'.
265
326
  Push the branch: git push -u origin $pr_branch
266
327
  Then open its PR — or run /plot-idea, which does both." ;;
267
- esac
328
+ esac
329
+ fi
268
330
 
269
- 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)"
270
369
 
271
370
  # --- a DRAFT is taken out of draft, not refused ------------------------------
272
371
  #
@@ -284,16 +383,35 @@ echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$
284
383
  # mergeable on either host — and stated as its own `step:` line so a caller
285
384
  # reading the output can see which half happened if the second one fails.
286
385
 
287
- 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
288
398
  today=$(date +%Y-%m-%d)
289
399
 
290
400
  if [ "$dry_run" = 1 ]; then
291
- [ "$pr_draft" = "true" ] && echo "step: would mark PR #$pr_number ready for review"
292
- echo "step: would merge PR #$pr_number"
293
- echo "step: would flip Phase → Approved and fill Approved: $today, $who, plan-PR #$pr_number merged"
294
- echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
295
- echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
296
- 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
297
415
  exit 0
298
416
  fi
299
417
 
@@ -307,7 +425,11 @@ fi
307
425
  # Merge commits, not squash: plan refinement history is the context a later
308
426
  # reader wants. `--delete-branch` retires idea/<slug>, which has no further job.
309
427
  merged_report="already"
310
- 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
311
433
  # Plan and code ride one branch; the PR merges once, at the end, and merging
312
434
  # it here would land an unfinished implementation on the default branch.
313
435
  merged_report="skipped-same-branch"
@@ -507,16 +629,30 @@ decide_transition() { # $1=file $2=channel → prints "<Phase>\t<record>\t<writ
507
629
  || { echo "plot-approve: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
508
630
  m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
509
631
  [ -n "$m" ] || { echo "plot-approve: cannot parse $f — refusing rather than guessing." >&2; return 1; }
510
- 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' \
511
646
  "$slug" \
512
647
  "$(printf '%s' "$m" | jq -r '.phase // ""')" \
513
648
  "$(printf '%s' "$m" | jq -r '.review // ""')" \
514
649
  "$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
515
650
  "$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
516
651
  "$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
517
- "$today" "$who" "$channel" \
518
- | node "$transition_mjs" 2>&1)
652
+ "$today" "$who" "$channel" "$people_csv" \
653
+ | node "$transition_mjs" --slices "$slices_file" 2>&1)
519
654
  rc=$?
655
+ rm -f "$slices_file"
520
656
  # Exit 1 is the domain's refusal and its sentence, tab-separated after the
521
657
  # rule that fired. Exit 2 is this script handing it something unreadable,
522
658
  # which no operator can act on — so it reports as the bug it is.
@@ -684,10 +820,11 @@ apply_local_writes() { # $1=root → sets phase_report record_report holds_repo
684
820
  # here rather than trusted from the caller's copy, because on the `pr` flow
685
821
  # those are different files and the plan on the default branch is the one that
686
822
  # counts.
687
- local channel="plan-PR #$pr_number merged"
688
- [ "$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"
689
826
  local decided record action recorded
690
- decided=$(decide_transition "$f" "$channel") || return 1
827
+ decided=$(decide_transition "$f" "$write_channel") || return 1
691
828
  record=$(printf '%s' "$decided" | cut -f2)
692
829
  action=$(printf '%s' "$decided" | cut -f3)
693
830
  recorded=$(printf '%s' "$decided" | cut -f4)
@@ -735,6 +872,9 @@ else
735
872
 
736
873
  bookbr="plot/approve-$slug"
737
874
  tmpwt="$wt_root/.plot-approve-$slug.$$"
875
+ # `git worktree add` creates the desk root when it is missing, so exclude it
876
+ # first: a booking run must not leave `.worktrees/` untracked in `git status`.
877
+ plot_exclude_desk_root "$main_root"
738
878
  # -B: a leftover branch from an earlier failed run must not block this one.
739
879
  # It is disposable by construction — created here, pushed, deleted.
740
880
  git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
@@ -782,13 +922,16 @@ else
782
922
  cleanup
783
923
  else
784
924
  # BRANCH PROTECTION FALLBACK — the only path where a micro-PR is right.
785
- # Never leave the merged plan stranded at `Phase: Draft`: the merge is
786
- # 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."
787
930
  echo "step: push rejected — opening a micro-PR instead"
788
931
  if git push -q origin "$bookbr" 2>/dev/null \
789
932
  && micro_url=$(bash "$script_dir/plot-host.sh" pr-create \
790
933
  --title "plot: approve $slug" \
791
- --body "Records the approval of \`$slug\` (plan-PR #$pr_number merged)." \
934
+ --body "$micro_body" \
792
935
  --base "$MAIN" --head "$bookbr" 2>/dev/null) \
793
936
  && micro_num=$(printf '%s' "$micro_url" | sed 's#.*/##') \
794
937
  && bash "$script_dir/plot-host.sh" pr-merge "$micro_num" --delete-branch >/dev/null 2>&1
@@ -798,8 +941,10 @@ else
798
941
  cleanup
799
942
  else
800
943
  push_report="rejected"
944
+ stranded_reason="PR #$pr_number IS MERGED"
945
+ [ "$in_session" = 1 ] && stranded_reason="the in-session approval IS DECIDED"
801
946
  echo "plot-approve: the approval is committed on '$bookbr' but could not reach $MAIN." >&2
802
- 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
803
948
  echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
804
949
  git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
805
950
  echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
@@ -809,6 +954,21 @@ else
809
954
  fi
810
955
  fi
811
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
+
812
972
  echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
813
973
  # THE RECEIPT IS SPENT HERE, on the action COMPLETING — never at the gate.
814
974
  # `plot-controller-gate.sh` clears on a receipt and LEAVES it, so an
@@ -101,9 +101,13 @@ Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
101
101
  the environment, exactly as the wrapper's other children do:
102
102
 
103
103
  PLOT_BRANCH the branch whose builds this monitor will report
104
- PLOT_WORKTREE the desk it reads the head sha from
104
+ PLOT_WORKTREE the desk it STARTS on; it follows the manifest's
105
+ `worktree` from then on (PLOT_MANIFEST_FILE below)
106
+ PLOT_MANIFEST_FILE the manifest this agent is named in, re-read each pass;
107
+ absent or unreadable keeps watching PLOT_WORKTREE
105
108
  PLOT_MONITOR_FILE where findings are published (default:
106
- $PLOT_WORKTREE/.plot-worker.monitor.build.jsonl)
109
+ $PLOT_WORKTREE/.plot-worker.monitor.build.jsonl). Set
110
+ explicitly, it WINS and does not follow a hop.
107
111
  PLOT_MONITOR_INTERVAL seconds between passes (default 30)
108
112
 
109
113
  --once take one sample and exit, rather than looping. A test
@@ -124,7 +128,17 @@ done
124
128
  monitor='BuildMonitor'
125
129
 
126
130
  branch="${PLOT_BRANCH:-}"
127
- worktree="${PLOT_WORKTREE:-}"
131
+ # The branch at start, kept for a detached or unreadable desk; `monitor_branch`
132
+ # re-reads the desk on every pass.
133
+ start_branch="$branch"
134
+ # THE DESK THIS MONITOR WAS LAUNCHED ON, FIXED FOR ITS WHOLE LIFE. `worktree`
135
+ # itself is no longer fixed: `monitor_pass` reassigns it every pass by asking
136
+ # `plot_watched_desk`, which follows a hop to the manifest's new `worktree`
137
+ # field. This is the launch desk `plot_watched_desk` falls back to when the
138
+ # manifest carries no override — never read directly after startup.
139
+ launched_worktree="${PLOT_WORKTREE:-}"
140
+ worktree="$launched_worktree"
141
+ manifest_file="${PLOT_MANIFEST_FILE:-}"
128
142
  # THIRTY SECONDS IS AFFORDABLE ONLY BECAUSE OF THE SILENCE RULE. This cadence
129
143
  # matches the WorkerMonitor's rather than the AgentMonitor's, and it asks a HOST
130
144
  # — which would be the rate problem the AgentMonitor's 300 s exists to avoid,
@@ -132,7 +146,11 @@ worktree="${PLOT_WORKTREE:-}"
132
146
  # is bounded by how long a build takes, not by how long a worker lives.
133
147
  interval="${PLOT_MONITOR_INTERVAL:-30}"
134
148
 
135
- findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.build.jsonl}}"
149
+ # IF `PLOT_MONITOR_FILE` IS SET IT WINS AND DOES NOT FOLLOW THE DESK — the
150
+ # existing contract this usage text already states. Otherwise `findings`
151
+ # follows `worktree`, reassigned alongside it at the top of `monitor_pass`.
152
+ monitor_file_override="${PLOT_MONITOR_FILE:-}"
153
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.build.jsonl}}"
136
154
 
137
155
  # THE SUBJECT, read the same way the other two monitors read it.
138
156
  pid_file="${PLOT_PID_FILE:-${worktree:+$worktree/.plot-worker.pid}}"
@@ -209,6 +227,25 @@ monitor_head_sha() { # → prints a sha, or nothing
209
227
  git -C "$worktree" rev-parse --verify --quiet HEAD 2>/dev/null || true
210
228
  }
211
229
 
230
+ # Which branch does the desk hold, right now?
231
+ #
232
+ # → prints the branch, or the branch the monitor started with when the desk is
233
+ # detached or unreadable
234
+ #
235
+ # READ ON EVERY PASS, NOT ONCE. A free agent starts with an empty `PLOT_BRANCH`
236
+ # and is handed its slices later, in the same desk; a dispatched agent hops to
237
+ # its next slice in the same desk too. A branch fixed at start made this monitor
238
+ # unaskable for every free agent (`monitor_run_for_sha` returns 2 on an empty
239
+ # branch) and wrong after every hop, so the correction path in
240
+ # `plot-worker-loop.sh` received no finding for most slices.
241
+ monitor_branch() { # → prints a branch, or nothing
242
+ local current=''
243
+ if [ -n "$worktree" ] && [ -d "$worktree" ]; then
244
+ current=$(git -C "$worktree" branch --show-current 2>/dev/null || true)
245
+ fi
246
+ printf '%s' "${current:-$start_branch}"
247
+ }
248
+
212
249
  # What does the host say about the run for ONE sha?
213
250
  #
214
251
  # → prints the run JSON, or nothing when there is no run for it
@@ -376,6 +413,14 @@ sample_finding() { # → prints "finding\tevidence", or nothing
376
413
  # One full pass: sample, publish only on a change of ANSWER-ABOUT-A-COMMIT.
377
414
  monitor_pass() {
378
415
  local row finding evidence head
416
+ # REASSIGNED ONCE, HERE, RATHER THAN THREADED THROUGH EACH READER. `worktree`
417
+ # feeds `monitor_head_sha`, `monitor_branch` and `publish()`'s own field, so a
418
+ # hop is picked up in one place rather than three. `PLOT_MONITOR_FILE`, when
419
+ # set, WINS and does not follow the desk — the existing contract the usage
420
+ # text states.
421
+ worktree=$(plot_watched_desk "$manifest_file" "$launched_worktree")
422
+ findings="${monitor_file_override:-${worktree:+$worktree/.plot-worker.monitor.build.jsonl}}"
423
+ branch=$(monitor_branch)
379
424
  head=$(monitor_head_sha)
380
425
  row=$(sample_finding)
381
426
  finding="${row%% *}"
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,14 +94,15 @@
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
108
  # /plot-init writes `PLOT_UNATTENDED=1 plot-worker-loop.sh`;