Skip to content

fix(devx): os-regen-merge picks the merge side per file, and refuses an uncommitted hand-off - #12895

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-12877-regen-merge-hazards
Aug 28, 2026
Merged

fix(devx): os-regen-merge picks the merge side per file, and refuses an uncommitted hand-off#12895
os-litant merged 1 commit into
mainfrom
claude/issue-12877-regen-merge-hazards

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12877

Two independently measured hazards in scripts/pm/os-regen-merge.sh. Both were
reproduced 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/pm
releases nothing (skip-changeset applied at PR-open time). No ratcheted file is
touched, 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 to
origin/main). Fixture: a retirement branch commits a hand-deletion inside a
merge=os-regen path; main touches only a non-generated file, exactly as the
live root-cause round established. Running the pristine script:

→ step 2: taking origin/main's side of every generated artifact
→ step 3: committing the merge BEFORE any regeneration
[feature 6c25a47] merge origin/main (os-regen artifacts taken from main; regeneration follows)
1 file changed, 1 insertion(+)
=== script exit status: 0 ===
HAZARD-1: REPRODUCED — the committed hand-deletion came back

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..HEAD is non-empty". I implemented that first, and
measuring it showed it is not safe on its own — it makes step 2 inert:

=== branch-edited regen paths === gen/both.txt gen/brchonly.txt
=== main-edited regen paths === gen/both.txt gen/mainonly.txt
--- driver invocation log --- DRIVER-INVOKED-ON:gen/both.txt
=== step 2 OLD would change === gen/both.txt gen/brchonly.txt
=== step 2 NEW (blanket skip) leaves === [ nothing ]
NO — step 2 is inert, step 3's commit branch is unreachable

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 the
merge 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/main is main's own tip and
every file reads as branch-edited):

casewhat git already didstep 2 now
branch changed it, main did notresolved trivially to the branch's bytes; nothing to reconcilekeep the branch's bytes + per-path notice — taking main's side would be a pure revert. This is the measured incident.
both sides changed itthe driver ran, exited 0, silently kept one sidetake main's side + loud per-path notice ("if this branch HAND-edited it, restore the branch bytes before regenerating")
main only / neitheralready main'stake main's side (a no-op)

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 other -- gen/b.json (what step 2 used to do)
porcelain: [M gen/b.json] (STAGED)
--- then a regeneration lands in the worktree
porcelain: [MM gen/b.json] (a bare `git commit` lands the STAGED side)
--- git restore --source=other -- gen/b.json (what step 2 does now)
worktree: B-main index: B-branch porcelain: [ M gen/b.json]

git checkout REF -- PATH writes the index as well as the tree; git restore --source=REF writes the tree only. With a lone unstaged M, a bare git commit lands nothing instead of the wrong side — a loud no-op beats a quiet
revert. 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, empty
pattern 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. MM is the state the card names and this is
its 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 -e death.

The runbook sentence

It lands in this script's own header and in the step-4 block it prints:

inspect the STAGED diff, not the working-tree diff, before committing

The two documented alternatives are both governed and line-ratcheted
(.claude/skills/pm-dispatch/references/landing-operations.md at 80,
AGENTS.md), so putting it there would have made this a governed PR and forced
a 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-test

23 rows over whole synthetic repos (a mock of git would pin the mock). The
fixture registers the os-regen driver as true — exit 0, keep ours, say
nothing — which is the real driver's shape for this purpose; without it git
never invokes a driver and the both-sides case cannot exist.

os-regen-merge --self-test
ok a clean run exits 0
ok branch-only regen path keeps the branch bytes
ok and is not reverted in the commit either
ok and the run names it as kept
ok both-sides regen path takes main s side
ok and the run names it as taken, loudly
ok and that repair is what step 3 commits
ok main-only regen path holds main s side
ok and draws no per-path notice at all
ok step 4 carries the staged-diff sentence
ok the hand-off leaves no uncommitted regen path
ok step 2 uses the non-staging spelling
ok and never the staging one
ok a regeneration on top of the run is unstaged M, never MM
ok an uncommitted regen path at hand-off is refused
ok and the refusal names the index/worktree split
ok a refused step-3 commit fails the run
ok and warns against regenerating over a staged index
ok a dirty tree is refused
ok and says so
ok running on main is refused
ok an empty os-regen path list is refused
ok and refuses to guess
✓ os-regen-merge self-test: all cases pass.

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 --source from git checkout REF --. What the
spelling buys is the abort path. A behavioural pin that cannot fail would be
worse than no pin.

