@plot-pm/board 0.16.2 → 0.17.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.
@@ -1,14 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # Plot helper: the ONE answer to "is this monitor's subject still there?"
3
3
  #
4
- # SOURCED, NOT RUN, by `plot-worker-monitor.sh` and `plot-agent-monitor.sh`.
5
- # Both need the same computation and neither renders it the same way, which is
6
- # the same shape as `plot-worker-state.sh` and `plot-pr-merged.sh` — and the
7
- # same reason. `plot-worker-state.sh` carried five of its six states in
8
- # duplicate until 2026-08-18, and the copies had already drifted on the sixth.
9
- # Two monitors deciding independently when to stop would drift the same way, and
10
- # the failure would be silent: one monitor left running forever while its twin
11
- # exits is exactly the leak this file exists to close, half-fixed.
4
+ # SOURCED, NOT RUN, by `plot-agent-monitor.sh`. The same shape as
5
+ # `plot-worker-state.sh` and `plot-pr-merged.sh` — and the same reason.
6
+ # `plot-worker-state.sh` carried five of its six states in duplicate until
7
+ # 2026-08-18, and the copies had already drifted on the sixth. A monitor
8
+ # deciding independently when to stop, with its computation copied rather than
9
+ # shared, would drift the same way, and the failure would be silent: a monitor
10
+ # left running forever is exactly the leak this file exists to close.
12
11
  #
13
12
  # ═══════════════════════════════════════════════════════════════════════════
14
13
  # WHY A MONITOR NEEDS THIS AT ALL
@@ -132,9 +131,19 @@ plot_monitor_subject() {
132
131
  pid=$(cat "$pid_file" 2>/dev/null | tr -d ' \n')
133
132
 
134
133
  # A file that exists but holds no digits is a half-written pid, which is the
135
- # startup window caught mid-`printf`. Not gone.
134
+ # startup window caught mid-`printf`. Not gone — UNLESS the wrapper has
135
+ # already recorded an exit beside it. `start_worker` removes that record
136
+ # before every start, so in the startup window it is absent. A worker loop
137
+ # that moves to a new desk empties the pid file it leaves
138
+ # (`move_worker_record`), and the wrapper still writes the exit into this
139
+ # desk when the loop ends; without this arm a monitor watching that desk
140
+ # would read `starting` forever.
136
141
  case "$pid" in
137
- '' | *[!0-9]*) printf 'starting'; return 0 ;;
142
+ '' | *[!0-9]*)
143
+ if [ -f "$(dirname "$pid_file")/.plot-worker.exit" ]; then
144
+ printf 'gone'; return 0
145
+ fi
146
+ printf 'starting'; return 0 ;;
138
147
  esac
139
148
 
140
149
  if kill -0 "$pid" 2>/dev/null; then
@@ -192,3 +201,45 @@ plot_monitor_wait() { # $1 = seconds to wait, $2 = pid file
192
201
  done
193
202
  return 0
194
203
  }
