@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/dist/board-server.mjs +157 -151
- package/package.json +9 -1
- package/plot-agent-monitor.sh +532 -0
- package/plot-approve.sh +151 -32
- package/plot-budget.sh +439 -0
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +17 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +214 -119
- package/plot-dispatch.sh +1327 -188
- package/plot-fleet-scan.sh +1005 -170
- package/plot-host.sh +1438 -61
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +345 -47
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +719 -61
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
- package/plot-transcript-quiet.sh +142 -0
- package/plot-worker-monitor.sh +644 -0
- package/plot-worker-state.sh +81 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plot-pm/board",
|
|
3
|
-
"version": "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
|