@plot-pm/board 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/plot-host.sh CHANGED
@@ -41,6 +41,12 @@
41
41
  # pr-ready <number> take a PR out of draft
42
42
  # merge the PR
43
43
  # pr-list [--state open|merged|closed|all] [--limit N] [--rich]
44
+ # [--repo <owner/repo>] pins the list to ONE
45
+ # repository, exactly as pr-state and pr-merged
46
+ # do. A checkout with remotes on two hosts lets
47
+ # an unpinned list resolve the wrong one, and a
48
+ # caller joining it against `origin/*` refs then
49
+ # reads every branch as having no PR.
44
50
  # JSON lines: {"number":N,"title":"...",
45
51
  # "state":"...","head":"..."}
46
52
  # --rich adds: draft, checks, mergeable, review,
@@ -122,10 +128,12 @@
122
128
  # issue-list [--limit N] open tracker issues as JSON lines:
123
129
  # {"number":N,"title":"…","url":"…",
124
130
  # "createdAt":"…"}
125
- # READ-ONLY, and the only issue op here: Plot
126
- # never writes to the tracker (no labels, no
127
- # assignees, no close-on-merge), because a copy
128
- # of tracker state ages into a lie.
131
+ # READ-ONLY. Plot writes no label, no assignee
132
+ # and no close-on-merge, because a copy of
133
+ # tracker state ages into a lie. The one write
134
+ # that exists is `issue-status`, and it records
135
+ # a status Plot itself just caused rather than
136
+ # mirroring one the tracker holds.
129
137
  # `url` is "" when the host omits it, and a
130
138
  # consumer renders the number as plain text
131
139
  # rather than inventing an address — the rule
@@ -157,8 +165,8 @@
157
165
  # tickets*, the failure this story is named for.
158
166
  # issue-view <number> ONE open issue as a single JSON object:
159
167
  # {"number":N,"title":"…","body":"…","url":"…"}
160
- # STILL READ-ONLY — the second issue op, and it
161
- # reads. The board's *Create plan* action needs
168
+ # READ-ONLY — the second of the two issue reads.
169
+ # The board's *Create plan* action needs
162
170
  # the issue's BODY as the problem statement, and
163
171
  # issue-list deliberately omits it: the list is
164
172
  # asked on a timer for every open issue, and a
@@ -183,6 +191,28 @@
183
191
  # as issue-list. Jira answers 404 for a missing
184
192
  # key, which is exit 3 here (the tracker moved),
185
193
  # never an empty body.
194
+ # issue-status <key> <status> THE ONE WRITE TO A TRACKER: record one status
195
+ # against one issue, and nothing else. No
196
+ # create, no close, no comment, no label — a
197
+ # plan referencing an issue is Plot's record,
198
+ # and the status is the single fact the tracker
199
+ # owns a copy of.
200
+ # JIRA ONLY. Exit 4 where `Tracker` is not jira:
201
+ # this adapter cannot be asked, which is neither
202
+ # a failure nor a silent success. The other
203
+ # vendor's projects surface is written by
204
+ # `plot-update-board.sh`, under its own
205
+ # credentials and through a different API — the
206
+ # reason the tracker port has two connectors.
207
+ # Prints `written` where the transition was
208
+ # performed and `no-target` where the workflow
209
+ # offers no such transition from the issue's
210
+ # current state — which is also what a repeated
211
+ # write reports, because the same status twice
212
+ # is the same status.
213
+ # The transition id is LOOKED UP, never guessed:
214
+ # ids are per workflow and per issue, so a
215
+ # hardcoded one writes to the wrong column.
186
216
  # pr-body <number> --body B replace the PR description
187
217
  # rate-limit both GitHub budgets from `gh api rate_limit`.
188
218
  # SUPERSEDED BY `limit`, and kept only because
