@plot-pm/board 0.9.1 → 0.11.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
@@ -37,7 +37,7 @@
37
37
  # A file with neither is reported as format "none" (pre-plot / legacy plan).
38
38
  #
39
39
  # The IMPLEMENTATION section (which branches, in which waves, with which PRs)
40
- # has TWO spellings, and this parser reads both:
40
+ # has THREE spellings, and this parser reads all of them:
41
41
  #
42
42
  # ## Branches (old) the branch rides the list line, meta mixed with prose:
43
43
  # ### Removed
@@ -47,6 +47,14 @@
47
47
  # ### Removed (Branch: bug/foo, PR: #300)
48
48
  # - loses its half
49
49
  #
50
+ # ## Slices the design-spec word for the same shape as `## Waves`,
51
+ # read by the same handler. A Slice holds one branch and
52
+ # belongs to one plan; a Wave is the fleet cohort that
53
+ # spans plans. The section here was always the former, so
54
+ # `## Slices` is the accurate name and `## Waves` is the
55
+ # one 132 delivered plans already carry. No plan is
56
+ # rewritten: both are read, forever if need be.
57
+ #
50
58
  # Both emit the SAME branches/prs/waves arrays. The new shape is the format Plot
51
59
  # writes and documents; the old one is kept readable because a format change owes
52
60
  # its estate a migration that moves files one at a time, and a plan moved one
@@ -95,8 +103,9 @@
95
103
  # branches branch names, sorted and unique, read from EITHER spelling:
96
104
  # the old `## Branches` section (a LIST ITEM whose first token
97
105
  # is the backtick-quoted name, matching the known prefixes) OR
98
- # the new `## Waves` section (`Branch:` in a `### ` heading —
99
- # see below). A backticked branch name anywhere else under
106
+ # the new `## Waves` / `## Slices` section (`Branch:` in a
107
+ # `### ` heading — see below). A backticked branch name
108
+ # anywhere else under
100
109
  # `## Branches` — mid-sentence, in a blockquote, in a comment,
101
110
  # on a wrapped continuation line — is a CITATION and claims
102
111
  # nothing: plans name each other branches to declare
@@ -111,6 +120,14 @@
111
120
  # `<!-- deferred -->` (bare, no colon) sets the flag with no
112
121
  # reason; `waves[].branches[].deferred_reason` carries the
113
122
  # sentence after the colon, "" where none was written.
123
+ # `<!-- waits: bug/other -->` names ONE branch this branch
124
+ # waits on, reported as `waves[].branches[].waits_on`. The key
125
+ # is ABSENT where no annotation was written — never "" — and
126
+ # the value is a branch name in this repo, not a plan slug and
127
+ # not a cross-repo reference. The parser reports what the file
128
+ # says: a prerequisite no plan declares still parses, and the
129
+ # scan is what turns that into a verdict. `waits:` and
130
+ # `deferred:` are independent — a branch may carry both.
114
131
  # prs PR numbers, sorted and unique, read from EITHER spelling:
115
132
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
116
133
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -282,6 +299,34 @@ function val_after_colon(s) {
282
299
  }
283
300
  # Template placeholders like "<!-- optional -->" mean "field absent".
284
301
  function strip_placeholder(s) { return (s ~ /^<!--/) ? "" : s }
302
+ # Blank out backtick-delimited inline code, so a marker PRINTED as a literal is
303
+ # not read as syntax. Markdown renders `<!--` between backticks as the four
304
+ # characters; this parser used to read it as a comment-open, and because such a
305
+ # line carries no closing marker it swallowed the rest of the file — one
306
+ # backticked marker in a summary line cost a plan its phase, type and branches.
307
+ #
308
+ # Only the comment rules consult this. The line itself is untouched everywhere
309
+ # else, because branch names live in backticks too and stripping them for real
310
+ # would empty every `## Branches` entry.
311
+ #
312
+ # Runs of backticks are matched longest-first so a ``code`` span closes against
313
+ # its own delimiter. An UNPAIRED backtick leaves its tail as-is: that is prose
314
+ # with a stray tick, and prose is exactly where a real comment may open.
315
+ # NOTE: the closing-run index is named `shut` because `close` is an awk builtin
316
+ # and cannot be a parameter name — it fails as a syntax error on the function
317
+ # signature, pointing nowhere near the cause.
318
+ function mask_code(s, out, n, tick, shut) {
319
+ out = ""
320
+ while ((n = index(s, "`")) > 0) {
321
+ out = out substr(s, 1, n - 1)
322
+ s = substr(s, n)
323
+ tick = ""
324
+ while (substr(s, 1, 1) == "`") { tick = tick "`"; s = substr(s, 2) }
325
+ if ((shut = index(s, tick)) == 0) return out tick s
326
+ s = substr(s, shut + length(tick))
327
+ }
328
+ return out s
329
+ }
285
330
  # First known phase token wins; NONE if empty; UNKNOWN otherwise.
