@plot-pm/board 0.8.1 → 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.
- package/dist/board-server.mjs +120 -111
- package/package.json +11 -2
- package/plot-approve.sh +628 -0
- package/plot-config.sh +14 -0
- package/plot-deliver.sh +488 -0
- package/plot-dispatch.sh +2028 -0
- package/plot-fleet-scan.sh +3496 -0
- package/plot-host.sh +1686 -0
- package/plot-plan-meta.sh +133 -16
- package/plot-reap.sh +286 -0
- package/plot-release-refs.sh +234 -0
- package/plot-resolve-artifact.sh +334 -0
- package/plot-worker-state.sh +725 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Delete the REMOTE REFS of a delivered plan's merged branches.
|
|
3
|
+
#
|
|
4
|
+
# Usage: plot-release-refs.sh [--yes] [--max N] <slug>
|
|
5
|
+
#
|
|
6
|
+
# <slug> the plan whose branches to release
|
|
7
|
+
# --yes actually delete; without it this reports and deletes nothing
|
|
8
|
+
# --max N bound the number of deletions
|
|
9
|
+
#
|
|
10
|
+
# WHY THIS EXISTS: branches are what the scan actually costs. Measured
|
|
11
|
+
# 2026-08-27 across four runs of the fleet scan:
|
|
12
|
+
#
|
|
13
|
+
# worktrees branches scan
|
|
14
|
+
# 54 43 462.9 s
|
|
15
|
+
# 42 43 51.3 s
|
|
16
|
+
# 11 43 218.5 s
|
|
17
|
+
# 11 34 111.5 s
|
|
18
|
+
#
|
|
19
|
+
# Worktree count does not order those runs — 11 worktrees was SLOWER than 42.
|
|
20
|
+
# What moved reliably was deleting nine merged branches: 218.5 s → 111.5 s,
|
|
21
|
+
# roughly halving it. The estate the scan walks is branches, and merged ones
|
|
22
|
+
# are pure cost. Reaping clears desks; this is what the scan notices.
|
|
23
|
+
#
|
|
24
|
+
# WHY A SEPARATE SCRIPT AND NOT PART OF `plot-reap.sh`. The reaper ends by
|
|
25
|
+
# saying what it is: "the branches and refs are untouched, deliberately — this
|
|
26
|
+
# removes CHECKOUTS... A reaped tree is re-creatable with `git worktree add`,
|
|
27
|
+
# so the destructive act is bounded to disk space... never to history." That is
|
|
28
|
+
# a stated LICENCE, and it does not extend here. A deleted ref is not
|
|
29
|
+
# re-creatable, so this act needs its own argument, its own guards and its own
|
|
30
|
+
# `--yes`. Folding it into the reaper would silently widen a licence that was
|
|
31
|
+
# written narrow on purpose.
|
|
32
|
+
#
|
|
33
|
+
# It is also SCOPED TO ONE PLAN, where the reaper is deliberately slug-blind.
|
|
34
|
+
# The reaper sweeps every worktree because a checkout is cheap to restore; this
|
|
35
|
+
# touches only the branches its plan names. A sweep that deleted every merged
|
|
36
|
+
# ref on the estate would satisfy "a delivered plan's merged branches lose
|
|
37
|
+
# their refs" and destroy unlanded work belonging to plans nobody delivered.
|
|
38
|
+
# The blast radius is bounded by the plan file.
|
|
39
|
+
#
|
|
40
|
+
# WHAT IS NEVER DELETED, in the order the tests run:
|
|
41
|
+
# 1. a branch annotated `deferred:` or `moved:` (given up, not finished)
|
|
42
|
+
# 2. a branch NO PR of which merged (unlanded work)
|
|
43
|
+
# 3. a branch with an OPEN PR (changeset-release/main)
|
|
44
|
+
# 4. a branch checked out in ANY worktree (somebody is reading it)
|
|
45
|
+
# 5. the default branch itself (never ours to delete)
|
|
46
|
+
#
|
|
47
|
+
# THE RULE THIS MUST NOT BREAK. `/plot-implement` says plainly: *"leave the ref
|
|
48
|
+
# in place — never delete a remote ref another session may be reading."* Read in
|
|
49
|
+
# context that rule governs GIVING A BRANCH UP — work that turned out
|
|
50
|
+
# unnecessary, wrongly cut, or blocked — and its reason is that
|
|
51
|
+
# `/plot-reconcile` needs the ref PLUS its `deferred:`/`moved:` annotation to
|
|
52
|
+
# tell deliberate abandonment from a dead worker.
|
|
53
|
+
#
|
|
54
|
+
# A branch whose PR merged is neither abandoned nor ambiguous: its work is on
|
|
55
|
+
# main, its PR is closed, and there is nothing for `/plot-reconcile` to resolve.
|
|
56
|
+
# The rule protects UNLANDED refs, and this touches only landed ones. Guards 1
|
|
57
|
+
# and 2 are that reconciliation, enforced.
|
|
58
|
+
set -u
|
|
59
|
+
|
|
60
|
+
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
61
|
+
|
|
62
|
+
DRY=1; MAX=0; slug=""
|
|
63
|
+
while [ $# -gt 0 ]; do
|
|
64
|
+
case "$1" in
|
|
65
|
+
--yes) DRY=0 ;;
|
|
66
|
+
--dry-run) DRY=1 ;;
|
|
67
|
+
--max) MAX="${2:-0}"; shift ;;
|
|
68
|
+
-h|--help) sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
69
|
+
-*) echo "plot-release-refs: unknown argument: $1" >&2; exit 2 ;;
|
|
70
|
+
*) slug="$1" ;;
|
|
71
|
+
esac
|
|
72
|
+
shift
|
|
73
|
+
done
|
|
74
|
+
|
|
75
|
+
die() { echo "plot-release-refs: $*" >&2; exit 2; }
|
|
76
|
+
|
|
77
|
+
[ -n "$slug" ] || die "need a plan slug (usage: plot-release-refs.sh [--yes] <slug>)"
|
|
78
|
+
command -v git >/dev/null 2>&1 || die "git not found"
|
|
79
|
+
git rev-parse --git-dir >/dev/null 2>&1 || die "not a git repository"
|
|
80
|
+
|
|
81
|
+
# The SAME gate the reaper uses, sourced rather than re-derived. `pr_merged`
|
|
82
|
+
# reads `mergedAt` on ANY PR (never `state`, never ancestry); `pr_open` answers
|
|
83
|
+
# the veto in guard 3.
|
|
84
|
+
. "$script_dir/plot-pr-merged.sh"
|
|
85
|
+
|
|
86
|
+
cfg() { bash "$script_dir/plot-config.sh" get "$1" "$2"; }
|
|
87
|
+
|
|
88
|
+
PLAN_DIR=$(cfg "Plan directory" "docs/plans/")
|
|
89
|
+
ACTIVE_DIR=$(cfg "Active index" "docs/plans/active/")
|
|
90
|
+
DELIVERED_DIR=$(cfg "Delivered index" "docs/plans/delivered/")
|
|
91
|
+
|
|
92
|
+
# The plan is resolved exactly as `plot-deliver.sh` resolves it, including the
|
|
93
|
+
# `delivered/` directory — which is not incidental. This runs AFTER a delivery,
|
|
94
|
+
# so by the time it looks the symlink has already moved, and a resolver that
|
|
95
|
+
# knew only `active/` would find nothing for every plan it is called about.
|
|
96
|
+
plan_file=""
|
|
97
|
+
for cand in "$PLAN_DIR"*"$slug".md "$ACTIVE_DIR$slug.md" "$DELIVERED_DIR$slug.md"; do
|
|
98
|
+
[ -e "$cand" ] && { plan_file="$cand"; break; }
|
|
99
|
+
done
|
|
100
|
+
[ -n "$plan_file" ] || die "no plan found for '$slug' — looked in $PLAN_DIR, $ACTIVE_DIR, $DELIVERED_DIR"
|
|
101
|
+
|
|
102
|
+
# The prefixes come from `Branch prefixes`, never a hardcoded list — the same
|
|
103
|
+
# derivation `plot-deliver.sh:144` and `plot-fleet-scan.sh:187` use. Without it
|
|
104
|
+
# this reads the parser's built-in default, and a project with its own prefixes
|
|
105
|
+
# would have EVERY branch of a plan silently disappear before the loop: the
|
|
106
|
+
# script would report `releasable=0` and look like it had nothing to do. That
|
|
107
|
+
# exact bug cost `plot-deliver.sh` four undeliverable plans on 2026-08-27.
|
|
108
|
+
#
|
|
109
|
+
# It fails safe (nothing is deleted) and is wrong all the same, and being wrong
|
|
110
|
+
# quietly is what makes it worth passing explicitly.
|
|
111
|
+
prefix_re=$(bash "$script_dir/plot-config.sh" get "Branch prefixes" "idea/, feature/, bug/, docs/, infra/" \
|
|
112
|
+
| tr -d ' ' | tr ',' '\n' | sed 's#/$##' | grep -v '^$' | paste -sd'|' - )
|
|
113
|
+
[ -n "$prefix_re" ] || prefix_re="idea|feature|bug|docs|infra"
|
|
114
|
+
|
|
115
|
+
meta=$(bash "$script_dir/plot-plan-meta.sh" --prefixes "$prefix_re" "$plan_file" 2>/dev/null) || meta=""
|
|
116
|
+
[ -n "$meta" ] || die "cannot parse '$plan_file' — refusing rather than guessing"
|
|
117
|
+
|
|
118
|
+
# The default branch, via the host adapter when it can answer. Guard 5 compares
|
|
119
|
+
# against it, and a wrong answer here can only ever protect MORE.
|
|
120
|
+
HOST="$script_dir/plot-host.sh"
|
|
121
|
+
DEFAULT=main
|
|
122
|
+
if [ -x "$HOST" ]; then
|
|
123
|
+
d=$("$HOST" default-branch 2>/dev/null) && [ -n "$d" ] && DEFAULT="$d"
|
|
124
|
+
fi
|
|
125
|
+
|
|
126
|
+
# Every branch currently checked out ANYWHERE, for guard 4.
|
|
127
|
+
#
|
|
128
|
+
# Collected once, before the loop, rather than asked per branch: `git worktree
|
|
129
|
+
# list` walks the whole estate and this script runs on the delivery path where
|
|
130
|
+
# that estate may hold dozens of trees. The answer cannot change underneath a
|
|
131
|
+
# single run in a way that matters — a worktree created mid-run holds a branch
|
|
132
|
+
# whose ref this run has not yet reached, and the next run sees it.
|
|
133
|
+
checked_out=$(git worktree list --porcelain 2>/dev/null \
|
|
134
|
+
| sed -n 's|^branch refs/heads/||p')
|
|
135
|
+
|
|
136
|
+
is_checked_out() {
|
|
137
|
+
printf '%s\n' "$checked_out" | grep -qxF "$1"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
released=0; kept=0; deleted=0
|
|
141
|
+
printf '%-8s %-52s %s\n' "verdict" "branch" "why"
|
|
142
|
+
|
|
143
|
+
# Branch and its deferred flag, one per line, from the plan's own parser.
|
|
144
|
+
#
|
|
145
|
+
# `plot-plan-meta.sh` is the plan-format contract, and asking it rather than
|
|
146
|
+
# grepping the file is what keeps this working across both plan dialects —
|
|
147
|
+
# `## Branches` lists and `## Waves` headings — without this script knowing
|
|
148
|
+
# which one it is reading.
|
|
149
|
+
while IFS=$'\t' read -r br deferred; do
|
|
150
|
+
[ -n "$br" ] || continue
|
|
151
|
+
|
|
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
|
|
158
|
+
|
|
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
|
|
168
|
+
|
|
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
|
|
177
|
+
fi
|
|
178
|
+
|
|
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
|
|
188
|
+
|
|
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"
|
|
197
|
+
kept=$((kept+1)); continue
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
if [ "$MAX" -gt 0 ] && [ "$released" -ge "$MAX" ]; then
|
|
201
|
+
printf '%-8s %-52s %s\n' "keep" "$br" "--max $MAX reached"
|
|
202
|
+
kept=$((kept+1)); continue
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
released=$((released+1))
|
|
206
|
+
if [ "$DRY" -eq 1 ]; then
|
|
207
|
+
printf '%-8s %-52s %s\n' "would" "$br" "merged — ref would be deleted"
|
|
208
|
+
else
|
|
209
|
+
# The REMOTE ref only. The local branch is left alone deliberately: it costs
|
|
210
|
+
# the scan nothing (the scan derives from `origin/<branch>`), and a local
|
|
211
|
+
# branch is the last copy of a reflog somebody may still want.
|
|
212
|
+
if git push origin --delete "$br" >/dev/null 2>&1; then
|
|
213
|
+
printf '%-8s %-52s %s\n' "released" "$br" "merged — remote ref deleted"
|
|
214
|
+
deleted=$((deleted+1))
|
|
215
|
+
else
|
|
216
|
+
# A ref already gone is the common case on a re-run, and it is a SUCCESS
|
|
217
|
+
# for this script's purpose: the end state asked for is the ref's absence.
|
|
218
|
+
if git ls-remote --exit-code --heads origin "$br" >/dev/null 2>&1; then
|
|
219
|
+
printf '%-8s %-52s %s\n' "FAILED" "$br" "git push --delete refused"
|
|
220
|
+
kept=$((kept+1))
|
|
221
|
+
else
|
|
222
|
+
printf '%-8s %-52s %s\n' "released" "$br" "remote ref already absent"
|
|
223
|
+
deleted=$((deleted+1))
|
|
224
|
+
fi
|
|
225
|
+
fi
|
|
226
|
+
fi
|
|
227
|
+
done < <(printf '%s' "$meta" | jq -r '
|
|
228
|
+
([.waves[]?.branches[]?] as $w
|
|
229
|
+
| if ($w | length) > 0 then $w
|
|
230
|
+
else [.branches[]? | {branch: ., deferred: false}] end)
|
|
231
|
+
| .[] | [.branch, (.deferred | tostring)] | @tsv' 2>/dev/null)
|
|
232
|
+
|
|
233
|
+
echo "summary: releasable=$released deleted=$deleted kept=$kept dry_run=$DRY"
|
|
234
|
+
exit 0
|
|
@@ -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
|