@plot-pm/board 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/board-server.mjs +124 -124
- package/package.json +5 -1
- package/plot-agent-monitor.sh +37 -13
- package/plot-approve.sh +6 -3
- package/plot-build-monitor.sh +435 -0
- package/plot-config.sh +8 -1
- package/plot-default-branch.sh +109 -0
- package/plot-deliver.sh +4 -2
- package/plot-dispatch.sh +500 -5
- package/plot-fleet-scan.sh +498 -175
- package/plot-host.sh +116 -13
- package/plot-monitor-subject.sh +194 -0
- package/plot-plan-meta.sh +111 -7
- package/plot-pr-merged.sh +180 -0
- package/plot-reap.sh +45 -5
- package/plot-release-refs.sh +188 -46
- package/plot-resolve-artifact.sh +115 -22
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",
|
|
@@ -23,14 +23,18 @@
|
|
|
23
23
|
"files": [
|
|
24
24
|
"dist/board-server.mjs",
|
|
25
25
|
"plot-agent-monitor.sh",
|
|
26
|
+
"plot-build-monitor.sh",
|
|
27
|
+
"plot-monitor-subject.sh",
|
|
26
28
|
"plot-approve.sh",
|
|
27
29
|
"plot-budget.sh",
|
|
28
30
|
"plot-config.sh",
|
|
31
|
+
"plot-default-branch.sh",
|
|
29
32
|
"plot-deliver.sh",
|
|
30
33
|
"plot-dispatch.sh",
|
|
31
34
|
"plot-fleet-scan.sh",
|
|
32
35
|
"plot-host.sh",
|
|
33
36
|
"plot-plan-meta.sh",
|
|
37
|
+
"plot-pr-merged.sh",
|
|
34
38
|
"plot-reap.sh",
|
|
35
39
|
"plot-release-refs.sh",
|
|
36
40
|
"plot-resolve-artifact.sh",
|
package/plot-agent-monitor.sh
CHANGED
|
@@ -227,21 +227,45 @@ publish() { # $1=finding $2=evidence $3=since
|
|
|
227
227
|
# helpers throw away: `gh` absent, unauthed, or failing is `unaskable`, and an
|
|
228
228
|
# unaskable host produces NO finding rather than a wrong one. A failure to
|
|
229
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.
|
|
230
242
|
monitor_pr_state() { # → 0 has a PR | 1 no PR | 2 unaskable
|
|
231
243
|
[ -n "$branch" ] || return 2
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
|
|
238
|
-
#
|
|
239
|
-
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 ;;
|
|
245
269
|
esac
|
|
246
270
|
}
|
|
247
271
|
|
package/plot-approve.sh
CHANGED
|
@@ -338,8 +338,11 @@ real_plan_path() { # $1 = plan file as found
|
|
|
338
338
|
rel=$(cd "$repo_root" && real_plan_path "$plan_file") || rel=""
|
|
339
339
|
[ -n "$rel" ] || die "$plan_file is outside the repository root"
|
|
340
340
|
|
|
341
|
-
# Flip `**
|
|
342
|
-
# section only. Both are the pre-Approved
|
|
341
|
+
# Flip `**State:** Draft` OR `**State:** Design` → `Approved` in the `## Status`
|
|
342
|
+
# section only. Both are the pre-Approved states this script advances from.
|
|
343
|
+
#
|
|
344
|
+
# Reads `State:` and `Phase:` alike: it changes the VALUE on whichever line
|
|
345
|
+
# carries it, so a plan written before the 2026-09-07 rename still approves.
|
|
343
346
|
#
|
|
344
347
|
# Scoped to that section because a plan that QUOTES a status block in its prose
|
|
345
348
|
# (this repo has several, documenting the format) would otherwise have its
|
|
@@ -353,7 +356,7 @@ flip_phase() { # $1=in $2=out → 0 if it changed the file, 1 if there was noth
|
|
|
353
356
|
awk '
|
|
354
357
|
BEGIN { section = ""; done = 0 }
|
|
355
358
|
/^## / { section = ($0 ~ /^## Status/) ? "status" : ""; print; next }
|
|
356
|
-
section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**phase[:*]/ {
|
|
359
|
+
section == "status" && !done && tolower($0) ~ /^[ \t]*[-*]?[ \t]*\**(state|phase)[:*]/ {
|
|
357
360
|
if (tolower($0) ~ /draft/) {
|
|
358
361
|
sub(/[Dd]raft/, "Approved")
|
|
359
362
|
done = 1
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Plot helper: the BuildMonitor — watches the RUN, and what it concluded about a sha.
|
|
3
|
+
#
|
|
4
|
+
# RUN, NOT SOURCED, and started by `start_worker()` in `plot-dispatch.sh` as a
|
|
5
|
+
# child of the wrapper, beside the WorkerMonitor and the AgentMonitor.
|
|
6
|
+
#
|
|
7
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
# THREE MONITORS, BECAUSE THERE ARE THREE SUBJECTS
|
|
9
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
#
|
|
11
|
+
# Two monitors became three, and the reason is the one that split the first two:
|
|
12
|
+
# a different subject on a different cadence.
|
|
13
|
+
#
|
|
14
|
+
# | monitor | subject | samples | asks |
|
|
15
|
+
# |-------------------|-------------|------------------------------|----------------------------|
|
|
16
|
+
# | **WorkerMonitor** | the process | ~30 s | is it doing anything? |
|
|
17
|
+
# | **AgentMonitor** | the desk | ~5 min | what does this agent owe? |
|
|
18
|
+
# | **BuildMonitor** | the run | ~30 s **while a run is live**| did the build change? |
|
|
19
|
+
#
|
|
20
|
+
# A Build is already an entity in the spec, with its own identity and its own
|
|
21
|
+
# state: DESIGN-build.md — *"is the thing that RUNS … one RESULT of one run"*,
|
|
22
|
+
# identified by its URL, holding a state, a start time and a duration. **A
|
|
23
|
+
# monitor per entity is the pattern, not an exception to it.**
|
|
24
|
+
#
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
# FOUR FINDINGS, AND `head moved` IS THE ONE THAT EARNS THIS MONITOR
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
#
|
|
29
|
+
# | finding | measurement |
|
|
30
|
+
# |--------------------------|----------------------------------------------------|
|
|
31
|
+
# | **build failed** | a run for this branch's head reached a failing conclusion |
|
|
32
|
+
# | **build passed** | it reached success |
|
|
33
|
+
# | **build needs approval** | it is `action_required` |
|
|
34
|
+
# | **head moved** | a newer sha exists, so the run in flight answers about the past |
|
|
35
|
+
#
|
|
36
|
+
# **`head moved` is why this cannot live in the AgentMonitor.** A build's
|
|
37
|
+
# subject is a SHA, not a branch. A green result for code nobody will merge is
|
|
38
|
+
# worse than no result — it invites a merge of the wrong thing. Measured
|
|
39
|
+
# 2026-08-30: two merge waiters reported on superseded runs and had to be
|
|
40
|
+
# stopped and re-armed.
|
|
41
|
+
#
|
|
42
|
+
# **`action_required` is a real state here, not an edge case.** Bot branches hit
|
|
43
|
+
# it — the release PR's runs need manual approval before they start. A monitor
|
|
44
|
+
# that folded it into "not passed yet" would report a build as pending forever
|
|
45
|
+
# while it waits for a click nobody knows is needed.
|
|
46
|
+
#
|
|
47
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
48
|
+
# IT POLLS NOTHING WHEN NO RUN IS LIVE
|
|
49
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
#
|
|
51
|
+
# That is what makes a 30-second cadence against a host affordable, and it is
|
|
52
|
+
# the property that separates this monitor's budget from the AgentMonitor's.
|
|
53
|
+
# The AgentMonitor's five-minute budget exists because it asks ON EVERY PASS;
|
|
54
|
+
# this one asks only while there is something to ask about.
|
|
55
|
+
#
|
|
56
|
+
# STRUCTURALLY, NOT INCIDENTALLY: `monitor_head_sha` is a LOCAL git read and it
|
|
57
|
+
# gates the host call. `sample_finding` returns before `monitor_run_for_sha` is
|
|
58
|
+
# ever reached whenever there is no head to ask about, and once a sha has
|
|
59
|
+
# reached a terminal conclusion it is never asked about again. A monitor that
|
|
60
|
+
# kept questioning an idle host is the rate problem this whole design avoids,
|
|
61
|
+
# so the silence is asserted by the tests rather than assumed.
|
|
62
|
+
#
|
|
63
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
# THE FINDINGS ARE TRANSITIONS, NOT CONDITIONS
|
|
65
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
66
|
+
#
|
|
67
|
+
# The other monitors report states that PERSIST — `owes a review` holds until a
|
|
68
|
+
# PR exists, and republishing it would be repeating one fact. A build's answer
|
|
69
|
+
# CHANGES ONCE AND STAYS: a run that failed has failed, and it will still have
|
|
70
|
+
# failed in thirty seconds.
|
|
71
|
+
#
|
|
72
|
+
# So the state this monitor carries is keyed by SHA as well as by finding.
|
|
73
|
+
# Publishing `build passed` for one sha does not suppress `build passed` for the
|
|
74
|
+
# next one — that would silence the answer an operator is actually waiting for,
|
|
75
|
+
# on the very push they pushed to get it. And once a sha's build is terminal,
|
|
76
|
+
# the sha is not asked about again: the answer cannot change, so continuing to
|
|
77
|
+
# poll would be spending a host round trip to re-learn a fact already published.
|
|
78
|
+
#
|
|
79
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
80
|
+
# IT OBSERVES; IT DOES NOT ACT
|
|
81
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
82
|
+
#
|
|
83
|
+
# It does not rerun a workflow, approve a run that is `action_required`, merge a
|
|
84
|
+
# PR that went green, or push a fix for one that went red. Every one of those is
|
|
85
|
+
# a judgement with a blast radius. Approving a run in particular is a human's
|
|
86
|
+
# call by construction — `action_required` EXISTS because a person is meant to
|
|
87
|
+
# look — and a monitor that clicked it would defeat the gate it is reporting.
|
|
88
|
+
#
|
|
89
|
+
# PUBLISHING IS ITS ONLY OUTPUT, as next door: no state file, no cache, nothing
|
|
90
|
+
# written into the repository it watches, not even a record of what it last
|
|
91
|
+
# published. The variables that make "publish on change" work live in memory and
|
|
92
|
+
# die with the process, so a restarted monitor re-derives them one interval late
|
|
93
|
+
# rather than reading a stale one.
|
|
94
|
+
set -uo pipefail
|
|
95
|
+
|
|
96
|
+
usage() {
|
|
97
|
+
cat >&2 <<'EOF'
|
|
98
|
+
Usage: plot-build-monitor.sh [--once]
|
|
99
|
+
|
|
100
|
+
Started by plot-dispatch.sh inside the worker's wrapper. Reads its subject from
|
|
101
|
+
the environment, exactly as the wrapper's other children do:
|
|
102
|
+
|
|
103
|
+
PLOT_BRANCH the branch whose builds this monitor will report
|
|
104
|
+
PLOT_WORKTREE the desk it reads the head sha from
|
|
105
|
+
PLOT_MONITOR_FILE where findings are published (default:
|
|
106
|
+
$PLOT_WORKTREE/.plot-worker.monitor.build.jsonl)
|
|
107
|
+
PLOT_MONITOR_INTERVAL seconds between passes (default 30)
|
|
108
|
+
|
|
109
|
+
--once take one sample and exit, rather than looping. A test
|
|
110
|
+
affordance: nothing dispatches this mode.
|
|
111
|
+
EOF
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
once=0
|
|
115
|
+
while [ $# -gt 0 ]; do
|
|
116
|
+
case "$1" in
|
|
117
|
+
--once) once=1 ;;
|
|
118
|
+
-h|--help) usage; exit 0 ;;
|
|
119
|
+
*) echo "plot-build-monitor: unknown argument '$1'" >&2; usage; exit 2 ;;
|
|
120
|
+
esac
|
|
121
|
+
shift
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
monitor='BuildMonitor'
|
|
125
|
+
|
|
126
|
+
branch="${PLOT_BRANCH:-}"
|
|
127
|
+
worktree="${PLOT_WORKTREE:-}"
|
|
128
|
+
# THIRTY SECONDS IS AFFORDABLE ONLY BECAUSE OF THE SILENCE RULE. This cadence
|
|
129
|
+
# matches the WorkerMonitor's rather than the AgentMonitor's, and it asks a HOST
|
|
130
|
+
# — which would be the rate problem the AgentMonitor's 300 s exists to avoid,
|
|
131
|
+
# were it asking on every pass. It is not: no live run, no question. The budget
|
|
132
|
+
# is bounded by how long a build takes, not by how long a worker lives.
|
|
133
|
+
interval="${PLOT_MONITOR_INTERVAL:-30}"
|
|
134
|
+
|
|
135
|
+
findings="${PLOT_MONITOR_FILE:-${worktree:+$worktree/.plot-worker.monitor.build.jsonl}}"
|
|
136
|
+
|
|
137
|
+
# THE SUBJECT, read the same way the other two monitors read it.
|
|
138
|
+
pid_file="${PLOT_PID_FILE:-${worktree:+$worktree/.plot-worker.pid}}"
|
|
139
|
+
|
|
140
|
+
# ONE ANSWER TO "IS MY SUBJECT STILL THERE?", shared with both siblings, for the
|
|
141
|
+
# reason `plot-monitor-subject.sh` documents: three monitors deciding
|
|
142
|
+
# independently when to stop would drift, and the failure would be silent.
|
|
143
|
+
# shellcheck source=./plot-monitor-subject.sh
|
|
144
|
+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-monitor-subject.sh"
|
|
145
|
+
|
|
146
|
+
# THE HOST ADAPTER, sourced for its path rather than its functions: the one
|
|
147
|
+
# operation this monitor asks is `plot-host.sh run-for-sha`, and `plot-host.sh`
|
|
148
|
+
# is the ONE place that talks to the host CLI. A monitor calling `gh` directly
|
|
149
|
+
# would be a second adapter, and the backend split (github/bitbucket) would have
|
|
150
|
+
# to be decided twice.
|
|
151
|
+
host_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plot-host.sh"
|
|
152
|
+
|
|
153
|
+
json_escape() { # $1 = raw → prints a JSON-safe string body
|
|
154
|
+
printf '%s' "$1" | python3 -c 'import json,sys; sys.stdout.write(json.dumps(sys.stdin.read())[1:-1])' 2>/dev/null \
|
|
155
|
+
|| printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# A FINDING CARRIES THE SAME FOUR FIELDS the other two monitors publish —
|
|
159
|
+
# `finding`, `since`, `evidence`, `measuredAt` — for the same reason: one
|
|
160
|
+
# subscriber will read all three files and must not need a third parser to do
|
|
161
|
+
# it.
|
|
162
|
+
#
|
|
163
|
+
# `since` HERE IS WHEN THE BUILD REACHED THIS ANSWER, as far as this monitor
|
|
164
|
+
# can tell — the pass that first saw it. On a 30 s cadence the gap to
|
|
165
|
+
# `measuredAt` is small by construction, which is the opposite of the
|
|
166
|
+
# AgentMonitor's case and follows from the same field meaning the same thing.
|
|
167
|
+
publish() { # $1=finding $2=evidence $3=since
|
|
168
|
+
local now
|
|
169
|
+
now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
170
|
+
local line
|
|
171
|
+
line=$(printf '{"monitor":"%s","branch":"%s","worktree":"%s","finding":"%s","since":"%s","evidence":"%s","measuredAt":"%s"}' \
|
|
172
|
+
"$monitor" \
|
|
173
|
+
"$(json_escape "$branch")" \
|
|
174
|
+
"$(json_escape "$worktree")" \
|
|
175
|
+
"$(json_escape "$1")" \
|
|
176
|
+
"${3:-$now}" \
|
|
177
|
+
"$(json_escape "$2")" \
|
|
178
|
+
"$now")
|
|
179
|
+
# Both destinations, for the reason the siblings give: the file is what a
|
|
180
|
+
# subscriber reads, and stdout lands in `.plot-worker.log` beside the agent's
|
|
181
|
+
# own output where an operator tailing a worker sees it.
|
|
182
|
+
[ -n "$findings" ] && printf '%s\n' "$line" >> "$findings" 2>/dev/null
|
|
183
|
+
printf 'plot-monitor %s\n' "$line"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# THE PORTS — two named seams, so every branch is reachable from a test
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
#
|
|
190
|
+
# Same convention as the four next door and the five beyond it: a test sources
|
|
191
|
+
# this file with `PLOT_MONITOR_NO_MAIN=1` and REDEFINES them. Here there are
|
|
192
|
+
# only two, and one of them is the host round trip — which is exactly the seam a
|
|
193
|
+
# test can least afford to drive for real. You cannot ask GitHub for a run that
|
|
194
|
+
# is `action_required` on demand, you cannot make a run vanish to order, and you
|
|
195
|
+
# certainly cannot arrange two runs for two shas at the instant a test needs
|
|
196
|
+
# them. Every one of those is a branch this monitor must get right.
|
|
197
|
+
|
|
198
|
+
# What sha is this branch's head, right now?
|
|
199
|
+
#
|
|
200
|
+
# → prints the sha, or nothing when it cannot be read
|
|
201
|
+
#
|
|
202
|
+
# LOCAL, AND IT GATES THE HOST CALL. This is the cheap reading that makes the
|
|
203
|
+
# silence rule structural: no head, no question. It reads the WORKTREE's HEAD
|
|
204
|
+
# rather than a remote ref, because the head a build should be about is the one
|
|
205
|
+
# the agent has actually produced — and because a fetch per pass would be a
|
|
206
|
+
# second network call on a 30-second loop.
|
|
207
|
+
monitor_head_sha() { # → prints a sha, or nothing
|
|
208
|
+
[ -n "$worktree" ] && [ -d "$worktree" ] || return 0
|
|
209
|
+
git -C "$worktree" rev-parse --verify --quiet HEAD 2>/dev/null || true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# What does the host say about the run for ONE sha?
|
|
213
|
+
#
|
|
214
|
+
# → prints the run JSON, or nothing when there is no run for it
|
|
215
|
+
# → returns 2 when the host could not be asked at all
|
|
216
|
+
#
|
|
217
|
+
# THE DISTINCTION BETWEEN "NO RUN" AND "COULD NOT ASK" IS THE POINT, and it is
|
|
218
|
+
# the same discipline `monitor_pr_state` keeps next door. An unreachable host is
|
|
219
|
+
# NOT evidence that a build is absent. A monitor that read a `gh` failure as "no
|
|
220
|
+
# run" would go silent about every build on the estate the moment a token
|
|
221
|
+
# expired — and silence is this monitor's healthy signal, so the failure would
|
|
222
|
+
# be invisible by construction.
|
|
223
|
+
#
|
|
224
|
+
# An EMPTY result with a reachable host is a real and common answer: the run has
|
|
225
|
+
# not been created yet. A monitor polling a fresh push sees exactly this until
|
|
226
|
+
# CI wakes up, and it is not a finding.
|
|
227
|
+
monitor_run_for_sha() { # $1 = sha → prints run JSON | rc 2 = unaskable
|
|
228
|
+
[ -n "$branch" ] || return 2
|
|
229
|
+
[ -x "$host_script" ] || return 2
|
|
230
|
+
local out
|
|
231
|
+
out=$("$host_script" run-for-sha "$branch" "$1" 2>/dev/null) || return 2
|
|
232
|
+
printf '%s' "$out"
|
|
233
|
+
return 0
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# READING ONE FIELD OUT OF THE RUN
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
#
|
|
240
|
+
# `jq` where it exists and `sed` where it does not, the same fallback shape
|
|
241
|
+
# `json_escape` uses above. A monitor that died because `jq` was missing would
|
|
242
|
+
# be a monitor that reported nothing on exactly the machines least likely to
|
|
243
|
+
# have anyone watching.
|
|
244
|
+
run_field() { # $1 = json, $2 = key → prints the value, or nothing for null
|
|
245
|
+
local v
|
|
246
|
+
if command -v jq >/dev/null 2>&1; then
|
|
247
|
+
v=$(printf '%s' "$1" | jq -r --arg k "$2" '.[$k] // empty' 2>/dev/null)
|
|
248
|
+
else
|
|
249
|
+
v=$(printf '%s' "$1" | sed -n 's/.*"'"$2"'":"\([^"]*\)".*/\1/p')
|
|
250
|
+
fi
|
|
251
|
+
printf '%s' "$v"
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# THE SAMPLER — one pass, using only the ports above
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
#
|
|
258
|
+
# THE STATE IS TWO VARIABLES AND IT IS DERIVED, as next door — but `published`
|
|
259
|
+
# is keyed by SHA here, because the findings are transitions rather than
|
|
260
|
+
# conditions. `published_sha` records which commit the standing answer is about,
|
|
261
|
+
# so the same word about a different commit is still news.
|
|
262
|
+
published=''
|
|
263
|
+
published_sha=''
|
|
264
|
+
since=''
|
|
265
|
+
# The shas whose builds have reached a terminal answer. Once a run has failed,
|
|
266
|
+
# passed, or been superseded, asking again spends a host round trip to re-learn
|
|
267
|
+
# a fact already published — so it is not asked. THIS is the second half of "it
|
|
268
|
+
# polls nothing when no run is live": the first half is having no head at all,
|
|
269
|
+
# and this is having no OPEN question about the head there is.
|
|
270
|
+
settled_shas=''
|
|
271
|
+
|
|
272
|
+
sha_is_settled() { # $1 = sha → 0 settled | 1 not
|
|
273
|
+
case " $settled_shas " in *" $1 "*) return 0 ;; esac
|
|
274
|
+
return 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# ONE FINDING PER PASS, AND `head moved` COMES FIRST
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
#
|
|
281
|
+
# Unlike the AgentMonitor's four, these are near-exclusive by construction — a
|
|
282
|
+
# run has one status. The one genuine overlap is the one that matters: a run
|
|
283
|
+
# that concluded `success` for a sha the branch has since moved past is BOTH
|
|
284
|
+
# "passed" and "superseded", and reporting it as passed is precisely the failure
|
|
285
|
+
# this monitor exists to prevent.
|
|
286
|
+
#
|
|
287
|
+
# So `head moved` is decided FIRST and about the run's own sha, not about the
|
|
288
|
+
# branch: if the answer in hand describes a commit that is no longer the head,
|
|
289
|
+
# the answer is about the past whatever it says.
|
|
290
|
+
sample_finding() { # → prints "finding\tevidence", or nothing
|
|
291
|
+
local head
|
|
292
|
+
head=$(monitor_head_sha)
|
|
293
|
+
|
|
294
|
+
# NO HEAD, NO QUESTION. The cheap local reading refuses before anything
|
|
295
|
+
# reaches the host — the structural form of "it polls nothing when no run is
|
|
296
|
+
# live". A worktree that is gone, or a branch with no commit yet, asks
|
|
297
|
+
# nothing at all.
|
|
298
|
+
[ -n "$head" ] || return 0
|
|
299
|
+
|
|
300
|
+
# ALREADY ANSWERED. The head's build reached a terminal conclusion on an
|
|
301
|
+
# earlier pass and a build's answer does not change back. Asking again would
|
|
302
|
+
# be the idle polling this design refuses.
|
|
303
|
+
sha_is_settled "$head" && return 0
|
|
304
|
+
|
|
305
|
+
local run rc
|
|
306
|
+
run=$(monitor_run_for_sha "$head"); rc=$?
|
|
307
|
+
|
|
308
|
+
# UNASKABLE — no finding. The host could not be asked, so nothing about the
|
|
309
|
+
# build is known. Reporting anything here would be inventing an answer out of
|
|
310
|
+
# a failure to observe.
|
|
311
|
+
[ "$rc" = 2 ] && return 0
|
|
312
|
+
|
|
313
|
+
# NO RUN YET — no finding, and not an error. The commit exists and CI has not
|
|
314
|
+
# created a run for it. This is the ordinary state of a freshly pushed sha,
|
|
315
|
+
# and it is what the monitor sees on every pass until the run appears.
|
|
316
|
+
[ -n "$run" ] || return 0
|
|
317
|
+
|
|
318
|
+
local run_sha status conclusion url
|
|
319
|
+
run_sha=$(run_field "$run" sha)
|
|
320
|
+
status=$(run_field "$run" status)
|
|
321
|
+
conclusion=$(run_field "$run" conclusion)
|
|
322
|
+
url=$(run_field "$run" url)
|
|
323
|
+
|
|
324
|
+
# 1. HEAD MOVED — the run in hand is about a commit that is no longer the
|
|
325
|
+
# head. Decided BEFORE the conclusion is read, because a green run for
|
|
326
|
+
# superseded code is the specific wrong answer this monitor was built to
|
|
327
|
+
# avoid: it invites a merge of the wrong thing. Measured 2026-08-30 — two
|
|
328
|
+
# merge waiters reported on superseded runs and had to be stopped and
|
|
329
|
+
# re-armed.
|
|
330
|
+
#
|
|
331
|
+
# This fires when the host answered about a DIFFERENT sha than the one asked
|
|
332
|
+
# about, which is the shape a race actually takes: the head moved between the
|
|
333
|
+
# local read and the host's reply.
|
|
334
|
+
if [ -n "$run_sha" ] && [ "$run_sha" != "$head" ]; then
|
|
335
|
+
printf 'head moved\tthe run at %s is for %s, but the branch head is now %s; its answer is about the past\n' \
|
|
336
|
+
"${url:-an unknown url}" "$run_sha" "$head"
|
|
337
|
+
return 0
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
# 2. BUILD NEEDS APPROVAL — a real state, not an edge case. Bot branches hit
|
|
341
|
+
# it: the release PR's runs need a manual click before they start. GitHub
|
|
342
|
+
# reports it as a `status` of `waiting`/`action_required` and as a
|
|
343
|
+
# `conclusion` of `action_required`, depending on where the run is, so both
|
|
344
|
+
# are read. Folding it into "not passed yet" would report the build pending
|
|
345
|
+
# forever while it waits for a click nobody knows is needed.
|
|
346
|
+
case "$status:$conclusion" in
|
|
347
|
+
*action_required*|waiting:*)
|
|
348
|
+
printf 'build needs approval\tthe run at %s for %s is waiting for a manual approval before it can start\n' \
|
|
349
|
+
"${url:-an unknown url}" "$head"
|
|
350
|
+
return 0
|
|
351
|
+
;;
|
|
352
|
+
esac
|
|
353
|
+
|
|
354
|
+
# 3 & 4. THE TERMINAL CONCLUSIONS. An empty conclusion means the run is still
|
|
355
|
+
# going — queued or in progress — and a monitor whose subject is a transition
|
|
356
|
+
# says nothing about a state that has not changed yet.
|
|
357
|
+
[ -n "$conclusion" ] || return 0
|
|
358
|
+
|
|
359
|
+
case "$conclusion" in
|
|
360
|
+
success)
|
|
361
|
+
printf 'build passed\tthe run at %s for %s concluded success\n' "${url:-an unknown url}" "$head"
|
|
362
|
+
return 0
|
|
363
|
+
;;
|
|
364
|
+
# EVERY OTHER TERMINAL CONCLUSION IS A FAILURE TO A READER WAITING ON GREEN.
|
|
365
|
+
# `failure`, `timed_out`, `cancelled` and `startup_failure` differ in cause
|
|
366
|
+
# and not in consequence: none of them is a build somebody may merge on. The
|
|
367
|
+
# cause is not thrown away — it rides in the evidence, where a reader
|
|
368
|
+
# deciding whether to rerun can see it.
|
|
369
|
+
*)
|
|
370
|
+
printf 'build failed\tthe run at %s for %s concluded %s\n' "${url:-an unknown url}" "$head" "$conclusion"
|
|
371
|
+
return 0
|
|
372
|
+
;;
|
|
373
|
+
esac
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# One full pass: sample, publish only on a change of ANSWER-ABOUT-A-COMMIT.
|
|
377
|
+
monitor_pass() {
|
|
378
|
+
local row finding evidence head
|
|
379
|
+
head=$(monitor_head_sha)
|
|
380
|
+
row=$(sample_finding)
|
|
381
|
+
finding="${row%% *}"
|
|
382
|
+
evidence=''
|
|
383
|
+
case "$row" in *" "*) evidence="${row#* }" ;; esac
|
|
384
|
+
[ -z "$row" ] && finding=''
|
|
385
|
+
|
|
386
|
+
# PUBLISH ON A CHANGE OF EITHER THE FINDING OR THE COMMIT IT IS ABOUT. The
|
|
387
|
+
# second half is what makes these transitions rather than conditions: `build
|
|
388
|
+
# passed` for a new sha is news even though the word is the same as last
|
|
389
|
+
# time, and suppressing it would silence exactly the answer an operator
|
|
390
|
+
# pushed in order to get.
|
|
391
|
+
if [ "$finding" != "$published" ] || { [ -n "$finding" ] && [ "$head" != "$published_sha" ]; }; then
|
|
392
|
+
if [ -n "$finding" ]; then
|
|
393
|
+
since=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
394
|
+
publish "$finding" "$evidence" "$since"
|
|
395
|
+
# SETTLED, so it is never asked about again. Every finding this monitor
|
|
396
|
+
# publishes is terminal for its sha: a failure stays failed, a pass stays
|
|
397
|
+
# passed, and a superseded run does not become current. Recording it here
|
|
398
|
+
# rather than in `sample_finding` keeps the decision beside the publish it
|
|
399
|
+
# follows from.
|
|
400
|
+
[ -n "$head" ] && settled_shas="$settled_shas $head"
|
|
401
|
+
fi
|
|
402
|
+
# NO CLEARING PUBLISH, and that is the difference from the AgentMonitor. A
|
|
403
|
+
# debt is cleared when it is paid — that is news. A build's answer is never
|
|
404
|
+
# withdrawn: `build failed` does not stop being true about that sha, and the
|
|
405
|
+
# next answer is a new finding about a new commit, which the sha key already
|
|
406
|
+
# carries. Publishing `clear` here would tell a subscriber a failure had
|
|
407
|
+
# been resolved when all that happened is the branch moved on.
|
|
408
|
+
published="$finding"
|
|
409
|
+
published_sha="$head"
|
|
410
|
+
fi
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# SOURCEABLE FOR TESTS, the same guard the siblings carry. A test that wants to
|
|
414
|
+
# drive `monitor_pass` against redefined ports needs the functions without the
|
|
415
|
+
# loop; everything above this line defines, and nothing below it runs when the
|
|
416
|
+
# guard is set.
|
|
417
|
+
[ -n "${PLOT_MONITOR_NO_MAIN:-}" ] && return 0 2>/dev/null
|
|
418
|
+
|
|
419
|
+
monitor_pass
|
|
420
|
+
[ "$once" = 1 ] && exit 0
|
|
421
|
+
|
|
422
|
+
# IT ENDS WITH ITS AGENT, by the mechanism `plot-monitor-subject.sh` documents
|
|
423
|
+
# and for the reason `docs/research/2026-08-30-what-ends-a-monitor.md` measured.
|
|
424
|
+
#
|
|
425
|
+
# PUBLISH FIRST, THEN LEAVE. The final pass runs with the agent already gone,
|
|
426
|
+
# and it matters here for a reason of its own: an agent that pushes and exits
|
|
427
|
+
# leaves a run still going, and the answer arrives after there is nobody left to
|
|
428
|
+
# see it. The last pass is the one chance to catch a build that concluded during
|
|
429
|
+
# the shutdown.
|
|
430
|
+
while plot_monitor_wait "$interval" "$pid_file"; do
|
|
431
|
+
monitor_pass
|
|
432
|
+
done
|
|
433
|
+
|
|
434
|
+
monitor_pass
|
|
435
|
+
exit 0
|
package/plot-config.sh
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
# Known keys (see the plot skill's Setup section):
|
|
27
27
|
# Project board | Branch prefixes | Plan directory | Active index |
|
|
28
28
|
# Delivered index | Sprint directory | Story directory | Story index |
|
|
29
|
-
# Plan template | Main branch | Board command
|
|
29
|
+
# Plan template | Worker prompt template | Main branch | Board command
|
|
30
30
|
# Worktree root where /plot-dispatch puts its worktrees. Read by
|
|
31
31
|
# plot-dispatch.sh; default is the repo's PARENT, which
|
|
32
32
|
# scatters `plot-wt-*` beside the checkout. An absolute
|
|
@@ -106,6 +106,13 @@
|
|
|
106
106
|
#
|
|
107
107
|
# `Plan template` is a repo-root-relative path to the plan template /plot-idea
|
|
108
108
|
# instantiates; when absent, /plot-idea falls back to the shipped template.
|
|
109
|
+
#
|
|
110
|
+
# `Worker prompt template` is the same shape for the worker prompt
|
|
111
|
+
# plot-install-prompt.sh writes into `.plot/worker-prompt.sh` at adoption: a
|
|
112
|
+
# repo-root-relative path (an absolute one is taken as given), falling back to
|
|
113
|
+
# the shipped `skills/plot/templates/worker-prompt.sh`. It names a STARTING
|
|
114
|
+
# POINT, never the file the loop runs — the loop always sources
|
|
115
|
+
# `.plot/worker-prompt.sh`, and nothing re-reads the template after adoption.
|
|
109
116
|
|
|
110
117
|
set -uo pipefail
|
|
111
118
|
|