@plot-pm/board 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Local Kanban board for Plot — a glanceable view of plan phases from docs/plans, with sprint and story filters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,16 +22,24 @@
22
22
  },
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
+ "plot-agent-monitor.sh",
26
+ "plot-build-monitor.sh",
27
+ "plot-monitor-subject.sh",
25
28
  "plot-approve.sh",
29
+ "plot-budget.sh",
26
30
  "plot-config.sh",
31
+ "plot-default-branch.sh",
27
32
  "plot-deliver.sh",
28
33
  "plot-dispatch.sh",
29
34
  "plot-fleet-scan.sh",
30
35
  "plot-host.sh",
31
36
  "plot-plan-meta.sh",
37
+ "plot-pr-merged.sh",
32
38
  "plot-reap.sh",
33
39
  "plot-release-refs.sh",
34
40
  "plot-resolve-artifact.sh",
41
+ "plot-transcript-quiet.sh",
42
+ "plot-worker-monitor.sh",
35
43
  "plot-worker-state.sh"
36
44
  ],
37
45
  "scripts": {
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the AgentMonitor — watches the DESK, and what the agent at it owes.
3
+ #
4
+ # RUN, NOT SOURCED, and started by `start_worker()` in `plot-dispatch.sh` as a
5
+ # child of the wrapper, beside the WorkerMonitor.
6
+ #
7
+ # ═══════════════════════════════════════════════════════════════════════════
8
+ # TWO MONITORS, BECAUSE THERE ARE TWO SUBJECTS
9
+ # ═══════════════════════════════════════════════════════════════════════════
10
+ #
11
+ # CLAUDE.md settles the split for new code: *"a state answering what is the
12
+ # process doing? goes on the worker; one answering what does this agent owe, or
13
+ # still hold? goes on the agent."* This is the second half.
14
+ #
15
+ # | monitor | samples | cadence | answers |
16
+ # |-------------------|--------------------|---------|--------------------------------|
17
+ # | **WorkerMonitor** | the process table | seconds | is this process doing anything?|
18
+ # | **AgentMonitor** | the desk, the host | minutes | does this agent still owe? |
19
+ #
20
+ # **They cannot share a cadence, which is the practical reason they are two.**
21
+ # CPU delta is meaningless unless sampled close together. Whether a branch has a
22
+ # PR is a host round trip, and asking it every few seconds is the rate problem
23
+ # this repository already measured at 127 git processes per scan. One subject
24
+ # wants tight sampling of a cheap fact; the other occasional sampling of an
25
+ # expensive one. Merging them would force one of those two to be wrong.
26
+ #
27
+ # ═══════════════════════════════════════════════════════════════════════════
28
+ # FOUR FINDINGS, AND SILENCE MEANS THE DESK OWES NOTHING
29
+ # ═══════════════════════════════════════════════════════════════════════════
30
+ #
31
+ # | finding | measurement |
32
+ # |-------------------------|----------------------------------------------------|
33
+ # | **owes a review** | commits ahead, tree clean, no PR |
34
+ # | **owes a gate** | commits ahead, and a repo gate the branch fails |
35
+ # | **owes an answer** | a `PLOT-BLOCKED*` marker in the tree |
36
+ # | **holds unlanded work** | uncommitted or unpushed changes in the tree |
37
+ #
38
+ # It published `nothing measured yet` on every pass until this slice, which was
39
+ # the Attaching slice saying honestly that it was attached and blind. That string
40
+ # is gone, and its absence is now the healthy signal: an attached monitor with
41
+ # nothing to say publishes NOTHING, and the AgentMonitor's file existing at all
42
+ # is what separates *watched and clean* from *never started*.
43
+ #
44
+ # ═══════════════════════════════════════════════════════════════════════════
45
+ # THE DEBT OUTLIVES THE AGENT'S ATTENTION, AND THAT IS THE POINT
46
+ # ═══════════════════════════════════════════════════════════════════════════
47
+ #
48
+ # The monitor does NOT have to catch the moment work finishes. A debt persists
49
+ # until it is paid — until a PR exists, until the marker is answered, until the
50
+ # commits are pushed — so a finding one interval late is as good as one on time.
51
+ # That is what licenses a five-minute cadence for findings about work that
52
+ # finishes in seconds, and it is why nothing here races the agent.
53
+ #
54
+ # ═══════════════════════════════════════════════════════════════════════════
55
+ # WHEN IT ARRIVES, A FINDING WILL NAME THE SLICE — NOT THE AGENT
56
+ # ═══════════════════════════════════════════════════════════════════════════
57
+ #
58
+ # Recorded here because it constrains the record shape this slice fixes, and a
59
+ # later slice that discovered it would have to change the contract instead of
60
+ # filling it in. An agent outlives its slice: it finishes one unit and takes
61
+ # another, so by the time *"this agent owes a review"* is read, the agent may be
62
+ # three commits into different work and the debt belongs to a branch it has
63
+ # left. That report would send someone to a desk where nothing is wrong.
64
+ #
65
+ # So the finding is keyed by BRANCH, and the agent appears only as who was at
66
+ # that desk when it happened. That is why `branch` is a field here rather than
67
+ # something a reader derives from the worktree.
68
+ #
69
+ # ═══════════════════════════════════════════════════════════════════════════
70
+ # IT OBSERVES; IT DOES NOT ACT — AND IT WRITES NOTHING AT ALL
71
+ # ═══════════════════════════════════════════════════════════════════════════
72
+ #
73
+ # It does not kill a process, open a PR, reap a worktree or restart an agent.
74
+ # Every one of those is a judgement with a blast radius, and `plot-reap.sh` and
75
+ # `plot-dispatch.sh` already own them behind their own refusals. That boundary
76
+ # is what makes a monitor safe to run continuously: a watcher that can only
77
+ # report is one nobody has to supervise.
78
+ #
79
+ # PUBLISHING IS ITS ONLY OUTPUT, and that is stricter than "it does not act".
80
+ # It writes no state file, no cache, no marker, and nothing into the repository
81
+ # it is watching — not even a record of what it last published. The two
82
+ # variables that make "publish on change" work live in memory and die with the
83
+ # process, so a restarted monitor re-derives them one interval late rather than
84
+ # reading a stale one. Opening the PR that `owes a review` calls for belongs to
85
+ # `feature/a-report-can-open-the-pr`, through the controller, and not here.
86
+ set -uo pipefail
87
+
88
+ usage() {
89
+ cat >&2 <<'EOF'
90
+ Usage: plot-agent-monitor.sh [--once]
91
+
92
+ Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
93
+ the environment, exactly as the wrapper's other children do:
94
+
95
+ PLOT_BRANCH the branch whose debts this monitor will report
96
+ PLOT_WORKTREE the desk it reads
97
+ PLOT_MONITOR_FILE where findings are published (default:
98
+ $PLOT_WORKTREE/.plot-worker.monitor.agent.jsonl)
99
+ PLOT_MONITOR_INTERVAL seconds between passes (default 300)
100
+
101
+ --once take one sample and exit, rather than looping. A test
102
+ affordance: nothing dispatches this mode.
103
+ EOF
104
+ }
105
+
106
+ once=0
107
+ while [ $# -gt 0 ]; do
108
+ case "$1" in
109
+ --once) once=1 ;;
110
+ -h|--help) usage; exit 0 ;;
111
+ *) echo "plot-agent-monitor: unknown argument '$1'" >&2; usage; exit 2 ;;
112
+ esac
113
+ shift
114
+ done
115
+
116
+ monitor='AgentMonitor'
117
+
118
+ branch="${PLOT_BRANCH:-}"
119
+ worktree="${PLOT_WORKTREE:-}"
120
+ # FIVE MINUTES IS A HOST BUDGET, NOT CAUTION. Its findings need a PR lookup, and
121
+ # this repository has already measured what happens when host questions ride a
122
+ # fast loop. Against a stall that lasted 50 minutes, five makes it visible 45
123
+ # minutes earlier than a person asking — the saving is in the order of
124
+ # magnitude, not the seconds. The default differs from the WorkerMonitor's 30s
125
+ # BY DESIGN; a shared default would be the merged cadence this split exists to
126
+ # prevent.
127
+ interval="${PLOT_MONITOR_INTERVAL:-300}"
128
+
129
+ findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.agent.jsonl}}"
130
+
131
+ # THE SUBJECT, read the same way the WorkerMonitor reads it: `.plot-worker.pid`
132
+ # names the AGENT, and the wrapper passes its path in `PLOT_PID_FILE`.
133
+ pid_file="${PLOT_PID_FILE:-${worktree:+$worktree/.plot-worker.pid}}"
134
+
135
+ # ONE ANSWER TO "IS MY SUBJECT STILL THERE?", shared with the WorkerMonitor.
136
+ # The 300 s cadence is exactly why this monitor must not decide separately: an
137
+ # AgentMonitor that checked only after a full sleep would outlive an agent that
138
+ # finished in ten seconds by nearly five minutes, on every dispatch. The helper
139
+ # splits the WAIT and leaves the PASS alone, so the host is still asked at 300 s
140
+ # and the two cadences stay apart.
141
+ # shellcheck source=./plot-monitor-subject.sh
142
+ . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-monitor-subject.sh"
143
+
144
+ # THE DESK'S OWN READINGS, borrowed rather than rewritten. `plot_worker_dirty`,
145
+ # `plot_worker_dirty_filter`, `plot_worker_blocked` and
146
+ # `plot_worker_blocked_file` already answer three of this monitor's four
147
+ # questions, and they are the SAME answers the fleet scan gives — which is the
148
+ # whole point of sourcing them. A monitor that decided independently what a
149
+ # dirty tree or a blocked desk looks like would drift from the scan, and the
150
+ # operator would get two components disagreeing about one worktree.
151
+ #
152
+ # `plot_worker_dirty_filter` matters most: it drops the `.plot-worker.` prefix,
153
+ # and this script APPENDS to `.plot-worker.monitor.agent.jsonl` inside the very
154
+ # worktree it measures. Without the filter every monitored desk would report
155
+ # `holds unlanded work` about the monitor's own findings file, one pass in,
156
+ # across the whole fleet.
157
+ #
158
+ # SOURCED WITH A GUARD because a monitor whose helper is missing must still say
159
+ # so rather than die silently in a detached shell nobody is reading. Each port
160
+ # below tests for its function with `command -v` and refuses rather than
161
+ # guessing.
162
+ plot_state_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-worker-state.sh"
163
+ # shellcheck source=plot-worker-state.sh
164
+ if [ -r "$plot_state_lib" ]; then . "$plot_state_lib"; fi
165
+
166
+ json_escape() { # $1 = raw → prints a JSON-safe string body
167
+ printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
168
+ || printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
169
+ }
170
+
171
+ # A FINDING CARRIES FOUR FIELDS: `finding`, `since`, `evidence`, `measuredAt` —
172
+ # the same four the WorkerMonitor publishes, for the same reason: one subscriber
173
+ # will read both files and must not need a second parser to do it.
174
+ #
175
+ # `since` AND `measuredAt` ARE DIFFERENT TIMES, and the gap matters more here
176
+ # than it does next door. This monitor samples every five minutes, so a debt
177
+ # that has held for an hour and one first seen at the last pass carry the same
178
+ # `measuredAt` and very different `since`. On a slow cadence, `since` is the
179
+ # field that says how long nobody noticed.
180
+ publish() { # $1=finding $2=evidence $3=since
181
+ local now
182
+ now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
183
+ local line
184
+ line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
185
+ "$monitor" \
186
+ "$(json_escape "$branch")" \
187
+ "$(json_escape "$worktree")" \
188
+ "$(json_escape "$1")" \
189
+ "${3:-$now}" \
190
+ "$(json_escape "$2")" \
191
+ "$now")
192
+ # Both destinations, for the WorkerMonitor's reason: the file is what a
193
+ # subscriber reads, and stdout lands in `.plot-worker.log` beside the agent's
194
+ # own output where an operator tailing a worker sees it without knowing a
195
+ # second file exists.
196
+ [ -n "$findings" ] && printf '%s\n' "$line" >> "$findings" 2>/dev/null
197
+ printf 'plot-monitor %s\n' "$line"
198
+ }
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # THE PORTS — five named seams, so every branch is reachable from a test
202
+ # ---------------------------------------------------------------------------
203
+ #
204
+ # Same convention as the WorkerMonitor's four, and for the same reason: a test
205
+ # sources this file with `PLOT_MONITOR_NO_MAIN=1` and REDEFINES them. Here the
206
+ # argument is stronger, because one of these seams is a HOST ROUND TRIP. A host
207
+ # that refuses is the state this monitor must handle correctly and the one a
208
+ # test can least afford to produce for real — you cannot break GitHub to see
209
+ # what happens, and waiting for it to break on its own is not a test.
210
+ #
211
+ # The seams are the ports; the sampler below is the logic. Nothing between them
212
+ # touches the machine or the host directly.
213
+
214
+ # Does the branch have a PR — open or merged — right now?
215
+ #
216
+ # → 0 yes | 1 no | 2 the host could not be asked
217
+ #
218
+ # THREE ANSWERS, AND THE THIRD IS THE WHOLE REASON THIS IS A PORT. `pr_merged`
219
+ # and `pr_open` both collapse an unreachable host into "no", which is right for
220
+ # THEIR callers: the reaper and the ref deleter are deciding whether to destroy
221
+ # something, and silence must never be permission. Here the direction inverts.
222
+ # A monitor that read an unreachable host as "no PR" would report `owes a
223
+ # review` about every branch on the estate the moment `gh` lost its token — a
224
+ # storm of findings whose common cause is that nothing was measured at all.
225
+ #
226
+ # So this asks the two questions separately and keeps the distinction the
227
+ # helpers throw away: `gh` absent, unauthed, or failing is `unaskable`, and an
228
+ # unaskable host produces NO finding rather than a wrong one. A failure to
229
+ # observe is not evidence of something to see.
230
+ #
231
+ # THE HOST IS ASKED THROUGH `plot-host.sh`, never `gh` directly. This port
232
+ # called `gh pr list` until 2026-09-05, so the monitor could see a PR on GitHub
233
+ # and nowhere else: on Bitbucket `command -v gh` failed and every branch on the
234
+ # estate read `unaskable` — the refusal was right about the wrong thing, since
235
+ # the host was reachable and simply not GitHub. `pr-state` answers on both.
236
+ #
237
+ # THE ADAPTER'S TWO OUTCOMES ARE ALREADY THIS PORT'S TWO. `pr-state` exits 0
238
+ # with `state:"NONE"` when the host answered and there is no PR, and non-zero
239
+ # when the call itself failed — which is exactly the line between `no PR` and
240
+ # `unaskable` that the `gh` version drew with `|| return 2`. So the split is
241
+ # read from the exit status and the payload together, and neither answer moved.
242
+ monitor_pr_state() { # → 0 has a PR | 1 no PR | 2 unaskable
243
+ [ -n "$branch" ] || return 2
244
+ local host_script out state
245
+ host_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-host.sh"
246
+ [ -r "$host_script" ] || return 2
247
+ # `</dev/null` so a host CLI that decides to prompt cannot hang a monitor
248
+ # whose whole contract is one bounded reading per pass.
249
+ out=$(bash "$host_script" pr-state "$branch" </dev/null 2>/dev/null) || return 2
250
+ # THE EXIT STATUS DECIDES WHETHER THE HOST WAS ASKED; the payload only says
251
+ # what it answered. `pr-state` exits non-zero when the call failed and 0 when
252
+ # it did not, so anything reaching this line is an answer.
253
+ #
254
+ # A MISS IS `NONE`, AND SO IS A NULL. `state:"NONE"` is the adapter's word for
255
+ # "no PR found", but the same miss reaches here as `state:null` when the host
256
+ # CLI answers with a payload carrying no PR — measured 2026-09-05 against
257
+ # `test/e2e/agent-monitor-reads.test.mjs`, where `gh pr view` on a branch with
258
+ # no PR produced `{"number":null,"state":null,…}` and exit 0.
259
+ #
260
+ # Reading that null as `unaskable` is what the first draft of this port did,
261
+ # and it is wrong in the direction that hides the finding: an agent that
262
+ # committed and opened nothing produced no `owes a review` at all, because the
263
+ # monitor believed it had never measured. A failure to observe is not evidence
264
+ # of something to see — but neither is an observation evidence of a failure.
265
+ state=$(printf '%s' "$out" | jq -r '.state // "NONE"' 2>/dev/null) || return 2
266
+ case "$state" in
267
+ NONE|null|"") return 1 ;;
268
+ *) return 0 ;;
269
+ esac
270
+ }
271
+
272
+ # Does the branch carry commits the default branch does not have?
273
+ #
274
+ # → 0 yes | 1 no | 2 unanswerable
275
+ #
276
+ # COUNTED AGAINST THE LOCAL `origin/<default>` REF, never a fetch — this monitor
277
+ # makes exactly one network call per pass and it is the PR lookup. And the
278
+ # `-- .` pathspec is load-bearing for the same measured reason it is next door:
279
+ # `plot-dispatch.sh` writes an empty `plot: claim <branch>` commit BEFORE the
280
+ # agent starts, so a bare count is never zero on a dispatched branch and `owes a
281
+ # review` would fire on every worker the moment it was born. `rev-list` with a
282
+ # pathspec keeps only commits that touched a file, and the claim is empty by
283
+ # construction.
284
+ monitor_has_commits() { # → 0 yes | 1 no | 2 unanswerable
285
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 2
286
+ local base n
287
+ base=$(git -C "$worktree" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
288
+ [ -n "$base" ] || { git -C "$worktree" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
289
+ [ -n "$base" ] || return 2
290
+ n=$(git -C "$worktree" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
291
+ case "$n" in ''|*[!0-9]*) return 2 ;; esac
292
+ [ "$n" -gt 0 ] && return 0
293
+ return 1
294
+ }
295
+
296
+ # Is the tree clean — nothing uncommitted that counts as work?
297
+ #
298
+ # Prints the dirty files, one per line, or nothing. Delegated wholesale to
299
+ # `plot_worker_dirty`, which is what makes this monitor's own findings file
300
+ # invisible to it: the filter drops the `.plot-worker.` prefix, so publishing
301
+ # INTO the worktree it watches does not make the desk read as holding unlanded
302
+ # work. Without that, every monitored worktree on the estate would report `holds
303
+ # unlanded work` about the monitor itself, forever, starting one pass in.
304
+ monitor_dirty() { # → the dirty files, one per line
305
+ command -v plot_worker_dirty >/dev/null 2>&1 || return 0
306
+ plot_worker_dirty "$worktree"
307
+ }
308
+
309
+ # Does a person owe this branch an answer?
310
+ #
311
+ # → 0 a `PLOT-BLOCKED*` marker is in the tree | 1 none
312
+ #
313
+ # Delegated to `plot_worker_blocked` so the monitor and the fleet scan agree
314
+ # about what a blocked desk looks like. The glob is deliberate on that side —
315
+ # the scan looks for `PLOT-BLOCKED*` rather than the exact name, so a worker
316
+ # that wrote `PLOT-BLOCKED-2.md` is still visible.
317
+ monitor_blocked() { # → 0 blocked | 1 not
318
+ command -v plot_worker_blocked >/dev/null 2>&1 || return 1
319
+ plot_worker_blocked "$worktree"
320
+ }
321
+
322
+ # Are there commits the branch's own upstream does not have?
323
+ #
324
+ # → 0 yes | 1 no or unanswerable
325
+ #
326
+ # ONLY `@{upstream}` ANSWERS THIS, and an absent upstream is UNANSWERABLE rather
327
+ # than answered zero. `plot_worker_task_state` records having got this wrong in
328
+ # both directions: counting against nothing counts the whole history from the
329
+ # root commit, and counting against the trunk marks every branch under review
330
+ # unpushed, because being ahead of the trunk is what having commits MEANS.
331
+ monitor_unpushed() { # → 0 yes | 1 no or unanswerable
332
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 1
333
+ local ahead
334
+ ahead=$(git -C "$worktree" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || return 1
335
+ case "$ahead" in ''|0|*[!0-9]*) return 1 ;; esac
336
+ return 0
337
+ }
338
+
339
+ # Does the branch add a changeset?
340
+ #
341
+ # → 0 yes | 1 no | 2 unanswerable
342
+ #
343
+ # THE ONLY GATE THIS MONITOR ASKS, and the brief fixes the boundary: a gate
344
+ # belongs here only if it can be answered FROM THE WORKTREE ALONE, in the time
345
+ # this pass already spends. *"Is there a new `.changeset/*.md`"* qualifies; *"do
346
+ # the tests pass"* does not, and asking it would turn a five-minute sample into
347
+ # a build. Running CI to predict CI is a second CI.
348
+ #
349
+ # NEW, not present — and the difference is the whole measurement.
350
+ # `.changeset/` holds SIBLINGS' changesets on `main` at any moment, so a branch
351
+ # that wrote none still sees a directory full of files. The question is which
352
+ # `.changeset/*.md` paths this branch ADDED against the base, which `git diff
353
+ # --name-only --diff-filter=A` answers without reading a single file.
354
+ #
355
+ # Measured 2026-08-30: `feature/the-workflows-decide-without-acting` had
356
+ # commits, a clean tree and no marker — every other finding said nothing — and
357
+ # no changeset, so it would have landed red.
358
+ monitor_changeset() { # → 0 has one | 1 none | 2 unanswerable
359
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 2
360
+ local base added
361
+ base=$(git -C "$worktree" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
362
+ [ -n "$base" ] || { git -C "$worktree" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
363
+ [ -n "$base" ] || return 2
364
+ added=$(git -C "$worktree" diff --name-only --diff-filter=A "$base...HEAD" -- '.changeset/*.md' 2>/dev/null) || return 2
365
+ [ -n "$added" ] && return 0
366
+ return 1
367
+ }
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # THE SAMPLER — one pass, using only the ports above
371
+ # ---------------------------------------------------------------------------
372
+ #
373
+ # THE STATE IS TWO VARIABLES AND IT IS DERIVED, exactly as next door.
374
+ # `published` is the finding currently standing and `since` is when it started
375
+ # holding. Nothing is written down: kill the monitor and the next one rebuilds
376
+ # both, one interval late. That is the plan's "one piece of state, derived
377
+ # rather than recorded" — and on this cadence it is also the reason a restart
378
+ # costs five minutes rather than a wrong answer.
379
+ published=''
380
+ since=''
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # ONE FINDING PER PASS, AND THE ORDER IS THE POINT
384
+ # ---------------------------------------------------------------------------
385
+ #
386
+ # The four findings are NOT mutually exclusive the way the WorkerMonitor's
387
+ # verdicts are — a desk can hold a marker AND uncommitted work AND commits with
388
+ # no PR, all true at once. The record carries one `finding`, so the sampler has
389
+ # to choose, and it chooses by WHAT THE READER MUST DO FIRST:
390
+ #
391
+ # 1. owes an answer a person is the blocker; nothing else can proceed
392
+ # 2. holds unlanded work the work is not safe yet; a PR would be incomplete
393
+ # 3. owes a review the work is complete and invisible
394
+ # 4. owes a gate the work is visible but would land red
395
+ #
396
+ # THAT IS THE ORDER `plot_worker_task_state` ALREADY USES — blocked, then dirty,
397
+ # then finished — and matching it is deliberate. Two components ranking the same
398
+ # desk's debts differently is the drift `plot-worker-state.sh` was extracted to
399
+ # end, and a subscriber reading both files would have to know which order it was
400
+ # looking at.
401
+ #
402
+ # `owes a gate` SITS BELOW `owes a review` RATHER THAN COMPETING WITH IT. A
403
+ # branch with no PR and no changeset owes a review FIRST: the plan's Acting
404
+ # slice opens the PR anyway and names the missing gate in the body, because
405
+ # withholding it would leave finished work invisible until someone happens to
406
+ # write the changeset — the exact failure this plan exists to end, one step
407
+ # later. So the gate is reported on its own only once a PR exists, where it is
408
+ # the one thing left to fix.
409
+ sample_finding() { # → prints "finding\tevidence", or nothing
410
+ local rc
411
+
412
+ # 1. OWES AN ANSWER — a marker in the tree. Asked FIRST because it is the one
413
+ # finding whose subject is a PERSON. Everything below describes work an agent
414
+ # could still finish; this one cannot move until somebody reads a file.
415
+ if monitor_blocked; then
416
+ local marker
417
+ marker=$(plot_worker_blocked_file "$worktree" 2>/dev/null)
418
+ printf 'owes an answer\ta %s marker is in the tree at %s; the agent stopped and a person has not answered\n' \
419
+ "${marker:-PLOT-BLOCKED}" "$worktree"
420
+ return 0
421
+ fi
422
+
423
+ # 2. HOLDS UNLANDED WORK — uncommitted, or committed and unpushed. Both are
424
+ # the same debt to a reader (work exists in one place only) and neither is
425
+ # safe to review, so they share a finding and differ in the evidence.
426
+ local dirty
427
+ dirty=$(monitor_dirty)
428
+ if [ -n "$dirty" ]; then
429
+ local n
430
+ n=$(printf '%s\n' "$dirty" | grep -c . )
431
+ printf 'holds unlanded work\t%s uncommitted file(s) at %s, the first being %s; the work exists only on this desk\n' \
432
+ "$n" "$worktree" "$(printf '%s\n' "$dirty" | head -1)"
433
+ return 0
434
+ fi
435
+ if monitor_unpushed; then
436
+ printf 'holds unlanded work\tthe branch carries commits its upstream does not have; the work exists only on this machine\n'
437
+ return 0
438
+ fi
439
+
440
+ # From here the tree is clean and unblocked, so the remaining two findings are
441
+ # both about commits. No commits means there is nothing to owe — an agent
442
+ # still thinking about a hard first slice owes nobody anything, and saying it
443
+ # does is what teaches an operator to ignore the word.
444
+ monitor_has_commits; rc=$?
445
+ [ "$rc" = 0 ] || return 0
446
+
447
+ # THE HOST IS ASKED ONCE PER PASS, AND ONLY HERE. Every cheaper reading above
448
+ # has already refused, so the five-minute budget buys exactly one round trip
449
+ # about a branch that genuinely looks finished.
450
+ local pr
451
+ monitor_pr_state; pr=$?
452
+
453
+ # 2b. UNASKABLE — no finding. The host could not be asked, so neither of the
454
+ # two findings below can be distinguished from its opposite. Reporting `owes a
455
+ # review` here would fire on every branch on the estate the moment `gh` lost
456
+ # its token.
457
+ [ "$pr" = 2 ] && return 0
458
+
459
+ # 3. OWES A REVIEW — commits, a clean tree, no PR. The finding this plan was
460
+ # written for: twice in one session, finished work sat on a branch with no PR
461
+ # and nothing noticed.
462
+ if [ "$pr" = 1 ]; then
463
+ printf 'owes a review\tthe branch carries commits, the tree is clean and no PR exists; finished work is invisible\n'
464
+ return 0
465
+ fi
466
+
467
+ # 4. OWES A GATE — a PR exists, so the work is visible, but a repo gate is
468
+ # unmet and it would land red.
469
+ local cs
470
+ monitor_changeset; cs=$?
471
+ if [ "$cs" = 1 ]; then
472
+ printf 'owes a gate\tthe branch has a PR but adds no .changeset/*.md; it would land red on the changeset gate\n'
473
+ return 0
474
+ fi
475
+
476
+ return 0
477
+ }
478
+
479
+ # One full pass: sample, publish only on a change.
480
+ monitor_pass() {
481
+ local row finding evidence
482
+ row=$(sample_finding)
483
+ finding="${row%% *}"
484
+ evidence=''
485
+ case "$row" in *" "*) evidence="${row#* }" ;; esac
486
+ [ -z "$row" ] && finding=''
487
+
488
+ # PUBLISH ONLY ON A CHANGE — the plan's "publishes on change". A monitor that
489
+ # republished `owes a review` every five minutes would fill the findings file
490
+ # with one fact repeated, and a subscriber could not tell a new debt from an
491
+ # old one. `since` is what carries the age instead.
492
+ #
493
+ # The clearing case is a publish too: a debt that was paid is news, and a
494
+ # board that never hears it leaves a stale entry up after the PR was opened.
495
+ # That is the half of "does NOT fire once a PR exists" a subscriber can act
496
+ # on — the finding stops standing AND the reader is told.
497
+ if [ "$finding" != "$published" ]; then
498
+ if [ -n "$finding" ]; then
499
+ since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
500
+ publish "$finding" "$evidence" "$since"
501
+ elif [ -n "$published" ]; then
502
+ since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
503
+ publish 'clear' "the ${published} finding no longer holds; this desk owes nothing measurable" "$since"
504
+ fi
505
+ published="$finding"
506
+ fi
507
+ }
508
+
509
+ # SOURCEABLE FOR TESTS. A test that wants to drive `monitor_pass` against
510
+ # redefined ports needs the functions without the loop; everything above this
511
+ # line defines, and nothing below it runs when the guard is set.
512
+ [ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
513
+
514
+ monitor_pass
515
+ [ "$once" = 1 ] && exit 0
516
+
517
+ # IT ENDS WITH ITS AGENT, for the reason and by the mechanism the WorkerMonitor
518
+ # does — `docs/research/2026-08-30-what-ends-a-monitor.md` has the measurement.
519
+ # Nothing ended either monitor before 2026-08-30: the wrapper `wait`s on the
520
+ # agent alone, so both children were re-parented to `init` and looped forever.
521
+ #
522
+ # PUBLISH FIRST, THEN LEAVE. The final pass below runs with the agent already
523
+ # gone, and on THIS monitor that pass is the one that matters most: an agent
524
+ # that exits having committed everything and opened nothing is precisely the
525
+ # failure the plan was written for, and the last pass is where it is caught.
526
+ # A monitor that died WITH its agent would miss it every time.
527
+ while plot_monitor_wait "$interval" "$pid_file"; do
528
+ monitor_pass
529
+ done
530
+
531
+ monitor_pass
532
+ exit 0