⚠️ The self-test is not wired into lint.yml in this PR — that needs a
workflow 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-test step.

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 verified
by an empty git diff HEADand a HEAD-blob hash match. Run from a committed
implementation, so the restore leg has a real reference.

ablationpredictedobserved
A — step 2 unconditional again2 hazard-1 rows red; notice row + both-sides row stay greenexactly that (2 failed)
B — hand-off assertion neutralised2 hand-off rows red, hazard-1 rows greenexactly that (2 failed)
C — staging spelling restored2 source-scan rows red; the behavioural MM row stays greenexactly that (2 failed)

Anchored greps for every leg: deleted text before=1 after=0, injected text
before=0 after=1. Restore after each leg and at the end:
blob 9634d1cb… == HEAD 9634d1cb…, git diff HEAD empty.

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 paths
passed; it derives its own change set from the merge base) → 10 families, run at
f56989d96, the final commit. Heavy run under os-verify-lock.sh with
OS_VERIFY_LOCK_SLOT=os-dev-12877; exit codes captured before any pipe.

os-verify-lock: VERDICT command-exit 0 · held the lock 32s · waited 0s

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) JUDGED
  • check: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.2
  • check:cli-command-ids — ✓ 288 command-id literal(s) across 104 file(s) … all resolve to a real command path
  • check:cross-package-test-inputs — All 117 self-test cases passed. / OK: 20 package(s) read outside themselves, all declared
  • check:entry-guard — ✓ 172 scripts/ file(s) — every entry guard goes through invoked-as.mjs
  • check:parse-guard — ✓ 171 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs
  • check:pnpm-filter-targets — ✓ 140/177 --filter occurrence(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 literals
  • check-ci-filter-parity.mjs — OK: all 109 declared cross-package glob(s) (84 unique) are covered
  • check-cross-package-test-inputs.mjs — OK: 20 package(s) read outside themselves, all declared
  • check: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 cover
scripts/pm — checked rather than assumed. The one 3.2 trap this diff had to
handle is called out at the code: "${arr[@]}" on an EMPTY array is an unbound
variable under set -u before bash 4.4, and "the branch edited no generated
artifact" is the ordinary case, so the exclusion list uses
${arr[@]+"${arr[@]}"}.

Repo-wide pnpm lint was narrowed, and the narrowing is measured rather than
asserted: eslint's own answer for the one changed file, via --format json, is
"File ignored because no matching configuration was supplied." — 1 file
requested, 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 -naP over the changed file exits 1 (no match).

CI runs the full farm regardless; nothing here waits on it.


Generated by Claude Code

…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.
@os-litantos-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 28, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review August 28, 2026 07:17
@os-litant
os-litant enabled auto-merge August 28, 2026 07:17
@os-litant
os-litant added this pull request to the merge queueAug 28, 2026
@github-merge-queue
github-merge-queueBot removed this pull request from the merge queue due to failed status checks Aug 28, 2026
@os-litantClaude

Copy link
Copy Markdown
CollaboratorAuthor

Dequeue root-cause (08:29Z, CI_FAILURE): not this PR's failure. The queue build's Lint & Type Check fails at check-adr-anchorsADR-0006 D4 is cited by 1 file(s), but ADR-0006 declares no D4 (packages/spec/src/data/object.zod.ts, untouched by this diff) — and the same red hits every queue build in the window (pr-12897, pr-12901, pr-12905), because it is main's red: the letter validation (#12785) plus the direct-merged ADR-0006 v4 addendum (#12736, bypass path — no merge-time re-validation) made an ancient phantom citation unresolvable on the base branch itself.

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

@os-litant
os-litant added this pull request to the merge queueAug 28, 2026
Merged via the queue into main with commit 4d11427Aug 28, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-12877-regen-merge-hazards branch August 28, 2026 09:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-litant@claude