@plot-pm/board 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plot-pm/board",
3
- "version": "0.9.1",
3
+ "version": "0.11.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,7 +22,9 @@
22
22
  },
23
23
  "files": [
24
24
  "dist/board-server.mjs",
25
+ "plot-agent-monitor.sh",
25
26
  "plot-approve.sh",
27
+ "plot-budget.sh",
26
28
  "plot-config.sh",
27
29
  "plot-deliver.sh",
28
30
  "plot-dispatch.sh",
@@ -32,6 +34,8 @@
32
34
  "plot-reap.sh",
33
35
  "plot-release-refs.sh",
34
36
  "plot-resolve-artifact.sh",
37
+ "plot-transcript-quiet.sh",
38
+ "plot-worker-monitor.sh",
35
39
  "plot-worker-state.sh"
36
40
  ],
37
41
  "scripts": {
@@ -45,6 +49,7 @@
45
49
  "test:integration": "pnpm build && vitest run"
46
50
  },
47
51
  "devDependencies": {
52
+ "@plot-pm/domain": "workspace:*",
48
53
  "@radix-ui/react-checkbox": "^1.3.0",
49
54
  "@radix-ui/react-popover": "^1.1.0",
50
55
  "@tailwindcss/vite": "^4.3.0",
@@ -0,0 +1,508 @@
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
+ monitor_pr_state() { # → 0 has a PR | 1 no PR | 2 unaskable
231
+ [ -n "$branch" ] || return 2
232
+ command -v gh >/dev/null 2>&1 || return 2
233
+ local out
234
+ # `--state all` and `mergedAt`, never `state`: a merged PR reports CLOSED, and
235
+ # squash-merge leaves the branch ahead of main forever, so neither the state
236
+ # word nor ancestry can answer this. The same reading `plot-pr-merged.sh`
237
+ # makes, and deliberately the same query, so the two never disagree about a
238
+ # branch.
239
+ out=$(gh pr list --head "$branch" --state all --limit 100 --json mergedAt,number 2>/dev/null) || return 2
240
+ # An empty LIST is a real answer — the host was asked and said none. Only a
241
+ # failed call is unaskable, which is what the `|| return 2` above separates.
242
+ case "$out" in
243
+ *'"number"'*) return 0 ;;
244
+ *) return 1 ;;
245
+ esac
246
+ }
247
+
248
+ # Does the branch carry commits the default branch does not have?
249
+ #
250
+ # → 0 yes | 1 no | 2 unanswerable
251
+ #
252
+ # COUNTED AGAINST THE LOCAL `origin/<default>` REF, never a fetch — this monitor
253
+ # makes exactly one network call per pass and it is the PR lookup. And the
254
+ # `-- .` pathspec is load-bearing for the same measured reason it is next door:
255
+ # `plot-dispatch.sh` writes an empty `plot: claim <branch>` commit BEFORE the
256
+ # agent starts, so a bare count is never zero on a dispatched branch and `owes a
257
+ # review` would fire on every worker the moment it was born. `rev-list` with a
258
+ # pathspec keeps only commits that touched a file, and the claim is empty by
259
+ # construction.
260
+ monitor_has_commits() { # → 0 yes | 1 no | 2 unanswerable
261
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 2
262
+ local base n
263
+ base=$(git -C "$worktree" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
264
+ [ -n "$base" ] || { git -C "$worktree" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
265
+ [ -n "$base" ] || return 2
266
+ n=$(git -C "$worktree" rev-list --count "$base..HEAD" -- . 2>/dev/null) || return 2
267
+ case "$n" in ''|*[!0-9]*) return 2 ;; esac
268
+ [ "$n" -gt 0 ] && return 0
269
+ return 1
270
+ }
271
+
272
+ # Is the tree clean — nothing uncommitted that counts as work?
273
+ #
274
+ # Prints the dirty files, one per line, or nothing. Delegated wholesale to
275
+ # `plot_worker_dirty`, which is what makes this monitor's own findings file
276
+ # invisible to it: the filter drops the `.plot-worker.` prefix, so publishing
277
+ # INTO the worktree it watches does not make the desk read as holding unlanded
278
+ # work. Without that, every monitored worktree on the estate would report `holds
279
+ # unlanded work` about the monitor itself, forever, starting one pass in.
280
+ monitor_dirty() { # → the dirty files, one per line
281
+ command -v plot_worker_dirty >/dev/null 2>&1 || return 0
282
+ plot_worker_dirty "$worktree"
283
+ }
284
+
285
+ # Does a person owe this branch an answer?
286
+ #
287
+ # → 0 a `PLOT-BLOCKED*` marker is in the tree | 1 none
288
+ #
289
+ # Delegated to `plot_worker_blocked` so the monitor and the fleet scan agree
290
+ # about what a blocked desk looks like. The glob is deliberate on that side —
291
+ # the scan looks for `PLOT-BLOCKED*` rather than the exact name, so a worker
292
+ # that wrote `PLOT-BLOCKED-2.md` is still visible.
293
+ monitor_blocked() { # → 0 blocked | 1 not
294
+ command -v plot_worker_blocked >/dev/null 2>&1 || return 1
295
+ plot_worker_blocked "$worktree"
296
+ }
297
+
298
+ # Are there commits the branch's own upstream does not have?
299
+ #
300
+ # → 0 yes | 1 no or unanswerable
301
+ #
302
+ # ONLY `@{upstream}` ANSWERS THIS, and an absent upstream is UNANSWERABLE rather
303
+ # than answered zero. `plot_worker_task_state` records having got this wrong in
304
+ # both directions: counting against nothing counts the whole history from the
305
+ # root commit, and counting against the trunk marks every branch under review
306
+ # unpushed, because being ahead of the trunk is what having commits MEANS.
307
+ monitor_unpushed() { # → 0 yes | 1 no or unanswerable
308
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 1
309
+ local ahead
310
+ ahead=$(git -C "$worktree" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || return 1
311
+ case "$ahead" in ''|0|*[!0-9]*) return 1 ;; esac
312
+ return 0
313
+ }
314
+
315
+ # Does the branch add a changeset?
316
+ #
317
+ # → 0 yes | 1 no | 2 unanswerable
318
+ #
319
+ # THE ONLY GATE THIS MONITOR ASKS, and the brief fixes the boundary: a gate
320
+ # belongs here only if it can be answered FROM THE WORKTREE ALONE, in the time
321
+ # this pass already spends. *"Is there a new `.changeset/*.md`"* qualifies; *"do
322
+ # the tests pass"* does not, and asking it would turn a five-minute sample into
323
+ # a build. Running CI to predict CI is a second CI.
324
+ #
325
+ # NEW, not present — and the difference is the whole measurement.
326
+ # `.changeset/` holds SIBLINGS' changesets on `main` at any moment, so a branch
327
+ # that wrote none still sees a directory full of files. The question is which
328
+ # `.changeset/*.md` paths this branch ADDED against the base, which `git diff
329
+ # --name-only --diff-filter=A` answers without reading a single file.
330
+ #
331
+ # Measured 2026-08-30: `feature/the-workflows-decide-without-acting` had
332
+ # commits, a clean tree and no marker — every other finding said nothing — and
333
+ # no changeset, so it would have landed red.
334
+ monitor_changeset() { # → 0 has one | 1 none | 2 unanswerable
335
+ [ -n "$worktree" ] && [ -d "$worktree" ] || return 2
336
+ local base added
337
+ base=$(git -C "$worktree" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
338
+ [ -n "$base" ] || { git -C "$worktree" rev-parse --verify --quiet origin/main >/dev/null 2>&1 && base='origin/main'; }
339
+ [ -n "$base" ] || return 2
340
+ added=$(git -C "$worktree" diff --name-only --diff-filter=A "$base...HEAD" -- '.changeset/*.md' 2>/dev/null) || return 2
341
+ [ -n "$added" ] && return 0
342
+ return 1
343
+ }
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # THE SAMPLER — one pass, using only the ports above
347
+ # ---------------------------------------------------------------------------
348
+ #
349
+ # THE STATE IS TWO VARIABLES AND IT IS DERIVED, exactly as next door.
350
+ # `published` is the finding currently standing and `since` is when it started
351
+ # holding. Nothing is written down: kill the monitor and the next one rebuilds
352
+ # both, one interval late. That is the plan's "one piece of state, derived
353
+ # rather than recorded" — and on this cadence it is also the reason a restart
354
+ # costs five minutes rather than a wrong answer.
355
+ published=''
356
+ since=''
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # ONE FINDING PER PASS, AND THE ORDER IS THE POINT
360
+ # ---------------------------------------------------------------------------
361
+ #
362
+ # The four findings are NOT mutually exclusive the way the WorkerMonitor's
363
+ # verdicts are — a desk can hold a marker AND uncommitted work AND commits with
364
+ # no PR, all true at once. The record carries one `finding`, so the sampler has
365
+ # to choose, and it chooses by WHAT THE READER MUST DO FIRST:
366
+ #
367
+ # 1. owes an answer a person is the blocker; nothing else can proceed
368
+ # 2. holds unlanded work the work is not safe yet; a PR would be incomplete
369
+ # 3. owes a review the work is complete and invisible
370
+ # 4. owes a gate the work is visible but would land red
371
+ #
372
+ # THAT IS THE ORDER `plot_worker_task_state` ALREADY USES — blocked, then dirty,
373
+ # then finished — and matching it is deliberate. Two components ranking the same
374
+ # desk's debts differently is the drift `plot-worker-state.sh` was extracted to
375
+ # end, and a subscriber reading both files would have to know which order it was
376
+ # looking at.
377
+ #
378
+ # `owes a gate` SITS BELOW `owes a review` RATHER THAN COMPETING WITH IT. A
379
+ # branch with no PR and no changeset owes a review FIRST: the plan's Acting
380
+ # slice opens the PR anyway and names the missing gate in the body, because
381
+ # withholding it would leave finished work invisible until someone happens to
382
+ # write the changeset — the exact failure this plan exists to end, one step
383
+ # later. So the gate is reported on its own only once a PR exists, where it is
384
+ # the one thing left to fix.
385
+ sample_finding() { # → prints "finding\tevidence", or nothing
386
+ local rc
387
+
388
+ # 1. OWES AN ANSWER — a marker in the tree. Asked FIRST because it is the one
389
+ # finding whose subject is a PERSON. Everything below describes work an agent
390
+ # could still finish; this one cannot move until somebody reads a file.
391
+ if monitor_blocked; then
392
+ local marker
393
+ marker=$(plot_worker_blocked_file "$worktree" 2>/dev/null)
394
+ printf 'owes an answer\ta %s marker is in the tree at %s; the agent stopped and a person has not answered\n' \
395
+ "${marker:-PLOT-BLOCKED}" "$worktree"
396
+ return 0
397
+ fi
398
+
399
+ # 2. HOLDS UNLANDED WORK — uncommitted, or committed and unpushed. Both are
400
+ # the same debt to a reader (work exists in one place only) and neither is
401
+ # safe to review, so they share a finding and differ in the evidence.
402
+ local dirty
403
+ dirty=$(monitor_dirty)
404
+ if [ -n "$dirty" ]; then
405
+ local n
406
+ n=$(printf '%s\n' "$dirty" | grep -c . )
407
+ printf 'holds unlanded work\t%s uncommitted file(s) at %s, the first being %s; the work exists only on this desk\n' \
408
+ "$n" "$worktree" "$(printf '%s\n' "$dirty" | head -1)"
409
+ return 0
410
+ fi
411
+ if monitor_unpushed; then
412
+ printf 'holds unlanded work\tthe branch carries commits its upstream does not have; the work exists only on this machine\n'
413
+ return 0
414
+ fi
415
+
416
+ # From here the tree is clean and unblocked, so the remaining two findings are
417
+ # both about commits. No commits means there is nothing to owe — an agent
418
+ # still thinking about a hard first slice owes nobody anything, and saying it
419
+ # does is what teaches an operator to ignore the word.
420
+ monitor_has_commits; rc=$?
421
+ [ "$rc" = 0 ] || return 0
422
+
423
+ # THE HOST IS ASKED ONCE PER PASS, AND ONLY HERE. Every cheaper reading above
424
+ # has already refused, so the five-minute budget buys exactly one round trip
425
+ # about a branch that genuinely looks finished.
426
+ local pr
427
+ monitor_pr_state; pr=$?
428
+
429
+ # 2b. UNASKABLE — no finding. The host could not be asked, so neither of the
430
+ # two findings below can be distinguished from its opposite. Reporting `owes a
431
+ # review` here would fire on every branch on the estate the moment `gh` lost
432
+ # its token.
433
+ [ "$pr" = 2 ] && return 0
434
+
435
+ # 3. OWES A REVIEW — commits, a clean tree, no PR. The finding this plan was
436
+ # written for: twice in one session, finished work sat on a branch with no PR
437
+ # and nothing noticed.
438
+ if [ "$pr" = 1 ]; then
439
+ printf 'owes a review\tthe branch carries commits, the tree is clean and no PR exists; finished work is invisible\n'
440
+ return 0
441
+ fi
442
+
443
+ # 4. OWES A GATE — a PR exists, so the work is visible, but a repo gate is
444
+ # unmet and it would land red.
445
+ local cs
446
+ monitor_changeset; cs=$?
447
+ if [ "$cs" = 1 ]; then
448
+ printf 'owes a gate\tthe branch has a PR but adds no .changeset/*.md; it would land red on the changeset gate\n'
449
+ return 0
450
+ fi
451
+
452
+ return 0
453
+ }
454
+
455
+ # One full pass: sample, publish only on a change.
456
+ monitor_pass() {
457
+ local row finding evidence
458
+ row=$(sample_finding)
459
+ finding="${row%% *}"
460
+ evidence=''
461
+ case "$row" in *" "*) evidence="${row#* }" ;; esac
462
+ [ -z "$row" ] && finding=''
463
+
464
+ # PUBLISH ONLY ON A CHANGE — the plan's "publishes on change". A monitor that
465
+ # republished `owes a review` every five minutes would fill the findings file
466
+ # with one fact repeated, and a subscriber could not tell a new debt from an
467
+ # old one. `since` is what carries the age instead.
468
+ #
469
+ # The clearing case is a publish too: a debt that was paid is news, and a
470
+ # board that never hears it leaves a stale entry up after the PR was opened.
471
+ # That is the half of "does NOT fire once a PR exists" a subscriber can act
472
+ # on — the finding stops standing AND the reader is told.
473
+ if [ "$finding" != "$published" ]; then
474
+ if [ -n "$finding" ]; then
475
+ since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
476
+ publish "$finding" "$evidence" "$since"
477
+ elif [ -n "$published" ]; then
478
+ since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
479
+ publish 'clear' "the ${published} finding no longer holds; this desk owes nothing measurable" "$since"
480
+ fi
481
+ published="$finding"
482
+ fi
483
+ }
484
+
485
+ # SOURCEABLE FOR TESTS. A test that wants to drive `monitor_pass` against
486
+ # redefined ports needs the functions without the loop; everything above this
487
+ # line defines, and nothing below it runs when the guard is set.
488
+ [ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
489
+
490
+ monitor_pass
491
+ [ "$once" = 1 ] && exit 0
492
+
493
+ # IT ENDS WITH ITS AGENT, for the reason and by the mechanism the WorkerMonitor
494
+ # does — `docs/research/2026-08-30-what-ends-a-monitor.md` has the measurement.
495
+ # Nothing ended either monitor before 2026-08-30: the wrapper `wait`s on the
496
+ # agent alone, so both children were re-parented to `init` and looped forever.
497
+ #
498
+ # PUBLISH FIRST, THEN LEAVE. The final pass below runs with the agent already
499
+ # gone, and on THIS monitor that pass is the one that matters most: an agent
500
+ # that exits having committed everything and opened nothing is precisely the
501
+ # failure the plan was written for, and the last pass is where it is caught.
502
+ # A monitor that died WITH its agent would miss it every time.
503
+ while plot_monitor_wait "$interval" "$pid_file"; do
504
+ monitor_pass
505
+ done
506
+
507
+ monitor_pass
508
+ exit 0