@plot-pm/board 0.10.0 → 0.12.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/plot-deliver.sh CHANGED
@@ -10,7 +10,7 @@
10
10
  # run did the work or found it already done); 1 on a refusal or a
11
11
  # failure, with the reason on stderr.
12
12
  #
13
- # WHY THIS EXISTS. The board computes `allWavesMerged` — exactly the condition
13
+ # WHY THIS EXISTS. The board computes `allSlicesMerged` — exactly the condition
14
14
  # that says a plan is ready to deliver — but the transition itself lives only in
15
15
  # `/plot-deliver`'s prose. So `Delivered` in the board asked for a caller with
16
16
  # nothing safe to call, and an implementer reaching that point would have
@@ -116,105 +116,64 @@ case "$phase" in
116
116
  esac
117
117
 
118
118
  # ---------------------------------------------------------------------------
119
- # Step 2 — verify all non-deferred branches are merged
119
+ # Step 2 — ask whether every non-deferred branch has merged
120
120
  # ---------------------------------------------------------------------------
121
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.
122
+ # This is one of Plot's four phase guardrails, and it is NO LONGER DECIDED HERE.
139
123
  #
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
124
+ # THE SCRIPT USED TO PARSE THE PLAN ITSELF. Ninety-odd lines of `sed` and `grep`
125
+ # re-derived the branches section, re-derived which lines named a branch, and
126
+ # re-derived which of those were deferred a second implementation of a job
127
+ # `plot-plan-meta.sh` already owns, with its own bug history. The measured ones:
128
+ # a `## Changelog` bullet read as a branch (four fully-merged plans undeliverable,
129
+ # 2026-08-27), and a `## Slices` section that parsed to ZERO branches so the gate
130
+ # passed having checked nothing (two approved plans, 2026-08-30).
131
+ #
132
+ # A THIRD was measured while this block was being removed, and it is the one
133
+ # that argues hardest for the removal. On
134
+ # `docs/plans/2026-08-21-waves-name-themselves.md` the old range matched a
135
+ # `## Waves` heading that opens DESIGN PROSE — the plan argues for that spelling
136
+ # and illustrates it — and closed at the next `## `, never reaching the real
137
+ # `## Branches` section a hundred lines below. It read three branch names out of
138
+ # the illustration, one of them `bug/one` from inside a code fence, and reported
139
+ # none of the plan's actual work. `plot-plan-meta.sh` reads the same file
140
+ # correctly. That plan was delivered by hand.
141
+ #
142
+ # So the reading stays in the scripts and the DECISION moves inside:
143
+ # `plot-plan-meta.sh` says which branches the plan names and which it gave up,
144
+ # `plot-impl-status.sh` says which the host merged, and the domain's `deliver`
145
+ # rule says whether that makes the plan deliverable. Manifesto Principle 3
146
+ # scripts collect and report — with the line drawn where the design plan draws
147
+ # it: parsing a plan is adaptation, "these branches make the plan deliverable"
148
+ # is a decision.
149
+ #
150
+ # THE REFUSAL SENTENCE COMES BACK FROM THE DOMAIN, unchanged in wording, so the
151
+ # two cannot describe one rule differently.
184
152
 
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
153
+ ask_mjs="$script_dir/board/plot-ask.mjs"
154
+ if [ ! -f "$ask_mjs" ]; then
155
+ die "cannot find $ask_mjs run 'pnpm build:board' to build it."
156
+ fi
203
157
 
204
- unmerged_branches=$(printf '%s' "$unmerged_branches" | grep -v '^$' || true)
158
+ verdict=$(PLOT_REPO_ROOT="$repo_root" PLOT_SCRIPTS_DIR="$script_dir" \
159
+ node "$ask_mjs" deliverable "$slug" "$plan_file" 2>/dev/null) || verdict=""
160
+ [ -n "$verdict" ] || die "cannot determine deliverability of '$slug' — refusing rather than guessing."
205
161
 
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
162
+ vfield() { printf '%s' "$verdict" | jq -r "$1" 2>/dev/null; }
212
163
 
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
164
+ if [ "$(vfield '.deliverable')" != "true" ]; then
165
+ die "$(vfield '.refusal')"
166
+ fi
217
167
 
