@plot-pm/board 0.11.0 → 0.13.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.
@@ -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,60p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
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 default branch, via the host adapter when it can answer. Guard 5 compares
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, for guard 4.
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
- | sed -n 's|^branch refs/heads/||p')
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
- is_checked_out() {
137
- printf '%s\n' "$checked_out" | grep -qxF "$1"
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
- # 5. The default branch is never ours to delete, whatever a plan says. A plan
153
- # that names it is malformed, and acting on that is unrecoverable.
154
- if [ "$br" = "$DEFAULT" ]; then
155
- printf '%-8s %-52s %s\n' "keep" "$br" "the default branch — never deleted"
156
- kept=$((kept+1)); continue
157
- fi
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
- # 1. Given up, not finished. A `deferred:`/`moved:` annotation is what
160
- # `/plot-reconcile` reads to tell deliberate abandonment from a dead
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
- # 2. THE GATE. Unlanded work keeps its ref, always `Done when` item 12, and
170
- # the assertion a naive implementation passes without, since a sweep that
171
- # deletes every ref of a delivered plan satisfies item 11 and destroys
172
- # work that exists nowhere else. `pr_merged` also returns false when the
173
- # host cannot be asked, so silence keeps the ref.
174
- if ! pr_merged "$br"; then
175
- printf '%-8s %-52s %s\n' "keep" "$br" "unlanded work — no merged PR"
176
- kept=$((kept+1)); continue
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
- # 3. An OPEN PR vetoes, even where an older PR merged. Measured by hand on
180
- # 2026-08-28: `changeset-release/main` is merged repeatedly, and Changesets
181
- # RECREATES and reuses that same branch for the next release so its ref
182
- # carries a live release PR while an older PR of its own has merged.
183
- # Deleting it disturbs the release in flight.
184
- if pr_open "$br"; then
185
- printf '%-8s %-52s %s\n' "keep" "$br" "an open PR is using this branch"
186
- kept=$((kept+1)); continue
187
- fi
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
- # 4. A ref another checkout is sitting on is one somebody is reading, and
190
- # deleting it pulls the branch out from under them. Measured 2026-08-28:
191
- # `bug/a-head-counts-its-own-waves` was merged AND checked out. This runs
192
- # after the reap, so a worktree still here is one the reaper's own five
193
- # measurements declined to remove its verdict is inherited, not
194
- # second-guessed.
195
- if is_checked_out "$br"; then
196
- printf '%-8s %-52s %s\n' "keep" "$br" "checked out in a worktree — somebody is reading it"
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
 
@@ -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 the artifact
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
- # exactly the artifact → the licensed case → repair
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
- # The one file this script may resolve. Named here as well as in the board's
74
- # contract because the two run in different languages and neither can import
75
- # the other's constant; the pairing is asserted by a test rather than trusted.
76
- ARTIFACT_PATH="skills/plot/scripts/board/board-server.mjs"
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 $ARTIFACT_PATH, rebuild, test"
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
- # EXACTLY the artifact: one path, that path, nothing else. Not "the artifact
278
- # among the conflicts" — an implementation asking that passes every
279
- # artifact-only case and silently repairs merges that need judgement as a whole.
280
- if [ "$n_unmerged" != "1" ] || [ "$unmerged" != "$ARTIFACT_PATH" ]; then
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 exactly the artifact — refusing"
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 rebuild overwrites it — and the
288
- # diff is never read. `--theirs` because the command needs a word.
289
- git -C "$wt" checkout --theirs -- "$ARTIFACT_PATH" 2>/dev/null \
290
- || git -C "$wt" checkout --ours -- "$ARTIFACT_PATH" 2>/dev/null \
291
- || true
292
- git -C "$wt" add -- "$ARTIFACT_PATH" 2>/dev/null || true
293
- echo "step: took a side of $ARTIFACT_PATH (either — the rebuild decides)"
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
- git -C "$wt" add -- "$ARTIFACT_PATH" 2>/dev/null || true
303
- echo "step: rebuilt $ARTIFACT_PATH"
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
@@ -754,3 +754,97 @@ plot_worker_state() { # $1=worktree $2=pr-fact → "state\tpid\tcode"
754
754
  # mistake in the other direction.
755
755
  printf 'ended\t%s\t' "$pid"
756
756
  }
757
+
758
+ # ---------------------------------------------------------------------------
759
+ # THE SAME READINGS, HANDED OUT RATHER THAN DECIDED
760
+ # ---------------------------------------------------------------------------
761
+ #
762
+ # `plot_worker_state` above gathers six facts and turns them into a word. So
763
+ # does `rules/agent-state.ts`, and `docs/shell-and-domain.md` says why both
764
+ # exist: this function is sourced inside per-branch loops and by the agent's own
765
+ # loop, where a `node` hop is 39 ms every caller pays on every pass, while the
766
+ # board and the supervisor are already in node and pay nothing.
767
+ #
768
+ # WHAT THE PAIR NEEDS IS THE READINGS, NOT THE WORD. A caller handed `finished`
769
+ # can only compare two strings; a caller handed the six facts can ask the domain
770
+ # rule and compare its answer to this file's. That is what the corpus test does,
771
+ # and it is why this function exists at all.
772
+ #
773
+ # ONE GATHERING, TWO CONSUMERS. Every fact below is read the way
774
+ # `plot_worker_state` reads it — the manifest first and the worktree file as the
775
+ # fallback, `plot_pid_is_current` for staleness, the same `PLOT-BLOCKED*` glob
776
+ # and the same dirty filter. A second gathering that drifted would make the
777
+ # corpus test compare this file against itself and pass while production broke.
778
+ #
779
+ # Prints one TAB-separated line, six fields:
780
+ #
781
+ # worktree_here pid_recorded liveness exit blocked dirty unpushed
782
+ #
783
+ # `liveness` is `live`, `stale` or `dead`; `exit` is the code as read, empty for
784
+ # an unreadable record and the literal `-` for an absent one, because an empty
785
+ # field cannot say which of the two it is and the rule answers them alike only
786
+ # because it was told they differ. The PR fact is NOT here: it comes from the
787
+ # caller, exactly as it does for `plot_worker_state`.
788
+ plot_worker_readings() { # $1=worktree → "here\tpid\tliveness\texit\tblocked\tdirty\tunpushed"
789
+ local wt="$1" pid="" started_at="" manifest_data="" manifest=""
790
+ local here=1 pid_recorded=0 liveness=dead exit_field='-' blocked=0 dirty=0 ahead=""
791
+
792
+ # NO WORKTREE IS ANSWERED FIRST, and it is a question about the worktree LIST
793
+ # rather than about anything inside one — the same split `worker_of` makes in
794
+ # `plot-fleet-scan.sh`, where `elsewhere` is decided before this file is
795
+ # reached. A caller iterating worktrees it found never sees this arm.
796
+ if [ -z "$wt" ] || [ ! -d "$wt" ]; then
797
+ printf '0\t0\tdead\t-\t0\t0\t'
798
+ return
799
+ fi
800
+
801
+ # THE MANIFEST IS PRIMARY, as above: it carries `pid` and `startedAt`
802
+ # together, and `startedAt` is what tells a reused pid from the real worker.
803
+ if manifest=$(plot_manifest_for_worktree "$wt" 2>/dev/null) && [ -n "$manifest" ]; then
804
+ if manifest_data=$(plot_read_manifest_pid "$manifest") && [ -n "$manifest_data" ]; then
805
+ pid=$(printf '%s' "$manifest_data" | cut -f1)
806
+ started_at=$(printf '%s' "$manifest_data" | cut -f2)
807
+ fi
808
+ fi
809
+ if [ -z "$pid" ] && [ -f "$wt/.plot-worker.pid" ]; then
810
+ pid=$(cat "$wt/.plot-worker.pid" 2>/dev/null | tr -d ' \n')
811
+ fi
812
+
813
+ # A pid of 0 and any non-numeric junk are NOT pids. `kill -0 0` signals the
814
+ # whole process group and succeeds, so a zero read as live reports `running`
815
+ # forever — rejected here exactly as `plot_worker_state` rejects it.
816
+ case "$pid" in
817
+ ''|0|*[!0-9]*) pid_recorded=0 ;;
818
+ *) pid_recorded=1 ;;
819
+ esac
820
+
821
+ if [ "$pid_recorded" = 1 ]; then
822
+ if kill -0 "$pid" 2>/dev/null; then
823
+ # A recorded start time closes the pid-reuse window. Without one the old
824
+ # behaviour applies and `kill -0` alone decides, which keeps an
825
+ # uncheckable pid honest rather than pessimistic.
826
+ if [ -n "$started_at" ] && ! plot_pid_is_current "$pid" "$started_at"; then
827
+ liveness=stale
828
+ else
829
+ liveness=live
830
+ fi
831
+ fi
832
+ fi
833
+
834
+ # THE EXIT RECORD, distinguishing absent from unreadable. `plot_worker_state`
835
+ # reaches `ended` for both, but by different routes, and a reading that
836
+ # collapsed them would hide which one a desk is in from anybody comparing.
837
+ if [ -f "$wt/.plot-worker.exit" ]; then
838
+ exit_field=$(cat "$wt/.plot-worker.exit" 2>/dev/null | tr -d ' \n')
839
+ fi
840
+
841
+ plot_worker_blocked "$wt" && blocked=1 || blocked=0
842
+ [ -n "$(plot_worker_dirty "$wt")" ] && dirty=1 || dirty=0
843
+ # UNPUSHED IS A REF QUESTION asked THROUGH the worktree, and an unreadable
844
+ # count stays EMPTY — `null` is not `false`, and a branch with no upstream
845
+ # cannot be asked at all.
846
+ ahead=$(git -C "$wt" rev-list --count '@{upstream}..HEAD' 2>/dev/null) || ahead=""
847
+
848
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s' \
849
+ "$here" "$pid_recorded" "$liveness" "$exit_field" "$blocked" "$dirty" "$ahead"
850
+ }