@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.
@@ -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
@@ -80,6 +80,15 @@
80
80
  # here can invoke a skill. Absent (or `none`) = the button
81
81
  # refuses and names this key as the fix, rather than
82
82
  # accepting the click and doing nothing.
83
+ # Brief command how /plot-dispatch runs an agent headless to WRITE a
84
+ # missing hand-off brief. The prompt is appended as one
85
+ # argument and asks for `/plot-implement <slug>`, whose
86
+ # step 4 owns brief authorship — this key adds no second
87
+ # brief writer. Absent (or `none`) = the capability is
88
+ # unavailable, never an error: the brief gate refuses as
89
+ # it does today and names `no-brief-command` as the
90
+ # reason, so a project that has never set the key behaves
91
+ # exactly as before.
83
92
  # Plot 2 posture keys (repo-declared ceremony bounds; all optional):
84
93
  # Plan PRs required | never | optional (never = hard gate)
85
94
  # Implementation home this repo | <repo/path list> | none
@@ -97,6 +106,13 @@
97
106
  #
98
107
  # `Plan template` is a repo-root-relative path to the plan template /plot-idea
99
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.
100
116
 
101
117
  set -uo pipefail
102
118
 
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env bash
2
+ # Plot helper: the ONE answer to "what is the default branch, and is origin/HEAD
3
+ # still pointing at something that exists?"
4
+ #
5
+ # SOURCED, NOT RUN. `. "$script_dir/plot-default-branch.sh"` defines
6
+ # `default_branch` and `repair_origin_head`; the file does nothing else on load.
7
+ # The same shape — and the same reason — as `plot-pr-merged.sh` and
8
+ # `plot-worker-state.sh`.
9
+ #
10
+ # WHY IT EXISTS. Twice on 2026-09-04, hours apart, `refs/remotes/origin/HEAD`
11
+ # pointed at `origin/plot-corpus-pin`, a branch that does not exist on the
12
+ # remote. `plot-dispatch.sh` could not resolve the default branch and refused
13
+ # every dispatch:
14
+ #
15
+ # plot-dispatch: cannot resolve 'origin/plot-corpus-pin' — refusing to dispatch.
16
+ #
17
+ # `git remote set-head origin --auto` fixed it both times, in under a second.
18
+ #
19
+ # THE FAILURE IS INVISIBLE TO EVERY READER, AND THAT IS THE POINT.
20
+ # `git symbolic-ref --short refs/remotes/origin/HEAD` on a corrupt symref exits
21
+ # **0** and prints `origin/plot-corpus-pin` — a plausible branch name. Ten
22
+ # scripts read it that way, and none of them can tell that answer from a good
23
+ # one. Only the `rev-parse` downstream fails, in a component that has already
24
+ # built `origin/<main>` out of it and can only report that the ref will not
25
+ # resolve. So the check belongs where the symref is READ, not where it is used.
26
+ #
27
+ # IT NAMES WHAT IT REPAIRED. A recurring corruption that is silently fixed is
28
+ # one nobody investigates: the line says what the symref pointed at and what it
29
+ # now points at, so a second occurrence is visible in a log rather than
30
+ # invisible in a working system.
31
+ #
32
+ # IT DOES NOT REPAIR A SYMREF THAT RESOLVES. Only an unresolvable one is broken.
33
+ # A clone whose `origin/HEAD` deliberately names a non-default branch is
34
+ # somebody's choice, and `--auto` would silently overrule it.
35
+ #
36
+ # WHAT LEAVES THE PIN BEHIND IS NOT REPAIRED HERE, and the leave-alone rule is
37
+ # what keeps this safe alongside it. `packages/domain/corpus/refs.corpus.test.ts`
38
+ # repoints `origin/HEAD` at a `plot-corpus-pin` ref it creates in `beforeAll`
39
+ # and restores in `afterAll` — deliberately, so two readings of the estate see
40
+ # one world. While that suite runs the pin RESOLVES, so this repairs nothing and
41
+ # the suite is unaffected. What was measured on 2026-09-04 is the state after a
42
+ # run that never reached its `afterAll`: the symref left behind, the ref it
43
+ # names gone. Fixing that belongs to the suite; this repairs the symptom and
44
+ # reports it loudly enough that the cause stays findable.
45
+
46
+ # Whether `refs/remotes/origin/HEAD` names a ref that exists.
47
+ #
48
+ # Reads the symref itself rather than `default_branch`'s answer, because the
49
+ # fallbacks below would mask exactly the state this tests.
50
+ #
51
+ # Usage: origin_head_resolves [<repo-dir>]
52
+ # Returns: 0 when it resolves — including when there is no symref at all, which
53
+ # is a FRESH CLONE rather than a corruption and has nothing to repair.
54
+ origin_head_resolves() {
55
+ local dir="${1:-.}" target
56
+ target=$(git -C "$dir" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null) || return 0
57
+ [ -n "$target" ] || return 0
58
+ git -C "$dir" rev-parse --verify --quiet "${target}^{commit}" >/dev/null 2>&1
59
+ }
60
+
61
+ # Repairs an unresolvable `refs/remotes/origin/HEAD`, and says what it did.
62
+ #
63
+ # The repair is `git remote set-head origin --auto`, which asks the remote. It
64
+ # is cheap — measured under a second — and it is the same command that fixed
65
+ # both occurrences by hand.
66
+ #
67
+ # Usage: repair_origin_head [<repo-dir>]
68
+ # Output: one line on stderr naming BOTH refs, when a repair happened.
69
+ # Returns: 0 whether or not it repaired, and 0 when the repair itself fails —
70
+ # this is a self-heal on a path that has its own refusal downstream,
71
+ # so it must never become a second way to stop.
72
+ repair_origin_head() {
73
+ local dir="${1:-.}" was now
74
+ origin_head_resolves "$dir" && return 0
75
+
76
+ was=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
77
+ if ! git -C "$dir" remote set-head origin --auto >/dev/null 2>&1; then
78
+ echo "plot: origin/HEAD points at '$was', which does not resolve, and 'git remote set-head origin --auto' failed — the remote could not be asked." >&2
79
+ return 0
80
+ fi
81
+ now=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
82
+ echo "plot: repaired origin/HEAD — it pointed at '$was', which does not exist on the remote; it now points at '${now:-<unset>}'." >&2
83
+ return 0
84
+ }
85
+
86
+ # The default branch, repairing an unresolvable `origin/HEAD` on the way.
87
+ #
88
+ # The fallbacks are the ones the callers already carried, in the same order: the
89
+ # symref, then `main`. What is new is that the symref is TESTED before it is
90
+ # believed.
91
+ #
92
+ # IT NEVER FALLS BACK TO THE CHECKOUT'S OWN BRANCH, and that is a refusal rather
93
+ # than an omission. `refs-git.ts:138` does exactly that, correctly — it answers
94
+ # a question about THIS checkout. A shell caller is asking which branch everyone
95
+ # shares, and answering with whatever branch this tree happens to sit on is the
96
+ # defect `dispatch.test.mjs` is named for: *"a shared approval is not hidden by
97
+ # a parked checkout"*, measured when a concurrent agent's `git checkout` blocked
98
+ # two correctly-approved plans in one session. `main` is a guess about the
99
+ # repository; the current branch is a guess about the operator's last command.
100
+ #
101
+ # Usage: default_branch [<repo-dir>]
102
+ # Output: the branch name on stdout, without the `origin/` prefix.
103
+ default_branch() {
104
+ local dir="${1:-.}" name
105
+ repair_origin_head "$dir"
106
+ name=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
107
+ [ -n "$name" ] || name="main"
108
+ printf '%s\n' "$name"
109
+ }