@@ -556,7 +586,23 @@ jenkins_build_map() {
556
586
  # `LC_ALL=C` on the match: the CLI localises its messages, and a matcher that
557
587
  # only works in English would silently reclassify every miss as an outage for
558
588
  # anyone else.
589
+ #
590
+ # A MISSING CLI IS NOT A MISS, AND THE BARE `not found` ALTERNATIVE COULD NOT
591
+ # TELL THEM APART. Measured 2026-09-06 with `gh` off `PATH`: the shell says
592
+ # `bash: gh: command not found`, which matched — so `pr-merged` answered
593
+ # `not-merged` where `plot-pr-merged.sh` answered `unaskable` about the same
594
+ # branch. `not-merged` reads to `rules/landed.ts` as `none` (the host spoke and
595
+ # said nothing merged), so `mayRemove` may permit a removal; `unaskable`
596
+ # refuses. `plot-release-refs.sh` deletes remote refs on that answer and a
597
+ # deleted ref is not re-creatable, so the two answers differ in the one
598
+ # direction this estate has said it will not fail in.
599
+ #
600
+ # The bare alternative stays — it is what recognises a Bitbucket or Jira miss,
601
+ # whose wording is neither `no pull requests found` nor `could not find`. What
602
+ # it excludes is the shell's own phrasing for an absent binary, which is a
603
+ # transport failure wearing a miss's words.
559
604
  is_lookup_miss() {
605
+ case "$1" in *'command not found'*) return 1 ;; esac
560
606
  LC_ALL=C grep -qiE 'no (pull request|pullrequest)s? (found|match)|could not find.*pull request|not found' <<<"$1"
561
607
  }
562
608
 
@@ -2067,6 +2113,13 @@ case "$op" in
2067
2113
  pr-list)
2068
2114
  state="open"
2069
2115
  rich=0
2116
+ # PIN THE LIST TO ONE REPOSITORY, the same `--repo` `pr-state` and
2117
+ # `pr-merged` already take. A checkout may carry several remotes on several
2118
+ # hosts, and an unpinned `gh pr list` resolves whichever of them it prefers
2119
+ # — so a caller comparing `origin/*` refs would join its refs against
2120
+ # another repository's PRs and report every branch as having none. The
2121
+ # caller knows which remote its refs came from; this op cannot guess it.
2122
+ repo_args=()
2070
2123
  # `gh pr list` and `bb pr list` both cap at 30 by default. That is invisible
2071
2124
  # with --state open (few repos have 30 open PRs) and bites immediately with
2072
2125
  # --state all, where the newest 30 crowd out every older merged PR. A caller
@@ -2078,6 +2131,7 @@ case "$op" in
2078
2131
  --state) state="${2:?}"; shift 2 ;;
2079
2132
  --limit) limit="${2:?}"; shift 2 ;;
2080
2133
  --rich) rich=1; shift ;;
2134
+ --repo) repo_args=(-R "${2:?}"); shift 2 ;;
2081
2135
  *) die "pr-list: unknown arg $1" ;;
2082
2136
  esac
2083
2137
  done
@@ -2196,7 +2250,7 @@ case "$op" in
2196
2250
  # $jstatus != "ok" → Jenkins could not answer; every row `unknown`.
2197
2251
  # $jentry == null → the branch has no Jenkins job; `none`.
2198
2252
  # otherwise → the joined colour's `checks`, job named on fail.
2199
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2253
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2200
2254
  --json number,title,state,headRefName,isDraft,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
2201
2255
  pr_list_report_truncation github "$limit" "$state" \
2202
2256
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2225,7 +2279,7 @@ case "$op" in
2225
2279
  }'
2226
2280
  else
2227
2281
  # GitHub without Jenkins (or Jenkins not configured): use GitHub rollup
2228
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2282
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2229
2283
  --json number,title,state,headRefName,isDraft,statusCheckRollup,mergeable,mergeStateStatus,reviewDecision,url)" || exit $?
2230
2284
  pr_list_report_truncation github "$limit" "$state" \
2231
2285
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2256,7 +2310,7 @@ case "$op" in
2256
2310
  }'
2257
2311
  fi
2258
2312
  else
2259
- _gh_raw="$(pr_list_call gh pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2313
+ _gh_raw="$(pr_list_call gh ${repo_args[@]+"${repo_args[@]}"} pr list --state "$state" ${limit_args[@]+"${limit_args[@]}"} \
2260
2314
  --json number,title,state,headRefName)" || exit $?
2261
2315
  pr_list_report_truncation github "$limit" "$state" \
2262
2316
  "$(jq 'length' <<<"$_gh_raw" 2>/dev/null || echo 0)"
@@ -2296,7 +2350,7 @@ case "$op" in
2296
2350
  # the GitHub arm uses, which is why it lives above the backend branch.
2297
2351
  # `bb`'s standing `unknown` becomes a real value where Jenkins answers.
2298
2352
  for _s in $bb_states; do
2299
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2353
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2300
2354
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2301
2355
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2302
2356
  printf '%s' "$_bb_raw" \
@@ -2325,7 +2379,7 @@ case "$op" in
2325
2379
  else
2326
2380
  # Bitbucket without Jenkins: checks remain unknown
2327
2381
  for _s in $bb_states; do
2328
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2382
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2329
2383
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2330
2384
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2331
2385
  printf '%s' "$_bb_raw" \
