Uh oh!
There was an error while loading. Please reload this page.
fix(devx): os-regen-merge picks the merge side per file, and refuses an uncommitted hand-off - #12895
Conversation
…an uncommitted hand-off Step 2 took origin/main's side of every merge=os-regen path unconditionally, which reverts a branch's own COMMITTED hand-deletions inside generated artifacts — measured twice on one retirement branch, and step 3 then committed the revert. The side is now chosen per file against the merge base captured before the merge: a path only the branch moved keeps the branch's bytes, a path both sides moved still takes main's side (that is the only case where the merge driver ran and dropped a side) and says so loudly per path. Step 2 also writes the worktree only, never the index, and the sequence ends with a hand-off assertion that refuses while any regen path is uncommitted — the MM state where a bare git commit lands the side nobody inspected. Adds --self-test with 23 cases over synthetic fixture repos.
Uh oh!
There was an error while loading. Please reload this page.
os-litant
commented
Aug 28, 2026
Dequeue root-cause (08:29Z, Fix exists and is up: PR #12917 (one-string tombstone correction + patch changeset; reproduce-then-green evidence in its body). Once it lands, this PR re-enters the queue via its still-armed auto-merge — no changes needed here; this PR's own build was otherwise green (Governed Surface Guard ✓, Spec Liveness ✓). Generated by Claude Code |
Fixes#12877
Two independently measured hazards in
scripts/pm/os-regen-merge.sh. Both werereproduced on synthetic fixture repos against the pristine script before
anything was changed, and the same fixtures are now the script's
--self-test.Single file changed:
scripts/pm/os-regen-merge.sh. No changeset —scripts/pmreleases nothing (
skip-changesetapplied at PR-open time). No ratcheted file istouched, so there is no cut ledger to report. Not a governed surface: today's
register prints
docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md.Hazard 1 — step 2 reverted committed hand-deletions
Reproduction against the pristine script (blob
85e85f98, byte-identical toorigin/main). Fixture: a retirement branch commits a hand-deletion inside amerge=os-regenpath; main touches only a non-generated file, exactly as thelive root-cause round established. Running the pristine script:
Exit 0, no warning, and step 3 committed the revert.
The fix, and why it is per-file with two branches rather than a blanket skip
The card and the dispatch both propose "skip every regen path where
git diff MERGE_BASE..HEADis non-empty". I implemented that first, andmeasuring it showed it is not safe on its own — it makes step 2 inert:
Git invokes a merge driver only where both sides changed a path, so every
path step 2 can act on is by construction a path the branch edited. Skip them
all and the driver's silent drop (
both v1-MAIN, lost above) rides into themerge commit unrepaired — trading the revert hazard for the silent-drop hazard
this script exists to close.
So the side is chosen per file, against the merge base captured before
step 1 (after the merge,
git merge-base HEAD origin/mainis main's own tip andevery file reads as branch-edited):
Both branches are shapes the card itself proposes; this uses each where it is
correct. Per file, not per pattern — the hot patterns are directory globs
over dozens of shards.
Hazard 2 — the MM staging trap
Reproduction of the mechanism, isolated on a clean tree:
git checkout REF -- PATHwrites the index as well as the tree;git restore --source=REFwrites the tree only. With a lone unstagedM, a baregit commitlands nothing instead of the wrong side — a loud no-op beats a quietrevert. This is the spelling AGENTS.md already prescribes.
Refuse, not auto-stage — and why
The dispatch offered "stage the regenerated paths itself or refuse while any
regen path is MM". Auto-staging is not available to this script: it deliberately
does not regenerate (step 4 is printed, not run), so "the regenerated paths"
do not exist while it is running, and staging whatever happens to be in the tree
is the commit-the-wrong-side defect inverted. Refusal is also what every existing
failure mode in this script is — not-a-worktree, on
main, dirty tree, emptypattern list, non-generated conflict are all loud refusals with a remedy.
So the sequence now ends with a hand-off assertion: every os-regen path must
be committed before step 4 begins.
MMis the state the card names and this isits superset — any uncommitted regen path at hand-off would be absorbed by step
4's "commit the regeneration as its own commit" unread. A refused step-3 commit
now also exits with a named remedy instead of a bare
set -edeath.The runbook sentence
It lands in this script's own header and in the step-4 block it prints:
The two documented alternatives are both governed and line-ratcheted
(
.claude/skills/pm-dispatch/references/landing-operations.mdat 80,AGENTS.md), so putting it there would have made this a governed PR and forceda net-0 cut. The script is where the repo already points for these steps —
AGENTS.md calls it "the in-repo authority", landing-operations says
「步骤以脚本自身为权威」 — and it is what the operator is reading at the moment
the sentence matters.
Self-test —
bash scripts/pm/os-regen-merge.sh --self-test23 rows over whole synthetic repos (a mock of git would pin the mock). The
fixture registers the
os-regendriver astrue— exit 0, keep ours, saynothing — which is the real driver's shape for this purpose; without it git
never invokes a driver and the both-sides case cannot exist.
Two rows are pinned at the source, and say so at the assertion: after step 3
commits, the index equals HEAD either way, so no assertion on a completed run
can separate
git restore --sourcefromgit checkout REF --. What thespelling buys is the abort path. A behavioural pin that cannot fail would be
worse than no pin.
lint.ymlin this PR — that needs aworkflow edit, outside the file surface this card was dispatched with. Filed as
issue #12893 instead, which also carries the collector-vs-one-step-per-script
question; it mirrors the existing
os-verify-lock.sh --self-teststep.Ablation — predictions written to disk before any mutation
Each leg: mutation proven on disk by anchored greps (deleted text and injected
text counted separately), restore under
trap … EXIT INT TERM, restore verifiedby an empty
git diff HEADand a HEAD-blob hash match. Run from a committedimplementation, so the restore leg has a real reference.
Anchored greps for every leg: deleted text
before=1 after=0, injected textbefore=0 after=1. Restore after each leg and at the end:blob 9634d1cb… == HEAD 9634d1cb…,git diff HEADempty.Honest note: the first run of ablation C over-mutated — its inline marker
comment swallowed the rest of the line, so it silently became ablation A as well
and reddened two rows I had not predicted. It was re-run cleanly (swapping only
the spelling, keeping the exclude argument), and the clean run matched the
prediction exactly. The table reports the clean run.
Gates — derived union, all green
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no pathspassed; it derives its own change set from the merge base) → 10 families, run at
f56989d96, the final commit. Heavy run underos-verify-lock.shwithOS_VERIFY_LOCK_SLOT=os-dev-12877; exit codes captured before any pipe.Each gate's own verdict line:
check:agent-test-spelling— ✓ 0 violations — 391 file(s) · 4334 bare--token(s) · 1205 launcher-rooted run(s) · 9 separator(s) JUDGEDcheck:bash32-floor— ✓ 22 tracked shell file(s) … name no bash 4+ construct outside a comment … census: 20 by .sh extension, 2 by shebang alone; 19 constructs checked, floor bash 3.2check:cli-command-ids— ✓ 288 command-id literal(s) across 104 file(s) … all resolve to a real command pathcheck:cross-package-test-inputs— All 117 self-test cases passed. / OK: 20 package(s) read outside themselves, all declaredcheck:entry-guard— ✓ 172 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:parse-guard— ✓ 171 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjscheck:pnpm-filter-targets— ✓ 140/177--filteroccurrence(s) across 30 file(s) resolve against 78 workspace package(s)check:watch-hint-literal— ✓ 15 ROOT_DIR_WATCH_HINTS declaration(s), every one an array of quoted literalscheck-ci-filter-parity.mjs— OK: all 109 declared cross-package glob(s) (84 unique) are coveredcheck-cross-package-test-inputs.mjs— OK: 20 package(s) read outside themselves, all declaredcheck:nul-bytes(every diff owes it) — check-nul-bytes: OK (scanned 7168 text file(s) … no raw ASCII control bytes)The bash-3.2 floor gate's population is
scripts/**, so it does coverscripts/pm— checked rather than assumed. The one 3.2 trap this diff had tohandle is called out at the code:
"${arr[@]}"on an EMPTY array is an unboundvariable under
set -ubefore bash 4.4, and "the branch edited no generatedartifact" is the ordinary case, so the exclusion list uses
${arr[@]+"${arr[@]}"}.Repo-wide
pnpm lintwas narrowed, and the narrowing is measured rather thanasserted: eslint's own answer for the one changed file, via
--format json, is"File ignored because no matching configuration was supplied."— 1 filerequested, 0 linted, 0 errors. The file is a shell script, outside every
population eslint's flat config declares, and it participates in no TS program,
so no untouched file's verdict can depend on it. Control bytes scanned
separately:
grep -naPover the changed file exits 1 (no match).CI runs the full farm regardless; nothing here waits on it.
Generated by Claude Code