@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.
- package/dist/board-server.mjs +258 -146
- package/package.json +3 -6
- package/plot-agent-manifest.sh +76 -15
- package/plot-agent-monitor.sh +43 -5
- package/plot-approve.sh +224 -73
- package/plot-config.sh +14 -14
- package/plot-deliver.sh +10 -741
- package/plot-dispatch.sh +282 -107
- package/plot-fleet-scan.sh +867 -170
- package/plot-host.sh +310 -76
- package/plot-monitor-subject.sh +61 -10
- package/plot-plan-meta.sh +155 -139
- package/plot-pr-merged.sh +16 -0
- package/plot-reap.sh +61 -28
- package/plot-state-receipt.sh +12 -2
- package/plot-tmp.sh +31 -3
- package/plot-worker-state.sh +458 -24
- package/plot-build-monitor.sh +0 -435
- package/plot-resolve-artifact.sh +0 -423
- package/plot-transcript-quiet.sh +0 -170
- package/plot-worker-monitor.sh +0 -705
package/plot-monitor-subject.sh
CHANGED
|
@@ -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-
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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]*)
|
|
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
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
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
|
|
619
|
-
# only on a branch whose line carries a `waits:` annotation, so a
|
|
620
|
-
# reading `waits_on` gets a
|
|
621
|
-
#
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1026
|
-
#
|
|
1027
|
-
#
|
|
1028
|
-
#
|
|
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
|
-
#
|
|
1031
|
-
# running to the closing marker the way a deferral reason does: a
|
|
1032
|
-
# sentence, a branch name is a token, and trailing prose inside
|
|
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 =
|
|
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 =
|
|
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
|
|
1168
|
-
#
|
|
1169
|
-
|
|
1170
|
-
|
|
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 =
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|