@@ -2334,7 +2388,7 @@ case "$op" in
2334
2388
  fi
2335
2389
  else
2336
2390
  for _s in $bb_states; do
2337
- _bb_raw="$(pr_list_call bb pr list --state "$_s" --json)" || exit $?
2391
+ _bb_raw="$(pr_list_call bb ${repo_args[@]+"${repo_args[@]}"} pr list --state "$_s" --json)" || exit $?
2338
2392
  pr_list_report_truncation bitbucket "$limit" "$_s" \
2339
2393
  "$(jq 'length' <<<"$_bb_raw" 2>/dev/null || echo 0)"
2340
2394
  printf '%s' "$_bb_raw" \
@@ -2690,6 +2744,55 @@ case "$op" in
2690
2744
  fi
2691
2745
  ;;
2692
2746
 
2747
+ issue-status)
2748
+ # THE ONE WRITE TO A TRACKER, and the amendment this op records: Plot writes
2749
+ # a STATUS to the tracker it was told about, and writes nothing else. No
2750
+ # ticket is created, none is closed, no comment, label or assignee is
2751
+ # touched. A plan referencing an issue stays Plot's record; the status is
2752
+ # the one fact the tracker owns a copy of, because it is the one a person
2753
+ # reads in the tracker rather than in Plot.
2754
+ #
2755
+ # JIRA ONLY, and deliberately so rather than by omission. This vendor's
2756
+ # projects surface has a script of its own (`plot-update-board.sh`) reached
2757
+ # by its own connector, and the two write through different APIs under
2758
+ # different credentials — which is why the tracker port has two connectors
2759
+ # rather than one arm with a branch. A repo whose `Tracker` is not jira gets
2760
+ # exit 4 here: this adapter cannot be asked, which is not a failure and not
2761
+ # a silent success.
2762
+ #
2763
+ # IDEMPOTENT BY NATURE. The same status written twice is the same status,
2764
+ # and Jira answers a transition to the state an issue already holds by
2765
+ # naming no such transition — which this reports as `no-target` rather than
2766
+ # as a failure.
2767
+ key="${1:?issue-status needs an issue key}"; shift
2768
+ want="${1:?issue-status needs a status}"; shift
2769
+ [ "$(tracker_scheme)" = "jira" ] || exit 4
2770
+ jira_require_config
2771
+ # THE TRANSITION IS LOOKED UP, NEVER GUESSED. Jira transitions are per
2772
+ # workflow and per issue: the id for "In Progress" differs between projects
2773
+ # and the transition may not be available from the issue's current state at
2774
+ # all. A hardcoded id writes a status to the wrong column silently.
2775
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions")"; curl_rc=$?
2776
+ body_json="$(jira_check "$raw" "$curl_rc")" || exit $?
2777
+ tid="$(printf '%s' "$body_json" | jq -r --arg want "$want" \
2778
+ '[.transitions[]? | select((.name // "" | ascii_downcase) == ($want | ascii_downcase)
2779
+ or ((.to.name // "" | ascii_downcase) == ($want | ascii_downcase)))]
2780
+ | .[0].id // ""')"
2781
+ if [ -z "$tid" ]; then
2782
+ # NO SUCH TRANSITION IS AN ANSWER, not a failure. The tracker was reached
2783
+ # and holds nowhere to put this status — either the workflow has no such
2784
+ # state or the issue is already in it. Reporting it as a failure would
2785
+ # make a repeated write look like an outage.
2786
+ printf '%s\n' 'no-target'
2787
+ exit 0
2788
+ fi
2789
+ raw="$(jira_curl "/rest/api/2/issue/$key/transitions" \
2790
+ -X POST -H 'Content-Type: application/json' \
2791
+ --data "$(jq -cn --arg id "$tid" '{transition:{id:$id}}')")"; curl_rc=$?
2792
+ jira_check "$raw" "$curl_rc" >/dev/null || exit $?
2793
+ printf '%s\n' 'written'
2794
+ ;;
2795
+
2693
2796
  pr-body)
2694
2797
  num="${1:?pr-body needs a PR number}"; shift
2695
2798
  body=""
@@ -2955,6 +3058,6 @@ case "$op" in
2955
3058
  ;;
2956
3059
 
2957
3060
  *)
2958
- die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|pr-body|rate-limit|limit|ci-limit|spend-rate)"
3061
+ die "unknown op '$op' (backend|default-branch|pr-state|pr-create|pr-merge|pr-list|issue-list|issue-view|issue-status|pr-body|rate-limit|limit|ci-limit|spend-rate)"
2959
3062
  ;;
