@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-plan-meta.sh CHANGED
@@ -20,9 +20,17 @@
20
20
  # Two plan formats are recognized:
21
21
  #
22
22
  # canonical the plan template's `## Status` body section:
23
- # - **Phase:** Approved
23
+ # - **State:** Approved
24
24
  # - **Type:** feature
25
- # (bullet, bold, and plain `Phase: ...` variants all accepted)
25
+ # (bullet, bold, and plain `State: ...` variants all accepted)
26
+ #
27
+ # `State:` is the field Plot writes. `Phase:` is the name it
28
+ # carried until 2026-09-07 and is read as the alternate, the
29
+ # same way front matter reads `status:` over `phase:` below.
30
+ # THE DUAL READ IS PERMANENT, NOT SCAFFOLDING: a plan file may
31
+ # have been written a year ago or copied from another project,
32
+ # and a Plot that refused to read `Phase:` would be worse at its
33
+ # own job than the one that confused two words.
26
34
  #
27
35
  # frontmatter YAML front matter at the top of the file:
28
36
  # ---
@@ -88,7 +96,8 @@
88
96
  # which is a queue. (`ready-for-review`/`in-review` DO
89
97
  # normalize onto `approved` — those are synonyms; this is not.)
90
98
  # phase_alt_raw secondary value when the file carries two (front matter
91
- # status: AND phase:), else ""
99
+ # status: AND phase:, or a `## Status` body carrying both
100
+ # State: AND Phase:), else ""
92
101
  # phase_alt normalized phase_alt_raw (NONE when absent)
93
102
  # type normalized plan type (feature|bug|docs|infra or "")
94
103
  # title plan title: front matter `title:` wins, else the first H1
@@ -120,6 +129,27 @@
120
129
  # `<!-- deferred -->` (bare, no colon) sets the flag with no
121
130
  # reason; `waves[].branches[].deferred_reason` carries the
122
131
  # sentence after the colon, "" where none was written.
132
+ # `<!-- waits: bug/other -->` names ONE branch this branch
133
+ # waits on, reported as `waves[].branches[].waits_on`. The key
134
+ # is ABSENT where no annotation was written — never "" — and
135
+ # the value is a branch name in this repo, not a plan slug and
136
+ # not a cross-repo reference. The parser reports what the file
137
+ # says: a prerequisite no plan declares still parses, and the
138
+ # scan is what turns that into a verdict. `waits:` and
139
+ # `deferred:` are independent — a branch may carry both.
140
+ # `<!-- builds: normalizeVersion, a shared helper -->` names
141
+ # what this slice BUILDS, reported as
142
+ # `waves[].branches[].builds`. OPTIONAL, like `Sprint:` and
143
+ # `Story:` — a docs plan, a rejection or a measurement builds
144
+ # nothing nameable, and nothing warns about its absence. The
145
+ # key is ABSENT where none was written, never "". The value
146
+ # runs to the closing marker rather than stopping at the first
147
+ # space the way `waits:` does: a prerequisite is a branch NAME
148
+ # and a deliverable is a name plus enough words to search for.
149
+ # An annotation rather than a `Builds:` field line precisely
150
+ # BECAUSE annotations already work in both slice dialects from
151
+ # one block of code — a field line would need two spellings,
152
+ # and the template writes the list dialect.
123
153
  # prs PR numbers, sorted and unique, read from EITHER spelling:
124
154
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
125
155
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -291,6 +321,34 @@ function val_after_colon(s) {
291
321
  }
292
322
  # Template placeholders like "<!-- optional -->" mean "field absent".
293
323
  function strip_placeholder(s) { return (s ~ /^<!--/) ? "" : s }