286
331
  function norm_phase(raw, lower, toks, n, i, t) {
287
332
  if (raw == "") return "NONE"
@@ -347,7 +392,7 @@ function reset_state() {
347
392
  # the field, so a consumer cannot mistake "never interrogated" for "asked
348
393
  # nothing".
349
394
  block_rounds = ""
350
- in_fm = 0; section = ""; in_comment = 0; in_challenge = 0; in_fence = 0; branches_seen = 0; waves_seen = 0
395
+ in_fm = 0; section = ""; in_comment = 0; in_challenge = 0; in_fence = 0; slices_seen = 0; slice_shape = ""
351
396
  delete branches; n_branches = 0
352
397
  delete prs; n_prs = 0
353
398
  delete malformed_prs; n_malformed_prs = 0
@@ -355,6 +400,7 @@ function reset_state() {
355
400
  delete issues; n_issues = 0
356
401
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
357
402
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
403
+ delete waits_of; delete waits_set
358
404
  delete started; n_started = 0
359
405
  fm_changelog = ""
360
406
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -499,7 +545,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
499
545
  if (wave_of[i] != w) continue
500
546
  out = out (first ? "" : ",") "{\"branch\":\"" jesc(ordered_b[i]) "\",\"deferred\":" deferred_of[i] \
501
547
  ",\"deferred_reason\":\"" jesc(deferred_why[i]) "\"" \
502
- ",\"claimed\":\"" jesc(claimed_of[i]) "\"}"
548
+ ",\"claimed\":\"" jesc(claimed_of[i]) "\""
549
+ # ABSENT, NOT EMPTY, where no prerequisite was declared. The key appears
550
+ # only on a branch whose line carries a `waits:` annotation, so a consumer
551
+ # reading `waits_on` gets a branch name or nothing — never a blank string
552
+ # that reads as a prerequisite with no name.
553
+ if (waits_set[i] == 1) out = out ",\"waits_on\":\"" jesc(waits_of[i]) "\""
554
+ out = out "}"
503
555
  first = 0
504
556
  }
505
557
  out = out "]}"
@@ -603,6 +655,10 @@ in_fm {
603
655
  else if (lower ~ /^rounds:/ && fm_rounds == "") fm_rounds = val_after_colon($0)
604
656
  next
605
657
  }
658
+ # A fence marker is a line whose first non-space run is ``` or ~~~ (an info
659
+ # string like ```markdown may follow). The marker line itself is never content.
660
+ /^[ \t]*(```|~~~)/ { in_fence = !in_fence; next }
661
+ in_fence { next }
606
662
  # Interior of multi-line HTML comments is non-content (template guidance
607
663
  # blocks); single-line "<!-- ... -->" placeholders are unaffected.
608
664
  #
@@ -622,13 +678,25 @@ in_comment {
622
678
  if ($0 ~ /-->/) { in_comment = 0; in_challenge = 0 }
623
679
  next
624
680
  }
625
- /<!--/ && $0 !~ /-->/ {
626
- in_comment = 1
627
- # A truncated block never closes; it simply runs to EOF as a comment, and the
628
- # round stays whatever was read before the truncation — absent if the "round"
629
- # line was itself lost. Nothing else in the record is affected either way.
630
- in_challenge = ($0 ~ /CHALLENGE-THE-PLAN-METADATA/) ? 1 : 0
631
- next
681
+ # A comment opens only where the marker is SYNTAX. Inside inline code it is a
682
+ # literal being quoted, and the fence rules above have already consumed code
683
+ # blocks so what reaches here is prose, minus its backticked spans.
684
+ #
685
+ # The cheap test guards the expensive one: /<!--/ on the raw line rejects the
686
+ # overwhelming majority, so mask_code runs once per line that actually carries a
687
+ # marker rather than twice per line of every plan. The masked verdict is decided
688
+ # INSIDE the rule so a line that turns out to be inline code simply falls
689
+ # through to the ordinary content rules below, carrying no state with it.
690
+ /<!--/ {
691
+ _masked = mask_code($0)
692
+ if (_masked ~ /<!--/ && _masked !~ /-->/) {
693
+ in_comment = 1
694
+ # A truncated block never closes; it simply runs to EOF as a comment, and
695
+ # the round stays whatever was read before the truncation — absent if the
696
+ # "round" line was itself lost. Nothing else in the record is affected.
697
+ in_challenge = ($0 ~ /CHALLENGE-THE-PLAN-METADATA/) ? 1 : 0
698
+ next
699
+ }
632
700
  }
633
701
  # A fenced code block is illustration, never contract — the same standing rule
634
702
  # comment interiors and repeated headings already follow. A plan that documents
@@ -642,23 +710,34 @@ in_comment {
642
710
  # example won its first-heading-wins guard and hid the real section). Toggling on
643
711
  # a fence fence-marker line closes both.
644
712
  #
645
- # A fence marker is a line whose first non-space run is ``` or ~~~ (an info
646
- # string like ```markdown may follow). The marker line itself is never content.
647
- /^[ \t]*(```|~~~)/ { in_fence = !in_fence; next }
648
- in_fence { next }
649
713
  # First H1 is the title fallback (front matter title: still wins in emit).
650
714
  /^#[ \t]/ && h1_title == "" { h1_title = trim(substr($0, 2)) }
651
715
  /^## / {
652
716
  if ($0 ~ /^## Status/) section = "status"
653
- # First `## Branches` wins: a plan documenting the plan format quotes the
717
+ # THE HEADING WORD NO LONGER PICKS THE LAYOUT. `## Branches`, `## Waves` and
718
+ # `## Slices` open ONE section, and which shape it holds is decided by the
719
+ # first `### ` heading in it — see `slice_shape` below.
720
+ #
721
+ # THEY USED TO SELECT IT, AND THAT MADE THE RENAME UNSAFE. The two layouts are
722
+ # genuinely different: the old one carries the branch on a LIST ITEM
723
+ # (`- ` + backtick + `feature/x` + backtick + ` -> #72`), the new one carries it in the
724
+ # heading (`### Name (Branch: feature/x, PR: #577)`). With the word choosing
725
+ # the consumer, renaming a heading silently changed which grammar was applied.
726
+ # Measured 2026-09-04 on `2026-08-14-parallel-agent-fleet.md`: renaming its
727
+ # `## Branches` to `## Slices` took it from **6 branches to 0**, with no error
728
+ # anywhere — the file still parsed, and simply held nothing.
729
+ #
730
+ # Deciding by shape makes the heading a NAME again. All three spellings read
731
+ # either layout, so a plan can be renamed without being rewritten, and the 48
732
+ # files still saying `## Branches` are safe to migrate a word at a time.
733
+ #
734
+ # First one wins, as before: a plan documenting the plan format quotes the
654
735
  # section in prose, and those later headings are illustration, not contract.
655
- else if ($0 ~ /^## Branches/) { section = branches_seen ? "" : "branches"; branches_seen = 1 }
656
- # `## Waves` is the new spelling: the branch and PR live in the `### ` heading,
657
- # the line below is prose. First one wins, for the same reason `## Branches`
658
- # does. A plan carries one or the other — but the parser reads both while the
659
- # migration moves 85 files, so a file moved one commit early never reads
660
- # as silently empty.
661
- else if ($0 ~ /^## Waves/) { section = waves_seen ? "" : "waves"; waves_seen = 1 }
736
+ else if ($0 ~ /^## Branches/ || $0 ~ /^## Waves/ || $0 ~ /^## Slices/) {
737
+ section = slices_seen ? "" : "slices"
738
+ slices_seen = 1
739
+ slice_shape = ""
740
+ }
662
741
  else if ($0 ~ /^## Approval/) section = "approval"
663
742
  # First `## Changelog` wins, for the same reason `## Branches` does: a plan
664
743
  # about the plan format quotes the section in prose, and the later heading is
@@ -676,11 +755,35 @@ section == "status" {
676
755
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
677
756
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
678
757
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**impl[:*]/ && canon_impl == "") canon_impl = val_after_colon($0)
679
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = val_after_colon($0)
758
+ # EACH TAKES THE FIRST LINE THAT CARRIES A VALUE, NOT THE FIRST LINE.
759
+ #
760
+ # A plan may hold both a record and an unfilled placeholder for the same
761
+ # field, on either side of each other. Seven plans here write the placeholder
762
+ # as a per-line comment —
763
+ #
764
+ # - **Delivered:** <!-- YYYY-MM-DD -->
765
+ #
766
+ # — beside a real `- **Delivered:** 2026-09-01`. `strip_placeholder` used to
767
+ # run in `emit_record`, AFTER first-wins had already chosen: the placeholder
768
+ # claimed the slot, was emptied a moment later, and the record it beat was
769
+ # gone. Whether a plan parsed correctly then depended on which line came
770
+ # first, and `append_delivered_line` decides that by where a `<!--` happens
771
+ # to stop its scan.
772
+ #
773
+ # A DELIVERED PLAN THAT READS `delivered_raw: ""` IS INVISIBLE TO THE SCAN,
774
+ # which reads its rolling window from that field — the same failure the
775
+ # comment-block fix addressed, reached by a different road. Measured
776
+ # 2026-09-01 on `a-machine-is-an-instance`: parsed correctly, but only
777
+ # because its record sat two lines above its placeholder.
778
+ #
779
+ # `started` never had this bug because it is a list and had to filter at
780
+ # capture to avoid storing empties. These four are scalars, and now do the
781
+ # same: a placeholder no longer counts as a value, so ORDER STOPS MATTERING.
782
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**design[:*]/ && canon_design == "") canon_design = strip_placeholder(val_after_colon($0))
680
783
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**rounds[:*]/ && canon_rounds == "") canon_rounds = val_after_colon($0)
681
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = val_after_colon($0)
682
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = val_after_colon($0)
683
- else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = val_after_colon($0)
784
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**approved[:*]/ && canon_approved == "") canon_approved = strip_placeholder(val_after_colon($0))
785
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**released[:*]/ && canon_released == "") canon_released = strip_placeholder(val_after_colon($0))
786
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**delivered[:*]/ && canon_delivered == "") canon_delivered = strip_placeholder(val_after_colon($0))
684
787
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**started[:*]/) {
685
788
  _s = strip_placeholder(val_after_colon($0))
686
789
  if (_s != "") started[++n_started] = _s
@@ -733,7 +836,23 @@ section == "changelog" {
733
836
  cl_open = 0
734
837
  next
735
838
  }
736
- section == "branches" {
839
+ # WHICH SHAPE THIS SECTION HOLDS, decided once from its first `### ` heading and
840
+ # then fixed for the rest of the section.
841
+ #
842
+ # `(Branch:` IS THE MARKER, and it is the only reliable one. A heading carrying
843
+ # it is the new shape by construction — that parenthetical is where the new
844
+ # layout puts the branch. A heading without it is the old shape, whose headings
845
+ # are bare names (`### Tracer`) and whose branches ride list items below.
846
+ #
847
+ # A SECTION WITH NO `### ` AT ALL is the old shape, and must be: a plan written
848
+ # before subheadings existed is one unnamed wave of list items, which is exactly
849
+ # what the old consumer produces. `slice_shape` therefore stays `""` until a
850
+ # heading is seen, and `""` routes to the old consumer.
851
+ section == "slices" && $0 ~ /^###[ \t]/ && slice_shape == "" {
852
+ slice_shape = (index($0, "(Branch:") > 0) ? "heading" : "list"
853
+ }
854
+
855
+ section == "slices" && slice_shape != "heading" {
737
856
  # `### <name>` opens a wave. Branches before any subheading belong to an
738
857
  # unnamed wave, so a pre-wave plan parses as exactly one wave.
739
858
  if ($0 ~ /^###[ \t]/) {
@@ -768,13 +887,64 @@ section == "branches" {
768
887
  # documented contract is that an annotation binds to the line carrying the
769
888
  # branch name. A deferral whose text is wrapped onto a continuation line is
770
889
  # not seen, exactly as `deferred` itself was not.
890
+ # `moved:` IS THE SAME ANSWER AS `deferred:`, and CLAUDE.md has said so since
891
+ # the reconcile scan was written: *"`deferred:`/`moved:` in the plan means
892
+ # reapable"*. `plot-reconcile-scan.sh:504` matches both in one arm; this
893
+ # parser matched only the first, so a slice given up with the other word read
894
+ # as still outstanding — to the board, the delivery gate and the fleet alike.
895
+ # Measured 2026-09-04 on `feature/the-reaper-sweeps-every-kind`: annotated
896
+ # `moved:` into the slice that superseded it, still `deferred:false` here.
897
+ #
898
+ # The two words differ in what they tell a READER — given up, versus taken
899
+ # somewhere else — and the reason is kept verbatim, so the distinction is not
900
+ # lost by being read alike.
771
901
  defer_note = ""
772
- if (index($0, "deferred:") > 0) {
902
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
773
903
  _d = $0
774
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
904
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
775
905
  sub(/[ \t]*-->.*$/, "", _d)
776
906
  defer_note = trim(_d)
777
907
  }
908
+ # THE PREREQUISITE THIS BRANCH NAMES: `<!-- waits: bug/other-branch -->`.
909
+ #
910
+ # ONE branch, never a list. A slice needing two prerequisites has not been cut
911
+ # finely enough, and a list invites a dependency graph nobody wants to debug.
912
+ # The greedy `.*` takes the LAST annotation when a line carries two, which is
913
+ # the same rule `deferred:` and `claimed:` already follow.
914
+ #
915
+ # The value is a BRANCH NAME, so it stops at the first whitespace rather than
916
+ # running to the closing marker the way a deferral reason does: a reason is a
917
+ # sentence, a branch name is a token, and trailing prose inside the comment
918
+ # would silently become part of a name that then matches nothing.
919
+ #
920
+ # `deferred:` is a judgement and `waits:` is a checkable fact, so the two are
921
+ # separate annotations and both may sit on one line. Read here, beside the
922
+ # other two, for the same reason: `match()` in the branch loop below clobbers
923
+ # RSTART/RLENGTH, so anything read from the whole line must be read first.
924
+ #
925
+ # `has_waits` carries presence separately from the value, because ABSENT and
926
+ # EMPTY are different answers — a branch declaring no prerequisite emits no
927
+ # `waits_on` key at all.
928
+ waits_note = ""
929
+ has_waits = 0
930
+ if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
931
+ _w = $0
932
+ sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
933
+ sub(/[ \t].*$/, "", _w)
934
+ sub(/-->.*$/, "", _w)
935
+ waits_note = trim(_w)
936
+ # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
937
+ # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
938
+ # annotation writes the literal marker in prose, and no comment-aware
939
+ # reading can tell that apart from the real thing on the same line — the
940
+ # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
941
+ #
942
+ # Reusing the branch prefixes rather than a new pattern: the prerequisite
943
+ # IS a branch in this repo, so the two must never disagree about what a
944
+ # branch name looks like.
945
+ if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
946
+ else waits_note = ""
947
+ }
778
948
  # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
779
949
  #
780
950
  # The old loop walked the line taking every backticked name on it, which is
@@ -799,7 +969,7 @@ section == "branches" {
799
969
  # plan can make about a branch, dropped for want of a colon. A reader
800
970
  # writing the shorter form has said the branch will not be built; the
801
971
  # parser now hears it.
802
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
972
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
803
973
  # The reason travels with the flag. Empty on every non-deferred branch, and
804
974
  # empty is also the honest answer for the bare form: the branch IS deferred
805
975
  # and no reason was recorded, which is a different statement from a reason
@@ -808,6 +978,10 @@ section == "branches" {
808
978
  # Claim reflection, written by the worker after its ref push succeeds. This
809
979
  # is a reflection, not the claim: git refs remain authoritative.
810
980
  claimed_of[n_branches] = claim_note
981
+ # The prerequisite travels with the branch. Presence is tracked separately
982
+ # so a branch that declares none emits no key.
983
+ waits_of[n_branches] = waits_note
984
+ waits_set[n_branches] = has_waits
811
985
  ordered_b[n_branches] = b
812
986
  }
813
987
  line = $0
@@ -842,7 +1016,7 @@ section == "branches" {
842
1016
  # The emitted arrays (branches, prs, waves) must be byte-identical to what the
843
1017
  # old shape produces for the same plan. So this shares every accumulation
844
1018
  # variable with the branches handler above; only the EXTRACTION differs.
845
- section == "waves" {
1019
+ section == "slices" && slice_shape == "heading" {
846
1020
  # Only the `### ` heading carries meta. Body lines are prose — never scanned
847
1021
  # for a branch or a PR, which is the whole point of the new shape.
848
1022
  if ($0 !~ /^###[ \t]/) next
@@ -871,13 +1045,47 @@ section == "waves" {
871
1045
  sub(/[ \t]*-->.*$/, "", _c)
872
1046
  claim_note = trim(_c)
873
1047
  }
1048
+ # `moved:` IS THE SAME ANSWER AS `deferred:`, and CLAUDE.md has said so since
1049
+ # the reconcile scan was written: *"`deferred:`/`moved:` in the plan means
1050
+ # reapable"*. `plot-reconcile-scan.sh:504` matches both in one arm; this
1051
+ # parser matched only the first, so a slice given up with the other word read
1052
+ # as still outstanding — to the board, the delivery gate and the fleet alike.
1053
+ # Measured 2026-09-04 on `feature/the-reaper-sweeps-every-kind`: annotated
1054
+ # `moved:` into the slice that superseded it, still `deferred:false` here.
1055
+ #
1056
+ # The two words differ in what they tell a READER — given up, versus taken
1057
+ # somewhere else — and the reason is kept verbatim, so the distinction is not
1058
+ # lost by being read alike.
874
1059
  defer_note = ""
875
- if (index($0, "deferred:") > 0) {
1060
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
876
1061
  _d = $0
877
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
1062
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
878
1063
  sub(/[ \t]*-->.*$/, "", _d)
879
1064
  defer_note = trim(_d)
880
1065
  }
1066
+ # The prerequisite, read exactly as the list-item spelling reads it. Both
1067
+ # dialects emit the same waves[], so a field added to one only would break
1068
+ # that contract the first time a plan migrated.
1069
+ waits_note = ""
1070
+ has_waits = 0
1071
+ if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
1072
+ _w = $0
1073
+ sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
1074
+ sub(/[ \t].*$/, "", _w)
1075
+ sub(/-->.*$/, "", _w)
1076
+ waits_note = trim(_w)
1077
+ # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
1078
+ # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
1079
+ # annotation writes the literal marker in prose, and no comment-aware
1080
+ # reading can tell that apart from the real thing on the same line — the
1081
+ # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
1082
+ #
1083
+ # Reusing the branch prefixes rather than a new pattern: the prerequisite
1084
+ # IS a branch in this repo, so the two must never disagree about what a
1085
+ # branch name looks like.
1086
+ if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1087
+ else waits_note = ""
1088
+ }
881
1089
 
882
1090
  # The branch is the `Branch:` value, matched against the known prefixes exactly
883
1091
  # as the old shape matched the backticked name. Written unquoted in the heading
@@ -894,9 +1102,11 @@ section == "waves" {
894
1102
  wave_seq[n_branches] = ++wave_count[n_waves]
895
1103
  # Bare `<!-- deferred -->` sets the flag with no reason, same as the old
896
1104
  # shape; `deferred:` carries the reason.
897
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
1105
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
898
1106
  deferred_why[n_branches] = defer_note
899
1107
  claimed_of[n_branches] = claim_note
1108
+ waits_of[n_branches] = waits_note
1109
+ waits_set[n_branches] = has_waits
900
1110
  ordered_b[n_branches] = b
901
1111
  }
902
1112