@plot-pm/board 0.10.0 → 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
@@ -120,6 +120,14 @@
120
120
  # `<!-- deferred -->` (bare, no colon) sets the flag with no
121
121
  # reason; `waves[].branches[].deferred_reason` carries the
122
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.
123
131
  # prs PR numbers, sorted and unique, read from EITHER spelling:
124
132
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
125
133
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -291,6 +299,34 @@ function val_after_colon(s) {
291
299
  }
292
300
  # Template placeholders like "<!-- optional -->" mean "field absent".
293
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
+ }
294
330
  # First known phase token wins; NONE if empty; UNKNOWN otherwise.
295
331
  function norm_phase(raw, lower, toks, n, i, t) {
296
332
  if (raw == "") return "NONE"
@@ -356,7 +392,7 @@ function reset_state() {
356
392
  # the field, so a consumer cannot mistake "never interrogated" for "asked
357
393
  # nothing".
358
394
  block_rounds = ""
359
- 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 = ""
360
396
  delete branches; n_branches = 0
361
397
  delete prs; n_prs = 0
362
398
  delete malformed_prs; n_malformed_prs = 0
@@ -364,6 +400,7 @@ function reset_state() {
364
400
  delete issues; n_issues = 0
365
401
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
366
402
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
403
+ delete waits_of; delete waits_set
367
404
  delete started; n_started = 0
368
405
  fm_changelog = ""
369
406
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -508,7 +545,13 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
508
545
  if (wave_of[i] != w) continue
509
546
  out = out (first ? "" : ",") "{\"branch\":\"" jesc(ordered_b[i]) "\",\"deferred\":" deferred_of[i] \
510
547
  ",\"deferred_reason\":\"" jesc(deferred_why[i]) "\"" \
511
- ",\"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 "}"
512
555
  first = 0
513
556
  }
514
557
  out = out "]}"
@@ -612,6 +655,10 @@ in_fm {
612
655
  else if (lower ~ /^rounds:/ && fm_rounds == "") fm_rounds = val_after_colon($0)
613
656
  next
614
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 }
615
662
  # Interior of multi-line HTML comments is non-content (template guidance
616
663
  # blocks); single-line "<!-- ... -->" placeholders are unaffected.
617
664
  #
@@ -631,13 +678,25 @@ in_comment {
631
678
  if ($0 ~ /-->/) { in_comment = 0; in_challenge = 0 }
632
679
  next
633
680
  }
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
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
+ }
641
700
  }
642
701
  # A fenced code block is illustration, never contract — the same standing rule
643
702
  # comment interiors and repeated headings already follow. A plan that documents