2960
3063
  esac
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE answer to "is this monitor's subject still there?"
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.
12
+ #
13
+ # ═══════════════════════════════════════════════════════════════════════════
14
+ # WHY A MONITOR NEEDS THIS AT ALL
15
+ # ═══════════════════════════════════════════════════════════════════════════
16
+ #
17
+ # Measured 2026-08-30 and written up in
18
+ # `docs/research/2026-08-30-what-ends-a-monitor.md`: **nothing ended a monitor.**
19
+ # On an ordinary finish and on a `Worker bound` timeout alike, the wrapper's
20
+ # `wait "$agent"` returns, the wrapper writes `.plot-worker.exit` and exits, and
21
+ # both monitors are re-parented to `init` and loop forever. 34 of 40 monitors on
22
+ # the machine were `ppid=1` at the time of measurement, and 100 forks cost
23
+ # 23.3 ms against 4.8 ms on a quiet estate.
24
+ #
25
+ # The one run whose monitors WERE terminated is explained in that document and
26
+ # is not a mechanism: `nohup` does not `setsid`, so an orphan keeps the
27
+ # DISPATCHING SHELL's process group and a group kill sweeps it up collaterally.
28
+ # That fires when a human closes a terminal and never when a worker finishes —
29
+ # the opposite of a lifetime.
30
+ #
31
+ # ═══════════════════════════════════════════════════════════════════════════
32
+ # THE SUBJECT IS THE AGENT, AND THAT IS THE WHOLE DESIGN
33
+ # ═══════════════════════════════════════════════════════════════════════════
34
+ #
35
+ # A monitor exists to watch a dispatched agent. When that agent is gone there is
36
+ # nothing left to measure, so the monitor has finished its job rather than been
37
+ # interrupted — which is why this is a MEASUREMENT and not a timer.
38
+ #
39
+ # The plan forbids a timer explicitly, and the measurement says why: *"a monitor
40
+ # exiting after N seconds regardless would pass the visible assertions and
41
+ # destroy the property the whole plan rests on — a monitor that stops publishing
42
+ # means something."* A monitor that stops because its subject stopped carries
43
+ # information. One that stops because a clock ran out carries none, and is
44
+ # indistinguishable from one that crashed.
45
+ #
46
+ # THE AGENT COVERS ALL THREE ENDINGS. `--stop` kills the agent
47
+ # (`plot-dispatch.sh:752`); the `Worker bound` kills the agent
48
+ # (`plot-worker-loop.sh:172`); an ordinary finish is the agent exiting. In every
49
+ # case the wrapper survives just long enough to write `.plot-worker.exit` and
50
+ # then exits too. So watching the agent is sufficient, and watching the WRAPPER
51
+ # would be wrong: the wrapper outlives the agent by design, and a monitor bound
52
+ # to it would publish about a desk whose agent left.
53
+ #
54
+ # ═══════════════════════════════════════════════════════════════════════════
55
+ # AN ABSENT PID FILE IS `starting`, NEVER `gone`
56
+ # ═══════════════════════════════════════════════════════════════════════════
57
+ #
58
+ # `plot-dispatch.sh:478` records a sub-millisecond gap in which the wrapper has
59
+ # started and `.plot-worker.pid` is not yet written. The monitors start INSIDE
60
+ # that window — they are backgrounded before the agent, deliberately, so they
61
+ # exist before their subject does.
62
+ #
63
+ # So a monitor that read an absent pid file as `gone` would exit immediately on
64
+ # every single dispatch, and the leak would be replaced by a monitor that never
65
+ # runs. That is worse than the bug: an absent monitor is invisible, where an
66
+ # orphaned one at least shows up in `ps`.
67
+ #
68
+ # Three answers, not two:
69
+ #
70
+ # starting no pid file yet, or an unreadable one — the wrapper has not
71
+ # written it. Keep going; say nothing.
72
+ # alive the pid file names a process that exists. Keep going.
73
+ # gone the pid file names a process that does not exist. Stop.
74
+ #
75
+ # `starting` and `alive` are both "keep going", and they are kept apart anyway
76
+ # because the reason differs and a caller reporting them identically would lose
77
+ # the distinction the startup window depends on.
78
+ #
79
+ # ═══════════════════════════════════════════════════════════════════════════
80
+ # THE LOWER BOUND IS THE CALLER'S, AND IT IS AN ORDERING
81
+ # ═══════════════════════════════════════════════════════════════════════════
82
+ #
83
+ # The plan requires a monitor to outlive its agent long enough to record its
84
+ # finding — the Attaching slice's property, which this slice must not eat. This
85
+ # file does not enforce that, because it cannot: it answers a question and
86
+ # renders nothing.
87
+ #
88
+ # What enforces it is the ORDER in the callers: publish the pass, THEN ask.
89
+ # Every monitor therefore gets one final published pass after its agent has
90
+ # gone, which is the lower bound expressed as sequence rather than as a sleep.
91
+ # A caller that asked first and published second would satisfy the upper bound
92
+ # and silently lose the lower one — so the order is asserted in the tests, not
93
+ # left to a comment.
94
+
95
+ # `kill -0` is the liveness question, and it is the same one
96
+ # `plot-worker-state.sh` asks. It sends no signal; it only reports whether the
97
+ # pid can be signalled. A pid we do not own answers EPERM rather than ESRCH,
98
+ # which `kill -0` still reports as success — correct here, since a process we
99
+ # cannot signal is nonetheless a process that exists.
100
+ #
101
+ # `$1` = the path to the agent's pid file (`.plot-worker.pid`).
102
+ # Prints exactly one of: starting | alive | gone
103
+ plot_monitor_subject() {
104
+ local pid_file="${1:-}" pid
105
+
106
+ # No path at all: a hand-run monitor with no worktree, which has no subject to
107
+ # outlive and must not exit on its first pass. `starting` is the honest answer
108
+ # — there is nothing here that says the subject is gone.
109
+ [ -n "$pid_file" ] || { printf 'starting'; return 0; }
110
+
111
+ # NO PID FILE SPLITS TWO CASES, and reading them as one is what made monitors
112
+ # immortal. `starting` is right only while the desk is still there and the
113
+ # wrapper has not yet written the pid. If the DIRECTORY the pid file lives in
114
+ # is gone, the desk was removed — there is no subject to wait for and none is
115
+ # coming, so the honest answer is `gone`.
116
+ #
117
+ # Measured on CI 2026-08-31: 14 monitors at PPID 1, aged 11-13 minutes, each
118
+ # holding a `sleep 1`, after every test in the reconcile suite had PASSED.
119
+ # A test's fixture is removed at teardown, so its pid file vanishes BEFORE the
120
+ # agent does; `plot_monitor_wait` then never sees `gone` and loops forever,
121
+ # holding node's event loop open until the job ceiling kills it. That is the
122
+ # whole of the reconcile-suite hang, and it is why this is a two-case answer
123
+ # rather than one.
124
+ #
125
+ # PRODUCTION IS UNCHANGED: a real worktree outlives its agent, so the
126
+ # directory is present and this reads `starting` exactly as before.
127
+ if [ ! -f "$pid_file" ]; then
128
+ [ -d "$(dirname "$pid_file")" ] && { printf 'starting'; return 0; }
129
+ printf 'gone'; return 0
130
+ fi
131
+
132
+ pid=$(cat "$pid_file" 2>/dev/null | tr -d ' \n')
133
+
134
+ # A file that exists but holds no digits is a half-written pid, which is the
135
+ # startup window caught mid-`printf`. Not gone.
136
+ case "$pid" in
137
+ '' | *[!0-9]*) printf 'starting'; return 0 ;;
138
+ esac
139
+
140
+ if kill -0 "$pid" 2>/dev/null; then
141
+ printf 'alive'
142
+ else
143
+ printf 'gone'
144
+ fi
145
+ }
146
+
147
+ # Sleep up to `$1` seconds, but stop early the moment the subject at `$2` is
148
+ # gone. Returns 0 to publish another pass, 1 to leave.
149
+ #
150
+ # ═══════════════════════════════════════════════════════════════════════════
151
+ # WHY THE WAIT IS SPLIT WHEN THE PUBLISHING IS NOT
152
+ # ═══════════════════════════════════════════════════════════════════════════
153
+ #
154
+ # THE TWO CADENCES MUST STAY APART. The plan is explicit: the WorkerMonitor
155
+ # samples the process table every 30 s because a CPU delta is meaningless
156
+ # sampled further apart, and the AgentMonitor asks the host every 300 s because
157
+ # this repo has already measured what host questions on a fast loop cost. *"One
158
+ # subject wants tight sampling of a cheap fact; the other occasional sampling of
159
+ # an expensive one. Merging them would force one of those two to be wrong."*
160
+ #
161
+ # SO ONLY THE WAIT IS SPLIT, NEVER THE PASS. Publishing still happens on the
162
+ # monitor's own interval, unchanged — nothing here makes the AgentMonitor ask
163
+ # the host more often, and its 300 s stays 300 s. What is split is the IDLE TIME
164
+ # between passes, into short naps with a `kill -0` between them.
165
+ #
166
+ # WITHOUT THIS, THE UPPER BOUND IS THE INTERVAL. An AgentMonitor checking only
167
+ # after its full sleep would outlive an agent that finished in ten seconds by
168
+ # nearly five minutes. Bounded, technically — and still an orphan on every
169
+ # dispatch, on an estate where dispatches are frequent. The measurement that
170
+ # opened this slice counted 34 orphans; a five-minute window would have counted
171
+ # plenty too.
172
+ #
173
+ # THE PROBE IS FREE, WHICH IS WHY IT MAY BE FREQUENT. `kill -0` sends no signal
174
+ # and asks no host — it is a single syscall against the process table, the same
175
+ # question `plot-worker-state.sh` asks. The expensive half of an AgentMonitor
176
+ # pass is the host round trip, and that is in the PASS, not here.
177
+ #
178
+ # THE NAP IS THE GRANULARITY, and one second is chosen against the WorkerMonitor
179
+ # rather than in the abstract: a monitor may not outlive its agent by more than
180
+ # the tighter of the two cadences, or the fast monitor's exit would be slower
181
+ # than its own sampling. Any interval SHORTER than one nap sleeps once and is
182
+ # unaffected, which keeps `PLOT_MONITOR_INTERVAL=1` in a test behaving exactly
183
+ # as it reads.
184
+ plot_monitor_wait() { # $1 = seconds to wait, $2 = pid file
185
+ local remaining="${1:-0}" pid_file="${2:-}" nap
186
+ while [ "$remaining" -gt 0 ]; do
187
+ nap=1
188
+ [ "$remaining" -lt 1 ] && nap="$remaining"
189
+ sleep "$nap" || return 1
190
+ remaining=$((remaining - nap))
191
+ [ "$(plot_monitor_subject "$pid_file")" = gone ] && return 1
192
+ done
193
+ return 0
194
+ }
package/plot-plan-meta.sh CHANGED
@@ -20,9 +20,17 @@
20
20
  # Two plan formats are recognized:
