@plot-pm/board 0.8.1 → 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.
- package/dist/board-server.mjs +120 -111
- package/package.json +11 -2
- package/plot-approve.sh +628 -0
- package/plot-config.sh +14 -0
- package/plot-deliver.sh +488 -0
- package/plot-dispatch.sh +2028 -0
- package/plot-fleet-scan.sh +3496 -0
- package/plot-host.sh +1686 -0
- package/plot-plan-meta.sh +133 -16
- package/plot-reap.sh +286 -0
- package/plot-release-refs.sh +234 -0
- package/plot-resolve-artifact.sh +334 -0
- package/plot-worker-state.sh +725 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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-
|
|
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",
|
package/plot-approve.sh
ADDED
|
@@ -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
|