324
+ # Blank out backtick-delimited inline code, so a marker PRINTED as a literal is
325
+ # not read as syntax. Markdown renders `<!--` between backticks as the four
326
+ # characters; this parser used to read it as a comment-open, and because such a
327
+ # line carries no closing marker it swallowed the rest of the file — one
328
+ # backticked marker in a summary line cost a plan its phase, type and branches.
329
+ #
330
+ # Only the comment rules consult this. The line itself is untouched everywhere
331
+ # else, because branch names live in backticks too and stripping them for real
332
+ # would empty every `## Branches` entry.
333
+ #
334
+ # Runs of backticks are matched longest-first so a ``code`` span closes against
335
+ # its own delimiter. An UNPAIRED backtick leaves its tail as-is: that is prose
336
+ # with a stray tick, and prose is exactly where a real comment may open.
337
+ # NOTE: the closing-run index is named `shut` because `close` is an awk builtin
338
+ # and cannot be a parameter name — it fails as a syntax error on the function
339
+ # signature, pointing nowhere near the cause.
340
+ function mask_code(s, out, n, tick, shut) {
341
+ out = ""
342
+ while ((n = index(s, "`")) > 0) {
343
+ out = out substr(s, 1, n - 1)
344
+ s = substr(s, n)
345
+ tick = ""
346
+ while (substr(s, 1, 1) == "`") { tick = tick "`"; s = substr(s, 2) }
347
+ if ((shut = index(s, tick)) == 0) return out tick s
348
+ s = substr(s, shut + length(tick))
349
+ }
350
+ return out s
351
+ }
294
352
  # First known phase token wins; NONE if empty; UNKNOWN otherwise.