21
21
  #
22
22
  # canonical the plan template's `## Status` body section:
23
- # - **Phase:** Approved
23
+ # - **State:** Approved
24
24
  # - **Type:** feature
25
- # (bullet, bold, and plain `Phase: ...` variants all accepted)
25
+ # (bullet, bold, and plain `State: ...` variants all accepted)
26
+ #
27
+ # `State:` is the field Plot writes. `Phase:` is the name it
28
+ # carried until 2026-09-07 and is read as the alternate, the
29
+ # same way front matter reads `status:` over `phase:` below.
30
+ # THE DUAL READ IS PERMANENT, NOT SCAFFOLDING: a plan file may
31
+ # have been written a year ago or copied from another project,
32
+ # and a Plot that refused to read `Phase:` would be worse at its
33
+ # own job than the one that confused two words.
26
34
  #
27
35
  # frontmatter YAML front matter at the top of the file:
28
36
  # ---
@@ -88,7 +96,8 @@
88
96
  # which is a queue. (`ready-for-review`/`in-review` DO
89
97
  # normalize onto `approved` — those are synonyms; this is not.)
90
98
  # phase_alt_raw secondary value when the file carries two (front matter
91
- # status: AND phase:), else ""
99
+ # status: AND phase:, or a `## Status` body carrying both
100
+ # State: AND Phase:), else ""
92
101
  # phase_alt normalized phase_alt_raw (NONE when absent)
