@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/plot-release-refs.sh
CHANGED
|
@@ -44,6 +44,35 @@
|
|
|
44
44
|
# 4. a branch checked out in ANY worktree (somebody is reading it)
|
|
45
45
|
# 5. the default branch itself (never ours to delete)
|
|
46
46
|
#
|
|
47
|
+
# THE SCRIPT DOES NOT OWN THE FIVE. They are conditions in
|
|
48
|
+
# `packages/domain/src/rules/reapable.ts`'s `finishedWith`, which states every
|
|
49
|
+
# condition that can hold a desk and judges none of them. This script and
|
|
50
|
+
# `plot-reap.sh` were asking about the same desk in two places, and they had
|
|
51
|
+
# already drifted: this one never asked whether a worker was alive, and the
|
|
52
|
+
# reaper never asked `pr_open`. Each was blind to a condition the other
|
|
53
|
+
# measured, and neither omission was argued for anywhere.
|
|
54
|
+
#
|
|
55
|
+
# WHAT CHANGED IS WHERE THE CONDITIONS ARE STATED, NOT WHICH ONES THIS SCRIPT
|
|
56
|
+
# ASKS. The rule also answers `liveWorker`, `uncommittedChanges` and
|
|
57
|
+
# `blockedMarker` — the reaper's three — and this script reads none of them.
|
|
58
|
+
# Folding them in *"would silently widen a licence that was written narrow on
|
|
59
|
+
# purpose"*, which is what line 30 above has warned since this script existed.
|
|
60
|
+
# The rule makes the difference VISIBLE; making it disappear is a different
|
|
61
|
+
# change, and it is not this one.
|
|
62
|
+
#
|
|
63
|
+
# `unknown` PERMITS HERE, AND THAT IS THE CALLER'S HALF. Four of the rule's
|
|
64
|
+
# conditions need a worktree and 69% of branches have none (22 of 32, measured
|
|
65
|
+
# 2026-09-06), so the rule answers `unknown` rather than inventing `false`. The
|
|
66
|
+
# reaper reads `unknown` as *nothing to reap*; this reads it as *no evidence
|
|
67
|
+
# against deletion*, which is exactly what it did before the rule existed.
|
|
68
|
+
# Refusing on silence is the estate's rule for an unreachable HOST — applied to
|
|
69
|
+
# a missing tree it would keep every ref on two branches in three and make this
|
|
70
|
+
# script useless where the scan cost is highest.
|
|
71
|
+
#
|
|
72
|
+
# The SCOPE is still this script's and is not shared. The rule answers about
|
|
73
|
+
# one branch and enumerates nothing; which branches to ask about stays bounded
|
|
74
|
+
# by the plan file, for the reason the paragraph above gives.
|
|
75
|
+
#
|
|
47
76
|
# THE RULE THIS MUST NOT BREAK. `/plot-implement` says plainly: *"leave the ref
|
|
48
77
|
# in place — never delete a remote ref another session may be reading."* Read in
|
|
49
78
|
# context that rule governs GIVING A BRANCH UP — work that turned out
|
|
@@ -65,7 +94,7 @@ while [ $# -gt 0 ]; do
|
|
|
65
94
|
--yes) DRY=0 ;;
|
|
66
95
|
--dry-run) DRY=1 ;;
|
|
67
96
|
--max) MAX="${2:-0}"; shift ;;
|
|
68
|
-
-h|--help) sed -n '2,
|
|
97
|
+
-h|--help) sed -n '2,86p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
69
98
|
-*) echo "plot-release-refs: unknown argument: $1" >&2; exit 2 ;;
|
|
70
99
|
*) slug="$1" ;;
|
|
71
100
|
esac
|
|
@@ -115,7 +144,17 @@ prefix_re=$(bash "$script_dir/plot-config.sh" get "Branch prefixes" "idea/, feat
|
|
|
115
144
|
meta=$(bash "$script_dir/plot-plan-meta.sh" --prefixes "$prefix_re" "$plan_file" 2>/dev/null) || meta=""
|
|
116
145
|
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
|
|
117
146
|
|
|
118
|
-
# The
|
|
147
|
+
# The shared rule, resolved from THIS SCRIPT's location rather than the cwd, and
|
|
148
|
+
# as a `file://` URL because `import()` needs one for an absolute path. Missing
|
|
149
|
+
# or unreadable, the decision below reports "could not be asked" and keeps every
|
|
150
|
+
# ref — the same fail-safe `plot-reap.sh` applies to the same module.
|
|
151
|
+
#
|
|
152
|
+
# THIS SCRIPT NOW NEEDS NODE. The alternative is a second implementation of the
|
|
153
|
+
# guards living in shell where nothing can test it, which is what this branch
|
|
154
|
+
# exists to end: the two copies had already drifted apart by three readings.
|
|
155
|
+
RULE_PATH="file://$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." 2>/dev/null && pwd)/packages/domain/src/rules/reapable.ts"
|
|
156
|
+
|
|
157
|
+
# The default branch, via the host adapter when it can answer. The rule compares
|
|
119
158
|
# against it, and a wrong answer here can only ever protect MORE.
|
|
120
159
|
HOST="$script_dir/plot-host.sh"
|
|
121
160
|
DEFAULT=main
|
|
@@ -123,18 +162,31 @@ if [ -x "$HOST" ]; then
|
|
|
123
162
|
d=$("$HOST" default-branch 2>/dev/null) && [ -n "$d" ] && DEFAULT="$d"
|
|
124
163
|
fi
|
|
125
164
|
|
|
126
|
-
# Every branch currently checked out ANYWHERE,
|
|
165
|
+
# Every branch currently checked out ANYWHERE, WITH THE TREE THAT HOLDS IT.
|
|
127
166
|
#
|
|
128
167
|
# Collected once, before the loop, rather than asked per branch: `git worktree
|
|
129
168
|
# list` walks the whole estate and this script runs on the delivery path where
|
|
130
169
|
# that estate may hold dozens of trees. The answer cannot change underneath a
|
|
131
170
|
# single run in a way that matters — a worktree created mid-run holds a branch
|
|
132
171
|
# whose ref this run has not yet reached, and the next run sees it.
|
|
172
|
+
#
|
|
173
|
+
# THE TREE'S STATE IS COLLECTED TOO, and that is what the shared rule needed.
|
|
174
|
+
# `finishedWith` answers four conditions from a worktree, and it answers them
|
|
175
|
+
# `unknown` where there is none — so it has to be told whether one was found.
|
|
176
|
+
# That is a reading, not a refusal: this script asks none of those four, and
|
|
177
|
+
# passing the tree is what lets the rule say `unknown` instead of inventing
|
|
178
|
+
# `false`. Git's own `prunable` distinguishes a listed tree whose directory is
|
|
179
|
+
# gone from one that was never made; both are unaskable, and the rule keeps the
|
|
180
|
+
# two words apart because an operator acts on them differently.
|
|
133
181
|
checked_out=$(git worktree list --porcelain 2>/dev/null \
|
|
134
|
-
|
|
|
182
|
+
| awk '/^worktree /{wt=substr($0,10); pr="no"}
|
|
183
|
+
/^prunable/{pr="yes"}
|
|
184
|
+
/^branch refs\/heads\//{print substr($0,19) "\t" wt "\t" pr}')
|
|
135
185
|
|
|
136
|
-
|
|
137
|
-
|
|
186
|
+
# The worktree holding a branch and whether git calls it prunable, or empty
|
|
187
|
+
# when no tree holds it.
|
|
188
|
+
worktree_of() {
|
|
189
|
+
printf '%s\n' "$checked_out" | awk -F'\t' -v b="$1" '$1 == b {print $2 "\t" $3; exit}'
|
|
138
190
|
}
|
|
139
191
|
|
|
140
192
|
released=0; kept=0; deleted=0
|
|
@@ -149,51 +201,141 @@ printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
|
149
201
|
while IFS=$'\t' read -r br deferred; do
|
|
150
202
|
[ -n "$br" ] || continue
|
|
151
203
|
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
204
|
+
# THE READINGS, each taken once and none judged here. The script holds no
|
|
205
|
+
# `if` about whether a ref may go — only about which conditions it asks and
|
|
206
|
+
# what to do with the answers.
|
|
207
|
+
#
|
|
208
|
+
# `pr_merged` reads `mergedAt` on ANY PR (never `state`, never ancestry) and
|
|
209
|
+
# answers false when the host cannot be asked, so silence keeps the ref.
|
|
210
|
+
# `pr_open` is asked separately rather than derived from it: a branch carries
|
|
211
|
+
# both, and `changeset-release/main` is the measured case.
|
|
212
|
+
merge=not-merged
|
|
213
|
+
pr_merged "$br" && merge=merged
|
|
158
214
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# worker, and it needs the REF to be there to read it against. Checked
|
|
162
|
-
# before the host is even asked: this is a decision a person already
|
|
163
|
-
# recorded, and no merge state overturns it.
|
|
164
|
-
if [ "$deferred" = "true" ]; then
|
|
165
|
-
printf '%-8s %-52s %s\n' "keep" "$br" "deferred — a given-up branch keeps its ref"
|
|
166
|
-
kept=$((kept+1)); continue
|
|
167
|
-
fi
|
|
215
|
+
open_pr=false
|
|
216
|
+
pr_open "$br" && open_pr=true
|
|
168
217
|
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
218
|
+
# WHETHER A TREE HOLDS THE BRANCH, AND IN WHAT STATE. Three words, because
|
|
219
|
+
# the rule answers its four tree-sourced conditions `unknown` without one and
|
|
220
|
+
# `unknown` is a reading rather than a failure. `vanished` is git's own
|
|
221
|
+
# `prunable`: it reads like `absent` to every condition — there is equally
|
|
222
|
+
# nothing to measure — and stays a separate word because `git worktree prune`
|
|
223
|
+
# is the repair for one and not the other.
|
|
224
|
+
#
|
|
225
|
+
# NOTHING INSIDE THE TREE IS READ. A live pid, an uncommitted file and a
|
|
226
|
+
# `PLOT-BLOCKED` marker are the reaper's three conditions and this script
|
|
227
|
+
# asks none of them; measuring them here would put a refusal within one edit's
|
|
228
|
+
# reach of a licence written narrow on purpose. A checked-out branch keeps its
|
|
229
|
+
# ref whatever is going on inside it, which is guard 4 and needs no reading
|
|
230
|
+
# from the tree at all.
|
|
231
|
+
wt_line=$(worktree_of "$br")
|
|
232
|
+
wt=${wt_line%%$'\t'*}
|
|
233
|
+
prunable=${wt_line#*$'\t'}
|
|
234
|
+
tree=absent
|
|
235
|
+
if [ -n "$wt" ]; then
|
|
236
|
+
if [ "$prunable" = "yes" ] || [ ! -d "$wt" ]; then tree=vanished; else tree=present; fi
|
|
177
237
|
fi
|
|
178
238
|
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
239
|
+
# THE DECISION. `packages/domain/src/rules/reapable.ts`, imported directly —
|
|
240
|
+
# the same shape and the same reason as `plot-reap.sh`: node 24 strips the
|
|
241
|
+
# types, so there is no build step between this script and the rule, and the
|
|
242
|
+
# JS arrives on STDIN from a QUOTED heredoc so the shell expands none of it.
|
|
243
|
+
#
|
|
244
|
+
# ONE RULE, TWO CALLERS, AND THE CALLERS STAY DIFFERENT. `finishedWith`
|
|
245
|
+
# STATES every condition that can hold a desk and JUDGES none of them; which
|
|
246
|
+
# conditions refuse a ref is this caller's half, and it names exactly the five
|
|
247
|
+
# this script has always asked. The reaper reads the same rule and names its
|
|
248
|
+
# own. Neither script gains the other's, which is what makes the difference
|
|
249
|
+
# visible instead of eliminating it.
|
|
250
|
+
#
|
|
251
|
+
# `unknown` PERMITS, AND THE ORDER OF THE TESTS IS THE ARGUMENT. Only the
|
|
252
|
+
# first two conditions are answerable without a tree, and 69% of branches have
|
|
253
|
+
# none. `=== "true"` is therefore the test at every guard: `unknown` falls
|
|
254
|
+
# through, exactly as this script behaved before the rule existed. That is
|
|
255
|
+
# deliberate and it is the caller's decision to make — the reaper reads the
|
|
256
|
+
# same `unknown` as *nothing to reap*.
|
|
257
|
+
#
|
|
258
|
+
# A rule that cannot be asked REFUSES: node missing, the import failing, the
|
|
259
|
+
# module throwing all leave `verdict` empty, and an empty verdict keeps the
|
|
260
|
+
# ref and says why. Silence is never permission on this path either — that is
|
|
261
|
+
# the module being absent, which is not the same reading as a condition
|
|
262
|
+
# answering `unknown`.
|
|
263
|
+
verdict=$(PLOT_BRANCH="$br" PLOT_DEFAULT="$DEFAULT" PLOT_MERGE="$merge" \
|
|
264
|
+
PLOT_GIVEN_UP="$deferred" PLOT_OPEN_PR="$open_pr" \
|
|
265
|
+
PLOT_TREE="$tree" \
|
|
266
|
+
PLOT_CHECKED_OUT="$([ -n "$wt" ] && echo true || echo false)" \
|
|
267
|
+
PLOT_RULE="$RULE_PATH" \
|
|
268
|
+
node --input-type=module - <<'NODE_EOF' 2>/dev/null
|
|
269
|
+
// An ABSOLUTE path derived from this script, never from the cwd: this runs
|
|
270
|
+
// wherever the operator invoked it, and the reconcile suite runs it against
|
|
271
|
+
// sandbox repos in the temp directory.
|
|
272
|
+
//
|
|
273
|
+
// NO APOSTROPHE MAY APPEAR ANYWHERE IN THIS BLOCK. bash 3.2 is /bin/bash on
|
|
274
|
+
// macOS, and it is what the reconcile suite runs this script under when it
|
|
275
|
+
// strips PATH. It parses the body of a quoted heredoc nested inside `$(...)`,
|
|
276
|
+
// so one contraction opens a string that never closes and the whole file fails
|
|
277
|
+
// to parse. The error reads `unexpected EOF` and names a line 30 further down,
|
|
278
|
+
// which points nowhere near the apostrophe.
|
|
279
|
+
const { finishedWith } = await import(process.env.PLOT_RULE);
|
|
280
|
+
|
|
281
|
+
const held = finishedWith({
|
|
282
|
+
branch: process.env.PLOT_BRANCH,
|
|
283
|
+
defaultBranch: process.env.PLOT_DEFAULT,
|
|
284
|
+
// This script never looks at the main checkout as a tree; the branch test
|
|
285
|
+
// the rule makes is what catches the default branch.
|
|
286
|
+
isMain: false,
|
|
287
|
+
// The three the reaper measures and this script does not. They are passed
|
|
288
|
+
// empty because the rule shape asks for them, and the tree reading below is
|
|
289
|
+
// what makes them honest: with no tree they answer `unknown`, and this
|
|
290
|
+
// caller reads none of the three either way.
|
|
291
|
+
workerPid: null,
|
|
292
|
+
dirtyPath: "",
|
|
293
|
+
blockedMarker: false,
|
|
294
|
+
merge: process.env.PLOT_MERGE,
|
|
295
|
+
givenUp: process.env.PLOT_GIVEN_UP === "true",
|
|
296
|
+
openPr: process.env.PLOT_OPEN_PR === "true",
|
|
297
|
+
checkedOut: process.env.PLOT_CHECKED_OUT === "true",
|
|
298
|
+
tree: process.env.PLOT_TREE,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// THE FIVE GUARDS, in the order they have always been tested, and each
|
|
302
|
+
// satisfied only by a condition answering `true`. `unknown` permits: it means
|
|
303
|
+
// no evidence against deletion, which on this estate is the majority reading
|
|
304
|
+
// and the one the script already acted on. `liveWorker`, `uncommittedChanges`
|
|
305
|
+
// and `blockedMarker` are in `held` and are deliberately not consulted.
|
|
306
|
+
const guards = [
|
|
307
|
+
["given-up", held.givenUp, ""],
|
|
308
|
+
["no-merged-pr", held.noMergedPr, ""],
|
|
309
|
+
["open-pr", held.openPr, ""],
|
|
310
|
+
["checked-out", held.checkedOut, ""],
|
|
311
|
+
["on-default-branch", held.onDefaultBranch, process.env.PLOT_DEFAULT],
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const refusal = guards.find(([, reading]) => reading === "true");
|
|
315
|
+
|
|
316
|
+
process.stdout.write(refusal === undefined ? "delete\t" : `${refusal[0]}\t${refusal[2]}`);
|
|
317
|
+
NODE_EOF
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
refusal=${verdict%%$'\t'*}
|
|
321
|
+
detail=${verdict#*$'\t'}
|
|
188
322
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
323
|
+
if [ "$refusal" != "delete" ]; then
|
|
324
|
+
# The rule named the refusal; this renders it. A verdict the rule could not
|
|
325
|
+
# produce is empty, and an empty refusal keeps the ref and says so.
|
|
326
|
+
case "$refusal" in
|
|
327
|
+
given-up) why="deferred — a given-up branch keeps its ref" ;;
|
|
328
|
+
no-merged-pr) why="unlanded work — no merged PR" ;;
|
|
329
|
+
open-pr) why="an open PR is using this branch" ;;
|
|
330
|
+
checked-out) why="checked out in a worktree — somebody is reading it" ;;
|
|
331
|
+
on-default-branch) why="the default branch — never deleted" ;;
|
|
332
|
+
# No arm for `live-worker`, `uncommitted-changes` or `blocked-marker`.
|
|
333
|
+
# The rule answers all three and this script consults none, so none can
|
|
334
|
+
# reach here; an arm for one would be the first line of a licence this
|
|
335
|
+
# script does not hold.
|
|
336
|
+
*) why="the rule could not be asked — keeping the ref" ;;
|
|
337
|
+
esac
|
|
338
|
+
printf '%-8s %-52s %s\n' "keep" "$br" "$why"
|
|
197
339
|
kept=$((kept+1)); continue
|
|
198
340
|
fi
|
|
199
341
|
|
package/plot-resolve-artifact.sh
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
# THE ONLY AUTOMATIC WRITE THIS SYSTEM GRANTS, and it is granted for three
|
|
10
10
|
# verified reasons rather than for convenience:
|
|
11
11
|
#
|
|
12
|
-
# 1. `-merge` KEEPS THE FILE VALID. `.gitattributes` marks
|
|
12
|
+
# 1. `-merge` KEEPS THE FILE VALID. `.gitattributes` marks every bundle
|
|
13
13
|
# `-merge`, so git keeps one side whole and writes NO conflict markers.
|
|
14
14
|
# The artifact stays buildable JavaScript *through* a conflict — which is
|
|
15
|
-
# why a script may touch it at all.
|
|
15
|
+
# why a script may touch it at all. `scripts/check-bundle-attributes.sh`
|
|
16
|
+
# is the gate that keeps that true of every bundle rather than of one.
|
|
16
17
|
# 2. THE REBUILD IS DETERMINISTIC. Measured: `build.mjs` embeds no timestamp
|
|
17
18
|
# and no randomness, so the output does not depend on which side was kept.
|
|
18
19
|
# 3. CI PROVES IT. The no-diff gate fails the build if the committed artifact
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
# AN EMPTY CONFLICT SET IS NOT A REFUSAL ABOUT FILES. Three cases, named apart,
|
|
53
54
|
# because two of them were once one:
|
|
54
55
|
#
|
|
55
|
-
#
|
|
56
|
+
# bundles only → the licensed case → repair
|
|
56
57
|
# other files present → needs judgement → not-artifact-only
|
|
57
58
|
# empty, no merge ran → nothing was observed → not-observed
|
|
58
59
|
#
|
|
@@ -70,10 +71,45 @@ set -uo pipefail
|
|
|
70
71
|
|
|
71
72
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
72
73
|
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
|
|
74
|
+
# THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
|
|
75
|
+
#
|
|
76
|
+
# It was one hardcoded filename until 2026-09-06, and that cost a repair the
|
|
77
|
+
# same day: PR #727 conflicted in `plot-registryd.mjs` — a `-merge` bundle with
|
|
78
|
+
# a deterministic rebuild, exactly the licensed case — and this script refused
|
|
79
|
+
# `not-artifact-only` against a list naming only `board-server.mjs`. The refusal
|
|
80
|
+
# was correct behaviour against a stale list. Hours later the same branch
|
|
81
|
+
# conflicted in `board-server.mjs` and was repaired automatically: same class of
|
|
82
|
+
# conflict, opposite outcome, one filename apart.
|
|
83
|
+
#
|
|
84
|
+
# DERIVED FROM `build.mjs`'S OWN DECLARATIONS, by the same pipeline
|
|
85
|
+
# `scripts/check-bundle-attributes.sh` uses, because a hand-written list here
|
|
86
|
+
# would be a fourth place to drift — and drift is the defect this replaces. The
|
|
87
|
+
# build declares each output as `const shippedX = path.join(here, '…')`, which
|
|
88
|
+
# is what an author writes when adding a bundle; nothing else has to be
|
|
89
|
+
# remembered. Nine bundles today; `plot-landed.mjs` arrived while the plan that
|
|
90
|
+
# asked for this was still in draft, and the derivation found it.
|
|
91
|
+
#
|
|
92
|
+
# `plot-monitor.mjs` IS DELIBERATELY ABSENT. It is committed and documented, and
|
|
93
|
+
# no `outfile` names it — nothing rebuilds it. Property 2 above is the whole
|
|
94
|
+
# licence, so a file with no deterministic rebuild cannot be on this list. The
|
|
95
|
+
# derivation reads the build, so it cannot ask for it.
|
|
96
|
+
#
|
|
97
|
+
# Still named in the board's contract as well, because the two run in different
|
|
98
|
+
# languages and neither can import the other's constant. The pairing is asserted
|
|
99
|
+
# by a test rather than trusted — and that test now asserts SET EQUALITY, since
|
|
100
|
+
# a set that agrees on one member and differs on another is exactly the drift
|
|
101
|
+
# this replaces.
|
|
102
|
+
#
|
|
103
|
+
# READ FROM THE REPOSITORY BEING REPAIRED, not from this script's own checkout.
|
|
104
|
+
# The script is vendored into the published package, where `packages/` does not
|
|
105
|
+
# exist — and it rebuilds with `pnpm build:board` inside the target repo, so the
|
|
106
|
+
# build that defines the set is the one that will run. Resolved below, once
|
|
107
|
+
# `repo_root` is known.
|
|
108
|
+
bundle_set() { # $1=repo root → one path per line, sorted
|
|
109
|
+
grep -aoE "shipped[A-Za-z]* = path\.join\([^)]*'[^']*'\)" "$1/packages/board/build.mjs" 2>/dev/null \
|
|
110
|
+
| sed -E "s|.*'\.\./\.\./([^']*)'.*|\1|" \
|
|
111
|
+
| sort -u
|
|
112
|
+
}
|
|
77
113
|
|
|
78
114
|
dry_run=0
|
|
79
115
|
branch=""
|
|
@@ -109,6 +145,19 @@ MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^o
|
|
|
109
145
|
|
|
110
146
|
repo_root=$(git rev-parse --show-toplevel)
|
|
111
147
|
|
|
148
|
+
# THE SET, resolved against the repository this run will rebuild.
|
|
149
|
+
ARTIFACT_PATHS=$(bundle_set "$repo_root")
|
|
150
|
+
|
|
151
|
+
# AN EMPTY DERIVATION REFUSES, and it must: the guard below asks whether every
|
|
152
|
+
# unmerged path is in this set, and against an empty set that question has no
|
|
153
|
+
# true answer to give — but a guard written the other way round would have said
|
|
154
|
+
# yes to everything. The build changing shape, or a checkout with no
|
|
155
|
+
# `packages/`, is a reason to stop rather than a reason to repair blind.
|
|
156
|
+
if [ -z "$ARTIFACT_PATHS" ]; then
|
|
157
|
+
echo "step: no bundles derived from packages/board/build.mjs — refusing"
|
|
158
|
+
finish refused no-bundle-set
|
|
159
|
+
fi
|
|
160
|
+
|
|
112
161
|
# WHICH WORKTREE HOLDS THIS BRANCH — ASK GIT, do not reconstruct the path from
|
|
113
162
|
# the branch name.
|
|
114
163
|
#
|
|
@@ -151,7 +200,10 @@ fi
|
|
|
151
200
|
|
|
152
201
|
if [ "$dry_run" = 1 ]; then
|
|
153
202
|
echo "step: would use worktree $wt"
|
|
154
|
-
echo "step: would merge origin/$MAIN, take a side of
|
|
203
|
+
echo "step: would merge origin/$MAIN, take a side of each conflicted bundle, rebuild, test"
|
|
204
|
+
printf 'step: bundle set (%s): %s\n' \
|
|
205
|
+
"$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)" \
|
|
206
|
+
"$(printf '%s' "$ARTIFACT_PATHS" | tr '\n' ' ')"
|
|
155
207
|
echo "step: would push only if pnpm run test:board passes"
|
|
156
208
|
finish refused dry-run
|
|
157
209
|
fi
|
|
@@ -274,23 +326,53 @@ if [ "$n_unmerged" = "0" ]; then
|
|
|
274
326
|
finish refused not-observed
|
|
275
327
|
fi
|
|
276
328
|
|
|
277
|
-
#
|
|
278
|
-
# among the conflicts" — an
|
|
279
|
-
#
|
|
280
|
-
|
|
329
|
+
# EVERY unmerged path is a bundle: the conflict set is a SUBSET of the bundle
|
|
330
|
+
# set, and nothing else is in it. NOT "a bundle is among the conflicts" — an
|
|
331
|
+
# implementation asking that passes every bundle-only case and silently repairs
|
|
332
|
+
# merges that need judgement as a whole. The claim stayed exact when the list
|
|
333
|
+
# grew from one file to nine; only the thing each path is checked against
|
|
334
|
+
# changed. A merge conflicting in a bundle AND anything else still needs a
|
|
335
|
+
# person, even though one of its files does not.
|
|
336
|
+
#
|
|
337
|
+
# WALKED PER PATH rather than compared as a whole, because the conflict set is
|
|
338
|
+
# an arbitrary subset of nine and there is no single string to compare it to.
|
|
339
|
+
# The direction is what keeps it exact: every element of the observed set must
|
|
340
|
+
# appear in the licensed set, so an unlicensed path can only ever refuse.
|
|
341
|
+
outside=""
|
|
342
|
+
while IFS= read -r conflict; do
|
|
343
|
+
[ -n "$conflict" ] || continue
|
|
344
|
+
if ! printf '%s\n' "$ARTIFACT_PATHS" | grep -qxF -- "$conflict"; then
|
|
345
|
+
outside="${outside}${outside:+ }$conflict"
|
|
346
|
+
fi
|
|
347
|
+
done <<EOF
|
|
348
|
+
$unmerged
|
|
349
|
+
EOF
|
|
350
|
+
|
|
351
|
+
if [ -n "$outside" ]; then
|
|
281
352
|
git -C "$wt" merge --abort >/dev/null 2>&1 || true
|
|
282
|
-
echo "step: conflict set is not
|
|
353
|
+
echo "step: conflict set is not bundles only — refusing"
|
|
283
354
|
printf 'step: unmerged: %s\n' "$(printf '%s' "$unmerged" | tr '\n' ' ')"
|
|
355
|
+
printf 'step: outside the bundle set: %s\n' "$outside"
|
|
284
356
|
finish refused not-artifact-only
|
|
285
357
|
fi
|
|
286
358
|
|
|
287
|
-
# 3. Take a side. WHICH SIDE CANNOT MATTER — the
|
|
288
|
-
# diff is never read. `--theirs` because the
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
359
|
+
# 3. Take a side of EACH conflicted bundle. WHICH SIDE CANNOT MATTER — the
|
|
360
|
+
# rebuild overwrites it — and the diff is never read. `--theirs` because the
|
|
361
|
+
# command needs a word.
|
|
362
|
+
#
|
|
363
|
+
# Only the paths that actually conflicted, never the whole set: a bundle git
|
|
364
|
+
# merged cleanly has no side to take, and `checkout --theirs` on an unmerged-
|
|
365
|
+
# stage-free path errors rather than doing nothing useful.
|
|
366
|
+
while IFS= read -r conflict; do
|
|
367
|
+
[ -n "$conflict" ] || continue
|
|
368
|
+
git -C "$wt" checkout --theirs -- "$conflict" 2>/dev/null \
|
|
369
|
+
|| git -C "$wt" checkout --ours -- "$conflict" 2>/dev/null \
|
|
370
|
+
|| true
|
|
371
|
+
git -C "$wt" add -- "$conflict" 2>/dev/null || true
|
|
372
|
+
echo "step: took a side of $conflict (either — the rebuild decides)"
|
|
373
|
+
done <<EOF
|
|
374
|
+
$unmerged
|
|
375
|
+
EOF
|
|
294
376
|
|
|
295
377
|
# 4. Rebuild, in the branch's OWN worktree. This is what makes the kept side
|
|
296
378
|
# irrelevant, and it is the property CI's no-diff gate then re-checks.
|
|
@@ -299,8 +381,19 @@ if ! (cd "$wt" && pnpm build:board >/dev/null 2>&1); then
|
|
|
299
381
|
echo "step: rebuild failed — pushing nothing"
|
|
300
382
|
finish abandoned build-failed
|
|
301
383
|
fi
|
|
302
|
-
|
|
303
|
-
|
|
384
|
+
# EVERY bundle is staged after the rebuild, not just the ones that conflicted.
|
|
385
|
+
# `pnpm build:board` regenerates all nine, and a rebuild triggered by one
|
|
386
|
+
# conflict can legitimately move another — the merge brought in source changes
|
|
387
|
+
# for the whole package. Staging only the conflicted paths would leave those
|
|
388
|
+
# modifications unstaged, and CI's no-diff gate would then fail the push for a
|
|
389
|
+
# file this run had already rebuilt correctly.
|
|
390
|
+
while IFS= read -r bundle; do
|
|
391
|
+
[ -n "$bundle" ] || continue
|
|
392
|
+
git -C "$wt" add -- "$bundle" 2>/dev/null || true
|
|
393
|
+
done <<EOF
|
|
394
|
+
$ARTIFACT_PATHS
|
|
395
|
+
EOF
|
|
396
|
+
printf 'step: rebuilt and staged %s bundle(s)\n' "$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)"
|
|
304
397
|
|
|
305
398
|
# The merge commit exists only once the rebuild has produced the artifact it
|
|
306
399
|
# will carry. Committing before the build would leave a commit holding a stale
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The ONE answer to "how long has this worktree's agent been quiet?" — sourced,
|
|
3
|
+
# not run, by `plot-worker-monitor.sh`.
|
|
4
|
+
#
|
|
5
|
+
# It reads the AGENT rather than the machine. A `claude -p` session appends a
|
|
6
|
+
# timestamped line to its transcript for every model turn, tool call and tool
|
|
7
|
+
# result; the seconds since the newest of those lines is how long the agent has
|
|
8
|
+
# produced nothing. A CPU sample answers *is this process on a core right now?*,
|
|
9
|
+
# which is a different question and is zero for most of a working agent's life.
|
|
10
|
+
#
|
|
11
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
# THREE ANSWERS, AND THE THIRD IS NOT A FAILURE
|
|
13
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
#
|
|
15
|
+
# <seconds> the newest transcript line is that many seconds old
|
|
16
|
+
# unavailable no transcript can be read for this worktree, INCLUDING the
|
|
17
|
+
# case where the arguments name no worktree at all
|
|
18
|
+
#
|
|
19
|
+
# `unavailable` is a first-class answer, settled by
|
|
20
|
+
# `the-registry-supervises-its-agents`: a capability the adopting project does
|
|
21
|
+
# not provide is UNAVAILABLE, never failed and never zero. A caller that read
|
|
22
|
+
# it as "quiet for 0 seconds" would report every unreadable agent healthy; one
|
|
23
|
+
# that read it as an error would refuse to run where Plot's own contract says
|
|
24
|
+
# it should degrade. So it is a word, and callers match on it.
|
|
25
|
+
#
|
|
26
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
# THE TRANSCRIPT IS FOUND BY PATH, NOT BY SESSION ID — AND THAT IS A CHOICE
|
|
28
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
#
|
|
30
|
+
# `.plot/worker-prompt.sh:29` DOES pass `--session-id` now (2026-09-04), so an
|
|
31
|
+
# exact join is available here and this deliberately does not take it. The
|
|
32
|
+
# reason is the question, not the capability: this asks *is anything happening
|
|
33
|
+
# at this desk*, and an operator's own session at the same worktree is a true
|
|
34
|
+
# answer to it. Joining on the worker's id alone would report a desk quiet
|
|
35
|
+
# while somebody is visibly working at it.
|
|
36
|
+
#
|
|
37
|
+
# THE OPPOSITE JOIN IS RIGHT FOR THE OPPOSITE QUESTION. *What has THIS agent
|
|
38
|
+
# spent* is per session and never per worktree — one project directory measured
|
|
39
|
+
# 2026-09-03 held 45 session files, 30 of them subagents, and a sum across them
|
|
40
|
+
# belongs to no one. `rules/spend.ts` states that side; the two read the same
|
|
41
|
+
# files and must not be made to share a join.
|
|
42
|
+
#
|
|
43
|
+
# So the join here is the one `plot-quiet-stretch.mjs` already made and proved
|
|
44
|
+
# on 23 real sessions: the runtime stores a session under
|
|
45
|
+
# `$HOME/.claude/projects/<slug>` where the slug is the WORKTREE PATH with `/`
|
|
46
|
+
# and `.` replaced by `-`. A dispatched worker has its own worktree, so the
|
|
47
|
+
# path identifies the DESK without any id being passed anywhere.
|
|
48
|
+
#
|
|
49
|
+
# `agent-` PREFIXED FILES ARE SKIPPED, for wave 1's reason: a subagent's
|
|
50
|
+
# transcript is a true statement about the wrong process. A worker whose
|
|
51
|
+
# subagent is chatting while the worker itself has stopped must read as quiet.
|
|
52
|
+
#
|
|
53
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
54
|
+
# THE NEWEST LINE ACROSS ALL OF A WORKTREE'S SESSIONS
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
56
|
+
#
|
|
57
|
+
# A worktree can hold several sessions: a worker that hopped waves, or an
|
|
58
|
+
# operator who opened a session at the same desk. Any of them producing output
|
|
59
|
+
# means SOMEBODY is working there, and the monitor's question is about the desk.
|
|
60
|
+
# Taking the maximum timestamp is the answer that never ends a live session
|
|
61
|
+
# because a stale sibling exists beside it.
|
|
62
|
+
|
|
63
|
+
# The runtime's project-slug derivation. Duplicated from `plot-quiet-stretch.mjs`
|
|
64
|
+
# — which duplicates it from the board's `projectSlug` — because a monitor on a
|
|
65
|
+
# 30s loop must not depend on a build step. One line, pinned by a test on each
|
|
66
|
+
# side.
|
|
67
|
+
plot_transcript_slug() { # $1=worktree path → slug
|
|
68
|
+
printf '%s' "$1" | tr '/.' '--'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Where the runtime keeps this worktree's transcripts, or "" if nowhere.
|
|
72
|
+
plot_transcript_dir() { # $1=worktree → directory path (may not exist)
|
|
73
|
+
local wt="$1" home="${PLOT_TRANSCRIPT_HOME:-${HOME:-}}"
|
|
74
|
+
[ -n "$wt" ] && [ -n "$home" ] || return 0
|
|
75
|
+
printf '%s/.claude/projects/%s' "$home" "$(plot_transcript_slug "$wt")"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Seconds since this worktree's agent last wrote anything.
|
|
79
|
+
#
|
|
80
|
+
# THE MTIME IS THE READING, not the file's contents. The runtime appends as it
|
|
81
|
+
# works, so the file's modification time IS the timestamp of its newest line —
|
|
82
|
+
# and reading it costs one `stat` rather than parsing a transcript that reaches
|
|
83
|
+
# tens of megabytes on a long session. Wave 1 parsed timestamps because it was
|
|
84
|
+
# measuring a DISTRIBUTION of past gaps; this needs only the newest, on a 30s
|
|
85
|
+
# loop, for as many workers as the machine holds.
|
|
86
|
+
#
|
|
87
|
+
# A CLOCK IS THE RIGHT INSTRUMENT HERE, and that is worth stating because Plot
|
|
88
|
+
# usually refuses one. `plot-estate-changed.sh` hashes content rather than
|
|
89
|
+
# reading mtime, because its question is *did this change?* and a checkout moves
|
|
90
|
+
# mtime without changing anything. This question is *how long since output?* —
|
|
91
|
+
# which is a question about elapsed time, and mtime is the measurement of it.
|
|
92
|
+
plot_transcript_quiet_seconds() { # $1=worktree → seconds | unavailable
|
|
93
|
+
local wt="$1" dir newest now mtime
|
|
94
|
+
dir=$(plot_transcript_dir "$wt")
|
|
95
|
+
[ -n "$dir" ] && [ -d "$dir" ] || { printf 'unavailable'; return 0; }
|
|
96
|
+
|
|
97
|
+
# The newest mtime across every non-`agent-` session file in the directory.
|
|
98
|
+
# `find -print0` and a while-read keep paths with spaces intact; `stat` is
|
|
99
|
+
# asked once per file, and a worktree holds one to eight.
|
|
100
|
+
newest=''
|
|
101
|
+
while IFS= read -r -d '' f; do
|
|
102
|
+
case "$(basename "$f")" in agent-*) continue ;; esac
|
|
103
|
+
mtime=$(plot_transcript_mtime "$f") || continue
|
|
104
|
+
[ -n "$mtime" ] || continue
|
|
105
|
+
if [ -z "$newest" ] || [ "$mtime" -gt "$newest" ] 2>/dev/null; then newest="$mtime"; fi
|
|
106
|
+
done < <(find "$dir" -maxdepth 1 -type f -name '*.jsonl' -print0 2>/dev/null)
|
|
107
|
+
|
|
108
|
+
# A directory that exists but holds no session file is still UNAVAILABLE. The
|
|
109
|
+
# runtime creates the directory when the project is first opened, so an empty
|
|
110
|
+
# one means no session has written here — which is exactly "no transcript can
|
|
111
|
+
# be read", not "quiet for a very long time".
|
|
112
|
+
[ -n "$newest" ] || { printf 'unavailable'; return 0; }
|
|
113
|
+
|
|
114
|
+
now=$(date +%s)
|
|
115
|
+
local quiet=$(( now - newest ))
|
|
116
|
+
# A transcript written in the future — a clock skew across a mounted volume —
|
|
117
|
+
# reads as zero rather than negative. An end condition comparing a negative
|
|
118
|
+
# against a threshold would behave correctly by accident here and not
|
|
119
|
+
# elsewhere; clamping says what is meant.
|
|
120
|
+
[ "$quiet" -lt 0 ] && quiet=0
|
|
121
|
+
printf '%s' "$quiet"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
# A file's modification time as a unix epoch. BSD and GNU `stat` disagree on the
|
|
125
|
+
# flag, and a monitor that works on the author's laptop and not in CI is a
|
|
126
|
+
# monitor nobody trusts.
|
|
127
|
+
#
|
|
128
|
+
# THE `||` IS NOT ENOUGH, AND CI MEASURED WHY. On Linux `stat -f` is not an
|
|
129
|
+
# unknown flag — it means FILESYSTEM info, and it SUCCEEDS. So the BSD form
|
|
130
|
+
# never falls through: it printed `Namelen: 255 Type: ext2/ext3` and the
|
|
131
|
+
# caller subtracted that from a clock. Two of this branch's own tests failed on
|
|
132
|
+
# it, 2026-09-02, having passed on macOS.
|
|
133
|
+
#
|
|
134
|
+
# So the answer is validated rather than trusted. Each form must yield digits;
|
|
135
|
+
# anything else is treated as that form not being available here.
|
|
136
|
+
plot_transcript_mtime() { # $1=file → epoch seconds
|
|
137
|
+
local m
|
|
138
|
+
m=$(stat -c '%Y' "$1" 2>/dev/null)
|
|
139
|
+
case "$m" in ''|*[!0-9]*) m=$(stat -f '%m' "$1" 2>/dev/null) ;; esac
|
|
140
|
+
case "$m" in ''|*[!0-9]*) return 1 ;; esac
|
|
141
|
+
printf '%s' "$m"
|
|
142
|
+
}
|