204
+
205
+ # ═══════════════════════════════════════════════════════════════════════════
206
+ # WHICH DESK A MONITOR WATCHES THIS PASS, AFTER A HOP MAY HAVE MOVED ITS AGENT
207
+ # ═══════════════════════════════════════════════════════════════════════════
208
+ #
209
+ # `PLOT_WORKTREE` is fixed at launch, but `update_manifest_on_hop` rewrites the
210
+ # manifest's `worktree` field when the loop cuts a new desk — so a monitor that
211
+ # never re-reads it watches the launch desk forever. This is the shell twin of
212
+ # `watchedDesk` in `packages/domain/src/rules/desk-manifest.ts`, duplicated
213
+ # rather than called for the reason `docs/shell-and-domain.md` gives: both
214
+ # monitors re-read this every pass (30 s and 300 s), and a 39 ms bundle hop paid
215
+ # on every pass forever is the cost the shell side exists to avoid.
216
+ # `desk-manifest.corpus.test.ts` holds the pair; a disagreement stops the
217
+ # branch rather than being adjusted away.
218
+ #
219
+ # `$1` = the manifest file this monitor was handed (`PLOT_MANIFEST_FILE`), may
220
+ # be empty or absent.
221
+ # `$2` = the desk this monitor was launched on (`PLOT_WORKTREE`).
222
+ # Prints the desk to watch THIS pass.
223
+ plot_watched_desk() { # $1 = manifest file, $2 = launch desk → prints the watched desk
224
+ local manifest_file="${1:-}" launched="${2:-}" field
225
+ # ABSENT IS NOT FALSE. No manifest file named, or one that is gone, both mean
226
+ # "watch what you were launched on" — never a crash and never "watch
227
+ # nothing". A hand-started monitor with no `PLOT_MANIFEST_FILE` is a supported
228
+ # shape, not an error.
229
+ if [ -n "$manifest_file" ] && [ -f "$manifest_file" ]; then
230
+ # Same grep-and-sed idiom `plot_manifest_for_worktree` uses: the manifest is
231
+ # pretty-printed one field per line, so this avoids parsing JSON in bash.
232
+ field=$(grep -m1 '"worktree":' "$manifest_file" 2>/dev/null | sed 's/.*"worktree": *"\([^"]*\)".*/\1/')
233
+ else
234
+ field=''
235
+ fi
236
+ # Whitespace-only reads as absent too, matching the rule: trim leading and
237
+ # trailing space before testing for emptiness.
238
+ field="${field#"${field%%[![:space:]]*}"}"
239
+ field="${field%"${field##*[![:space:]]}"}"
240
+ if [ -n "$field" ]; then
241
+ printf '%s' "$field"
242
+ else
243
+ printf '%s' "$launched"
244
+ fi
245
+ }
package/plot-plan-meta.sh CHANGED
@@ -130,14 +130,19 @@
130
130
  # `<!-- deferred -->` (bare, no colon) sets the flag with no
131
131
  # reason; `waves[].branches[].deferred_reason` carries the
132
132
  # sentence after the colon, "" where none was written.
133
- # `<!-- waits: bug/other -->` names ONE branch this branch
134
- # waits on, reported as `waves[].branches[].waits_on`. The key
135
- # is ABSENT where no annotation was written — never "" — and
136
- # the value is a branch name in this repo, not a plan slug and
137
- # not a cross-repo reference. The parser reports what the file
138
- # says: a prerequisite no plan declares still parses, and the
139
- # scan is what turns that into a verdict. `waits:` and
140
- # `deferred:` are independent — a branch may carry both.
133
+ # `<!-- waits: bug/other -->` names branches this branch waits
134
+ # on, reported as `waves[].branches[].waits_on`, A LIST, in the
135
+ # line's order, duplicates removed — several `<!-- waits: … -->`
136
+ # comments on one line, or one `<!-- waits: x, y -->`, give the
137
+ # same list. The key is ABSENT where no annotation was written
138
+ # — never `[]` — and each value is a branch name in this repo,
139
+ # not a plan slug and not a cross-repo reference. The parser
140
+ # reports what the file says: a prerequisite no plan declares
141
+ # still parses, and the scan is what turns that into a verdict.
142
+ # `waits:` and `deferred:` are independent — a branch may carry
143
+ # both. A `waits:` value the parser cannot read as a branch, on
144
+ # a line that names one, is reported in `unread_waits[]`
145
+ # instead — never dropped silently.
141
146
  # `<!-- builds: normalizeVersion, a shared helper -->` names
142
147
  # what this slice BUILDS, reported as
143
148
  # `waves[].branches[].builds`. OPTIONAL, like `Sprint:` and
@@ -208,6 +213,14 @@
208
213
  # heading, which carries no `Branch:`. Covers both slice
209
214
  # consumers, so a heading lost to the first-heading latch
210
215
  # (#1042) is named too. ALWAYS present; [] when none.
216
+ # unread_waits `{ branch, value }` pairs, in document order, for a `waits:`
217
+ # marker on a line that names a branch whose value the parser
218
+ # could not read as one — a report, not a refusal. The marker
219
+ # is dropped from `waits_on` rather than silently discarding
220
+ # it: a plan documenting the annotation as prose on a line with
221
+ # no branch stays silent, since reporting that would fire on
222
+ # every such plan. A marker inside a code span is quoted, not
223
+ # written, and is read nowhere. ALWAYS present; [] when none.
211
224
  # issues tracker issue numbers this plan answers, from the `## Status`
212
225
  # `Issue:` line or front matter `issue:` (sorted, unique).
213
226
  # A DEDICATED field, never a scan of the body for `#NNN`: a
@@ -294,7 +307,7 @@ if [ ${#files[@]} -eq 0 ] && [ ${#missing[@]} -eq 0 ]; then
294
307
  fi
295
308
 
296
309
  for f in ${missing[@]+"${missing[@]}"}; do
297
- printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"unread_branch_headings":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
310
+ printf '{"file":"%s","format":"none","error":"file not found","phase_raw":"","phase":"NONE","phase_alt_raw":"","phase_alt":"NONE","type":"","title":"","sprint":"","story":"","assignee":"","branches":[],"prs":[],"issues":[],"malformed_prs":[],"changelog":[],"long_wave_names":[],"unread_branch_headings":[],"unread_waits":[],"review_raw":"","review":"NONE","impl_raw":"","impl":"NONE","design_raw":"","approved_raw":"","released_raw":"","delivered_raw":"","started_raw":[]}\n' \
298
311
  "$(printf '%s' "$f" | sed 's/\\/\\\\/g; s/"/\\"/g')"
299
312
  done
300
313
 
@@ -332,6 +345,53 @@ function jesc(s) {
332
345
  return s
333
346
  }
334
347
  function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
348
+ # Every `<!-- waits: … -->` marker on a line, each value split on commas, in
349
+ # the line order with duplicates removed, written into outArr/outN (outN is
350
+ # outArr[0] — awk scalars are pass-by-value, so a count returned through a
351
+ # parameter needs an array slot instead). A value that does not look like a
352
+ # branch name is appended to the global pending_unread_value[] instead of
353
+ # outArr, with its count in pending_unread_n; the caller (the branch match
354
+ # below, which alone knows the branch this line names) drains it into
355
+ # unread_waits_branch[]/unread_waits_value[].
356
+ #
357
+ # BWK awk (macOS) and POSIX awk elsewhere have no `match(s, re, arr)` and no
358
+ # `gensub` — both gawk-only. So this loops with plain `match()` plus `substr()`
359
+ # over the remainder of the line, the same technique the rest of this parser
360
+ # already uses for `prs` and `issues`.
361
+ #
362
+ # A MARKER INSIDE A CODE SPAN IS QUOTED, NOT WRITTEN: Markdown renders it as
363
+ # text, so it annotates nothing. Spans are removed before the search. Measured
364
+ # on `main`: `2026-09-01-a-slice-can-wait-on-another-plan.md` quotes
365
+ # `<!-- waits: <branch> -->` on its own branch line, and without this strip it
366
+ # reported as an unread wait.
367
+ function parse_waits(s, outArr, raw, parts, np, i, seen, n_out) {
368
+ n_out = 0
369
+ delete pending_unread_value; pending_unread_n = 0
370
+ gsub(/`[^`]*`/, "", s)
371
+ while (match(s, /<!--[ \t]*waits:[ \t]*/)) {
372
+ s = substr(s, RSTART + RLENGTH)
373
+ raw = s
374
+ sub(/-->.*$/, "", raw)
375
+ np = split(trim(raw), parts, /[ \t]*,[ \t]*/)
376
+ for (i = 1; i <= np; i++) {
377
+ if (parts[i] == "") continue
378
+ if (parts[i] !~ "^(" PREFIXES ")/[^ \t,]+$") pending_unread_value[++pending_unread_n] = parts[i]
379
+ else if (!(parts[i] in seen)) outArr[++n_out] = parts[i]
380
+ seen[parts[i]] = 1
381
+ }
382
+ }
383
+ outArr[0] = n_out
384
+ return n_out
385
+ }
386
+ # The value of the LAST `<!-- key: value -->` marker on a line, trimmed, the
387
+ # text up to its closing `-->`. `key` is a regex alternation (`deferred|moved`).
388
+ # Both slice dialects read `claimed:`, `deferred:`, `builds:` and `agent:`
389
+ # through this one reader. Each caller keeps its own presence test.
390
+ function marker_value(s, key) {
391
+ sub("^.*<!--[ \t]*(" key "):[ \t]*", "", s)
392
+ sub(/[ \t]*-->.*$/, "", s)
393
+ return trim(s)
394
+ }
335
395
  # Value after the first colon, stripped of bold markers / quotes / space.
336
396
  function val_after_colon(s) {
337
397
  sub(/^[^:]*:/, "", s); sub(/^\**[ \t]*/, "", s)
@@ -442,14 +502,15 @@ function reset_state() {
442
502
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
443
503
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
444
504
  delete branch_heading
445
- delete waits_of; delete waits_set
505
+ delete waits_of; delete waits_set; delete waits_n
506
+ delete unread_waits_branch; delete unread_waits_value; n_unread_waits = 0
446
507
  delete builds_of; delete builds_set
447
508
  delete agent_of; delete agent_set
448
509
  delete started; n_started = 0
449
510
  fm_changelog = ""
450
511
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
451
512
  }
452
- function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, issue2, i, j, v, is_dup, out, sorted_b, sorted_p, sorted_i, nb, np, ni, num_issues, str_issues, n_num_i, n_str_i, issue_is_str) {
513
+ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assignee, review, impl, design, approved, delivered, issue, issue2, i, j, k, v, is_dup, out, sorted_b, sorted_p, sorted_i, nb, np, ni, num_issues, str_issues, n_num_i, n_str_i, issue_is_str) {
453
514
  # THE PHASE IS READ FROM THE FIELD PLOT WRITES. A canonical `State:`/`Phase:`
454
515
  # outranks front matter, because every lifecycle script writes the canonical
455
516
  # body and none writes front matter: `plot-approve.sh` holds zero front-matter
@@ -615,11 +676,18 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
615
676
  out = out (first ? "" : ",") "{\"branch\":\"" jesc(ordered_b[i]) "\",\"deferred\":" deferred_of[i] \
616
677
  ",\"deferred_reason\":\"" jesc(deferred_why[i]) "\"" \
617
678
  ",\"claimed\":\"" jesc(claimed_of[i]) "\""
618
- # ABSENT, NOT EMPTY, where no prerequisite was declared. The key appears
619
- # only on a branch whose line carries a `waits:` annotation, so a consumer
620
- # reading `waits_on` gets a branch name or nothing — never a blank string
621
- # that reads as a prerequisite with no name.
622
- if (waits_set[i] == 1) out = out ",\"waits_on\":\"" jesc(waits_of[i]) "\""
679
+ # ABSENT, NOT EMPTY ARRAY, where no prerequisite was declared. The key
680
+ # appears only on a branch whose line carries a `waits:` annotation, so a
681
+ # consumer reading `waits_on` gets a list of one or more names or no key
682
+ # at all — never `[]`, which would read as a declared wait on nothing.
683
+ # waits_of is flattened as waits_of[i,k] (k = 1..waits_n[i]) since this
684
+ # awk has no nested arrays; store_waits() fills it per branch, in
685
+ # document order, duplicates removed.
686
+ if (waits_set[i] == 1) {
687
+ out = out ",\"waits_on\":["
688
+ for (k = 1; k <= waits_n[i]; k++) out = out (k > 1 ? "," : "") "\"" jesc(waits_of[i, k]) "\""
689
+ out = out "]"
690
+ }
623
691
  # ABSENT, NOT EMPTY, for the same reason `waits_on` is: a slice that
624
692
  # names no deliverable emits no key, so a consumer reads a name or
625
693
  # nothing. An empty string would read as a deliverable called "".
@@ -672,6 +740,17 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
672
740
  }
673
741
  }
674
742
  out = out "]"
743
+ # unread_waits[]: a `waits:` marker on a branch line whose value the parser
744
+ # could not read as a branch, `{ branch, value }` in document order — a
745
+ # report, not a refusal. Filled by store_waits() as each branch is matched;
746
+ # a `waits:` syntax example on a PROSE line (no branch claim) never reaches
747
+ # store_waits(), so it stays silent, as today.
748
+ out = out ",\"unread_waits\":["
749
+ for (i = 1; i <= n_unread_waits; i++) {
750
+ out = out (i > 1 ? "," : "") "{\"branch\":\"" jesc(unread_waits_branch[i]) "\"" \
751
+ ",\"value\":\"" jesc(unread_waits_value[i]) "\"}"
752
+ }
753
+ out = out "]"
675
754
  out = out ",\"review_raw\":\"" jesc(review) "\",\"review\":\"" norm_review(review) "\""
676
755
  out = out ",\"impl_raw\":\"" jesc(impl) "\",\"impl\":\"" norm_impl(impl) "\""
677
756
  out = out ",\"design_raw\":\"" jesc(design) "\""
@@ -703,6 +782,24 @@ function note_branch_heading( h) {
703
782
  sub(/[ \t]*<!--.*$/, "", h)
704
783
  branch_heading[n_waves] = h
705
784
  }
785
+ # Copies the line-local waits list parse_waits() just filled (line_waits[],
786
+ # line_waits[0] = count) into the flattened per-branch storage, and drains
787
+ # pending_unread_value[] — the values parse_waits() could not read as a branch
788
+ # — into unread_waits_branch[]/unread_waits_value[], now that the branch this
789
+ # line claims (n = n_branches, b = the branch) is known. Called from both
790
+ # slice dialects at the moment each claims a branch, so a `waits:` syntax
791
+ # example on a line with NO branch claim never drains the queue and stays
792
+ # silent — the same rule the old single-value reading followed.
793
+ function store_waits(n, b, k) {
794
+ waits_n[n] = line_waits[0] + 0
795
+ for (k = 1; k <= waits_n[n]; k++) waits_of[n, k] = line_waits[k]
796
+ waits_set[n] = (waits_n[n] > 0) ? 1 : 0
797
+ for (k = 1; k <= pending_unread_n; k++) {
798
+ unread_waits_branch[++n_unread_waits] = b
799
+ unread_waits_value[n_unread_waits] = pending_unread_value[k]
800
+ }
801
+ delete pending_unread_value; pending_unread_n = 0
802
+ }
706
803
  # The longest wave name that still reads as a label, not prose. A JUDGEMENT, not
707
804
  # a measurement: the longest legitimate name in the estate is `Offered first`
708
805
  # (13), and the offender this exists to catch is a 53-character sentence, so the
@@ -978,13 +1075,7 @@ section == "slices" && slice_shape != "heading" {
978
1075
  # a reflection, not the claim: git refs remain authoritative. Computed before
979
1076
  # the branch loop below — match() clobbers RSTART/RLENGTH, which that loop
980
1077
  # needs to advance.
981
- claim_note = ""
982
- if (index($0, "claimed:") > 0) {
983
- _c = $0
984
- sub(/^.*<!--[ \t]*claimed:[ \t]*/, "", _c)
985
- sub(/[ \t]*-->.*$/, "", _c)
986
- claim_note = trim(_c)
987
- }
1078
+ claim_note = (index($0, "claimed:") > 0) ? marker_value($0, "claimed") : ""
988
1079
  # THE REASON FOR THE DEFERRAL, and not merely the fact of one.
989
1080
  #
990
1081
  # `<!-- deferred: verified already implemented 2026-08-17 — startRepair() at
@@ -1013,24 +1104,19 @@ section == "slices" && slice_shape != "heading" {
1013
1104
  # The two words differ in what they tell a READER — given up, versus taken
1014
1105
  # somewhere else — and the reason is kept verbatim, so the distinction is not
1015
1106
  # lost by being read alike.
1016
- defer_note = ""
1017
- if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
1018
- _d = $0
1019
- sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
1020
- sub(/[ \t]*-->.*$/, "", _d)
1021
- defer_note = trim(_d)
1022
- }
1023
- # THE PREREQUISITE THIS BRANCH NAMES: `<!-- waits: bug/other-branch -->`.
1107
+ defer_note = (index($0, "deferred:") > 0 || index($0, "moved:") > 0) ? marker_value($0, "deferred|moved") : ""
1108
+ # THE PREREQUISITES THIS BRANCH NAMES: `<!-- waits: bug/other-branch -->`.
1024
1109
  #
1025
- # ONE branch, never a list. A slice needing two prerequisites has not been cut
1026
- # finely enough, and a list invites a dependency graph nobody wants to debug.
1027
- # The greedy `.*` takes the LAST annotation when a line carries two, which is
1028
- # the same rule `deferred:` and `claimed:` already follow.
1110
+ # A LIST, not one name. Several `<!-- waits: … -->` comments on a line, or one
1111
+ # `<!-- waits: x, y -->`, give the same list in document order, duplicates
1112
+ # removed — see parse_waits(). The old greedy `sub()` took only the LAST
1113
+ # annotation when a line carried two, which silently dropped every other
1114
+ # prerequisite a slice had cleared for merge eligibility by itself (#1153).
1029
1115
  #
1030
- # The value is a BRANCH NAME, so it stops at the first whitespace rather than
1031
- # running to the closing marker the way a deferral reason does: a reason is a
1032
- # sentence, a branch name is a token, and trailing prose inside the comment
1033
- # would silently become part of a name that then matches nothing.
1116
+ # Each value is a BRANCH NAME, so it stops at the first whitespace or comma
1117
+ # rather than running to the closing marker the way a deferral reason does: a
1118
+ # reason is a sentence, a branch name is a token, and trailing prose inside
1119
+ # the comment would silently become part of a name that then matches nothing.
1034
1120
  #
1035
1121
  # `deferred:` is a judgement and `waits:` is a checkable fact, so the two are
1036
1122
  # separate annotations and both may sit on one line. Read here, beside the
@@ -1039,7 +1125,15 @@ section == "slices" && slice_shape != "heading" {
1039
1125
  #
1040
1126
  # `has_waits` carries presence separately from the value, because ABSENT and
1041
1127
  # EMPTY are different answers — a branch declaring no prerequisite emits no
1042
- # `waits_on` key at all.
1128
+ # `waits_on` key at all, never `[]`.
1129
+ #
1130
+ # A value that does not look like a branch is NOT dropped — parse_waits()
1131
+ # queues it in pending_unread_value[], and once the branch this line claims is
1132
+ # known (below), it is copied into unread_waits_branch[]/unread_waits_value[].
1133
+ # A line with no branch claim never drains the queue, so a `waits:` syntax
1134
+ # example in prose — not on a branch line — stays silent, as today.
1135
+ delete line_waits
1136
+ has_waits = (parse_waits($0, line_waits) > 0)
1043
1137
  # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
1044
1138
  #
1045
1139
  # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
@@ -1064,35 +1158,8 @@ section == "slices" && slice_shape != "heading" {
1064
1158
  # `has_builds` carries presence separately from the value, exactly as
1065
1159
  # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
1066
1160
  # a deliverable or nothing and never a blank string that looks like one.
1067
- builds_note = ""
1068
- has_builds = 0
1069
- if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
1070
- _bl = $0
1071
- sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
1072
- sub(/[ \t]*-->.*$/, "", _bl)
1073
- builds_note = trim(_bl)
1074
- if (builds_note != "") has_builds = 1
1075
- }
1076
- waits_note = ""
1077
- has_waits = 0
1078
- if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
1079
- _w = $0
1080
- sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
1081
- sub(/[ \t].*$/, "", _w)
1082
- sub(/-->.*$/, "", _w)
1083
- waits_note = trim(_w)
1084
- # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
1085
- # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
1086
- # annotation writes the literal marker in prose, and no comment-aware
1087
- # reading can tell that apart from the real thing on the same line — the
1088
- # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
1089
- #
1090
- # Reusing the branch prefixes rather than a new pattern: the prerequisite
1091
- # IS a branch in this repo, so the two must never disagree about what a
1092
- # branch name looks like.
1093
- if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1094
- else waits_note = ""
1095
- }
1161
+ builds_note = ($0 ~ /<!--[ \t]*builds:[ \t]*/) ? marker_value($0, "builds") : ""
1162
+ has_builds = (builds_note != "")
1096
1163
  # WHICH KIND OF AGENT THIS SLICE NEEDS: `<!-- agent: reviewer -->`.
1097
1164
  #
1098
1165
  # `--agent <name>` was the only selector and only an operator could type it.
@@ -1122,15 +1189,8 @@ section == "slices" && slice_shape != "heading" {
1122
1189
  # `has_agent` carries presence separately from the value, as all three
1123
1190
  # annotations before it do: a slice naming no kind emits no key, so dispatch
1124
1191
  # reads a name or nothing and never a blank string that looks like one.
1125
- agent_note = ""
1126
- has_agent = 0
1127
- if ($0 ~ /<!--[ \t]*agent:[ \t]*/) {
1128
- _ag = $0
1129
- sub(/^.*<!--[ \t]*agent:[ \t]*/, "", _ag)
1130
- sub(/[ \t]*-->.*$/, "", _ag)
1131
- agent_note = trim(_ag)
1132
- if (agent_note != "") has_agent = 1
1133
- }
1192
+ agent_note = ($0 ~ /<!--[ \t]*agent:[ \t]*/) ? marker_value($0, "agent") : ""
1193
+ has_agent = (agent_note != "")
1134
1194
  # ONE LIST ITEM, AT MOST ONE CLAIM — an `if`, not the `while` this was.
1135
1195
  #
1136
1196
  # The old loop walked the line taking every backticked name on it, which is
@@ -1164,10 +1224,10 @@ section == "slices" && slice_shape != "heading" {
1164
1224
  # Claim reflection, written by the worker after its ref push succeeds. This
1165
1225
  # is a reflection, not the claim: git refs remain authoritative.
1166
1226
  claimed_of[n_branches] = claim_note
1167
- # The prerequisite travels with the branch. Presence is tracked separately
1168
- # so a branch that declares none emits no key.
1169
- waits_of[n_branches] = waits_note
1170
- waits_set[n_branches] = has_waits
1227
+ # The prerequisites travel with the branch, flattened as waits_of[n,1..k]
1228
+ # since this awk has no nested arrays. Presence is tracked separately so a
1229
+ # branch that declares none emits no key.
1230
+ store_waits(n_branches, b)
1171
1231
  builds_of[n_branches] = builds_note
1172
1232
  builds_set[n_branches] = has_builds
1173
1233
  # The kind travels with the branch, presence tracked separately so a slice
@@ -1231,13 +1291,7 @@ section == "slices" && slice_shape == "heading" {
1231
1291
 
1232
1292
  # Claim/deferral annotations bind to the line carrying the branch name, which
1233
1293
  # is the heading. Read before any match() below, which clobbers RSTART/RLENGTH.
1234
- claim_note = ""
1235
- if (index($0, "claimed:") > 0) {
1236
- _c = $0
1237
- sub(/^.*<!--[ \t]*claimed:[ \t]*/, "", _c)
1238
- sub(/[ \t]*-->.*$/, "", _c)
1239
- claim_note = trim(_c)
1240
- }
1294
+ claim_note = (index($0, "claimed:") > 0) ? marker_value($0, "claimed") : ""
1241
1295
  # `moved:` IS THE SAME ANSWER AS `deferred:`, and CLAUDE.md has said so since
1242
1296
  # the reconcile scan was written: *"`deferred:`/`moved:` in the plan means
1243
1297
  # reapable"*. `plot-reconcile-scan.sh:504` matches both in one arm; this
@@ -1249,16 +1303,7 @@ section == "slices" && slice_shape == "heading" {
1249
1303
  # The two words differ in what they tell a READER — given up, versus taken
1250
1304
  # somewhere else — and the reason is kept verbatim, so the distinction is not
1251
1305
  # lost by being read alike.
1252
- defer_note = ""
1253
- if (index($0, "deferred:") > 0 || index($0, "moved:") > 0) {
1254
- _d = $0
1255
- sub(/^.*<!--[ \t]*(deferred|moved):[ \t]*/, "", _d)
1256
- sub(/[ \t]*-->.*$/, "", _d)
1257
- defer_note = trim(_d)
1258
- }
1259
- # The prerequisite, read exactly as the list-item spelling reads it. Both
1260
- # dialects emit the same waves[], so a field added to one only would break
1261
- # that contract the first time a plan migrated.
1306
+ defer_note = (index($0, "deferred:") > 0 || index($0, "moved:") > 0) ? marker_value($0, "deferred|moved") : ""
1262
1307
  # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
1263
1308
  #
1264
1309
  # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
@@ -1283,48 +1328,20 @@ section == "slices" && slice_shape == "heading" {
1283
1328
  # `has_builds` carries presence separately from the value, exactly as
1284
1329
  # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
1285
1330
  # a deliverable or nothing and never a blank string that looks like one.
1286
- builds_note = ""
1287
- has_builds = 0
1288
- if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
1289
- _bl = $0
1290
- sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
1291
- sub(/[ \t]*-->.*$/, "", _bl)
1292
- builds_note = trim(_bl)
1293
- if (builds_note != "") has_builds = 1
1294
- }
1295
- waits_note = ""
1296
- has_waits = 0
1297
- if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
1298
- _w = $0
1299
- sub(/^.*<!--[ \t]*waits:[ \t]*/, "", _w)
1300
- sub(/[ \t].*$/, "", _w)
1301
- sub(/-->.*$/, "", _w)
1302
- waits_note = trim(_w)
1303
- # THE VALUE MUST LOOK LIKE A BRANCH, and that check is what keeps a
1304
- # SYNTAX EXAMPLE from becoming a declaration. A plan that documents the
1305
- # annotation writes the literal marker in prose, and no comment-aware
1306
- # reading can tell that apart from the real thing on the same line — the
1307
- # branch prefixes can. `<branch>` is not a branch name; `bug/x` is.
1308
- #
1309
- # Reusing the branch prefixes rather than a new pattern: the prerequisite
1310
- # IS a branch in this repo, so the two must never disagree about what a
1311
- # branch name looks like.
1312
- if (waits_note ~ "^(" PREFIXES ")/[^ \t]+$") has_waits = 1
1313
- else waits_note = ""
1314
- }
1331
+ builds_note = ($0 ~ /<!--[ \t]*builds:[ \t]*/) ? marker_value($0, "builds") : ""
1332
+ has_builds = (builds_note != "")
1333
+ # The prerequisites, read exactly as the list-item spelling reads them — see
1334
+ # parse_waits() and the comment at the list-item block for why the value is a
1335
+ # list, why it stops at whitespace/comma, and how an unreadable value reaches
1336
+ # unread_waits instead of being dropped.
1337
+ delete line_waits
1338
+ has_waits = (parse_waits($0, line_waits) > 0)
1315
1339
  # The agent kind, read exactly as the list-item spelling reads it. Both
1316
1340
  # dialects emit the same waves[], so a field added to one only would break that
1317
1341
  # contract the first time a plan migrated. See the list-item block for why the
1318
1342
  # value runs to the closing marker and why this one cannot validate itself.
1319
- agent_note = ""
1320
- has_agent = 0
1321
- if ($0 ~ /<!--[ \t]*agent:[ \t]*/) {
1322
- _ag = $0
1323
- sub(/^.*<!--[ \t]*agent:[ \t]*/, "", _ag)
1324
- sub(/[ \t]*-->.*$/, "", _ag)
1325
- agent_note = trim(_ag)
1326
- if (agent_note != "") has_agent = 1
1327
- }
1343
+ agent_note = ($0 ~ /<!--[ \t]*agent:[ \t]*/) ? marker_value($0, "agent") : ""
1344
+ has_agent = (agent_note != "")
1328
1345
 
1329
1346
  # The branch is the `Branch:` value, matched against the known prefixes exactly
1330
1347
  # as the old shape matched the backticked name. Written unquoted in the heading
@@ -1351,8 +1368,7 @@ section == "slices" && slice_shape == "heading" {
1351
1368
  deferred_of[n_branches] = ($0 ~ /<!--[ \t]*(deferred|moved)[ \t]*(:|-->)/) ? "true" : "false"
1352
1369
  deferred_why[n_branches] = defer_note
1353
1370
  claimed_of[n_branches] = claim_note
1354
- waits_of[n_branches] = waits_note
1355
- waits_set[n_branches] = has_waits
1371
+ store_waits(n_branches, b)
1356
1372
  builds_of[n_branches] = builds_note
1357
1373
  builds_set[n_branches] = has_builds
1358
1374
  agent_of[n_branches] = agent_note
package/plot-pr-merged.sh CHANGED
@@ -70,6 +70,19 @@
70
70
  # Both halves are pinned in `test/reconcile/host.test.mjs`, so the exemption
71
71
  # rests on tests that fail when it stops being true.
72
72
  #
73
+ # AN EMPTY BRANCH IS NOT ASKED ABOUT. `gh pr list --head ""` applies no filter,
74
+ # so every PR in the repository matches: measured 2026-10-01 on `origin/main`
75
+ # (`56a978ea`), `_plot_merged_lookup ""` answered `found`, `pr_merged ""` exited
76
+ # 0 — merged — and `pr_merged_heads ""` printed 98 lines, the head of every
77
+ # merged PR here. So each of the three lookups refuses an empty branch BEFORE
78
+ # `command -v gh`, answering `unaskable` rather than `none` because a lookup that
79
+ # did not run is silence and `none` would claim the host spoke; `pr_open ""` then
80
+ # casts no veto, which is safe only because `pr_merged ""` refuses on the same
81
+ # branch, as `mayRemove` asserts. The guard sits here and not at the call sites:
82
+ # all five callers guard it today and that is precisely why the defect stayed
83
+ # latent, so a sixth caller omitting the guard would be told a ref may be
84
+ # deleted.
85
+ #
73
86
  # `mergedAt` IS READ, NEVER `state`. A merged PR reports state CLOSED, and
74
87
  # trusting `state` would refuse every squash-merged branch — which is the whole
75
88
  # population these scripts exist for. Squash-merge rewrites the commits, so the
@@ -122,6 +135,7 @@ _plot_landed() {
122
135
  # is silence, and silence is never permission.
123
136
  _plot_merged_lookup() {
124
137
  local br="$1" out
138
+ [ -n "$br" ] || { echo unaskable; return; }
125
139
  command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
126
140
  out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt 2>/dev/null) \
127
141
  || { echo unaskable; return; }
@@ -131,6 +145,7 @@ _plot_merged_lookup() {
131
145
  # What the host says about an OPEN PR on this branch: found / none / unaskable.
132
146
  _plot_open_lookup() {
133
147
  local br="$1" out
148
+ [ -n "$br" ] || { echo unaskable; return; }
134
149
  command -v gh >/dev/null 2>&1 || { echo unaskable; return; }
135
150
  out=$(gh pr list --head "$br" --state open --limit 1 --json number 2>/dev/null) \
136
151
  || { echo unaskable; return; }
@@ -195,6 +210,7 @@ pr_open() {
195
210
  # merged.
196
211
  pr_merged_heads() {
197
212
  local br="$1" out
213
+ [ -n "$br" ] || return 1
198
214
  command -v gh >/dev/null 2>&1 || return 1
199
215
  out=$(gh pr list --head "$br" --state all --limit 100 --json mergedAt,headRefOid 2>/dev/null) \
200
216
  || return 1