93
102
  # type normalized plan type (feature|bug|docs|infra or "")
94
103
  # title plan title: front matter `title:` wins, else the first H1
@@ -128,6 +137,19 @@
128
137
  # says: a prerequisite no plan declares still parses, and the
129
138
  # scan is what turns that into a verdict. `waits:` and
130
139
  # `deferred:` are independent — a branch may carry both.
140
+ # `<!-- builds: normalizeVersion, a shared helper -->` names
141
+ # what this slice BUILDS, reported as
142
+ # `waves[].branches[].builds`. OPTIONAL, like `Sprint:` and
143
+ # `Story:` — a docs plan, a rejection or a measurement builds
144
+ # nothing nameable, and nothing warns about its absence. The
145
+ # key is ABSENT where none was written, never "". The value
146
+ # runs to the closing marker rather than stopping at the first
147
+ # space the way `waits:` does: a prerequisite is a branch NAME
148
+ # and a deliverable is a name plus enough words to search for.
149
+ # An annotation rather than a `Builds:` field line precisely
150
+ # BECAUSE annotations already work in both slice dialects from
151
+ # one block of code — a field line would need two spellings,
152
+ # and the template writes the list dialect.
131
153
  # prs PR numbers, sorted and unique, read from EITHER spelling:
132
154
  # `→ #NNN` / `→ owner/repo#NNN` links in the `## Branches`