168
+ merged_count=$(vfield '.merged')
169
+ deferred_count=$(vfield '.deferred')
170
+ # Empty rather than `0`, so the suffix below stays absent where the old block
171
+ # left it absent — `${x:+...}` fires on any non-empty value, and "0 deferred"
172
+ # is a line no run has ever printed.
173
+ [ "$deferred_count" = "0" ] && deferred_count=""
174
+ if [ "$merged_count" = "0" ] && [ -z "$deferred_count" ]; then
175
+ echo "step: no branches found in plan — proceeding (nothing to verify)"
176
+ else
218
177
  echo "step: verified $merged_count branch(es) merged${deferred_count:+, $deferred_count deferred}"
219
178
  fi
220
179
 
@@ -267,13 +226,18 @@ rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
267
226
  # The filename, for symlink creation.
268
227
  plan_basename=$(basename "$rel")
269
228
 
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"
229
+ # Flip `**State:** Approved` → `Delivered` in the `## Status` section only.
230
+ # Reads `State:` and `Phase:` alike: it changes the VALUE on whichever line
231
+ # carries it, so a plan written before the 2026-09-07 rename still delivers.
232
+ #
233
+ # READS ONE FILE AND WRITES ANOTHER, rather than editing in place. It edited in
234
+ # place until 2026-09-02, which is what let the phase land without its record —
235
+ # see write_transition() below.
236
+ flip_phase() { # $1=in $2=out → 0 if it changed the file, 1 if nothing to flip
273
237
  awk '
274
238
  BEGIN { section = ""; done = 0 }
275
239
  /^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
276
- section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ {
240
+ section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**(state|phase)[:*]/ {
277
241
  if (tolower($0) ~ /approved/) {
278
242
  sub(/[Aa]pproved/, "Delivered")
279
243
  done = 1
@@ -282,17 +246,35 @@ flip_phase() { # $1=file → 0 if it changed the file, 1 if nothing to flip
282
246
  }
283
247
  { print }
284
248
  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"
249
+ ' "$1" > "$2"
289
250
  }
290
251
 
291
252
  # Insert one `- **Delivered:** YYYY-MM-DD` line into the plan's `## Status` section.
292
253
  # 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"
254
+ #
255
+ # IT STOPS AT AN HTML COMMENT, and that is the whole of the fix made 2026-09-01.
256
+ # The plan template ends `## Status` with
257
+ #
258
+ # <!-- Transition records — written by the workflow commands, not by hand:
259
+ # - **Started:** <date>, <who>, <branch>
260
+ # -->
261
+ #
262
+ # and those `- **Started:**` lines ARE list items. The scan tracked the last one
263
+ # it saw, so on any plan whose `Started:` records had been filled in, the record
264
+ # was appended INSIDE the comment — inert, and invisible to
265
+ # `plot-plan-meta.sh`, which reported `delivered_raw: ""` for a plan that had
266
+ # just been delivered. Measured on `a-browser-test-serves-its-own-state`: phase
267
+ # flipped to Delivered, two `Delivered:` lines written into the comment (the
268
+ # script is idempotent on the PHASE, so a second run wrote a second line), and
269
+ # no readable record at all.
270
+ #
271
+ # A comment is where a plan keeps the shape of a record rather than a record, so
272
+ # the insertion point is the last list item BEFORE one — never inside.
273
+ #
274
+ # READS ONE FILE AND WRITES ANOTHER, for the reason flip_phase() gives.
275
+ append_delivered_line() { # $1=in $2=out $3=record
276
+ local line
277
+ line="- **Delivered:** $3"
296
278
  awk -v line="$line" '
297
279
  { lines[++n] = $0 }
298
280
  END {
@@ -304,8 +286,11 @@ append_delivered_line() { # $1=file $2=date
304
286
  insert = start
305
287
  for (i = start + 1; i <= n; i++) {
306
288
  if (lines[i] ~ /^##[ \t]/) break
289
+ # An HTML comment ends the writable region. Checked BEFORE the
290
+ # placeholder arms so a commented-out `- **Delivered:**` template line
291
+ # is never mistaken for the slot to fill.
292
+ if (lines[i] ~ /<!--/) break
307
293
  if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Delivered:\*\*[ \t]*$/) { slot = i; break }
308
- if (lines[i] ~ /^[ \t]*[-*][ \t]*\*\*Delivered:\*\*[ \t]*<!--/) { slot = i; break }
309
294
  if (lines[i] ~ /^[ \t]*[-*][ \t]/) insert = i
310
295
  }
311
296
 
@@ -315,8 +300,116 @@ append_delivered_line() { # $1=file $2=date
315
300
  if (!slot && i == insert) print line
316
301
  }
317
302
  }
318
- ' "$f" > "$f.plot-tmp" || { rm -f "$f.plot-tmp"; return 1; }
319
- mv "$f.plot-tmp" "$f"
303
+ ' "$1" > "$2"
304
+ }
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # THE TRANSITION — one value, decided in the domain, written whole or not at all
308
+ # ---------------------------------------------------------------------------
309
+ #
310
+ # WHAT THIS CLOSES. The phase and the record were two independent steps until
311
+ # 2026-09-02: `flip_phase` edited the file, and `append_delivered_line` then
312
+ # edited it again. The second could fail — a plan with no `## Status` heading is
313
+ # its documented refusal — and the first had already landed. That is not
314
+ # theoretical: measured 2026-08-20, a plan carrying `Phase: Delivered` with no
315
+ # `Delivered:` line was filtered out of `plot-fleet-scan.sh` ENTIRELY, which
316
+ # reads its delivered window from `delivered_raw` — the record itself. The scan
317
+ # reported zero plans for it. A phase without its record does not make a plan
318
+ # half-delivered; it makes it invisible.
319
+ #
320
+ # SO THE TWO ARE ONE WRITE. The domain says so in a type: `transitions/plan.ts`
321
+ # gives `Decision` a required `phase` AND a required `record`, so a decision
322
+ # missing either does not compile. `plot-transition.mjs` carries that value out
323
+ # to here, and this function is the half that could still have broken it —
324
+ # both edits run against scratch copies, and the plan file is replaced only
325
+ # once both have succeeded.
326
+ #
327
+ # THE DOMAIN DECIDES, THIS PERFORMS. The phase word and the record's text come
328
+ # back from `plot-transition.mjs`; the awk that knows where a `## Status` line
329
+ # lives stays here, because that is adaptation and it carries bug history worth
330
+ # keeping (the HTML-comment fix above, 2026-09-01).
331
+ #
332
+ # IDEMPOTENCE IS UNCHANGED, and it is still the source that answers. The domain
333
+ # reads the phase and the record THIS SCRIPT PARSED FROM THE FILE IT IS ABOUT TO
334
+ # WRITE, and answers `already` when both are present — the same question the two
335
+ # steps asked separately, asked once. No progress file appears here, because a
336
+ # progress file is exactly what would disagree with the repository when somebody
337
+ # intervened by hand between two runs.
338
+ transition_mjs="$script_dir/board/plot-transition.mjs"
339
+
340
+ # Ask the domain for the transition, and refuse in its words.
341
+ #
342
+ # Called with the file's OWN parse rather than the caller's: on the booking-
343
+ # worktree flow those are different files, and the plan on the default branch is
344
+ # the one that counts.
345
+ decide_transition() { # $1=file → prints "<Phase>\t<record>\t<write|already>"
346
+ local f="$1" m answer rc
347
+ [ -f "$transition_mjs" ] \
348
+ || { echo "plot-deliver: cannot find $transition_mjs — run 'pnpm build:board'." >&2; return 1; }
349
+ m=$(bash "$script_dir/plot-plan-meta.sh" "$f" 2>/dev/null) || m=""
350
+ [ -n "$m" ] || { echo "plot-deliver: cannot parse $f — refusing rather than guessing." >&2; return 1; }
351
+ answer=$(printf 'deliver\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t\t\t\n' \
352
+ "$slug" \
353
+ "$(printf '%s' "$m" | jq -r '.phase // ""')" \
354
+ "$(printf '%s' "$m" | jq -r '.review // ""')" \
355
+ "$(printf '%s' "$m" | jq -r '.approved_raw // ""')" \
356
+ "$(printf '%s' "$m" | jq -r '.delivered_raw // ""')" \
357
+ "$(printf '%s' "$m" | jq -r '.released_raw // ""')" \
358
+ "$today" \
359
+ | node "$transition_mjs" 2>&1)
360
+ rc=$?
361
+ # Exit 1 is the domain's refusal and its sentence, tab-separated after the
362
+ # rule that fired. Exit 2 is this script handing it something unreadable,
363
+ # which no operator can act on — so it reports as the bug it is.
364
+ if [ "$rc" != 0 ]; then
365
+ if [ "$rc" = 1 ]; then
366
+ echo "plot-deliver: $(printf '%s' "$answer" | cut -f2-)" >&2
367
+ else
368
+ echo "plot-deliver: $answer" >&2
369
+ fi
370
+ return 1
371
+ fi
372
+ printf '%s' "$answer"
373
+ }
374
+
375
+ # Apply the decided transition: the phase and the record, or neither.
376
+ #
377
+ # BOTH EDITS RUN AGAINST SCRATCH FILES and the plan is replaced by one `mv`.
378
+ # An `## Status` section that cannot take the record leaves the file exactly as
379
+ # it was found — INCLUDING ITS PHASE — so re-running is still the repair, and
380
+ # the half-state that made a plan invisible cannot be reached from here.
381
+ #
382
+ # `$3` says whether the file already carries the record. A plan can carry one
383
+ # while its phase lags — written by hand, or an earlier run cut between the two
384
+ # writes — and appending a second is how one plan came to hold two `Delivered:`
385
+ # lines (2026-09-01). The domain returns the written record unchanged in that
386
+ # case, so the phase still flips and nothing is inserted.
387
+ write_transition() { # $1=file $2=record $3=recorded(yes|no) → sets phase_report record_report
388
+ local f="$1" record="$2" recorded="$3" a="$1.plot-phase" b="$1.plot-record" flipped=0
389
+
390
+ if flip_phase "$f" "$a"; then flipped=1; else flipped=0; fi
391
+ # awk wrote `$a` either way; where nothing flipped it is a faithful copy, so
392
+ # the record still has a file to be inserted into.
393
+ [ -s "$a" ] || { rm -f "$a"; echo "plot-deliver: could not read $rel" >&2; return 1; }
394
+
395
+ if [ "$recorded" = "yes" ]; then
396
+ mv "$a" "$f" || { rm -f "$a"; return 1; }
397
+ record_report="already"
398
+ else
399
+ if ! append_delivered_line "$a" "$b" "$record"; then
400
+ rm -f "$a" "$b"
401
+ echo "plot-deliver: $rel has no '## Status' section — nowhere to record the delivery." >&2
402
+ echo " Nothing was written: the phase is not flipped either, because a phase" >&2
403
+ echo " with no record is invisible to the scan. Fix the section and re-run." >&2
404
+ return 1
405
+ fi
406
+ mv "$b" "$f" || { rm -f "$a" "$b"; return 1; }
407
+ rm -f "$a"
408
+ record_report="written"
409
+ fi
410
+
411
+ phase_report=$([ "$flipped" = 1 ] && echo flipped || echo already)
412
+ return 0
320
413
  }
321
414
 
322
415
  # Update the sprint item annotation for this plan.
@@ -387,21 +480,23 @@ apply_local_writes() { # $1=root → sets phase_report record_report index_repo
387
480
  local root="$1" f="$1/$rel"
388
481
  [ -f "$f" ] || { echo "plot-deliver: $rel is not present in $root" >&2; return 1; }
389
482
 
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
483
+ # Step 3 — THE TRANSITION: the phase and its record, together or not at all.
484
+ #
485
+ # ONE STEP WHERE THERE WERE TWO, which is the whole of this change. The domain
486
+ # decides what the two lines say and whether they are owed at all; this writes
487
+ # them as one replacement of the file. Already-done test: the domain answers
488
+ # `already` when the file it is about to write ALREADY carries both, which is
489
+ # the source asked directly — never a progress file.
490
+ local decided record action recorded
491
+ decided=$(decide_transition "$f") || return 1
492
+ record=$(printf '%s' "$decided" | cut -f2)
493
+ action=$(printf '%s' "$decided" | cut -f3)
494
+ recorded=$(printf '%s' "$decided" | cut -f4)
495
+ if [ "$action" = "already" ]; then
496
+ phase_report="already"
397
497
  record_report="already"
398
498
  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
499
+ write_transition "$f" "$record" "$recorded" || return 1
405
500
  fi
406
501
 
407
502
  # Step 5 — move the index symlink (best effort).