@plot-pm/board 0.16.2 → 0.16.3

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.
@@ -1,423 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Plot helper: repair an ARTIFACT-ONLY merge conflict on one branch.
3
- # Usage: plot-resolve-artifact.sh [--dry-run] <branch>
4
- # --dry-run print the sequence and the worktree it would use; change nothing
5
- # <branch> the branch to repair — must already exist on origin
6
- # Output: one `step:` line per stage, then a machine-countable footer:
7
- # summary: branch=<name> outcome=pushed|abandoned|refused reason=<word>
8
- #
9
- # THE ONLY AUTOMATIC WRITE THIS SYSTEM GRANTS, and it is granted for three
10
- # verified reasons rather than for convenience:
11
- #
12
- # 1. `-merge` KEEPS THE FILE VALID. `.gitattributes` marks every bundle
13
- # `-merge`, so git keeps one side whole and writes NO conflict markers.
14
- # The artifact stays buildable JavaScript *through* a conflict — which is
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.
17
- # 2. THE REBUILD IS DETERMINISTIC. Measured: `build.mjs` embeds no timestamp
18
- # and no randomness, so the output does not depend on which side was kept.
19
- # 3. CI PROVES IT. The no-diff gate fails the build if the committed artifact
20
- # does not match a fresh rebuild.
21
- #
22
- # Together those make this the one repair whose correctness is checkable
23
- # WITHOUT JUDGEMENT. That is the whole licence. No other failure has these
24
- # three properties, and none may be added to this path — widening the entry
25
- # condition removes the argument that grants the permission, even if the code
26
- # looks correct.
27
- #
28
- # THIS IS A SCRIPT AND NOT AN AGENT, deliberately. Every step below is fixed
29
- # and nothing between them is a decision, which is *precisely* what licenses the
30
- # automation. Handing the sequence to an agent would introduce judgement exactly
31
- # where its absence is the permission. (Measured on 2026-08-17: this repo has no
32
- # `Worker command` configured either, so plot-dispatch.sh would report
33
- # `worker=unconfigured` and start nothing — but the shape is the reason, not
34
- # the measurement.)
35
- #
36
- # TESTS RUN BEFORE THE PUSH. The CI no-diff gate is what makes the repair
37
- # checkable, and CI runs only AFTER a push — so a resolver that pushed and
38
- # waited would manufacture exactly the state this exists to remove: a red PR in
39
- # the queue. The sequence therefore ends on `pnpm run test:board` green in the
40
- # branch's own worktree, and CI becomes confirmation rather than discovery.
41
- #
42
- # IF THE SUITE FAILS, NOTHING IS PUSHED. The repair stopped being mechanical the
43
- # moment its own gate said so; the branch is left exactly as it was, and the
44
- # board reports it as a conflict a human owns.
45
- #
46
- # IT MERGES ONLY IN A WORKTREE THAT IS IDLE. A worktree carrying modifications
47
- # belongs to whoever made them — measured on 2026-08-17, the resolver ran its
48
- # merge inside one an agent was actively editing. It refuses `worktree-busy`
49
- # rather than reaching in, which the plan names the honest minimum: a second
50
- # worktree on the same branch is not available to it anyway, since git refuses a
51
- # second checkout of one branch.
52
- #
53
- # AN EMPTY CONFLICT SET IS NOT A REFUSAL ABOUT FILES. Three cases, named apart,
54
- # because two of them were once one:
55
- #
56
- # bundles only → the licensed case → repair
57
- # other files present → needs judgement → not-artifact-only
58
- # empty, no merge ran → nothing was observed → not-observed
59
- #
60
- # The last is not a smaller version of the middle. `not-artifact-only` asserts
61
- # something about the files that conflicted, and a set of zero has none to
62
- # assert it about — saying it there sends a reader to look for files nobody ever
63
- # examined.
64
- #
65
- # WHICH SIDE IS TAKEN CANNOT MATTER, and the diff is never read. `--theirs` is
66
- # named here only because `git checkout` needs a word: the rebuild overwrites
67
- # whichever side was kept. Never phrase it as "take ours" — under `git merge`
68
- # *ours* is the branch being merged into, under `git rebase` it is the upstream,
69
- # and this repo rebases routinely.
70
- set -uo pipefail
71
-
72
- script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
73
- . "$script_dir/plot-tmp.sh"
74
-
75
- # THE FILES THIS SCRIPT MAY RESOLVE — a SET, and derived rather than listed.
76
- #
77
- # It was one hardcoded filename until 2026-09-06, and that cost a repair the
78
- # same day: PR #727 conflicted in `plot-registryd.mjs` — a `-merge` bundle with
79
- # a deterministic rebuild, exactly the licensed case — and this script refused
80
- # `not-artifact-only` against a list naming only `board-server.mjs`. The refusal
81
- # was correct behaviour against a stale list. Hours later the same branch
82
- # conflicted in `board-server.mjs` and was repaired automatically: same class of
83
- # conflict, opposite outcome, one filename apart.
84
- #
85
- # DERIVED FROM `build.mjs`'S OWN DECLARATIONS, by the same pipeline
86
- # `scripts/check-bundle-attributes.sh` uses, because a hand-written list here
87
- # would be a fourth place to drift — and drift is the defect this replaces. The
88
- # build declares each output as `const shippedX = path.join(here, '…')`, which
89
- # is what an author writes when adding a bundle; nothing else has to be
90
- # remembered. Nine bundles today; `plot-landed.mjs` arrived while the plan that
91
- # asked for this was still in draft, and the derivation found it.
92
- #
93
- # `plot-monitor.mjs` IS DELIBERATELY ABSENT. It is committed and documented, and
94
- # no `outfile` names it — nothing rebuilds it. Property 2 above is the whole
95
- # licence, so a file with no deterministic rebuild cannot be on this list. The
96
- # derivation reads the build, so it cannot ask for it.
97
- #
98
- # Still named in the board's contract as well, because the two run in different
99
- # languages and neither can import the other's constant. The pairing is asserted
100
- # by a test rather than trusted — and that test now asserts SET EQUALITY, since
101
- # a set that agrees on one member and differs on another is exactly the drift
102
- # this replaces.
103
- #
104
- # READ FROM THE REPOSITORY BEING REPAIRED, not from this script's own checkout.
105
- # The script is vendored into the published package, where `packages/` does not
106
- # exist — and it rebuilds with `pnpm build:board` inside the target repo, so the
107
- # build that defines the set is the one that will run. Resolved below, once
108
- # `repo_root` is known.
109
- bundle_set() { # $1=repo root → one path per line, sorted
110
- grep -aoE "shipped[A-Za-z]* = path\.join\([^)]*'[^']*'\)" "$1/packages/board/build.mjs" 2>/dev/null \
111
- | sed -E "s|.*'\.\./\.\./([^']*)'.*|\1|" \
112
- | sort -u
113
- }
114
-
115
- dry_run=0
116
- branch=""
117
- while [ $# -gt 0 ]; do
118
- case "$1" in
119
- --dry-run) dry_run=1 ;;
120
- -*) echo "plot-resolve-artifact: unknown option '$1'" >&2; exit 2 ;;
121
- *) branch="$1" ;;
122
- esac
123
- shift
124
- done
125
-
126
- # The footer travels on EVERY exit path, including the refusals. A run that
127
- # ends without one is indistinguishable from a crash, and a silent automatic
128
- # write is the failure mode this whole plan exists to remove.
129
- finish() { # $1=outcome $2=reason
130
- echo "summary: branch=$branch outcome=$1 reason=$2"
131
- case "$1" in
132
- pushed) exit 0 ;;
133
- *) exit 1 ;;
134
- esac
135
- }
136
-
137
- [ -n "$branch" ] || { echo "usage: plot-resolve-artifact.sh [--dry-run] <branch>" >&2; exit 2; }
138
-
139
- git rev-parse --git-dir >/dev/null 2>&1 || {
140
- echo "plot-resolve-artifact: not a git repository" >&2
141
- finish refused not-a-repo
142
- }
143
-
144
- MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
145
- [ -n "$MAIN" ] || MAIN="main"
146
-
147
- repo_root=$(git rev-parse --show-toplevel)
148
-
149
- # THE SET, resolved against the repository this run will rebuild.
150
- ARTIFACT_PATHS=$(bundle_set "$repo_root")
151
-
152
- # AN EMPTY DERIVATION REFUSES, and it must: the guard below asks whether every
153
- # unmerged path is in this set, and against an empty set that question has no
154
- # true answer to give — but a guard written the other way round would have said
155
- # yes to everything. The build changing shape, or a checkout with no
156
- # `packages/`, is a reason to stop rather than a reason to repair blind.
157
- if [ -z "$ARTIFACT_PATHS" ]; then
158
- echo "step: no bundles derived from packages/board/build.mjs — refusing"
159
- finish refused no-bundle-set
160
- fi
161
-
162
- # WHICH WORKTREE HOLDS THIS BRANCH — ASK GIT, do not reconstruct the path from
163
- # the branch name.
164
- #
165
- # MEASURED, and recorded at length in plot-dispatch.sh's held_worktree: a
166
- # hand-made worktree is named for the branch with its TYPE dropped, so a gate
167
- # that guessed `plot-wt-<flattened>` missed a worktree with six modified files
168
- # in it. Desks carry two naming conventions — `plot-wt-*` beside the repo from
169
- # older dispatches, and unprefixed names under the desk root — so a path guess
170
- # has two ways to be wrong. So the read asks git, and only the
171
- # CREATE-a-fresh-one fallback below composes a name (under the desk root, so the
172
- # fresh worktree lands where dispatch would have put it).
173
- #
174
- # `git worktree list --porcelain` emits `worktree <path>` then `branch
175
- # refs/heads/<name>` per entry; the branch line is matched and the path taken
176
- # from the preceding one. A branch already dispatched is thus repaired in the
177
- # worktree it is already checked out in rather than in a second copy of itself —
178
- # git refuses a second checkout of one branch, and the two would fight over the
179
- # same index if it did not.
180
- wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$branch" '
181
- /^worktree / { path = substr($0, 10) }
182
- /^branch / { if (substr($0, 8) == want) { print path; exit } }')
183
-
184
- # No existing worktree holds it — compose the path a fresh one will take, under
185
- # the desk root `plot-dispatch.sh` also asks for, so the fresh worktree lands
186
- # where dispatch would have put it. The root is the MAIN checkout's, and there
187
- # is no fallback: an unaskable rule refuses the repair.
188
- # shellcheck source=plot-desk-root.sh
189
- . "$script_dir/plot-desk-root.sh"
190
- if [ -z "$wt" ]; then
191
- wt_root=$(plot_desk_root "$(plot_repo_root)") || finish refused no-desk-root
192
- wt="$wt_root/$(printf '%s' "$branch" | tr '/' '-')"
193
- fi
194
-
195
- if [ "$dry_run" = 1 ]; then
196
- echo "step: would use worktree $wt"
197
- echo "step: would merge origin/$MAIN, take a side of each conflicted bundle, rebuild, test"
198
- printf 'step: bundle set (%s): %s\n' \
199
- "$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)" \
200
- "$(printf '%s' "$ARTIFACT_PATHS" | tr '\n' ' ')"
201
- echo "step: would push only if pnpm run test:board passes"
202
- finish refused dry-run
203
- fi
204
-
205
- git fetch -q origin "$MAIN" "$branch" 2>/dev/null || true
206
-
207
- # ONE REPAIR AT A TIME, AND NEVER TWO ON ONE BRANCH.
208
- #
209
- # A second run while the first is working would fight over the same worktree:
210
- # the merge, the rebuild and the five-minute suite all write into it, and two of
211
- # them interleaved produce an artifact belonging to neither run. The lock is a
212
- # DIRECTORY rather than a file because `mkdir` is atomic on every filesystem
213
- # this runs on — two processes racing it, one wins, and the loser learns it lost
214
- # from the exit code rather than from a check-then-write that both pass.
215
- #
216
- # The board guards its own in-flight repairs too, in memory. Both are needed and
217
- # neither is redundant: the board's registry cannot see a repair started by a
218
- # second board or by a human at a shell, and this lock cannot stop the board
219
- # from spawning (it learns only after the spawn). The lock is the authority.
220
- lock="$repo_root/.plot/state/resolve-$(printf '%s' "$branch" | tr '/' '-').lock"
221
- mkdir -p "$(dirname "$lock")" 2>/dev/null || true
222
- if ! mkdir "$lock" 2>/dev/null; then
223
- echo "step: a repair is already in flight for $branch ($lock)"
224
- finish refused already-in-flight
225
- fi
226
- # Released on every exit, including a kill. A lock that outlives its process
227
- # would make one interrupted repair block the branch forever — and the repair is
228
- # idempotent, so there is nothing to protect after the process is gone. A TERM
229
- # or INT stops the repair (143 or 130) after the lock is released.
230
- plot_on_exit 'rmdir "$lock" 2>/dev/null || true'
231
-
232
- if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
233
- # A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the
234
- # resolver ran `git merge` inside a worktree an agent was actively editing —
235
- # zero unmerged paths, three modified files, work in progress. It refused
236
- # before writing anything, but that was luck rather than design.
237
- #
238
- # Reuse is right when the worktree is IDLE; the name alone does not say so. A
239
- # worktree with modifications is one whose owner is mid-thought, and merging
240
- # into it would either fail on "local changes would be overwritten" or, worse,
241
- # succeed and fold a stranger's uncommitted work into a merge commit this
242
- # script then pushes.
243
- #
244
- # The honest minimum is to refuse, and the plan names it acceptable: creating a
245
- # scratch worktree is impossible anyway while git holds this branch checked out
246
- # here, since git refuses a second checkout of one branch.
247
- #
248
- # `--porcelain` rather than a parsed `git status`: it is the stable interface,
249
- # and an untracked file is deliberately NOT counted — a stray log or an
250
- # editor's scratch file is not work in progress, and `merge` does not touch it.
251
- echo "step: reusing worktree $wt"
252
- busy=$(git -C "$wt" status --porcelain --untracked-files=no 2>/dev/null)
253
- if [ -n "$busy" ]; then
254
- echo "step: worktree has modifications that are not this repair's — refusing"
255
- printf 'step: modified: %s\n' "$(printf '%s' "$busy" | sed 's/^...//' | tr '\n' ' ')"
256
- finish refused worktree-busy
257
- fi
258
- else
259
- plot_exclude_desk_root "$(plot_repo_root)"
260
- if ! git worktree add -q "$wt" "$branch" 2>/dev/null; then
261
- if ! git worktree add -q -b "$branch" "$wt" "origin/$branch" 2>/dev/null; then
262
- echo "plot-resolve-artifact: cannot create a worktree for $branch at $wt" >&2
263
- finish refused no-worktree
264
- fi
265
- fi
266
- echo "step: worktree $wt"
267
- fi
268
-
269
- # THE FIXED SEQUENCE. Five steps, no decision between them.
270
-
271
- # 1. Merge. The conflict is EXPECTED — that is why we are here — so a non-zero
272
- # exit is not yet a failure. What decides is which paths came back
273
- # unmerged, checked next.
274
- git -C "$wt" merge --no-edit "origin/$MAIN" >/dev/null 2>&1
275
- merge_status=$?
276
-
277
- if [ "$merge_status" -eq 0 ]; then
278
- # Nothing conflicted after all — the prediction was made from refs that have
279
- # since moved, which is the direction this repo already knows they move in.
280
- # The merge stands; there is nothing to repair and nothing to prove, so this
281
- # pushes nothing rather than pushing a merge nobody asked for.
282
- git -C "$wt" merge --abort >/dev/null 2>&1 || true
283
- git -C "$wt" reset -q --hard "HEAD" >/dev/null 2>&1 || true
284
- echo "step: no conflict on merge — nothing to repair"
285
- finish refused no-conflict
286
- fi
287
-
288
- # 2. VERIFY THE SET, HERE, AGAINST THE REAL MERGE.
289
- #
290
- # The board classified from `merge-tree`, which predicts IN MEMORY from the refs
291
- # this machine holds. This is the merge itself, and it is the only place the set
292
- # is a fact rather than a forecast — a stale ref makes the prediction wrong in
293
- # the reassuring direction, so the entry condition is re-checked against reality
294
- # before anything is written.
295
- #
296
- # The set is read once and asked TWO questions, in order: was anything observed
297
- # at all, and — only then — was it exactly the artifact.
298
- unmerged=$(git -C "$wt" diff --name-only --diff-filter=U)
299
- n_unmerged=$(printf '%s\n' "$unmerged" | grep -c . || true)
300
-
301
- # AN EMPTY SET IS NOT A SMALL SET — it is the absence of a reading.
302
- #
303
- # The merge exited non-zero, so something went wrong; but a conflict is not the
304
- # only thing that ends a merge non-zero. A merge that never STARTED — refused
305
- # because the worktree was dirty, because a merge was already in progress, or
306
- # because the ref could not be resolved — exits non-zero too and leaves no
307
- # unmerged paths behind. Zero paths therefore answers a different question than
308
- # one or three do: those say WHICH files conflicted, zero says NOBODY LOOKED.
309
- #
310
- # Measured on 2026-08-17, and the defect this branch exists for: the resolver
311
- # reused a worktree in which no merge was running, read zero paths, compared
312
- # zero against one, and reported `not-artifact-only` — a name asserting
313
- # something about files it had never examined. The refusal was right; its reason
314
- # was wrong, and the wrong reason sent a reader looking for conflicts that did
315
- # not exist.
316
- #
317
- # So the two refusals are named apart. `not-artifact-only` is a claim about an
318
- # observed set and may only be said when there was one.
319
- if [ "$n_unmerged" = "0" ]; then
320
- git -C "$wt" merge --abort >/dev/null 2>&1 || true
321
- echo "step: the merge reported failure but left no unmerged paths — nothing was observed"
322
- finish refused not-observed
323
- fi
324
-
325
- # EVERY unmerged path is a bundle: the conflict set is a SUBSET of the bundle
326
- # set, and nothing else is in it. NOT "a bundle is among the conflicts" — an
327
- # implementation asking that passes every bundle-only case and silently repairs
328
- # merges that need judgement as a whole. The claim stayed exact when the list
329
- # grew from one file to nine; only the thing each path is checked against
330
- # changed. A merge conflicting in a bundle AND anything else still needs a
331
- # person, even though one of its files does not.
332
- #
333
- # WALKED PER PATH rather than compared as a whole, because the conflict set is
334
- # an arbitrary subset of nine and there is no single string to compare it to.
335
- # The direction is what keeps it exact: every element of the observed set must
336
- # appear in the licensed set, so an unlicensed path can only ever refuse.
337
- outside=""
338
- while IFS= read -r conflict; do
339
- [ -n "$conflict" ] || continue
340
- if ! printf '%s\n' "$ARTIFACT_PATHS" | grep -qxF -- "$conflict"; then
341
- outside="${outside}${outside:+ }$conflict"
342
- fi
343
- done <<EOF
344
- $unmerged
345
- EOF
346
-
347
- if [ -n "$outside" ]; then
348
- git -C "$wt" merge --abort >/dev/null 2>&1 || true
349
- echo "step: conflict set is not bundles only — refusing"
350
- printf 'step: unmerged: %s\n' "$(printf '%s' "$unmerged" | tr '\n' ' ')"
351
- printf 'step: outside the bundle set: %s\n' "$outside"
352
- finish refused not-artifact-only
353
- fi
354
-
355
- # 3. Take a side of EACH conflicted bundle. WHICH SIDE CANNOT MATTER — the
356
- # rebuild overwrites it — and the diff is never read. `--theirs` because the
357
- # command needs a word.
358
- #
359
- # Only the paths that actually conflicted, never the whole set: a bundle git
360
- # merged cleanly has no side to take, and `checkout --theirs` on an unmerged-
361
- # stage-free path errors rather than doing nothing useful.
362
- while IFS= read -r conflict; do
363
- [ -n "$conflict" ] || continue
364
- git -C "$wt" checkout --theirs -- "$conflict" 2>/dev/null \
365
- || git -C "$wt" checkout --ours -- "$conflict" 2>/dev/null \
366
- || true
367
- git -C "$wt" add -- "$conflict" 2>/dev/null || true
368
- echo "step: took a side of $conflict (either — the rebuild decides)"
369
- done <<EOF
370
- $unmerged
371
- EOF
372
-
373
- # 4. Rebuild, in the branch's OWN worktree. This is what makes the kept side
374
- # irrelevant, and it is the property CI's no-diff gate then re-checks.
375
- if ! (cd "$wt" && pnpm build:board >/dev/null 2>&1); then
376
- git -C "$wt" merge --abort >/dev/null 2>&1 || true
377
- echo "step: rebuild failed — pushing nothing"
378
- finish abandoned build-failed
379
- fi
380
- # EVERY bundle is staged after the rebuild, not just the ones that conflicted.
381
- # `pnpm build:board` regenerates all nine, and a rebuild triggered by one
382
- # conflict can legitimately move another — the merge brought in source changes
383
- # for the whole package. Staging only the conflicted paths would leave those
384
- # modifications unstaged, and CI's no-diff gate would then fail the push for a
385
- # file this run had already rebuilt correctly.
386
- while IFS= read -r bundle; do
387
- [ -n "$bundle" ] || continue
388
- git -C "$wt" add -- "$bundle" 2>/dev/null || true
389
- done <<EOF
390
- $ARTIFACT_PATHS
391
- EOF
392
- printf 'step: rebuilt and staged %s bundle(s)\n' "$(printf '%s\n' "$ARTIFACT_PATHS" | grep -c .)"
393
-
394
- # The merge commit exists only once the rebuild has produced the artifact it
395
- # will carry. Committing before the build would leave a commit holding a stale
396
- # artifact if the build then failed — exactly what CI's no-diff gate catches,
397
- # arriving as a push instead of as a refusal.
398
- if ! git -C "$wt" commit -q --no-edit 2>/dev/null; then
399
- echo "step: nothing to commit after the rebuild"
400
- finish abandoned nothing-to-commit
401
- fi
402
-
403
- # 5. THE GATE. Green in this worktree BEFORE the push, never CI after it.
404
- # A resolver that pushed and let CI decide passes every correctness check
405
- # above and manufactures a red PR in the queue — the exact stuck state this
406
- # plan exists to remove.
407
- echo "step: running pnpm run test:board"
408
- if ! (cd "$wt" && pnpm run test:board >/dev/null 2>&1); then
409
- # NOTHING IS PUSHED, and the merge is undone so the branch is left exactly as
410
- # it was found. A half-repaired branch would be a third state nobody named.
411
- git -C "$wt" reset -q --hard "HEAD~1" 2>/dev/null || true
412
- echo "step: test:board failed — pushing nothing, this is a conflict a human owns"
413
- finish abandoned tests-failed
414
- fi
415
- echo "step: test:board passed"
416
-
417
- if ! git -C "$wt" push -q origin "HEAD:$branch" 2>/dev/null; then
418
- echo "step: push rejected — the branch moved under us; leaving the repair local"
419
- finish abandoned push-failed
420
- fi
421
-
422
- echo "step: pushed $branch"
423
- finish pushed artifact-conflict-resolved