@plot-pm/board 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: perform the MECHANICAL half of delivering a plan.
3
+ # Usage: plot-deliver.sh [--dry-run] [--who <name>] <slug>
4
+ # --dry-run say what would happen; write nothing, push nothing
5
+ # --who the name recorded in the `Delivered:` line (default: git user.name)
6
+ # <slug> the plan to deliver
7
+ # Output: one `step:` line per step, then a machine-countable summary:
8
+ # summary: phase=flipped record=written index=moved sprint=updated push=clean
9
+ # Exit 0 when the plan is Delivered 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. The board computes `allWavesMerged` — exactly the condition
14
+ # that says a plan is ready to deliver — but the transition itself lives only in
15
+ # `/plot-deliver`'s prose. So `Delivered` in the board asked for a caller with
16
+ # nothing safe to call, and an implementer reaching that point would have
17
+ # rebuilt the phase flip, the `Delivered:` record and the symlink move in
18
+ # TypeScript. That is precisely the drift the `plot-approve.sh` split removed.
19
+ #
20
+ # This script is the `plot-approve.sh` of delivery: one implementation, two
21
+ # entrances. `/plot-deliver` keeps the judgement — the completeness check, the
22
+ # partial-deliverable question — and delegates the writes here. The board calls
23
+ # this script directly (or via an agent when a `Deliver command` is set).
24
+ #
25
+ # Manifesto Principle 3 draws the line: SCRIPTS COLLECT AND REPORT; SKILLS
26
+ # INTERPRET AND ADAPT. Flipping a phase and writing a dated record is
27
+ # collecting. Deciding whether PARTIAL work counts as delivered is interpreting,
28
+ # and stays in the skill.
29
+ #
30
+ # IT IS IDEMPOTENT, BECAUSE ONE STEP CANNOT BE UNDONE. The push is irreversible;
31
+ # everything before it is local. A run interrupted between flipping the phase
32
+ # and pushing leaves the plan at `Delivered` in a worktree but still `Approved`
33
+ # on main — so `plot-deliver.sh <slug>` may run any number of times, and RUN IT
34
+ # AGAIN is the repair for every interruption.
35
+ #
36
+ # Each step asks THE SOURCE IT WOULD HAVE WRITTEN whether it is already done:
37
+ # the plan file for the phase and the record, the index directories for the
38
+ # symlink, the sprint file for the annotation. Never a progress file of its own.
39
+ #
40
+ # WHAT IT REFUSES, and why refusing beats guessing:
41
+ # - phase is not `approved` — nothing to deliver. (Already-Delivered is NOT a
42
+ # refusal: it is the idempotent case, and the run still checks the record
43
+ # and index.)
44
+ # - any non-deferred branch is unmerged — work is not done. This is one of
45
+ # Plot's four phase guardrails, moved from prose into an exit code.
46
+ #
47
+ # macOS bash 3.2 throughout: no associative arrays, no bash-4 line readers.
48
+ set -uo pipefail
49
+
50
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
51
+
52
+ dry_run=0
53
+ who_override=""
54
+ slug=""
55
+ while [ $# -gt 0 ]; do
56
+ case "$1" in
57
+ --dry-run) dry_run=1 ;;
58
+ --who) who_override="${2:?--who needs a value}"; shift ;;
59
+ -h|--help) sed -n '2,12p' "$0"; exit 0 ;;
60
+ -*) echo "plot-deliver: unknown flag '$1'" >&2; exit 1 ;;
61
+ *) slug="$1" ;;
62
+ esac
63
+ shift
64
+ done
65
+
66
+ die() { echo "plot-deliver: $*" >&2; exit 1; }
67
+
68
+ [ -n "$slug" ] || die "need a plan slug (usage: plot-deliver.sh [--dry-run] <slug>)"
69
+ git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
70
+
71
+ cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
72
+
73
+ repo_root=$(git rev-parse --show-toplevel)
74
+ wt_root=$(cd "$repo_root/.." && pwd)
75
+
76
+ PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
77
+ ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
78
+ DELIVERED_DIR=$(cfg "Delivered index" "docs/plans/delivered/")
79
+ SPRINT_DIR=$(cfg "Sprint directory" "docs/sprints/")
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Step 1 — find the plan and read its state
83
+ # ---------------------------------------------------------------------------
84
+
85
+ plan_file=""
86
+ for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
87
+ [ -e "$cand" ] && { plan_file="$cand"; break; }
88
+ done
89
+ [ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $PLAN_DIR, $ACTIVE_DIR, $DELIVERED_DIR"
90
+
91
+ meta=$(bash "$script_dir/plot-plan-meta.sh" "$plan_file" 2>/dev/null) || meta=""
92
+ [ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
93
+
94
+ jfield() { printf '%s' "$meta" | jq -r "$1" 2>/dev/null; }
95
+
96
+ phase=$(jfield '.phase')
97
+ sprint=$(jfield '.sprint')
98
+ delivered_raw=$(jfield '.delivered_raw')
99
+
100
+ # --- refusal 1: the phase ---------------------------------------------------
101
+ #
102
+ # `approved` and `delivered` both proceed: Delivered is the idempotent case.
103
+ # A run that finds the phase already flipped still has a record to check, an
104
+ # index link to move, and an annotation to update — the very half-states this
105
+ # script exists to repair.
106
+ case "$phase" in
107
+ approved|delivered) ;;
108
+ released)
109
+ die "plan '$slug' is already released — nothing to deliver." ;;
110
+ draft|design)
111
+ die "plan '$slug' is still '$phase' — approve it first." ;;
112
+ NONE|"")
113
+ die "cannot read the phase of '$slug' ($plan_file) — refusing rather than guessing." ;;
114
+ *)
115
+ die "plan '$slug' is in phase '$phase' — only an Approved plan can be delivered." ;;
116
+ esac
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Step 2 — verify all non-deferred branches are merged
120
+ # ---------------------------------------------------------------------------
121
+ #
122
+ # This is one of Plot's four phase guardrails. We call plot-impl-status.sh to
123
+ # check the merge state of all branches. Any branch that is not MERGED and not
124
+ # deferred is a refusal.
125
+
126
+ # Parse branches from the plan file, respecting deferred annotations.
127
+ # Read the plan's branches section (from EITHER spelling — ## Branches or ## Waves).
128
+ plan_content=$(cat "$plan_file")
129
+
130
+ # Extract branch lines from ## Branches or ## Waves section
131
+ branches_section=$(printf '%s' "$plan_content" | sed -n '/^## *[Bb]ranches\|^## *[Ww]aves/,/^## /p')
132
+
133
+ # A BRANCH LINE CARRIES A BRANCH PREFIX, and without that test a changelog
134
+ # bullet is read as a branch. The section range above closes at the next `## `,
135
+ # but a plan whose `## Changelog` bullet mentions a backticked identifier —
136
+ # `impl`, `pr_ready`, `--migrate`, `/api/story` were the four measured on
137
+ # 2026-08-27 — hands one of those to the merge check, which then refuses
138
+ # delivery over a branch that does not exist and never will.
139
+ #
140
+ # Four fully-merged plans were undeliverable for this reason. The prefixes come
141
+ # from `Branch prefixes` rather than a hardcoded list, the same derivation
142
+ # `plot-fleet-scan.sh:187` uses, so a project with its own prefixes is read
143
+ # correctly and one with none falls back to Plot's defaults.
144
+ prefix_re=$(bash "$script_dir/plot-config.sh" get "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
145
+ | tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' - )
146
+ [ -n "$prefix_re" ] || prefix_re="idea|feature|bug|docs|infra"
147
+
148
+ # Parse branches from old-style ## Branches section (backtick-quoted on list lines)
149
+ old_style_branches=$(printf '%s' "$branches_section" \
150
+ | grep -oE "^- \`($prefix_re)/[A-Za-z0-9_./-]+\`" 2>/dev/null \
151
+ | sed 's/^- `//; s/`$//' \
152
+ | sort -u || true)
153
+
154
+ # Parse branches from new-style ## Waves section (Branch: in ### headings)
155
+ new_style_branches=$(printf '%s' "$branches_section" \
156
+ | grep -oE "### .*\(Branch: ($prefix_re)/[A-Za-z0-9_./-]+" 2>/dev/null \
157
+ | sed 's/.*Branch: //' \
158
+ | sort -u || true)
159
+
160
+ # Combine both styles
161
+ all_branches=$(printf '%s\n%s' "$old_style_branches" "$new_style_branches" | grep -v '^$' | sort -u || true)
162
+
163
+ if [ -z "$all_branches" ]; then
164
+ echo "step: no branches found in plan — proceeding (nothing to verify)"
165
+ else
166
+ # Check which branches are deferred. A deferred branch has `<!-- deferred:` on its line.
167
+ deferred_branches=""
168
+ non_deferred_branches=""
169
+
170
+ for br in $all_branches; do
171
+ # Check both spellings: old style has branch on a `- \`branch\`` line,
172
+ # new style has branch in a `### ... (Branch: branch ...)` heading.
173
+ if printf '%s' "$branches_section" | grep -F "\`$br\`" | grep -q '<!-- *deferred' 2>/dev/null; then
174
+ deferred_branches="${deferred_branches}${br}
175
+ "
176
+ elif printf '%s' "$branches_section" | grep "Branch: $br" | grep -q '<!-- *deferred' 2>/dev/null; then
177
+ deferred_branches="${deferred_branches}${br}
178
+ "
179
+ else
180
+ non_deferred_branches="${non_deferred_branches}${br}
181
+ "
182
+ fi
183
+ done
184
+
185
+ # Trim trailing newlines
186
+ non_deferred_branches=$(printf '%s' "$non_deferred_branches" | grep -v '^$' || true)
187
+ deferred_branches=$(printf '%s' "$deferred_branches" | grep -v '^$' || true)
188
+
189
+ # Get implementation status for all branches
190
+ impl_status=$(bash "$script_dir/plot-impl-status.sh" "$slug" 2>/dev/null) || impl_status='{"prs":[]}'
191
+
192
+ # Check each non-deferred branch is merged
193
+ unmerged_branches=""
194
+ for br in $non_deferred_branches; do
195
+ [ -z "$br" ] && continue
196
+ # Look for this branch in the impl status. A branch is merged if its PR state is MERGED.
197
+ pr_state=$(printf '%s' "$impl_status" | jq -r --arg br "$br" '.prs[] | select(.branch == $br) | .state' 2>/dev/null || true)
198
+ if [ "$pr_state" != "MERGED" ]; then
199
+ unmerged_branches="${unmerged_branches}${br}
200
+ "
201
+ fi
202
+ done
203
+
204
+ unmerged_branches=$(printf '%s' "$unmerged_branches" | grep -v '^$' || true)
205
+
206
+ if [ -n "$unmerged_branches" ]; then
207
+ unmerged_count=$(printf '%s\n' "$unmerged_branches" | wc -l | tr -d ' ')
208
+ unmerged_list=$(printf '%s' "$unmerged_branches" | tr '\n' ', ' | sed 's/, $//')
209
+ die "cannot deliver: $unmerged_count branch(es) not merged: $unmerged_list
210
+ Merge them first, or mark them deferred with \`<!-- deferred: <reason> -->\`."
211
+ fi
212
+
213
+ deferred_count=0
214
+ [ -n "$deferred_branches" ] && deferred_count=$(printf '%s\n' "$deferred_branches" | wc -l | tr -d ' ')
215
+ merged_count=$(printf '%s\n' "$non_deferred_branches" | wc -l | tr -d ' ')
216
+ [ -z "$non_deferred_branches" ] && merged_count=0
217
+
218
+ echo "step: verified $merged_count branch(es) merged${deferred_count:+, $deferred_count deferred}"
219
+ fi
220
+
221
+ MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
222
+ [ -n "$MAIN" ] || MAIN=$(bash "$script_dir/plot-host.sh" default-branch 2>/dev/null) || MAIN=""
223
+ [ -n "$MAIN" ] || MAIN="main"
224
+
225
+ who="${who_override:-${PLOT_DELIVER_WHO:-$(git config user.name 2>/dev/null || echo plot)}}"
226
+ today=$(date +%Y-%m-%d)
227
+
228
+ if [ "$dry_run" = 1 ]; then
229
+ echo "step: would flip Phase → Delivered and fill Delivered: $today"
230
+ echo "step: would move active/ → delivered/ symlink"
231
+ echo "step: would update the sprint annotation${sprint:+ (sprint: $sprint)}"
232
+ echo "summary: phase=would record=would index=would sprint=would push=would"
233
+ exit 0
234
+ fi
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Steps 3-6 — the local writes, in a booking worktree off origin/<default>
238
+ # ---------------------------------------------------------------------------
239
+ #
240
+ # A SEPARATE WORKTREE, not a checkout here. The caller's working tree may carry
241
+ # uncommitted work, and switching it out from under them is exactly the write
242
+ # this script otherwise refuses.
243
+
244
+ # The CANONICAL plan file, not the index symlink.
245
+ real_plan_path() { # $1 = plan file as found
246
+ local p="$1" d b t
247
+ d=$(cd "$(dirname "$p")" 2>/dev/null && pwd) || return 1
248
+ b=$(basename "$p")
249
+ t=$(readlink "$d/$b" 2>/dev/null || true)
250
+ if [ -n "$t" ]; then
251
+ case "$t" in
252
+ /*) d=$(cd "$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
253
+ *) d=$(cd "$d/$(dirname "$t")" 2>/dev/null && pwd) || return 1 ;;
254
+ esac
255
+ b=$(basename "$t")
256
+ fi
257
+ case "$d" in
258
+ "$repo_root") printf '%s' "$b" ;;
259
+ "$repo_root"/*) printf '%s/%s' "${d#$repo_root/}" "$b" ;;
260
+ *) return 1 ;;
261
+ esac
262
+ }
263
+
264
+ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
265
+ [ -n "$rel" ] || die "$plan_file is outside the repository root"
266
+
267
+ # The filename, for symlink creation.
268
+ plan_basename=$(basename "$rel")
269
+
270
+ # Flip `**Phase:** Approved` → `Delivered` in the `## Status` section only.
271
+ flip_phase() { # $1=file → 0 if it changed the file, 1 if nothing to flip
272
+ local f="$1"
273
+ awk '
274
+ BEGIN { section = ""; done = 0 }
275
+ /^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
276
+ section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ {
277
+ if (tolower($0) ~ /approved/) {
278
+ sub(/[Aa]pproved/, "Delivered")
279
+ done = 1
280
+ changed = 1
281
+ }
282
+ }
283
+ { print }
284
+ END { exit (changed ? 0 : 1) }
285
+ ' "$f" > "$f.plot-tmp"
286
+ local rc=$?
287
+ if [ "$rc" = 0 ]; then mv "$f.plot-tmp" "$f"; else rm -f "$f.plot-tmp"; fi
288
+ return "$rc"
289
+ }
290
+
291
+ # Insert one `- **Delivered:** YYYY-MM-DD` line into the plan's `## Status` section.
292
+ # Fills the placeholder first; falls back to appending after the last list item.
293
+ append_delivered_line() { # $1=file $2=date
294
+ local f="$1" line
295
+ line="- **Delivered:** $2"
296
+ awk -v line="$line" '
297
+ { lines[++n] = $0 }
298
+ END {
299
+ for (i = 1; i <= n; i++) {
300
+ if (lines[i] ~ /^##[ \t]*[Ss]tatus[ \t]*$/) { start = i; break }
301
+ }
302
+ if (!start) exit 1
303
+
304
+ insert = start
305
+ for (i = start + 1; i <= n; i++) {
306
+ if (lines[i] ~ /^##[ \t]/) break
307
+ if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Delivered:\*\*[ \t]*$/) { slot = i; break }
308
+ if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Delivered:\*\*[ \t]*<!--/) { slot = i; break }
309
+ if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
310
+ }
311
+
312
+ for (i = 1; i <= n; i++) {
313
+ if (i == slot) { print line; continue } # replaces the empty placeholder
314
+ print lines[i]
315
+ if (!slot && i == insert) print line
316
+ }
317
+ }
318
+ ' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
319
+ mv "$f.plot-tmp" "$f"
320
+ }
321
+
322
+ # Update the sprint item annotation for this plan.
323
+ update_sprint_annotation() { # $1=worktree root → prints none|updated|already|missing
324
+ local root="$1" f found=""
325
+ [ -n "$sprint" ] || { printf 'none'; return 0; }
326
+ local dir="$root/${SPRINT_DIR#/}"
327
+ [ -d "$dir" ] || { printf 'missing'; return 0; }
328
+ # Find the sprint file by content (the [<slug>] reference), not by filename.
329
+ for f in "$dir"/*.md; do
330
+ [ -e "$f" ] || continue
331
+ grep -q "\[$slug\]" "$f" 2>/dev/null && { found="$f"; break; }
332
+ done
333
+ [ -n "$found" ] || { printf 'missing'; return 0; }
334
+
335
+ local before after
336
+ before=$(cat "$found")
337
+ after=$(awk -v slug="$slug" '
338
+ index($0, "[" slug "]") == 0 { print; next }
339
+ {
340
+ line = $0
341
+ # Check the box
342
+ sub(/\[ \]/, "[x]", line)
343
+ # Update or add status annotation
344
+ if (index(line, "<!--") == 0) {
345
+ line = line " <!-- status: delivered -->"
346
+ } else {
347
+ if (line ~ /status:[ \t]*[a-z-]+/) sub(/status:[ \t]*[a-z-]+/, "status: delivered", line)
348
+ else sub(/-->/, ", status: delivered -->", line)
349
+ }
350
+ print line
351
+ }
352
+ ' "$found")
353
+ if [ "$before" = "$after" ]; then printf 'already'; return 0; fi
354
+ printf '%s\n' "$after" > "$found"
355
+ printf 'updated'
356
+ }
357
+
358
+ # Move the active/ symlink to delivered/.
359
+ move_index_symlink() { # $1=worktree root → prints moved|created|already|skipped
360
+ local root="$1"
361
+ local active_link="$root/${ACTIVE_DIR#/}$slug.md"
362
+ local delivered_link="$root/${DELIVERED_DIR#/}$slug.md"
363
+ local delivered_dir="$root/${DELIVERED_DIR#/}"
364
+
365
+ # Ensure delivered/ directory exists (best effort)
366
+ mkdir -p "$delivered_dir" 2>/dev/null || true
367
+
368
+ # Check if already in delivered/
369
+ if [ -e "$delivered_link" ]; then
370
+ # Remove from active/ if still there
371
+ git rm -q --ignore-unmatch "$active_link" 2>/dev/null || true
372
+ printf 'already'
373
+ return 0
374
+ fi
375
+
376
+ # Create the delivered/ symlink (relative path to the plan)
377
+ ln -sfn "../$plan_basename" "$delivered_link" 2>/dev/null || { printf 'skipped'; return 0; }
378
+
379
+ # Remove from active/ (best effort, ignore if not there)
380
+ git rm -q --ignore-unmatch "$active_link" 2>/dev/null || true
381
+
382
+ printf 'moved'
383
+ }
384
+
385
+ # The whole local half, run inside one directory.
386
+ apply_local_writes() { # $1=root → sets phase_report record_report index_report sprint_report
387
+ local root="$1" f="$1/$rel"
388
+ [ -f "$f" ] || { echo "plot-deliver: $rel is not present in $root" >&2; return 1; }
389
+
390
+ # Step 3 — flip the phase. Already-done test: the file no longer says Approved.
391
+ if flip_phase "$f"; then phase_report="flipped"; else phase_report="already"; fi
392
+
393
+ # Step 4 — fill the Delivered: record. Already-done test: it is non-empty.
394
+ local rec
395
+ rec=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null | jq -r '.delivered_raw // ""' 2>/dev/null)
396
+ if [ -n "$rec" ]; then
397
+ record_report="already"
398
+ else
399
+ if append_delivered_line "$f" "$today"; then
400
+ record_report="written"
401
+ else
402
+ echo "plot-deliver: $rel has no '## Status' section — nowhere to record the delivery" >&2
403
+ return 1
404
+ fi
405
+ fi
406
+
407
+ # Step 5 — move the index symlink (best effort).
408
+ index_report=$(move_index_symlink "$root")
409
+
410
+ # Step 6 — update the sprint annotation.
411
+ sprint_report=$(update_sprint_annotation "$root")
412
+ return 0
413
+ }
414
+
415
+ phase_report="" record_report="" index_report="skipped" sprint_report="none"
416
+ push_report="n/a"
417
+
418
+ # Fetch and create a booking worktree off origin/<default>.
419
+ git fetch -q origin "$MAIN" 2>/dev/null
420
+
421
+ bookbr="plot/deliver-$slug"
422
+ tmpwt="$wt_root/.plot-deliver-$slug.$$"
423
+ # -B: a leftover branch from an earlier failed run must not block this one.
424
+ git worktree add -q -B "$bookbr" "$tmpwt" "origin/$MAIN" 2>/dev/null \
425
+ || die "could not prepare a booking worktree at $tmpwt"
426
+
427
+ cleanup() {
428
+ git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
429
+ git branch -D "$bookbr" >/dev/null 2>&1 || true
430
+ }
431
+
432
+ if ! apply_local_writes "$tmpwt"; then
433
+ cleanup
434
+ exit 1
435
+ fi
436
+
437
+ # Stage the plan file and index changes.
438
+ git -C "$tmpwt" add -- "$rel" >/dev/null 2>&1 || true
439
+ git -C "$tmpwt" add -- "${ACTIVE_DIR#/}" >/dev/null 2>&1 || true
440
+ git -C "$tmpwt" add -- "${DELIVERED_DIR#/}" >/dev/null 2>&1 || true
441
+ [ "$sprint_report" = "updated" ] && git -C "$tmpwt" add -- "${SPRINT_DIR#/}" >/dev/null 2>&1
442
+
443
+ if git -C "$tmpwt" diff --cached --quiet 2>/dev/null; then
444
+ # THE IDEMPOTENT EXIT. Everything this run would have written was already
445
+ # on the default branch, so there is nothing to push and nothing wrong.
446
+ push_report="nothing-to-commit"
447
+ echo "step: nothing to commit — the delivery is already recorded on $MAIN"
448
+ cleanup
449
+ else
450
+ if ! git -C "$tmpwt" -c "user.name=$who" commit -q -m "plot: deliver $slug"; then
451
+ cleanup
452
+ die "could not commit the delivery"
453
+ fi
454
+
455
+ push_out=$(bash "$script_dir/plot-push-main.sh" "$bookbr" "$MAIN" 2>&1)
456
+ push_rc=$?
457
+ printf '%s\n' "$push_out" | sed 's/^/ /'
458
+ if [ "$push_rc" = 0 ]; then
459
+ push_report=$(printf '%s' "$push_out" | sed -n 's/^push: \([a-z]*\).*/\1/p' | head -1)
460
+ [ -n "$push_report" ] || push_report="unknown"
461
+ cleanup
462
+ else
463
+ # BRANCH PROTECTION FALLBACK — open a micro-PR if push is rejected.
464
+ echo "step: push rejected — opening a micro-PR instead"
465
+ if git push -q origin "$bookbr" 2>/dev/null \
466
+ && micro_url=$(bash "$script_dir/plot-host.sh" pr-create \
467
+ --title "plot: deliver $slug" \
468
+ --body "Records the delivery of \`$slug\`." \
469
+ --base "$MAIN" --head "$bookbr" 2>/dev/null) \
470
+ && micro_num=$(printf '%s' "$micro_url" | sed 's#.*/##') \
471
+ && bash "$script_dir/plot-host.sh" pr-merge "$micro_num" --delete-branch >/dev/null 2>&1
472
+ then
473
+ push_report="micro-pr"
474
+ echo "step: delivery landed via micro-PR $micro_url"
475
+ cleanup
476
+ else
477
+ push_report="rejected"
478
+ echo "plot-deliver: the delivery is committed on '$bookbr' but could not reach $MAIN." >&2
479
+ echo " Land '$bookbr' by hand, or re-run this command once the push works." >&2
480
+ git worktree remove --force "$tmpwt" >/dev/null 2>&1 || true
481
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
482
+ exit 1
483
+ fi
484
+ fi
485
+ fi
486
+
487
+ echo "summary: phase=$phase_report record=$record_report index=$index_report sprint=$sprint_report push=$push_report"
488
+ exit 0