133
155
  # section, OR `PR: #NNN` in a `## Waves` `### ` heading. The
@@ -382,7 +404,7 @@ function reset_state() {
382
404
  fm_review = ""; fm_impl = ""; fm_approved = ""; fm_started = ""; fm_released = ""
383
405
  fm_delivered = ""; fm_design = ""
384
406
  fm_rounds = ""
385
- canon_phase = ""; canon_type = ""
407
+ canon_state = ""; canon_phase = ""; canon_type = ""
386
408
  canon_sprint = ""; canon_story = ""; canon_assignee = ""
387
409
  canon_review = ""; canon_impl = ""; canon_approved = ""; canon_released = ""
388
410
  canon_delivered = ""; canon_design = ""
@@ -401,6 +423,7 @@ function reset_state() {
401
423
  delete wave_names; delete wave_of; delete wave_seq; delete wave_count
402
424
  delete deferred_of; delete deferred_why; delete claimed_of; delete ordered_b; n_waves = 0
403
425
  delete waits_of; delete waits_set
426
+ delete builds_of; delete builds_set
404
427
  delete started; n_started = 0
405
428
  fm_changelog = ""
406
429
  delete changelog; n_changelog = 0; changelog_seen = 0; cl_open = 0
@@ -411,8 +434,14 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
411
434
  praw = (fm_status != "") ? fm_status : fm_phase
412
435
  palt_raw = (fm_status != "" && fm_phase != "") ? fm_phase : ""
413
436
  traw = fm_type
414
- } else if (canon_phase != "") {
415
- fmt = "canonical"; praw = canon_phase; palt_raw = ""; traw = canon_type
437
+ } else if (canon_state != "" || canon_phase != "") {
438
+ # `State:` is primary and `Phase:` the alternate, exactly as front matter
439
+ # reads `status:` over `phase:`. A file carrying both reports the
440
+ # disagreement rather than hiding it.
441
+ fmt = "canonical"
442
+ praw = (canon_state != "") ? canon_state : canon_phase
443
+ palt_raw = (canon_state != "" && canon_phase != "") ? canon_phase : ""
444
+ traw = canon_type
416
445
  } else {
417
446
  fmt = "none"; praw = ""; palt_raw = ""; traw = ""
418
447
  }
@@ -551,6 +580,10 @@ function emit_record( fmt, praw, palt_raw, traw, title, sprint, story, assigne
551
580
  # reading `waits_on` gets a branch name or nothing — never a blank string
552
581
  # that reads as a prerequisite with no name.
553
582
  if (waits_set[i] == 1) out = out ",\"waits_on\":\"" jesc(waits_of[i]) "\""
583
+ # ABSENT, NOT EMPTY, for the same reason `waits_on` is: a slice that
584
+ # names no deliverable emits no key, so a consumer reads a name or
585
+ # nothing. An empty string would read as a deliverable called "".
586
+ if (builds_set[i] == 1) out = out ",\"builds\":\"" jesc(builds_of[i]) "\""
554
587
  out = out "}"
555
588
  first = 0
556
589
  }
@@ -748,7 +781,8 @@ in_comment {
748
781
  }
749
782
  section == "status" {
750
783
  lower = tolower($0)
751
- if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
784
+ if (lower ~ /^[ \t]*[-*]?[ \t]*\**state[:*]/ && canon_state == "") canon_state = val_after_colon($0)
785
+ else if (lower ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ && canon_phase == "") canon_phase = val_after_colon($0)
752
786
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**type[:*]/ && canon_type == "") canon_type = val_after_colon($0)
753
787
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**sprint[:*]/ && canon_sprint == "") canon_sprint = val_after_colon($0)
754
788
  else if (lower ~ /^[ \t]*[-*]?[ \t]*\**story[:*]/ && canon_story == "") canon_story = val_after_colon($0)