295
353
  function norm_phase(raw, lower, toks, n, i, t) {
296
354
  if (raw == "") return "NONE"
@@ -346,7 +404,7 @@ function reset_state() {
346
404
  fm_review = ""; fm_impl = ""; fm_approved = ""; fm_started = ""; fm_released = ""
347
405
  fm_delivered = ""; fm_design = ""
348
406
  fm_rounds = ""
349
- canon_phase = ""; canon_type = ""
407
+ canon_state = ""; canon_phase = ""; canon_type = ""
350
408
  canon_sprint = ""; canon_story = ""; canon_assignee = ""
351
409
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
352
410
  canon_delivered = ""; canon_design = ""
@@ -356,7 +414,7 @@ function reset_state() {
356
414
  # the field, so a consumer cannot mistake "never interrogated" for "asked
357
415
  # nothing".
358
416
  block_rounds = ""
359
- in_fm = 0; section = ""; in_comment = 0; in_challenge = 0; in_fence = 0; branches_seen = 0; waves_seen = 0
417
+ in_fm = 0; section = ""; in_comment = 0; in_challenge = 0; in_fence = 0; slices_seen = 0; slice_shape = ""
360
418
  delete branches; n_branches = 0
361
419
  delete prs; n_prs = 0
362
420
  delete malformed_prs; n_malformed_prs = 0
@@ -364,6 +422,8 @@ function reset_state() {
364
422
  delete issues; n_issues = 0
365
423
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
366
424
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
425
+ delete waits_of; delete waits_set
426
+ delete builds_of; delete builds_set
367
427
  delete started; n_started = 0
368
428
  fm_changelog = ""
369
429
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -374,8 +434,14 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
374
434
  praw = (fm_status != "") ? fm_status : fm_phase
375
435
  palt_raw = (fm_status != "" && fm_phase != "") ? fm_phase : ""
376
436
  traw = fm_type
377
- } else if (canon_phase != "") {
378
- fmt = "canonical"; praw = canon_phase; palt_raw = ""; traw = canon_type
437
+ } else if (canon_state != "" || canon_phase != "") {
438
+ # `State:` is primary and `Phase:` the alternate, exactly as front matter
439
+ # reads `status:` over `phase:`. A file carrying both reports the
440
+ # disagreement rather than hiding it.
441
+ fmt = "canonical"
442
+ praw = (canon_state != "") ? canon_state : canon_phase
443
+ palt_raw = (canon_state != "" && canon_phase != "") ? canon_phase : ""
444
+ traw = canon_type
379
445
  } else {
380
446
  fmt = "none"; praw = ""; palt_raw = ""; traw = ""
381
447
  }
@@ -508,7 +574,17 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
508
574
  if (wave_of[i] != w) continue
509
575
  out = out (first ? "" : ",") "{\"branch\":\"" jesc(ordered_b[i]) "\",\"deferred\":" deferred_of[i] \
510
576
  ",\"deferred_reason\":\"" jesc(deferred_why[i]) "\"" \
511
- ",\"claimed\":\"" jesc(claimed_of[i]) "\"}"
577
+ ",\"claimed\":\"" jesc(claimed_of[i]) "\""
578
+ # ABSENT, NOT EMPTY, where no prerequisite was declared. The key appears
579
+ # only on a branch whose line carries a `waits:` annotation, so a consumer
580
+ # reading `waits_on` gets a branch name or nothing — never a blank string
581
+ # that reads as a prerequisite with no name.
582
+ if (waits_set[i] == 1) out = out ",\"waits_on\":\"" jesc(waits_of[i]) "\""
583
+ # ABSENT, NOT EMPTY, for the same reason `waits_on` is: a slice that
584
+ # names no deliverable emits no key, so a consumer reads a name or
585
+ # nothing. An empty string would read as a deliverable called "".
586
+ if (builds_set[i] == 1) out = out ",\"builds\":\"" jesc(builds_of[i]) "\""
587
+ out = out "}"
512
588
  first = 0
513
589
  }
514
590
  out = out "]}"
@@ -612,6 +688,10 @@ in_fm {
612
688
  else if (lower ~ /^rounds:/ && fm_rounds == "") fm_rounds = val_after_colon($0)
613
689
  next
614
690
  }
691
+ # A fence marker is a line whose first non-space run is ``` or ~~~ (an info
692
+ # string like ```markdown may follow). The marker line itself is never content.
693
+ /^[ \t]*(```|~~~)/ { in_fence = !in_fence; next }
694
+ in_fence { next }
615
695
  # Interior of multi-line HTML comments is non-content (template guidance
616
696
  # blocks); single-line "<!-- ... -->" placeholders are unaffected.
617
697
  #
@@ -631,13 +711,25 @@ in_comment {
631
711
  if ($0 ~ /-->/) { in_comment = 0; in_challenge = 0 }
632
712
  next
633
713
  }
634
- /<!--/ && $0 !~ /-->/ {
635
- in_comment = 1
636
- # A truncated block never closes; it simply runs to EOF as a comment, and the
637
- # round stays whatever was read before the truncation — absent if the "round"
638
- # line was itself lost. Nothing else in the record is affected either way.
639
- in_challenge = ($0 ~ /CHALLENGE-THE-PLAN-METADATA/) ? 1 : 0
640
- next
714
+ # A comment opens only where the marker is SYNTAX. Inside inline code it is a
715
+ # literal being quoted, and the fence rules above have already consumed code
716
+ # blocks so what reaches here is prose, minus its backticked spans.
717
+ #
718
+ # The cheap test guards the expensive one: /<!--/ on the raw line rejects the
719
+ # overwhelming majority, so mask_code runs once per line that actually carries a
720
+ # marker rather than twice per line of every plan. The masked verdict is decided
721
+ # INSIDE the rule so a line that turns out to be inline code simply falls
722
+ # through to the ordinary content rules below, carrying no state with it.
723
+ /<!--/ {
724
+ _masked = mask_code($0)
725
+ if (_masked ~ /<!--/ && _masked !~ /-->/) {
726
+ in_comment = 1
727
+ # A truncated block never closes; it simply runs to EOF as a comment, and
728
+ # the round stays whatever was read before the truncation — absent if the
729
+ # "round" line was itself lost. Nothing else in the record is affected.
730
+ in_challenge = ($0 ~ /CHALLENGE-THE-PLAN-METADATA/) ? 1 : 0
731
+ next
732
+ }
641
733
  }
642
734
  # A fenced code block is illustration, never contract — the same standing rule
643
735
  # comment interiors and repeated headings already follow. A plan that documents
@@ -651,30 +743,34 @@ in_comment {
651
743
  # example won its first-heading-wins guard and hid the real section). Toggling on
652
744
  # a fence fence-marker line closes both.
653
745
  #
654
- # A fence marker is a line whose first non-space run is ``` or ~~~ (an info
655
- # string like ```markdown may follow). The marker line itself is never content.
656
- /^[ \t]*(```|~~~)/ { in_fence = !in_fence; next }
657
- in_fence { next }
658
746
  # First H1 is the title fallback (front matter title: still wins in emit).
659
747
  /^#[ \t]/ && h1_title == "" { h1_title = trim(substr($0, 2)) }
660
748
  /^## / {
661
749
  if ($0 ~ /^## Status/) section = "status"
662
- # First `## Branches` wins: a plan documenting the plan format quotes the
750
+ # THE HEADING WORD NO LONGER PICKS THE LAYOUT. `## Branches`, `## Waves` and
751
+ # `## Slices` open ONE section, and which shape it holds is decided by the
752
+ # first `### ` heading in it — see `slice_shape` below.
753
+ #
754
+ # THEY USED TO SELECT IT, AND THAT MADE THE RENAME UNSAFE. The two layouts are
755
+ # genuinely different: the old one carries the branch on a LIST ITEM
756
+ # (`- ` + backtick + `feature/x` + backtick + ` -> #72`), the new one carries it in the
757
+ # heading (`### Name (Branch: feature/x, PR: #577)`). With the word choosing
758
+ # the consumer, renaming a heading silently changed which grammar was applied.
759
+ # Measured 2026-09-04 on `2026-08-14-parallel-agent-fleet.md`: renaming its
760
+ # `## Branches` to `## Slices` took it from **6 branches to 0**, with no error
761
+ # anywhere — the file still parsed, and simply held nothing.
762
+ #
763
+ # Deciding by shape makes the heading a NAME again. All three spellings read
764
+ # either layout, so a plan can be renamed without being rewritten, and the 48
765
+ # files still saying `## Branches` are safe to migrate a word at a time.
766
+ #
767
+ # First one wins, as before: a plan documenting the plan format quotes the
663
768
  # section in prose, and those later headings are illustration, not contract.
664
- else if ($0 ~ /^## Branches/) { section = branches_seen ? "" : "branches"; branches_seen = 1 }
665
- # `## Waves` is the new spelling: the branch and PR live in the `### ` heading,
666
- # the line below is prose. First one wins, for the same reason `## Branches`
667
- # does. A plan carries one or the other — but the parser reads both while the
668
- # migration moves 85 files, so a file moved one commit early never reads
669
- # as silently empty.
670
- # `## Slices` is the spelling the design spec uses, and `## Waves` is what 132
671
- # plans already say. They are ONE section here, sharing waves_seen, because the
672
- # shape is identical: the branch and PR ride the `### ` heading either way. A
673
- # third arm would be a second implementation of a re-spelling, and the two
674
- # would drift. No existing plan is rewritten to say Slices — a delivered plan
675
- # describes what was built in the vocabulary of its day, and churning 132
676
- # files git blame for a word buys nothing. New plans may use either.
677
- else if ($0 ~ /^## Waves/ || $0 ~ /^## Slices/) { section = waves_seen ? "" : "waves"; waves_seen = 1 }
769
+ else if ($0 ~ /^## Branches/ || $0 ~ /^## Waves/ || $0 ~ /^## Slices/) {
770
+ section = slices_seen ? "" : "slices"
771
+ slices_seen = 1
772
+ slice_shape = ""
773
+ }
678
774
  else if ($0 ~ /^## Approval/) section = "approval"
679
775
  # First `## Changelog` wins, for the same reason `## Branches` does: a plan
680
776
  # about the plan format quotes the section in prose, and the later heading is
@@ -685,18 +781,43 @@ in_fence { next }
685
781
  }
686
782
  section == "status" {
687
783
  lower = tolower($0)
688
- if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
784
+ if (lower ~ /^[ \t]*[-*]?[ \t]*\**state[:*]/ && canon_state == "") canon_state = val_after_colon($0)
785
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
689
786
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**type[:*]/ && canon_type == "") canon_type = val_after_colon($0)
690
787
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**sprint[:*]/ && canon_sprint == "") canon_sprint = val_after_colon($0)
691
788
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**story[:*]/ && canon_story == "") canon_story = val_after_colon($0)
692
789
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
693
790
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
694
791
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
695
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = val_after_colon($0)
792
+ # EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
793
+ #
794
+ # A plan may hold both a record and an unfilled placeholder for the same
795
+ # field, on either side of each other. Seven plans here write the placeholder
796
+ # as a per-line comment —
797
+ #
798
+ # - **Delivered:** <!-- YYYY-MM-DD -->
799
+ #
800
+ # — beside a real `- **Delivered:** 2026-09-01`. `strip_placeholder` used to
801
+ # run in `emit_record`, AFTER first-wins had already chosen: the placeholder
802
+ # claimed the slot, was emptied a moment later, and the record it beat was
803
+ # gone. Whether a plan parsed correctly then depended on which line came
804
+ # first, and `append_delivered_line` decides that by where a `<!--` happens
805
+ # to stop its scan.
806
+ #
807
+ # A DELIVERED PLAN THAT READS `delivered_raw: ""` IS INVISIBLE TO THE SCAN,
808
+ # which reads its rolling window from that field — the same failure the
809
+ # comment-block fix addressed, reached by a different road. Measured
810
+ # 2026-09-01 on `a-machine-is-an-instance`: parsed correctly, but only
811
+ # because its record sat two lines above its placeholder.
812
+ #
813
+ # `started` never had this bug because it is a list and had to filter at
814
+ # capture to avoid storing empties. These four are scalars, and now do the
815
+ # same: a placeholder no longer counts as a value, so ORDER STOPS MATTERING.
816
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = strip_placeholder(val_after_colon($0))
696
817
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**rounds[:*]/ && canon_rounds == "") canon_rounds = val_after_colon($0)
697
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = val_after_colon($0)
698
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = val_after_colon($0)
699
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = val_after_colon($0)
818
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = strip_placeholder(val_after_colon($0))
819
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = strip_placeholder(val_after_colon($0))
820
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = strip_placeholder(val_after_colon($0))
700
821
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**started[:*]/) {
701
822
  _s = strip_placeholder(val_after_colon($0))
702
823
  if (_s != "") started[++n_started] = _s
@@ -749,7 +870,23 @@ section == "changelog" {
749
870
  cl_open = 0
750
871
  next
751
872
  }
752
- section == "branches" {
873
+ # WHICH SHAPE THIS SECTION HOLDS, decided once from its first `### ` heading and
874
+ # then fixed for the rest of the section.
875
+ #
876
+ # `(Branch:` IS THE MARKER, and it is the only reliable one. A heading carrying
877
+ # it is the new shape by construction — that parenthetical is where the new
878
+ # layout puts the branch. A heading without it is the old shape, whose headings
879
+ # are bare names (`### Tracer`) and whose branches ride list items below.
880
+ #
881
+ # A SECTION WITH NO `### ` AT ALL is the old shape, and must be: a plan written
882
+ # before subheadings existed is one unnamed wave of list items, which is exactly
883
+ # what the old consumer produces. `slice_shape` therefore stays `""` until a
884
+ # heading is seen, and `""` routes to the old consumer.
885
+ section == "slices" && $0 ~ /^###[ \t]/ && slice_shape == "" {
886
+ slice_shape = (index($0, "(Branch:") > 0) ? "heading" : "list"
887
+ }
888
+
889
+ section == "slices" && slice_shape != "heading" {
753
890
  # `### <name>` opens a wave. Branches before any subheading belong to an
754
891
  # unnamed wave, so a pre-wave plan parses as exactly one wave.
755
892
  if ($0 ~ /^###[ \t]/) {
@@ -784,13 +921,97 @@ section == "branches" {
784
921
  # documented contract is that an annotation binds to the line carrying the
785
922
  # branch name. A deferral whose text is wrapped onto a continuation line is
786
923
  # not seen, exactly as `deferred` itself was not.
924
+ # `moved:` IS THE SAME ANSWER AS `deferred:`, and CLAUDE.md has said so since
925
+ # the reconcile scan was written: *"`deferred:`/`moved:` in the plan means
926
+ # reapable"*. `plot-reconcile-scan.sh:504` matches both in one arm; this
927
+ # parser matched only the first, so a slice given up with the other word read
928
+ # as still outstanding — to the board, the delivery gate and the fleet alike.
929
+ # Measured 2026-09-04 on `feature/the-reaper-sweeps-every-kind`: annotated
930
+ # `moved:` into the slice that superseded it, still `deferred:false` here.
931
+ #
932
+ # The two words differ in what they tell a READER — given up, versus taken
933
+ # somewhere else — and the reason is kept verbatim, so the distinction is not
934
+ # lost by being read alike.
787
935
  defer_note = ""
788
- if (index($0, "deferred:") > 0) {
936
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
789
937
  _d = $0
790
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
938
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
791
939
  sub(/[ \t]*-->.*$/, "", _d)
792
940
  defer_note = trim(_d)
793
941
  }
942
+ # THE PREREQUISITE THIS BRANCH NAMES: `<!-- waits: bug/other-branch -->`.
943
+ #
944
+ # ONE branch, never a list. A slice needing two prerequisites has not been cut
945
+ # finely enough, and a list invites a dependency graph nobody wants to debug.
946
+ # The greedy `.*` takes the LAST annotation when a line carries two, which is
947
+ # the same rule `deferred:` and `claimed:` already follow.
948
+ #
949
+ # The value is a BRANCH NAME, so it stops at the first whitespace rather than
950
+ # running to the closing marker the way a deferral reason does: a reason is a
951
+ # sentence, a branch name is a token, and trailing prose inside the comment
952
+ # would silently become part of a name that then matches nothing.
953
+ #
954
+ # `deferred:` is a judgement and `waits:` is a checkable fact, so the two are
955
+ # separate annotations and both may sit on one line. Read here, beside the
956
+ # other two, for the same reason: `match()` in the branch loop below clobbers
957
+ # RSTART/RLENGTH, so anything read from the whole line must be read first.
958
+ #
959
+ # `has_waits` carries presence separately from the value, because ABSENT and
960
+ # EMPTY are different answers — a branch declaring no prerequisite emits no
961
+ # `waits_on` key at all.
962
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
963
+ #
964
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
965
+ # slice dialects with no dialect-specific code. Annotations bind to the line
966
+ # carrying the branch name — the list item in one spelling, the `### `
967
+ # heading in the other — and both dialects already read `deferred:`,
968
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
969
+ # would have had two spellings to parse, and the template writes the LIST
970
+ # dialect, so the heading-only version would be absent from every plan
971
+ # created from it.
972
+ #
973
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
974
+ # and each slice builds one; a plan-level list is searched as a whole and
975
+ # reported against the wrong slice.
976
+ #
977
+ # The value runs to the closing marker, the way `deferred:` does and unlike
978
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
979
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
980
+ # a shared helper` — and cutting it at the first space would leave the half
981
+ # that identifies it.
982
+ #
983
+ # `has_builds` carries presence separately from the value, exactly as
984
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
985
+ # a deliverable or nothing and never a blank string that looks like one.
986
+ builds_note = ""
987
+ has_builds = 0
988
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
989
+ _bl = $0
990
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
991
+ sub(/[ \t]*-->.*$/, "", _bl)
992
+ builds_note = trim(_bl)
993
+ if (builds_note != "") has_builds = 1
994
+ }
995
+ waits_note = ""
996
+ has_waits = 0
997
+ if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
998
+ _w = $0
999
+ sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
1000
+ sub(/[ \t].*$/, "", _w)
1001
+ sub(/-->.*$/, "", _w)
1002
+ waits_note = trim(_w)
1003
+ # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
1004
+ # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
1005
+ # annotation writes the literal marker in prose, and no comment-aware
1006
+ # reading can tell that apart from the real thing on the same line — the
1007
+ # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
1008
+ #
1009
+ # Reusing the branch prefixes rather than a new pattern: the prerequisite
1010
+ # IS a branch in this repo, so the two must never disagree about what a
1011
+ # branch name looks like.
1012
+ if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1013
+ else waits_note = ""
1014
+ }
794
1015
  # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
795
1016
  #
796
1017
  # The old loop walked the line taking every backticked name on it, which is
@@ -815,7 +1036,7 @@ section == "branches" {
815
1036
  # plan can make about a branch, dropped for want of a colon. A reader
816
1037
  # writing the shorter form has said the branch will not be built; the
817
1038
  # parser now hears it.
818
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
1039
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
819
1040
  # The reason travels with the flag. Empty on every non-deferred branch, and
820
1041
  # empty is also the honest answer for the bare form: the branch IS deferred
821
1042
  # and no reason was recorded, which is a different statement from a reason
@@ -824,6 +1045,12 @@ section == "branches" {
824
1045
  # Claim reflection, written by the worker after its ref push succeeds. This
825
1046
  # is a reflection, not the claim: git refs remain authoritative.
826
1047
  claimed_of[n_branches] = claim_note
1048
+ # The prerequisite travels with the branch. Presence is tracked separately
1049
+ # so a branch that declares none emits no key.
1050
+ waits_of[n_branches] = waits_note
1051
+ waits_set[n_branches] = has_waits
1052
+ builds_of[n_branches] = builds_note
1053
+ builds_set[n_branches] = has_builds
827
1054
  ordered_b[n_branches] = b
828
1055
  }
829
1056
  line = $0
@@ -858,7 +1085,7 @@ section == "branches" {
858
1085
  # The emitted arrays (branches, prs, waves) must be byte-identical to what the
859
1086
  # old shape produces for the same plan. So this shares every accumulation
860
1087
  # variable with the branches handler above; only the EXTRACTION differs.
861
- section == "waves" {
1088
+ section == "slices" && slice_shape == "heading" {
862
1089
  # Only the `### ` heading carries meta. Body lines are prose — never scanned
863
1090
  # for a branch or a PR, which is the whole point of the new shape.
864
1091
  if ($0 !~ /^###[ \t]/) next
@@ -887,13 +1114,80 @@ section == "waves" {
887
1114
  sub(/[ \t]*-->.*$/, "", _c)
888
1115
  claim_note = trim(_c)
889
1116
  }
1117
+ # `moved:` IS THE SAME ANSWER AS `deferred:`, and CLAUDE.md has said so since
1118
+ # the reconcile scan was written: *"`deferred:`/`moved:` in the plan means
1119
+ # reapable"*. `plot-reconcile-scan.sh:504` matches both in one arm; this
1120
+ # parser matched only the first, so a slice given up with the other word read
1121
+ # as still outstanding — to the board, the delivery gate and the fleet alike.
1122
+ # Measured 2026-09-04 on `feature/the-reaper-sweeps-every-kind`: annotated
1123
+ # `moved:` into the slice that superseded it, still `deferred:false` here.
1124
+ #
1125
+ # The two words differ in what they tell a READER — given up, versus taken
1126
+ # somewhere else — and the reason is kept verbatim, so the distinction is not
1127
+ # lost by being read alike.
890
1128
  defer_note = ""
891
- if (index($0, "deferred:") > 0) {
1129
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
892
1130
  _d = $0
893
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
1131
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
894
1132
  sub(/[ \t]*-->.*$/, "", _d)
895
1133
  defer_note = trim(_d)
896
1134
  }
1135
+ # The prerequisite, read exactly as the list-item spelling reads it. Both
1136
+ # dialects emit the same waves[], so a field added to one only would break
1137
+ # that contract the first time a plan migrated.
1138
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
1139
+ #
1140
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
1141
+ # slice dialects with no dialect-specific code. Annotations bind to the line
1142
+ # carrying the branch name — the list item in one spelling, the `### `
1143
+ # heading in the other — and both dialects already read `deferred:`,
1144
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
1145
+ # would have had two spellings to parse, and the template writes the LIST
1146
+ # dialect, so the heading-only version would be absent from every plan
1147
+ # created from it.
1148
+ #
1149
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
1150
+ # and each slice builds one; a plan-level list is searched as a whole and
1151
+ # reported against the wrong slice.
1152
+ #
1153
+ # The value runs to the closing marker, the way `deferred:` does and unlike
1154
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
1155
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
1156
+ # a shared helper` — and cutting it at the first space would leave the half
1157
+ # that identifies it.
1158
+ #
1159
+ # `has_builds` carries presence separately from the value, exactly as
1160
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
1161
+ # a deliverable or nothing and never a blank string that looks like one.
1162
+ builds_note = ""
1163
+ has_builds = 0
1164
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
1165
+ _bl = $0
1166
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
1167
+ sub(/[ \t]*-->.*$/, "", _bl)
1168
+ builds_note = trim(_bl)
1169
+ if (builds_note != "") has_builds = 1
1170
+ }
1171
+ waits_note = ""
1172
+ has_waits = 0
1173
+ if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
1174
+ _w = $0
1175
+ sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
1176
+ sub(/[ \t].*$/, "", _w)
1177
+ sub(/-->.*$/, "", _w)
1178
+ waits_note = trim(_w)
1179
+ # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
1180
+ # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
1181
+ # annotation writes the literal marker in prose, and no comment-aware
1182
+ # reading can tell that apart from the real thing on the same line — the
1183
+ # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
1184
+ #
1185
+ # Reusing the branch prefixes rather than a new pattern: the prerequisite
1186
+ # IS a branch in this repo, so the two must never disagree about what a
1187
+ # branch name looks like.
1188
+ if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1189
+ else waits_note = ""
1190
+ }
897
1191
 
898
1192
  # The branch is the `Branch:` value, matched against the known prefixes exactly
899
1193
  # as the old shape matched the backticked name. Written unquoted in the heading
@@ -910,9 +1204,13 @@ section == "waves" {
910
1204
  wave_seq[n_branches] = ++wave_count[n_waves]
911
1205
  # Bare `<!-- deferred -->` sets the flag with no reason, same as the old
912
1206
  # shape; `deferred:` carries the reason.
913
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
1207
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
914
1208
  deferred_why[n_branches] = defer_note
915
1209
  claimed_of[n_branches] = claim_note
1210
+ waits_of[n_branches] = waits_note
1211
+ waits_set[n_branches] = has_waits
1212
+ builds_of[n_branches] = builds_note
1213
+ builds_set[n_branches] = has_builds
916
1214
  ordered_b[n_branches] = b
917
1215
  }
918
1216