@plot-pm/board 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,334 @@
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 the artifact
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.
16
+ # 2. THE REBUILD IS DETERMINISTIC. Measured: `build.mjs` embeds no timestamp
17
+ # and no randomness, so the output does not depend on which side was kept.
18
+ # 3. CI PROVES IT. The no-diff gate fails the build if the committed artifact
19
+ # does not match a fresh rebuild.
20
+ #
21
+ # Together those make this the one repair whose correctness is checkable
22
+ # WITHOUT JUDGEMENT. That is the whole licence. No other failure has these
23
+ # three properties, and none may be added to this path — widening the entry
24
+ # condition removes the argument that grants the permission, even if the code
25
+ # looks correct.
26
+ #
27
+ # THIS IS A SCRIPT AND NOT AN AGENT, deliberately. Every step below is fixed
28
+ # and nothing between them is a decision, which is *precisely* what licenses the
29
+ # automation. Handing the sequence to an agent would introduce judgement exactly
30
+ # where its absence is the permission. (Measured on 2026-08-17: this repo has no
31
+ # `Worker command` configured either, so plot-dispatch.sh would report
32
+ # `worker=unconfigured` and start nothing — but the shape is the reason, not
33
+ # the measurement.)
34
+ #
35
+ # TESTS RUN BEFORE THE PUSH. The CI no-diff gate is what makes the repair
36
+ # checkable, and CI runs only AFTER a push — so a resolver that pushed and
37
+ # waited would manufacture exactly the state this exists to remove: a red PR in
38
+ # the queue. The sequence therefore ends on `pnpm run test:board` green in the
39
+ # branch's own worktree, and CI becomes confirmation rather than discovery.
40
+ #
41
+ # IF THE SUITE FAILS, NOTHING IS PUSHED. The repair stopped being mechanical the
42
+ # moment its own gate said so; the branch is left exactly as it was, and the
43
+ # board reports it as a conflict a human owns.
44
+ #
45
+ # IT MERGES ONLY IN A WORKTREE THAT IS IDLE. A worktree carrying modifications
46
+ # belongs to whoever made them — measured on 2026-08-17, the resolver ran its
47
+ # merge inside one an agent was actively editing. It refuses `worktree-busy`
48
+ # rather than reaching in, which the plan names the honest minimum: a second
49
+ # worktree on the same branch is not available to it anyway, since git refuses a
50
+ # second checkout of one branch.
51
+ #
52
+ # AN EMPTY CONFLICT SET IS NOT A REFUSAL ABOUT FILES. Three cases, named apart,
53
+ # because two of them were once one:
54
+ #
55
+ # exactly the artifact → the licensed case → repair
56
+ # other files present → needs judgement → not-artifact-only
57
+ # empty, no merge ran → nothing was observed → not-observed
58
+ #
59
+ # The last is not a smaller version of the middle. `not-artifact-only` asserts
60
+ # something about the files that conflicted, and a set of zero has none to
61
+ # assert it about — saying it there sends a reader to look for files nobody ever
62
+ # examined.
63
+ #
64
+ # WHICH SIDE IS TAKEN CANNOT MATTER, and the diff is never read. `--theirs` is
65
+ # named here only because `git checkout` needs a word: the rebuild overwrites
66
+ # whichever side was kept. Never phrase it as "take ours" — under `git merge`
67
+ # *ours* is the branch being merged into, under `git rebase` it is the upstream,
68
+ # and this repo rebases routinely.
69
+ set -uo pipefail
70
+
71
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
72
+
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"
77
+
78
+ dry_run=0
79
+ branch=""
80
+ while [ $# -gt 0 ]; do
81
+ case "$1" in
82
+ --dry-run) dry_run=1 ;;
83
+ -*) echo "plot-resolve-artifact: unknown option '$1'" >&2; exit 2 ;;
84
+ *) branch="$1" ;;
85
+ esac
86
+ shift
87
+ done
88
+
89
+ # The footer travels on EVERY exit path, including the refusals. A run that
90
+ # ends without one is indistinguishable from a crash, and a silent automatic
91
+ # write is the failure mode this whole plan exists to remove.
92
+ finish() { # $1=outcome $2=reason
93
+ echo "summary: branch=$branch outcome=$1 reason=$2"
94
+ case "$1" in
95
+ pushed) exit 0 ;;
96
+ *) exit 1 ;;
97
+ esac
98
+ }
99
+
100
+ [ -n "$branch" ] || { echo "usage: plot-resolve-artifact.sh [--dry-run] <branch>" >&2; exit 2; }
101
+
102
+ git rev-parse --git-dir >/dev/null 2>&1 || {
103
+ echo "plot-resolve-artifact: not a git repository" >&2
104
+ finish refused not-a-repo
105
+ }
106
+
107
+ MAIN=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')
108
+ [ -n "$MAIN" ] || MAIN="main"
109
+
110
+ repo_root=$(git rev-parse --show-toplevel)
111
+
112
+ # WHICH WORKTREE HOLDS THIS BRANCH — ASK GIT, do not reconstruct the path from
113
+ # the branch name.
114
+ #
115
+ # MEASURED, and recorded at length in plot-dispatch.sh's held_worktree: a
116
+ # hand-made worktree is named for the branch with its TYPE dropped, so a gate
117
+ # that guessed `plot-wt-<flattened>` missed a worktree with six modified files
118
+ # in it. This site had the same shape — it composed `plot-wt-<flattened>` under
119
+ # `repo_root/..` — and this change would make the guess worse, not better: the
120
+ # new `Worktree root:` key introduces a SECOND naming convention, giving a
121
+ # path guess a second way to be wrong. So the read asks git, and only the
122
+ # CREATE-a-fresh-one fallback below composes a name (via dispatch's rule, so the
123
+ # fresh worktree lands where dispatch would have put it).
124
+ #
125
+ # `git worktree list --porcelain` emits `worktree <path>` then `branch
126
+ # refs/heads/<name>` per entry; the branch line is matched and the path taken
127
+ # from the preceding one. A branch already dispatched is thus repaired in the
128
+ # worktree it is already checked out in rather than in a second copy of itself —
129
+ # git refuses a second checkout of one branch, and the two would fight over the
130
+ # same index if it did not.
131
+ wt=$(git worktree list --porcelain </dev/null 2>/dev/null | awk -v want="refs/heads/$branch" '
132
+ /^worktree / { path = substr($0, 10) }
133
+ /^branch / { if (substr($0, 8) == want) { print path; exit } }')
134
+
135
+ # No existing worktree holds it — compose the path a fresh one will take, by the
136
+ # same root+prefix rule plot-dispatch.sh uses. Under a `Worktree root:` key the
137
+ # root moves and the `plot-wt-` prefix drops; absent it, today's behaviour.
138
+ if [ -z "$wt" ]; then
139
+ wt_root=$("$script_dir/plot-config.sh" get "Worktree root" "")
140
+ if [ -z "$wt_root" ]; then
141
+ wt_root=$(cd "$repo_root/.." && pwd)
142
+ wt="$wt_root/plot-wt-$(printf '%s' "$branch" | tr '/' '-')"
143
+ else
144
+ case "$wt_root" in
145
+ /*) : ;;
146
+ *) wt_root="$repo_root/$wt_root" ;;
147
+ esac
148
+ wt="${wt_root%/}/$(printf '%s' "$branch" | tr '/' '-')"
149
+ fi
150
+ fi
151
+
152
+ if [ "$dry_run" = 1 ]; then
153
+ echo "step: would use worktree $wt"
154
+ echo "step: would merge origin/$MAIN, take a side of $ARTIFACT_PATH, rebuild, test"
155
+ echo "step: would push only if pnpm run test:board passes"
156
+ finish refused dry-run
157
+ fi
158
+
159
+ git fetch -q origin "$MAIN" "$branch" 2>/dev/null || true
160
+
161
+ # ONE REPAIR AT A TIME, AND NEVER TWO ON ONE BRANCH.
162
+ #
163
+ # A second run while the first is working would fight over the same worktree:
164
+ # the merge, the rebuild and the five-minute suite all write into it, and two of
165
+ # them interleaved produce an artifact belonging to neither run. The lock is a
166
+ # DIRECTORY rather than a file because `mkdir` is atomic on every filesystem
167
+ # this runs on — two processes racing it, one wins, and the loser learns it lost
168
+ # from the exit code rather than from a check-then-write that both pass.
169
+ #
170
+ # The board guards its own in-flight repairs too, in memory. Both are needed and
171
+ # neither is redundant: the board's registry cannot see a repair started by a
172
+ # second board or by a human at a shell, and this lock cannot stop the board
173
+ # from spawning (it learns only after the spawn). The lock is the authority.
174
+ lock="$repo_root/.plot/state/resolve-$(printf '%s' "$branch" | tr '/' '-').lock"
175
+ mkdir -p "$(dirname "$lock")" 2>/dev/null || true
176
+ if ! mkdir "$lock" 2>/dev/null; then
177
+ echo "step: a repair is already in flight for $branch ($lock)"
178
+ finish refused already-in-flight
179
+ fi
180
+ # Released on every exit, including a kill. A lock that outlives its process
181
+ # would make one interrupted repair block the branch forever — and the repair is
182
+ # idempotent, so there is nothing to protect after the process is gone.
183
+ trap 'rmdir "$lock" 2>/dev/null || true' EXIT INT TERM
184
+
185
+ if [ -d "$wt" ] && git worktree list --porcelain | grep -qx "worktree $wt"; then
186
+ # A REUSED WORKTREE MAY BELONG TO SOMEONE ELSE, and on 2026-08-17 one did: the
187
+ # resolver ran `git merge` inside a worktree an agent was actively editing —
188
+ # zero unmerged paths, three modified files, work in progress. It refused
189
+ # before writing anything, but that was luck rather than design.
190
+ #
191
+ # Reuse is right when the worktree is IDLE; the name alone does not say so. A
192
+ # worktree with modifications is one whose owner is mid-thought, and merging
193
+ # into it would either fail on "local changes would be overwritten" or, worse,
194
+ # succeed and fold a stranger's uncommitted work into a merge commit this
195
+ # script then pushes.
196
+ #
197
+ # The honest minimum is to refuse, and the plan names it acceptable: creating a
198
+ # scratch worktree is impossible anyway while git holds this branch checked out
199
+ # here, since git refuses a second checkout of one branch.
200
+ #
201
+ # `--porcelain` rather than a parsed `git status`: it is the stable interface,
202
+ # and an untracked file is deliberately NOT counted — a stray log or an
203
+ # editor's scratch file is not work in progress, and `merge` does not touch it.
204
+ echo "step: reusing worktree $wt"
205
+ busy=$(git -C "$wt" status --porcelain --untracked-files=no 2>/dev/null)
206
+ if [ -n "$busy" ]; then
207
+ echo "step: worktree has modifications that are not this repair's — refusing"
208
+ printf 'step: modified: %s\n' "$(printf '%s' "$busy" | sed 's/^...//' | tr '\n' ' ')"
209
+ finish refused worktree-busy
210
+ fi
211
+ else
212
+ if ! git worktree add -q "$wt" "$branch" 2>/dev/null; then
213
+ if ! git worktree add -q -b "$branch" "$wt" "origin/$branch" 2>/dev/null; then
214
+ echo "plot-resolve-artifact: cannot create a worktree for $branch at $wt" >&2
215
+ finish refused no-worktree
216
+ fi
217
+ fi
218
+ echo "step: worktree $wt"
219
+ fi
220
+
221
+ # THE FIXED SEQUENCE. Five steps, no decision between them.
222
+
223
+ # 1. Merge. The conflict is EXPECTED — that is why we are here — so a non-zero
224
+ # exit is not yet a failure. What decides is which paths came back
225
+ # unmerged, checked next.
226
+ git -C "$wt" merge --no-edit "origin/$MAIN" >/dev/null 2>&1
227
+ merge_status=$?
228
+
229
+ if [ "$merge_status" -eq 0 ]; then
230
+ # Nothing conflicted after all — the prediction was made from refs that have
231
+ # since moved, which is the direction this repo already knows they move in.
232
+ # The merge stands; there is nothing to repair and nothing to prove, so this
233
+ # pushes nothing rather than pushing a merge nobody asked for.
234
+ git -C "$wt" merge --abort >/dev/null 2>&1 || true
235
+ git -C "$wt" reset -q --hard "HEAD" >/dev/null 2>&1 || true
236
+ echo "step: no conflict on merge — nothing to repair"
237
+ finish refused no-conflict
238
+ fi
239
+
240
+ # 2. VERIFY THE SET, HERE, AGAINST THE REAL MERGE.
241
+ #
242
+ # The board classified from `merge-tree`, which predicts IN MEMORY from the refs
243
+ # this machine holds. This is the merge itself, and it is the only place the set
244
+ # is a fact rather than a forecast — a stale ref makes the prediction wrong in
245
+ # the reassuring direction, so the entry condition is re-checked against reality
246
+ # before anything is written.
247
+ #
248
+ # The set is read once and asked TWO questions, in order: was anything observed
249
+ # at all, and — only then — was it exactly the artifact.
250
+ unmerged=$(git -C "$wt" diff --name-only --diff-filter=U)
251
+ n_unmerged=$(printf '%s\n' "$unmerged" | grep -c . || true)
252
+
253
+ # AN EMPTY SET IS NOT A SMALL SET — it is the absence of a reading.
254
+ #
255
+ # The merge exited non-zero, so something went wrong; but a conflict is not the
256
+ # only thing that ends a merge non-zero. A merge that never STARTED — refused
257
+ # because the worktree was dirty, because a merge was already in progress, or
258
+ # because the ref could not be resolved — exits non-zero too and leaves no
259
+ # unmerged paths behind. Zero paths therefore answers a different question than
260
+ # one or three do: those say WHICH files conflicted, zero says NOBODY LOOKED.
261
+ #
262
+ # Measured on 2026-08-17, and the defect this branch exists for: the resolver
263
+ # reused a worktree in which no merge was running, read zero paths, compared
264
+ # zero against one, and reported `not-artifact-only` — a name asserting
265
+ # something about files it had never examined. The refusal was right; its reason
266
+ # was wrong, and the wrong reason sent a reader looking for conflicts that did
267
+ # not exist.
268
+ #
269
+ # So the two refusals are named apart. `not-artifact-only` is a claim about an
270
+ # observed set and may only be said when there was one.
271
+ if [ "$n_unmerged" = "0" ]; then
272
+ git -C "$wt" merge --abort >/dev/null 2>&1 || true
273
+ echo "step: the merge reported failure but left no unmerged paths — nothing was observed"
274
+ finish refused not-observed
275
+ fi
276
+
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
281
+ git -C "$wt" merge --abort >/dev/null 2>&1 || true
282
+ echo "step: conflict set is not exactly the artifact — refusing"
283
+ printf 'step: unmerged: %s\n' "$(printf '%s' "$unmerged" | tr '\n' ' ')"
284
+ finish refused not-artifact-only
285
+ fi
286
+
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)"
294
+
295
+ # 4. Rebuild, in the branch's OWN worktree. This is what makes the kept side
296
+ # irrelevant, and it is the property CI's no-diff gate then re-checks.
297
+ if ! (cd "$wt" && pnpm build:board >/dev/null 2>&1); then
298
+ git -C "$wt" merge --abort >/dev/null 2>&1 || true
299
+ echo "step: rebuild failed — pushing nothing"
300
+ finish abandoned build-failed
301
+ fi
302
+ git -C "$wt" add -- "$ARTIFACT_PATH" 2>/dev/null || true
303
+ echo "step: rebuilt $ARTIFACT_PATH"
304
+
305
+ # The merge commit exists only once the rebuild has produced the artifact it
306
+ # will carry. Committing before the build would leave a commit holding a stale
307
+ # artifact if the build then failed — exactly what CI's no-diff gate catches,
308
+ # arriving as a push instead of as a refusal.
309
+ if ! git -C "$wt" commit -q --no-edit 2>/dev/null; then
310
+ echo "step: nothing to commit after the rebuild"
311
+ finish abandoned nothing-to-commit
312
+ fi
313
+
314
+ # 5. THE GATE. Green in this worktree BEFORE the push, never CI after it.
315
+ # A resolver that pushed and let CI decide passes every correctness check
316
+ # above and manufactures a red PR in the queue — the exact stuck state this
317
+ # plan exists to remove.
318
+ echo "step: running pnpm run test:board"
319
+ if ! (cd "$wt" && pnpm run test:board >/dev/null 2>&1); then
320
+ # NOTHING IS PUSHED, and the merge is undone so the branch is left exactly as
321
+ # it was found. A half-repaired branch would be a third state nobody named.
322
+ git -C "$wt" reset -q --hard "HEAD~1" 2>/dev/null || true
323
+ echo "step: test:board failed — pushing nothing, this is a conflict a human owns"
324
+ finish abandoned tests-failed
325
+ fi
326
+ echo "step: test:board passed"
327
+
328
+ if ! git -C "$wt" push -q origin "HEAD:$branch" 2>/dev/null; then
329
+ echo "step: push rejected — the branch moved under us; leaving the repair local"
330
+ finish abandoned push-failed
331
+ fi
332
+
333
+ echo "step: pushed $branch"
334
+ finish pushed artifact-conflict-resolved