@@ -925,6 +959,39 @@ section == "slices" && slice_shape != "heading" {
925
959
  # `has_waits` carries presence separately from the value, because ABSENT and
926
960
  # EMPTY are different answers — a branch declaring no prerequisite emits no
927
961
  # `waits_on` key at all.
962
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
963
+ #
964
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
965
+ # slice dialects with no dialect-specific code. Annotations bind to the line
966
+ # carrying the branch name — the list item in one spelling, the `### `
967
+ # heading in the other — and both dialects already read `deferred:`,
968
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
969
+ # would have had two spellings to parse, and the template writes the LIST
970
+ # dialect, so the heading-only version would be absent from every plan
971
+ # created from it.
972
+ #
973
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
974
+ # and each slice builds one; a plan-level list is searched as a whole and
975
+ # reported against the wrong slice.
976
+ #
977
+ # The value runs to the closing marker, the way `deferred:` does and unlike
978
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
979
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
980
+ # a shared helper` — and cutting it at the first space would leave the half
981
+ # that identifies it.
982
+ #
983
+ # `has_builds` carries presence separately from the value, exactly as
984
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
985
+ # a deliverable or nothing and never a blank string that looks like one.
986
+ builds_note = ""
987
+ has_builds = 0
988
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
989
+ _bl = $0
990
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
991
+ sub(/[ \t]*-->.*$/, "", _bl)
992
+ builds_note = trim(_bl)
993
+ if (builds_note != "") has_builds = 1
994
+ }
928
995
  waits_note = ""
929
996
  has_waits = 0
930
997
  if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
@@ -982,6 +1049,8 @@ section == "slices" && slice_shape != "heading" {
982
1049
  # so a branch that declares none emits no key.
983
1050
  waits_of[n_branches] = waits_note
984
1051
  waits_set[n_branches] = has_waits
1052
+ builds_of[n_branches] = builds_note
1053
+ builds_set[n_branches] = has_builds
985
1054
  ordered_b[n_branches] = b
986
1055
  }
987
1056
  line = $0
@@ -1066,6 +1135,39 @@ section == "slices" && slice_shape == "heading" {
1066
1135
  # The prerequisite, read exactly as the list-item spelling reads it. Both
1067
1136
  # dialects emit the same waves[], so a field added to one only would break
1068
1137
  # that contract the first time a plan migrated.
1138
+ # WHAT THIS SLICE BUILDS: `<!-- builds: normalizeVersion, a shared helper -->`.
1139
+ #
1140
+ # AN ANNOTATION, NOT A FIELD LINE, and that is what makes it work in BOTH
1141
+ # slice dialects with no dialect-specific code. Annotations bind to the line
1142
+ # carrying the branch name — the list item in one spelling, the `### `
1143
+ # heading in the other — and both dialects already read `deferred:`,
1144
+ # `claimed:` and `waits:` with the identical block. A `Builds:` field line
1145
+ # would have had two spellings to parse, and the template writes the LIST
1146
+ # dialect, so the heading-only version would be absent from every plan
1147
+ # created from it.
1148
+ #
1149
+ # IT BELONGS TO THE SLICE, NEVER TO THE PLAN. A plan builds several things
1150
+ # and each slice builds one; a plan-level list is searched as a whole and
1151
+ # reported against the wrong slice.
1152
+ #
1153
+ # The value runs to the closing marker, the way `deferred:` does and unlike
1154
+ # `waits:`. A prerequisite is a branch NAME and stops at whitespace; a
1155
+ # deliverable is a name plus enough words to search for — `normalizeVersion,
1156
+ # a shared helper` — and cutting it at the first space would leave the half
1157
+ # that identifies it.
1158
+ #
1159
+ # `has_builds` carries presence separately from the value, exactly as
1160
+ # `waits:` does: a slice declaring nothing emits no key, so a consumer reads
1161
+ # a deliverable or nothing and never a blank string that looks like one.
1162
+ builds_note = ""
1163
+ has_builds = 0
1164
+ if ($0 ~ /<!--[ \t]*builds:[ \t]*/) {
1165
+ _bl = $0
1166
+ sub(/^.*<!--[ \t]*builds:[ \t]*/, "", _bl)
1167
+ sub(/[ \t]*-->.*$/, "", _bl)
1168
+ builds_note = trim(_bl)
1169
+ if (builds_note != "") has_builds = 1
1170
+ }
1069
1171
  waits_note = ""
1070
1172
  has_waits = 0
1071
1173
  if ($0 ~ /<!--[ \t]*waits:[ \t]*/) {
@@ -1107,6 +1209,8 @@ section == "slices" && slice_shape == "heading" {
1107
1209
  claimed_of[n_branches] = claim_note
1108
1210
  waits_of[n_branches] = waits_note
1109
1211
  waits_set[n_branches] = has_waits
1212
+ builds_of[n_branches] = builds_note
1213
+ builds_set[n_branches] = has_builds
1110
1214
  ordered_b[n_branches] = b
1111
1215
  }
1112
1216