@plot-pm/board 0.9.0 → 0.10.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.9.0",
3
+ "version": "0.10.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",
@@ -22,8 +22,17 @@
22
22
  },
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
+ "plot-approve.sh",
25
26
  "plot-config.sh",
26
- "plot-plan-meta.sh"
27
+ "plot-deliver.sh",
28
+ "plot-dispatch.sh",
29
+ "plot-fleet-scan.sh",
30
+ "plot-host.sh",
31
+ "plot-plan-meta.sh",
32
+ "plot-reap.sh",
33
+ "plot-release-refs.sh",
34
+ "plot-resolve-artifact.sh",
35
+ "plot-worker-state.sh"
27
36
  ],
28
37
  "scripts": {
29
38
  "dev": "vite",
@@ -36,6 +45,7 @@
36
45
  "test:integration": "pnpm build && vitest run"
37
46
  },
38
47
  "devDependencies": {
48
+ "@plot-pm/domain": "workspace:*",
39
49
  "@radix-ui/react-checkbox": "^1.3.0",
40
50
  "@radix-ui/react-popover": "^1.1.0",
41
51
  "@tailwindcss/vite": "^4.3.0",
@@ -0,0 +1,628 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: perform the MECHANICAL half of approving a plan.
3
+ # Usage: plot-approve.sh [--dry-run] [--who <name>] <slug>
4
+ # --dry-run say what would happen; merge nothing, write nothing, push nothing
5
+ # --who the name recorded in the `Approved:` line (default: git user.name)
6
+ # <slug> the plan to approve
7
+ # Output: one `step:` line per step, then a machine-countable summary:
8
+ # summary: merged=yes phase=flipped record=written holds=1 sprint=none push=clean
9
+ # Exit 0 when the plan is Approved on the default branch (whether this
10
+ # run did the work or found it already done); 1 on a refusal or a
11
+ # failure, with the reason on stderr.
12
+ #
13
+ # WHY THIS EXISTS. `Start work` on the board calls plot-dispatch.sh, a script
14
+ # Plot ships, and works out of the box. `Approve` beside it called
15
+ # `sh -c '<Approve command> ...'` and did not, because no such script existed —
16
+ # so the board reached for an agent, and the button rendered dimmed on every
17
+ # card in a repo that declares no `Approve command`.
18
+ #
19
+ # The justification for the asymmetry did not survive the comparison.
20
+ # `Worker command` is per-project because dispatch starts an agent that WRITES
21
+ # AN IMPLEMENTATION — genuinely unknowable to Plot (Principle 5). Approving,
22
+ # under `Review: pr`, is seven writes with no judgement in any of them:
23
+ # read the ceremony answers and the PR state, merge the plan PR, flip the
24
+ # phase, fill the `Approved:` record, clear the `.plot/hold` entries, update
25
+ # the sprint annotation, push. Every one is gh, git, or one line into a
26
+ # markdown file. Approving writes one line; dispatching starts a program that
27
+ # writes a codebase.
28
+ #
29
+ # Manifesto Principle 3 draws the line where this problem is: SCRIPTS COLLECT
30
+ # AND REPORT; SKILLS INTERPRET AND ADAPT. Merging a PR whose number the plan
31
+ # already records, and writing a dated line into a known field, is collecting.
32
+ # Deciding whether a plan is READY is interpreting, and stays in the skill —
33
+ # along with the in-session walkthrough, the ballot tally, the two ceremony
34
+ # questions, and the tracer-bullet suggestion.
35
+ #
36
+ # SEVEN STEPS, NOT FIVE. The hold and the sprint annotation are here because
37
+ # they are writes with no decision in them, and because leaving either to a
38
+ # caller re-creates the split this script exists to close. An approval that
39
+ # leaves the hold in place STILL BLOCKS commits; one that skips the annotation
40
+ # makes `/plot-sprint status` wrong. Five of seven steps would be a
41
+ # half-approval, which is worse than none.
42
+ #
43
+ # IT IS IDEMPOTENT, BECAUSE ONE STEP CANNOT BE UNDONE. Step 2 merges the PR and
44
+ # that write is irreversible; everything after it is local. A run interrupted
45
+ # between the merge and the push leaves the PR merged while the plan on the
46
+ # default branch still reads `Phase: Draft` — the exact state the skill names as
47
+ # the thing never to allow. So `plot-approve.sh <slug>` may run any number of
48
+ # times, and RUN IT AGAIN is the repair for every interruption.
49
+ #
50
+ # Each step asks THE SOURCE IT WOULD HAVE WRITTEN whether it is already done:
51
+ # pr-state for the merge, plot-plan-meta.sh for the phase and the record, the
52
+ # hold file for the holds, the sprint file for the annotation. Never a progress
53
+ # file of its own — that would be a second source of truth, free to disagree
54
+ # with the repository exactly when a human intervened by hand between two runs,
55
+ # which is the case it would exist for. Git and the files ARE the state
56
+ # (Principle 1).
57
+ #
58
+ # Reordering to put the merge LAST was the alternative and it is worse: it
59
+ # would leave a window where the plan reads `Approved` while its PR is still
60
+ # open, and where the `Approved:` record names a PR number that never merged —
61
+ # trading a recoverable half-state for a lying one.
62
+ #
63
+ # IT SURVIVES ITS OWN REPO'S GATE. plot-phase-gate.sh is a PreToolUse hook that
64
+ # blocks implementation commits while the governing plan is Draft — and this
65
+ # script commits exactly then, because rewriting the phase IS the transition.
66
+ # The gate lets plan-file-only commits through (`outside_plans` returns false
67
+ # when every effective path is under the plan directory), which is why this
68
+ # script stages ONLY the plan file and the two bookkeeping files, and never
69
+ # `git add -A`. That is not a nicety: an -A here would stage whatever else the
70
+ # booking worktree happened to hold and hand the gate a reason to block.
71
+ #
72
+ # THE HOLD IS KEYED BY BRANCH, NOT BY PLAN. plot-phase-gate.sh:121 matches
73
+ # `$1 == b` against the branch NAME, and a plan names several branches. So this
74
+ # reads the plan's `## Branches` section and removes the entry for each — the
75
+ # plan is what connects a slug to the branch names the hold file speaks in.
76
+ # Entries for branches this plan does not name stay exactly where they are:
77
+ # approving one piece of work must not release someone else's gate. There is no
78
+ # `.plot/hold` in this repo at all, so the ABSENT path is the common one and is
79
+ # never an error.
80
+ #
81
+ # WHAT IT REFUSES, and why refusing beats guessing:
82
+ # - phase is not `draft` or `design` — nothing to approve. (Design is the
83
+ # transitional phase before Approved; approving it is its forward exit.)
84
+ # - `Review:` is not `pr` — `in-session` and `ballot` need a human in the
85
+ # room. Every plan in this repo declares `Review: pr`, so this fires for
86
+ # nothing today and is still load-bearing: /plot-idea offers all three, and
87
+ # a script treating an unfamiliar `Review:` as `pr` would approve a plan
88
+ # nobody discussed, silently, with a commit indistinguishable from a
89
+ # legitimate one.
90
+ # - the PR is draft, closed, or absent — the skill's preconditions, moved
91
+ # from prose into an exit code.
92
+ # Refusing is this script's job; EXPLAINING is its output. The board surfaces a
93
+ # failing command's own words on the card, so a refusal reaches the reader
94
+ # without the board learning any of these rules.
95
+ #
96
+ # macOS bash 3.2 throughout: no associative arrays, no bash-4 line readers.
97
+ # (Named literally, the repo's own bash-3.2 lint would flag this comment.)
98
+ set -uo pipefail
99
+
100
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
101
+
102
+ dry_run=0
103
+ who_override=""
104
+ slug=""
105
+ while [ $# -gt 0 ]; do
106
+ case "$1" in
107
+ --dry-run) dry_run=1 ;;
108
+ --who) who_override="${2:?--who needs a value}"; shift ;;
109
+ -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
110
+ -*) echo "plot-approve: unknown flag '$1'" >&2; exit 1 ;;
111
+ *) slug="$1" ;;
112
+ esac
113
+ shift
114
+ done
115
+
116
+ die() { echo "plot-approve: $*" >&2; exit 1; }
117
+
118
+ [ -n "$slug" ] || die "need a plan slug (usage: plot-approve.sh [--dry-run] <slug>)"
119
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
120
+
121
+ cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
122
+
123
+ repo_root=$(git rev-parse --show-toplevel)
124
+ wt_root=$(cd "$repo_root/.." && pwd)
125
+
126
+ PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
127
+ ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
128
+ SPRINT_DIR=$(cfg "Sprint directory" "docs/sprints/")
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Step 1 — read the plan's ceremony answers and the PR state
132
+ # ---------------------------------------------------------------------------
133
+
134
+ plan_file=""
135
+ for cand in "$ACTIVE_DIR$slug.md" "$PLAN_DIR"*"$slug".md; do
136
+ [ -e "$cand" ] && { plan_file="$cand"; break; }
137
+ done
138
+ [ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $ACTIVE_DIR and $PLAN_DIR"
139
+
140
+ meta=$(bash "$script_dir/plot-plan-meta.sh" "$plan_file" 2>/dev/null) || meta=""
141
+ [ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
142
+
143
+ jfield() { printf '%s' "$meta" | jq -r "$1" 2>/dev/null; }
144
+
145
+ phase=$(jfield '.phase')
146
+ review=$(jfield '.review')
147
+ impl=$(jfield '.impl')
148
+ sprint=$(jfield '.sprint')
149
+ approved_raw=$(jfield '.approved_raw')
150
+
151
+ # The plan's branches, one per line. This is what the hold file is keyed by.
152
+ plan_branches=$(jfield '.branches[]?')
153
+
154
+ # --- refusal 1: the phase ---------------------------------------------------
155
+ #
156
+ # `draft` and `design` both approve: Design is the transitional phase before
157
+ # Approved — a spike or tracer bullet that answered its question — and approving
158
+ # it is the forward exit the plan gives it. `approved` is NOT a refusal either:
159
+ # it is the idempotent case. A run that finds the phase already flipped still
160
+ # has holds to clear, an annotation to check, and a record that may be missing —
161
+ # the very half-states this script exists to repair. Only phases with nothing to
162
+ # approve are refused.
163
+ case "$phase" in
164
+ draft|design|approved) ;;
165
+ delivered|released)
166
+ die "plan '$slug' is already $phase — nothing to approve." ;;
167
+ NONE|"")
168
+ die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing." ;;
169
+ *)
170
+ die "plan '$slug' is in phase '$phase' — only a Draft or Design plan can be approved." ;;
171
+ esac
172
+
173
+ # --- refusal 2: the review channel ------------------------------------------
174
+ #
175
+ # NONE means a pre-Plot-2 plan on an idea branch, which the skill documents as
176
+ # `pr` by default. An unrecognised value is refused rather than defaulted:
177
+ # "carry on" is the shape of stale assumption this whole story keeps finding.
178
+ case "$review" in
179
+ pr|NONE) ;;
180
+ in-session)
181
+ die "plan '$slug' declares 'Review: in-session' — the reviewer is a human in the room.
182
+ A script cannot stand in for one. Approve it with /plot-approve $slug." ;;
183
+ ballot)
184
+ die "plan '$slug' declares 'Review: ballot' — the tally is the approval.
185
+ A script cannot read a ballot. Approve it with /plot-approve $slug." ;;
186
+ *)
187
+ die "plan '$slug' records an unrecognised 'Review:' answer ('$review').
188
+ Refusing rather than treating it as 'pr' — that would approve a plan nobody discussed." ;;
189
+ esac
190
+
191
+ MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
192
+ [ -n "$MAIN" ] || MAIN=$(bash "$script_dir/plot-host.sh" default-branch 2>/dev/null) || MAIN=""
193
+ [ -n "$MAIN" ] || MAIN="main"
194
+
195
+ # The PR that carries the plan. `Impl: same branch` puts plan and code on the
196
+ # work branch, so its PR is the WORK branch's — and it must not be merged here
197
+ # (it merges once, at the end, carrying the implementation with it).
198
+ same_branch=0
199
+ [ "$impl" = "same-branch" ] && same_branch=1
200
+
201
+ if [ "$same_branch" = 1 ]; then
202
+ pr_branch="$slug"
203
+ for p in $(cfg "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" | tr ',' ' '); do
204
+ p="${p%/}"; p="${p# }"
205
+ [ -z "$p" ] && continue
206
+ [ "$p" = "idea" ] && continue
207
+ if git show-ref --verify --quiet "refs/heads/$p/$slug" \
208
+ || git show-ref --verify --quiet "refs/remotes/origin/$p/$slug"; then
209
+ pr_branch="$p/$slug"
210
+ break
211
+ fi
212
+ done
213
+ else
214
+ pr_branch="idea/$slug"
215
+ fi
216
+
217
+ pr_json=$(bash "$script_dir/plot-host.sh" pr-state "$pr_branch" 2>/dev/null) || pr_json=""
218
+ [ -n "$pr_json" ] || pr_json='{"number":0,"state":"NONE","draft":false,"url":""}'
219
+ pr_number=$(printf '%s' "$pr_json" | jq -r '.number // 0' 2>/dev/null)
220
+ pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "NONE"' 2>/dev/null)
221
+ pr_draft=$(printf '%s' "$pr_json" | jq -r '.draft // false' 2>/dev/null)
222
+
223
+ # --- refusal 3: the PR ------------------------------------------------------
224
+ case "$pr_state" in
225
+ MERGED) ;;
226
+ OPEN) ;;
227
+ CLOSED)
228
+ die "the plan PR for '$slug' (#$pr_number) is closed. Reopen it or create a new one." ;;
229
+ NONE|*)
230
+ die "no PR found for branch '$pr_branch'. Run /plot-idea first, or push the branch." ;;
231
+ esac
232
+
233
+ echo "step: plan $plan_file — phase=$phase review=${review} impl=${impl} pr=#$pr_number($pr_state)"
234
+
235
+ # --- a DRAFT is taken out of draft, not refused ------------------------------
236
+ #
237
+ # This refused until 2026-08-22 — *"still a draft PR. Mark it ready for review
238
+ # first"* — on the argument the skill states: the approval IS the PR's non-draft
239
+ # state, so approving a draft would approve something declaring itself unfinished.
240
+ #
241
+ # The argument holds between two people and costs the author a detour: they
242
+ # opened the plan, read it, and the one control that should conclude that is
243
+ # refusing. So the gate is not removed, it is PASSED BY THE SAME ACT: taking the
244
+ # PR out of draft is the first half of approving it, and the click that approves
245
+ # is the reader saying they have read.
246
+ #
247
+ # Ordered ready-then-merge because the reverse cannot exist — a draft PR is not
248
+ # mergeable on either host — and stated as its own `step:` line so a caller
249
+ # reading the output can see which half happened if the second one fails.
250
+
251
+ who="${who_override:-${PLOT_APPROVE_WHO:-$(git config user.name 2>/dev/null || echo plot)}}"
252
+ today=$(date +%Y-%m-%d)
253
+
254
+ if [ "$dry_run" = 1 ]; then
255
+ [ "$pr_draft" = "true" ] && echo "step: would mark PR #$pr_number ready for review"
256
+ echo "step: would merge PR #$pr_number"
257
+ echo "step: would flip Phase → Approved and fill Approved: $today, $who, plan-PR #$pr_number merged"
258
+ echo "step: would clear .plot/hold entries for: $(printf '%s' "$plan_branches" | tr '\n' ' ')"
259
+ echo "step: would update the sprint annotation${sprint:+ in $SPRINT_DIR (sprint $sprint)}"
260
+ echo "summary: merged=would phase=would record=would holds=would sprint=would push=would"
261
+ exit 0
262
+ fi
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Step 2 — merge the plan PR. THE ONE IRREVERSIBLE WRITE.
266
+ # ---------------------------------------------------------------------------
267
+ #
268
+ # Already-done test: pr-state reports MERGED. That is the source this step
269
+ # would have written, asked directly.
270
+ #
271
+ # Merge commits, not squash: plan refinement history is the context a later
272
+ # reader wants. `--delete-branch` retires idea/<slug>, which has no further job.
273
+ merged_report="already"
274
+ if [ "$same_branch" = 1 ]; then
275
+ # Plan and code ride one branch; the PR merges once, at the end, and merging
276
+ # it here would land an unfinished implementation on the default branch.
277
+ merged_report="skipped-same-branch"
278
+ echo "step: merge skipped — 'Impl: same branch' keeps PR #$pr_number open for the implementation"
279
+ elif [ "$pr_state" = "MERGED" ]; then
280
+ echo "step: PR #$pr_number is already merged — the approval already happened"
281
+ else
282
+ # OUT OF DRAFT FIRST, where it is one — see the note above refusal 3. Its own
283
+ # step line, and its own refusal: a `pr-ready` that fails must not be read as
284
+ # a merge that failed, because the repairs differ (a permission or a CLI too
285
+ # old, against a conflict or a branch protection).
286
+ #
287
+ # Nothing is written before this point, so dying here leaves the estate
288
+ # exactly as it was found — the same property the merge failure below relies
289
+ # on, and the reason both refusals say so in the same words.
290
+ if [ "$pr_draft" = "true" ]; then
291
+ if bash "$script_dir/plot-host.sh" pr-ready "$pr_number" >/dev/null 2>&1; then
292
+ echo "step: marked PR #$pr_number ready for review"
293
+ else
294
+ die "could not take PR #$pr_number out of draft. Nothing else was written; mark it ready on the host and re-run."
295
+ fi
296
+ fi
297
+ if bash "$script_dir/plot-host.sh" pr-merge "$pr_number" --delete-branch >/dev/null 2>&1; then
298
+ merged_report="yes"
299
+ echo "step: merged PR #$pr_number"
300
+ else
301
+ die "could not merge PR #$pr_number. Nothing else was written; re-run once the merge works."
302
+ fi
303
+ fi
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Steps 3-6 — the local writes, all in one booking worktree off origin/<default>
307
+ # ---------------------------------------------------------------------------
308
+ #
309
+ # A SEPARATE WORKTREE, not a checkout here. The caller's working tree may carry
310
+ # uncommitted work, and switching it out from under them to record a note is
311
+ # exactly the write this script otherwise refuses. plot-dispatch.sh books the
312
+ # same way and for the same reason.
313
+ #
314
+ # `same branch` and the direct flow are the exceptions: their plan lives on the
315
+ # current branch, where it is already checked out, and the record belongs there.
316
+
317
+ # The CANONICAL plan file, not the index symlink: a later active/ → delivered/
318
+ # move would carry the symlink and leave the record behind.
319
+ real_plan_path() { # $1 = plan file as found
320
+ local p="$1" d b t
321
+ d=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1
322
+ b=$(basename "$p")
323
+ t=$(readlink "$d/$b" 2>/dev/null || true)
324
+ if [ -n "$t" ]; then
325
+ case "$t" in
326
+ /*) d=$(cd "$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
327
+ *) d=$(cd "$d/$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
328
+ esac
329
+ b=$(basename "$t")
330
+ fi
331
+ case "$d" in
332
+ "$repo_root") printf '%s' "$b" ;;
333
+ "$repo_root"/*) printf '%s/%s' "${d#$repo_root/}" "$b" ;;
334
+ *) return 1 ;;
335
+ esac
336
+ }
337
+
338
+ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
339
+ [ -n "$rel" ] || die "$plan_file is outside the repository root"
340
+
341
+ # Flip `**Phase:** Draft` OR `**Phase:** Design` → `Approved` in the `## Status`
342
+ # section only. Both are the pre-Approved phases this script advances from.
343
+ #
344
+ # Scoped to that section because a plan that QUOTES a status block in its prose
345
+ # (this repo has several, documenting the format) would otherwise have its
346
+ # illustration rewritten too — a silent corruption of the very files that
347
+ # specify the format.
348
+ flip_phase() { # $1=file → 0 if it changed the file, 1 if there was nothing to flip
349
+ local f="$1"
350
+ awk '
351
+ BEGIN { section = ""; done = 0 }
352
+ /^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
353
+ section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ {
354
+ if (tolower($0) ~ /draft/) {
355
+ sub(/[Dd]raft/, "Approved")
356
+ done = 1
357
+ changed = 1
358
+ } else if (tolower($0) ~ /design/) {
359
+ sub(/[Dd]esign/, "Approved")
360
+ done = 1
361
+ changed = 1
362
+ }
363
+ }
364
+ { print }
365
+ END { exit (changed ? 0 : 1) }
366
+ ' "$f" > "$f.plot-tmp"
367
+ local rc=$?
368
+ if [ "$rc" = 0 ]; then mv "$f.plot-tmp" "$f"; else rm -f "$f.plot-tmp"; fi
369
+ return "$rc"
370
+ }
371
+
372
+ # Insert one `- **Approved:** ...` line into the plan's `## Status` section.
373
+ #
374
+ # THIS IS append_started_line()'s SHAPE, DELIBERATELY. That function
375
+ # (plot-dispatch.sh:423) was repaired on 2026-08-17 after appending below
376
+ # `- **Delivered:**` instead of filling the empty placeholder the template
377
+ # ships — the parser still read it, so nothing failed loudly, but the block
378
+ # listed a start after a delivery and two plans had to be tidied by hand.
379
+ # A second implementation that re-derived the awk would repeat exactly that
380
+ # bug, so this one fills the placeholder first and only falls back to appending
381
+ # after the last list item for plans that never had one (pre-Plot-2 files).
382
+ #
383
+ # A plan with no `## Status` heading is a REFUSAL, not a best-effort append:
384
+ # plot-plan-meta.sh reads these records out of that section, so a line below it
385
+ # parses as nothing at all — a record that exists on disk and not in the data is
386
+ # worse than no record, because it looks written.
387
+ append_approved_line() { # $1=file $2=date $3=who $4=channel
388
+ local f="$1" line
389
+ line="- **Approved:** $2, $3, $4"
390
+ awk -v line="$line" '
391
+ { lines[++n] = $0 }
392
+ END {
393
+ for (i = 1; i <= n; i++) {
394
+ if (lines[i] ~ /^##[ \t]*[Ss]tatus[ \t]*$/) { start = i; break }
395
+ }
396
+ if (!start) exit 1
397
+
398
+ insert = start
399
+ for (i = start + 1; i <= n; i++) {
400
+ if (lines[i] ~ /^##[ \t]/) break
401
+ if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Approved:\*\*[ \t]*$/) { slot = i; break }
402
+ if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
403
+ }
404
+
405
+ for (i = 1; i <= n; i++) {
406
+ if (i == slot) { print line; continue } # replaces the empty placeholder
407
+ print lines[i]
408
+ if (!slot && i == insert) print line
409
+ }
410
+ }
411
+ ' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
412
+ mv "$f.plot-tmp" "$f"
413
+ }
414
+
415
+ # Remove the `.plot/hold` entry for EVERY branch the plan names, and nothing
416
+ # else. String equality on the first field, exactly as the gate reads it
417
+ # (plot-phase-gate.sh:121) — a pattern here would release gates the plan never
418
+ # named. Returns the number of entries removed on stdout.
419
+ clear_holds() { # $1=worktree root; reads $plan_branches
420
+ local root="$1" hold="$1/.plot/hold" removed=0
421
+ [ -f "$hold" ] || { printf '0'; return 0; }
422
+ [ -n "$plan_branches" ] || { printf '0'; return 0; }
423
+ # SPACE-separated, not newline-separated: BSD awk (macOS bash 3.2's awk)
424
+ # rejects a literal newline inside a -v value outright — "newline in string".
425
+ # A git branch name cannot contain a space (git check-ref-format forbids it),
426
+ # so a space is a safe separator where a newline is not portable.
427
+ local list
428
+ list=$(printf '%s' "$plan_branches" | tr '\n' ' ')
429
+ removed=$(awk -v branches="$list" '
430
+ BEGIN { n = split(branches, b, " "); for (i = 1; i <= n; i++) if (b[i] != "") want[b[i]] = 1 }
431
+ ($1 in want) { c++; next }
432
+ { print > OUT }
433
+ END { print c + 0 }
434
+ ' OUT="$hold.plot-tmp" "$hold")
435
+ # awk never creates OUT when every line matched; an empty hold file is the
436
+ # honest result of removing the last entry, not a reason to leave it.
437
+ [ -f "$hold.plot-tmp" ] || : > "$hold.plot-tmp"
438
+ mv "$hold.plot-tmp" "$hold"
439
+ printf '%s' "$removed"
440
+ }
441
+
442
+ # Update the sprint item annotation this plan appears in:
443
+ # - [ ] [slug] description <!-- pr: #N, status: draft, branch: feature/slug -->
444
+ # /plot-sprint READS these (`pr`, `status`, `branch`) and /plot-approve writes
445
+ # them, so an approval that skips this makes `/plot-sprint status` wrong rather
446
+ # than merely incomplete.
447
+ #
448
+ # A plan in NO sprint is a no-op, never an error — that is the common case.
449
+ # Already-done test: the annotation already carries `status: approved`.
450
+ update_sprint_annotation() { # $1=worktree root → prints none|updated|already|missing
451
+ local root="$1" f found=""
452
+ [ -n "$sprint" ] || { printf 'none'; return 0; }
453
+ local dir="$root/${SPRINT_DIR#/}"
454
+ [ -d "$dir" ] || { printf 'missing'; return 0; }
455
+ # Found by CONTENT, not by filename. The `Sprint:` field holds a slug while
456
+ # the file is named `<week>-<slug>.md` — and the week prefix is written in
457
+ # whatever case /plot-sprint's ISO week produced (`2026-W33-...` measured),
458
+ # so a glob on the slug misses it on a case-sensitive filesystem. The item
459
+ # line `- [ ] [<slug>] ...` is the actual relationship, and it is what
460
+ # /plot-sprint reads back.
461
+ for f in "$dir"/*.md; do
462
+ [ -e "$f" ] || continue
463
+ grep -q "\[$slug\]" "$f" 2>/dev/null && { found="$f"; break; }
464
+ done
465
+ [ -n "$found" ] || { printf 'missing'; return 0; }
466
+
467
+ local before after
468
+ before=$(cat "$found")
469
+ after=$(awk -v slug="$slug" -v pr="$pr_number" -v br="$(printf '%s' "$plan_branches" | head -1)" '
470
+ index($0, "[" slug "]") == 0 { print; next }
471
+ {
472
+ line = $0
473
+ if (index(line, "<!--") == 0) {
474
+ line = line " <!-- pr: #" pr ", status: approved" (br != "" ? ", branch: " br : "") " -->"
475
+ } else {
476
+ if (line ~ /status:[ \t]*[a-z-]+/) sub(/status:[ \t]*[a-z-]+/, "status: approved", line)
477
+ else sub(/-->/, ", status: approved -->", line)
478
+ if (line ~ /pr:[ \t]*#?[0-9a-z]+/) sub(/pr:[ \t]*#?[0-9a-z]+/, "pr: #" pr, line)
479
+ else sub(/<!--/, "<!-- pr: #" pr ",", line)
480
+ if (br != "") {
481
+ if (line ~ /branch:[ \t]*[^,>]+/) sub(/branch:[ \t]*[^,>]*[^,> \t]/, "branch: " br, line)
482
+ else sub(/-->/, ", branch: " br " -->", line)
483
+ }
484
+ }
485
+ print line
486
+ }
487
+ ' "$found")
488
+ if [ "$before" = "$after" ]; then printf 'already'; return 0; fi
489
+ printf '%s\n' "$after" > "$found"
490
+ printf 'updated'
491
+ }
492
+
493
+ # The whole local half, run inside one directory — the booking worktree in the
494
+ # `pr` flow, the caller's own repo root in the `same branch` / direct flow.
495
+ apply_local_writes() { # $1=root → sets phase_report record_report holds_report sprint_report
496
+ local root="$1" f="$1/$rel"
497
+ [ -f "$f" ] || { echo "plot-approve: $rel is not present in $root" >&2; return 1; }
498
+
499
+ # Step 3 — flip the phase. Already-done test: the file no longer says Draft.
500
+ if flip_phase "$f"; then phase_report="flipped"; else phase_report="already"; fi
501
+
502
+ # Step 4 — fill the Approved: record. Already-done test: it is non-empty in
503
+ # THE FILE BEING WRITTEN, re-parsed here rather than trusted from the caller's
504
+ # copy: on the `pr` flow those are different files, and the plan on the
505
+ # default branch is the one that counts.
506
+ local rec
507
+ rec=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null | jq -r '.approved_raw // ""' 2>/dev/null)
508
+ if [ -n "$rec" ]; then
509
+ record_report="already"
510
+ else
511
+ local channel="plan-PR #$pr_number merged"
512
+ [ "$same_branch" = 1 ] && channel="plan-PR #$pr_number reviewed"
513
+ if append_approved_line "$f" "$today" "$who" "$channel"; then
514
+ record_report="written"
515
+ else
516
+ echo "plot-approve: $rel has no '## Status' section — nowhere to record the approval" >&2
517
+ return 1
518
+ fi
519
+ fi
520
+
521
+ # Step 5 — clear the holds, keyed by branch.
522
+ holds_report=$(clear_holds "$root")
523
+
524
+ # Step 6 — the sprint annotation.
525
+ sprint_report=$(update_sprint_annotation "$root")
526
+ return 0
527
+ }
528
+
529
+ phase_report="" record_report="" holds_report="0" sprint_report="none"
530
+ push_report="n/a"
531
+
532
+ # `Impl: same branch` records on the work branch, in place. Everything else
533
+ # records on the default branch, through a disposable branch.
534
+ if [ "$same_branch" = 1 ]; then
535
+ apply_local_writes "$repo_root" || exit 1
536
+ git -C "$repo_root" add -- "$rel" >/dev/null 2>&1 || true
537
+ [ -f "$repo_root/.plot/hold" ] && git -C "$repo_root" add -- .plot/hold >/dev/null 2>&1
538
+ [ "$sprint_report" = "updated" ] && git -C "$repo_root" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
539
+ if git -C "$repo_root" diff --cached --quiet 2>/dev/null; then
540
+ push_report="nothing-to-commit"
541
+ echo "step: nothing to commit — the approval was already recorded"
542
+ else
543
+ git -C "$repo_root" commit -q -m "plot: approve $slug" || die "could not commit the approval"
544
+ push_report="local"
545
+ echo "step: recorded on $(git -C "$repo_root" branch --show-current) — push it with the implementation"
546
+ fi
547
+ else
548
+ # Fetched even under --offline: recording is a push, so the network is
549
+ # already required, and a stale origin/<default> guarantees a
550
+ # non-fast-forward. --offline is honoured everywhere it can be, and this is
551
+ # not one of those places.
552
+ git fetch -q origin "$MAIN" 2>/dev/null
553
+
554
+ bookbr="plot/approve-$slug"
555
+ tmpwt="$wt_root/.plot-approve-$slug.$$"
556
+ # -B: a leftover branch from an earlier failed run must not block this one.
557
+ # It is disposable by construction — created here, pushed, deleted.
558
+ git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
559
+ || die "could not prepare a booking worktree at $tmpwt"
560
+
561
+ cleanup() {
562
+ git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
563
+ git branch -D "$bookbr" >/dev/null 2>&1 || true
564
+ }
565
+
566
+ if ! apply_local_writes "$tmpwt"; then
567
+ cleanup
568
+ exit 1
569
+ fi
570
+
571
+ git -C "$tmpwt" add -- "$rel" >/dev/null 2>&1 || true
572
+ [ -f "$tmpwt/.plot/hold" ] && git -C "$tmpwt" add -- .plot/hold >/dev/null 2>&1
573
+ [ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
574
+
575
+ if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
576
+ # THE IDEMPOTENT EXIT. Everything this run would have written was already
577
+ # on the default branch, so there is nothing to push and nothing wrong.
578
+ push_report="nothing-to-commit"
579
+ echo "step: nothing to commit — the approval is already recorded on $MAIN"
580
+ cleanup
581
+ else
582
+ if ! git -C "$tmpwt" -c "user.name=$who" commit -q -m "plot: approve $slug"; then
583
+ cleanup
584
+ die "could not commit the approval"
585
+ fi
586
+ # plot-push-main.sh rather than a bare `git push`: a repo whose protection
587
+ # is configured but NOT ENFORCED waves the push through with exit 0 and
588
+ # only a notice on stderr, so a bare push cannot tell a bypass from a clean
589
+ # landing. Its words are carried verbatim — which rules were stepped over
590
+ # and which checks did not run is information only the remote has.
591
+ push_out=$(bash "$script_dir/plot-push-main.sh" "$bookbr" "$MAIN" 2>&1)
592
+ push_rc=$?
593
+ printf '%s\n' "$push_out" | sed 's/^/ /'
594
+ if [ "$push_rc" = 0 ]; then
595
+ push_report=$(printf '%s' "$push_out" | sed -n 's/^push: \([a-z]*\).*/\1/p' | head -1)
596
+ [ -n "$push_report" ] || push_report="unknown"
597
+ cleanup
598
+ else
599
+ # BRANCH PROTECTION FALLBACK — the only path where a micro-PR is right.
600
+ # Never leave the merged plan stranded at `Phase: Draft`: the merge is
601
+ # done and irreversible, so the recorded phase must follow it.
602
+ echo "step: push rejected — opening a micro-PR instead"
603
+ if git push -q origin "$bookbr" 2>/dev/null \
604
+ && micro_url=$(bash "$script_dir/plot-host.sh" pr-create \
605
+ --title "plot: approve $slug" \
606
+ --body "Records the approval of \`$slug\` (plan-PR #$pr_number merged)." \
607
+ --base "$MAIN" --head "$bookbr" 2>/dev/null) \
608
+ && micro_num=$(printf '%s' "$micro_url" | sed 's#.*/##') \
609
+ && bash "$script_dir/plot-host.sh" pr-merge "$micro_num" --delete-branch >/dev/null 2>&1
610
+ then
611
+ push_report="micro-pr"
612
+ echo "step: approval landed via micro-PR $micro_url"
613
+ cleanup
614
+ else
615
+ push_report="rejected"
616
+ echo "plot-approve: the approval is committed on '$bookbr' but could not reach $MAIN." >&2
617
+ echo " PR #$pr_number IS MERGED — the plan must not stay at Phase: Draft." >&2
618
+ echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
619
+ git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
620
+ echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
621
+ exit 1
622
+ fi
623
+ fi
624
+ fi
625
+ fi
626
+
627
+ echo "summary: merged=$merged_report phase=$phase_report record=$record_report holds=$holds_report sprint=$sprint_report push=$push_report"
628
+ exit 0