@@ -651,30 +710,34 @@ in_comment {
651
710
  # example won its first-heading-wins guard and hid the real section). Toggling on
652
711
  # a fence fence-marker line closes both.
653
712
  #
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
713
  # First H1 is the title fallback (front matter title: still wins in emit).
659
714
  /^#[ \t]/ && h1_title == "" { h1_title = trim(substr($0, 2)) }
660
715
  /^## / {
661
716
  if ($0 ~ /^## Status/) section = "status"
662
- # 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
663
735
  # 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 }
736
+ else if ($0 ~ /^## Branches/ || $0 ~ /^## Waves/ || $0 ~ /^## Slices/) {
737
+ section = slices_seen ? "" : "slices"
738
+ slices_seen = 1
739
+ slice_shape = ""
740
+ }
678
741
  else if ($0 ~ /^## Approval/) section = "approval"
679
742
  # First `## Changelog` wins, for the same reason `## Branches` does: a plan
680
743
  # about the plan format quotes the section in prose, and the later heading is
@@ -692,11 +755,35 @@ section == "status" {
692
755
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**issue[:*]/ && canon_issue == "") canon_issue = val_after_colon($0)
693
756
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**review[:*]/ && canon_review == "") canon_review = val_after_colon($0)
694
757
  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)
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))
696
783
  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)
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))
700
787
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**started[:*]/) {
701
788
  _s = strip_placeholder(val_after_colon($0))
702
789
  if (_s != "") started[++n_started] = _s
@@ -749,7 +836,23 @@ section == "changelog" {
749
836
  cl_open = 0
750
837
  next
751
838
  }
752
- 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" {
753
856
  # `### <name>` opens a wave. Branches before any subheading belong to an
754
857
  # unnamed wave, so a pre-wave plan parses as exactly one wave.
755
858
  if ($0 ~ /^###[ \t]/) {
@@ -784,13 +887,64 @@ section == "branches" {
784
887
  # documented contract is that an annotation binds to the line carrying the
785
888
  # branch name. A deferral whose text is wrapped onto a continuation line is
786
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.
787
901
  defer_note = ""
788
- if (index($0, "deferred:") > 0) {
902
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
789
903
  _d = $0
790
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
904
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
791
905
  sub(/[ \t]*-->.*$/, "", _d)
792
906
  defer_note = trim(_d)
793
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
+ }
794
948
  # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
795
949
  #
796
950
  # The old loop walked the line taking every backticked name on it, which is
@@ -815,7 +969,7 @@ section == "branches" {
815
969
  # plan can make about a branch, dropped for want of a colon. A reader
816
970
  # writing the shorter form has said the branch will not be built; the
817
971
  # parser now hears it.
818
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
972
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
819
973
  # The reason travels with the flag. Empty on every non-deferred branch, and
820
974
  # empty is also the honest answer for the bare form: the branch IS deferred
821
975
  # and no reason was recorded, which is a different statement from a reason
@@ -824,6 +978,10 @@ section == "branches" {
824
978
  # Claim reflection, written by the worker after its ref push succeeds. This
825
979
  # is a reflection, not the claim: git refs remain authoritative.
826
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
827
985
  ordered_b[n_branches] = b
828
986
  }
829
987
  line = $0
@@ -858,7 +1016,7 @@ section == "branches" {
858
1016
  # The emitted arrays (branches, prs, waves) must be byte-identical to what the
859
1017
  # old shape produces for the same plan. So this shares every accumulation
860
1018
  # variable with the branches handler above; only the EXTRACTION differs.
861
- section == "waves" {
1019
+ section == "slices" && slice_shape == "heading" {
862
1020
  # Only the `### ` heading carries meta. Body lines are prose — never scanned
863
1021
  # for a branch or a PR, which is the whole point of the new shape.
864
1022
  if ($0 !~ /^###[ \t]/) next
@@ -887,13 +1045,47 @@ section == "waves" {
887
1045
  sub(/[ \t]*-->.*$/, "", _c)
888
1046
  claim_note = trim(_c)
889
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.
890
1059
  defer_note = ""
891
- if (index($0, "deferred:") > 0) {
1060
+ if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
892
1061
  _d = $0
893
- sub(/^.*<!--[ \t]*deferred:[ \t]*/, "", _d)
1062
+ sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
894
1063
  sub(/[ \t]*-->.*$/, "", _d)
895
1064
  defer_note = trim(_d)
896
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
+ }
897
1089
 
898
1090
  # The branch is the `Branch:` value, matched against the known prefixes exactly
899
1091
  # as the old shape matched the backticked name. Written unquoted in the heading
@@ -910,9 +1102,11 @@ section == "waves" {
910
1102
  wave_seq[n_branches] = ++wave_count[n_waves]
911
1103
  # Bare `<!-- deferred -->` sets the flag with no reason, same as the old
912
1104
  # shape; `deferred:` carries the reason.
913
- deferred_of[n_branches] = ($0 ~ /<!--[ \t]*deferred[ \t]*(:|-->)/) ? "true" : "false"
1105
+ deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
914
1106
  deferred_why[n_branches] = defer_note
915
1107
  claimed_of[n_branches] = claim_note
1108
+ waits_of[n_branches] = waits_note
1109
+ waits_set[n_branches] = has_waits
916
1110
  ordered_b[n_branches] = b
917
1111
  }
918
1112