Skip to content

fix(pm): os-regen-merge.sh partitions step-1 conflicts by generatedness — a NOT_DRIVER_MANAGED path is resolved by regeneration, not by hand (#18047) - #18089

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-18047-os-regen-merge-generatedness-partition
Sep 14, 2026
Merged

os-project-manager merged 1 commit into
mainfrom
claude/issue-18047-os-regen-merge-generatedness-partition

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #18047

scripts/pm/os-regen-merge.sh's step-1 conflict report partitioned the conflicted set against the merge=os-regen routing list alone, so a generated path that is deliberately not routed was labelled NON-generated and sent to a hand merge — while the third line of the same message forbids resolving a generated file textually. The operator could satisfy neither sentence, and nothing in the output said which governed.

Premise readings, taken before writing (worktree at 57343f761)

# premise reading when
P1 the partition keys solely on routing membership; the script references regen-artifacts.mjs 0 times holdsgrep -c 'regen-artifacts' scripts/pm/os-regen-merge.sh0; non_regen_conflicts was the set difference at :368-374 2026-09-14T00:28Z
P2 NOT_DRIVER_MANAGED is exported as data and names packages/spec/src/migrations/registry.ts holds, and is not sufficient — see “the design call” below 2026-09-14T00:31Z
P3 git check-attr merge reads unspecified for registry.ts, os-regen for the lit control holdspackages/spec/src/migrations/registry.ts: merge: unspecified, packages/spec/authorable-surface/system.json: merge: os-regen 2026-09-14T00:29:43Z
P4 no self-test fixture covers a generated + NOT-routed conflict holds — case 6 builds only driver-routed MIXED rows; the word NOT_DRIVER_MANAGED did not appear in the file 2026-09-14T00:30Z

⚠️ The worktree was cut from origin/main after a sibling fetch advanced it past the sha in the dispatch: base is 57343f761, with 7ef05f997 an ancestor of it (git merge-base --is-ancestor ⇒ exit 0).

The design call triage fenced — no new marker was invented, and P2 needed one more reading

Triage left open whether the generated-but-unrouted set is machine-readable or stays prose, and fenced it: ⚠️ If the implementer finds the three-case partition cannot be done without inventing that marker, that is a new surface ⇒ report it rather than inventing one silently.」

NOT_DRIVER_MANAGED does name registry.ts, so membership is readable as data and the script reads it. But membership alone cannot carry class 3's message, and that is a reading of the ledger rather than a judgement call: of its 30 tracked entries, “resolve by regeneration” is correct for three and wrong for the other 27.

  • packages/spec/src/migrations/registry.ts, skills/README.md and content/docs/ai/skills-reference.mdx are MIXED — a generator owns the text between a marker pair, a human owns everything outside it. The module's own header names exactly these three as the files NOT_DRIVER_MANAGED “turns away”. One more of that shape (content/docs/permissions/tenant-audit-census.mdx) is reached through a directory entry.
  • the fifteen test-typecheck-debt.json ledgers, docs-import-surface.baseline.json and their neighbours are shrink-only ratchets whose own entries say a merge must never recompute them. packages/sdui-parser/objectui-lockstep.json cannot be regenerated here at all (it needs a sibling checkout); the scaffold templates' generator refuses a file it did not already stamp; packages/spec/src/conversions/registry.ts and docs/audits/** have no generator whatsoever.

⛔ The entry's gen field is not the discriminator either. It is an accounting field — recorded where the generator appears in no REGEN_ARTIFACTS row — so every ratchet above carries one while docs-import-surface.baseline.json, which gen:docs really does write, carries none. Keying the message on gen would send seventeen paths to a regeneration their own ledger entry forbids: this card's defect again, one class over.

The discriminator used instead is the generated-region marker pair in the conflicted file, which is the property the message actually depends on. ⛔ That is not a new marker: both vocabularies are already written by the tree's own generators, and scripts/check-role-word.mjs spells the second one once as a consumer and states the rule — “a rename happens at the generators and arrives here, not the other way round.” So no new surface was created and nothing was added to NOT_DRIVER_MANAGED; the diff is one file.

⚠️ Reported rather than assumed: class 3 therefore prints two per-path readings, not one. The card's suggested wording is class 3's marked shape; the unmarked shape gets the opposite instruction. A single blanket “resolve by regeneration” for all 30 entries would have been a new unobeyable instruction of exactly the reported kind.

The three printed cases

Classes 1 and 2 are byte-for-byte unchanged and, when no class-3 path is present, the branch they live in is the pre-existing if/elif/else verbatim — all 51 existing self-test cases pass untouched.

  1. neither routed nor declared → today's message, unchanged; and now the only one carrying the blanket “⛔ Do not resolve generated files textually” line, which is true of a set that by construction holds no generated file.
  2. routed and conflicted (MIXED) → today's message, unchanged (fix(devx): partition os-regen-merge.sh's step-1 conflict message by class #14733's fix, and it is correct).
  3. declared in NOT_DRIVER_MANAGED → new, one reading per path. Real output, from the new fixture:
✗ merge stopped on conflicts, and some are in files a generator writes which
  are deliberately NOT driver-managed — ⛔ neither the non-generated rule nor
  the MIXED one governs those, so each is named below with its own:
    ⚠ generated/marked.txt — GENERATED IN MARKED REGIONS, deliberately NOT driver-managed.
      ⛔ Do NOT hand-merge the generated regions — resolve them by REGENERATION:
      take either side to reach a committable state, commit the merge (step 3),
      then run its generator and commit that as its own commit:
        pnpm gen:fixture-marked
      ⚠ THE TWO SIDES DIFFER OUTSIDE THE GENERATED REGIONS (4 line(s)).
        Taking a side DROPS the other side's hand-written text there —
        silently, and with every gate green: a `check:` on this file proves
        it equals its generated sources and is no witness for the prose.
        ⛔ Carry those lines over BEFORE you regenerate.
    ⚠ generated/regions-only.txt — GENERATED IN MARKED REGIONS, deliberately NOT driver-managed.
      ⛔ Do NOT hand-merge the generated regions — resolve them by REGENERATION:
      take either side to reach a committable state, commit the merge (step 3),
      then run its generator and commit that as its own commit:
        pnpm gen:fixture-regions
      ✓ the two sides are identical outside the generated regions, so taking
        either side drops no hand-written text.
    ⚠ ledgers/whole.json — generator-touched and deliberately NOT driver-managed.
      It carries no generated-region markers, so there is no half a
      regeneration would restore: resolve it BY HAND (semantic merge, both
      intents stack). ⛔ Do NOT regenerate it as part of this merge — the
      ledger keeps the driver off it because a mid-merge recompute describes
      the half-merged tree; its `why` in scripts/regen-artifacts.mjs is the
      authority on what it may be regenerated from, and when.
  non-generated (resolve by hand — semantic merge, both intents stack):
    build/ignored.json
    src/plain.txt
  Resolve every class above by ITS OWN rule, then rerun this script to redo
  the generated-artifact half.

The addendum's caveat is answered, not delegated. Comment 5654282996 asked for the check the tooling never makes, and the #18062 transplant 5654438150 supplied its live cost: on PR #17835 registry.ts was resolved take-a-side-and-regenerate, and step18.conversionIds / step18.rationale — hand-authored regions outside the markers — were dropped silently, so a 17→18 hop stopped applying while a 115-family gate sweep stayed green. The script now reads both sides out of the index it already holds (:2: ours, :3: theirs), strips the generated regions from each, and reports whether the remainders differ, with a line count. The clean case prints its own , so the finding is falsifiable rather than decorative.

How the script learns generatedness

NOT_DRIVER_MANAGED is read at run time from scripts/regen-artifacts.mjs — one node --input-type=module -e call importing the module through pathToFileURL — for the same reason .gitattributes is read at run time. ⛔ No hard-coded path list. The regeneration command is built by the module's own ownerRunCommand, never assembled in the shell, so the string this script prints stays the command the pre-commit gate spawns.

Three things the reader is deliberate about:

  • untracked: true rows are dropped. They are gitignored build output git never merges; the day one becomes tracked, git-merge-regen.mjs --self-test refuses, so this skip hides nothing. Pinned: a tracked file at such a path gets no class-3 reading.
  • An unreadable ledger is loud. A silent empty list would restore the defect with the evidence removed, so the run says the ledger could not be read and makes no class-3 claim it cannot support. Pinned in case 9d.
  • One git diff per ledger row, and ⛔ never with an empty pathspecgit diff --diff-filter=U -- with no pathspec matches everything, which would promote every conflict into class 3. An empty ledger runs no git diff at all.

The class-3 fixture (P4's gap), and the discriminating reading

--self-test gains st_fixture_ndm_conflict, a synthetic repo whose conflicts are all unrouted and whose ledger declares four of the five. It carries every reading class 3 has to make in one run, including both halves of the card's own dark-control warning — an unlisted path reads exactly like a non-generated one from the routing side alone:

fixture path declared? markers? expected class
generated/marked.txt yes yes, sides differ outside them 3 — regenerate + the carry-over finding
generated/regions-only.txt yes yes, sides differ only inside 3 — regenerate, no prose at stake (the firing control)
ledgers/whole.json yes no 3 — ⛔ do not regenerate in this merge
build/ignored.json yes, untracked no 1 — the row is dropped
src/plain.txt no no 1 — today's message, unchanged

Two mutations keep the new cases falsifiable, in the style cases 6b and 8b already use. 9b empties the ledger read and the whole set collapses back into class 1: GENERATED IN MARKED REGIONS ⇒ 0, conflicts in NON-generated files ⇒ 1, Do not resolve generated files textually ⇒ 1 — the reported defect, reproduced on demand. 9c pins the real ledger rather than the fixture that models it: the module still declares packages/spec/src/migrations/registry.ts, still records gen:migration-registry for it, a fabricated path is absent (the control), and git check-attr still reads unspecified for it — so routing it or dropping its entry reddens here instead of silently reverting the label.

Verification

✓ os-regen-merge self-test: all cases pass.

79 cases, at 1f8f50dd9: the 51 that existed before, unchanged and unweakened (no case deleted, no expectation loosened), plus 28 new ones. Before the change the same file ran 51/51.

node scripts/pm/dispatch-gates.mjs --commands scripts/pm/os-regen-merge.sh derived 26 families; all 26 ran, all exited 0, and --ran reconciles:

Run reconciliation — 26 derived, 26 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 26 derived famil(ies) accounted for — 26 run, 0 NOT-MEASURED

Named in that set and worth quoting: pnpm check:bash32-floor, pnpm check:nul-bytes, pnpm check:parse-guard, pnpm check:entry-guard, pnpm check:pnpm-filter-targets, node scripts/check-self-test-wired.mjs, node scripts/check-scripts-symbol-anchors.mjs — each exit 0.

shellcheck is not a family here: it is installed nowhere in this container and nothing in .github/workflows/ or package.json invokes it, so there was no shell-escape residue check to derive. bash -n parses clean, as do both mutated copies the self-test builds.

pnpm lint is CI's run, not this PR's, and the narrowing is measured rather than asserted: ① the population read from eslint's own config — every files glob in eslint.config.mjs is **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} or a narrower JS/TS subset, and no shell extension appears in any of them; ② the file count from --format json — this PR's one changed path returns errorCount: 0 with "File ignored because no matching configuration was supplied.", i.e. it contributes zero files to the linted population; ③ invariance for untouched files — eslint.config.mjs:327 records that the repo “never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file”, so no file this diff does not touch can change verdict.

Changeset

⛔ None owed, and the mechanism is the label rather than a path rule: changeset-check in pr-automation.yml counts added .changeset/*.md files and errors when the count is zero unless the PR carries skip-changeset (or is the changesets release PR). There is no path-based exemption in the gate, so the label is the declaration. scripts/pm/os-regen-merge.sh ships in no package's files[] — it is a PM-loop tool run by hand, invoked by no workflow — so nothing published moves and skip-changeset is the correct declaration.

Acceptance notes

Out of scope, noted and ⛔ not filed:

Clause-②: no


Generated by Claude Code

The step-1 conflict report partitioned the conflicted set against the
`merge=os-regen` routing list alone, so `non_regen_conflicts` was the set
difference and nothing asked whether a path was generated. A generated
path that is deliberately NOT routed — `NOT_DRIVER_MANAGED` in
`scripts/regen-artifacts.mjs` — therefore drew the "NON-generated files,
resolve those by hand" message together with its "do not resolve
generated files textually" line, two sentences about one file with
nothing saying which governs.

The conflicted set is now partitioned by generatedness first and routing
second, into three classes: unrouted and undeclared (today's message,
unchanged), routed and MIXED (today's message, unchanged), and declared
in `NOT_DRIVER_MANAGED`, which gets a new per-path reading.

Class 3 is not one instruction, and the ledger is what shows it:
"resolve by regeneration" is right for three of its thirty tracked
entries and wrong for the rest, whose own entries say a merge must never
recompute them. The discriminator is the generated-region marker pair in
the conflicted file, not the entry's `gen` field, which is an accounting
field carried by every ratchet in the list.

For a marked file the report also answers the caveat instead of
delegating it: it reads both sides out of the index, strips the
generated regions from each, and says whether the remainders differ —
the PR #17835 shape, where taking a side dropped two hand-authored
regions silently with every gate green.

Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8
Co-authored-by: Claude <noreply@anthropic.com>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Contract review

Head: 1f8f50dd (PR #18089, card #18047) — reviewed at 2026-09-14T01:06Z by the skills seat at the contract-review tier. Non-governed path (scripts/pm/os-regen-merge.sh only) ⇒ the in-seat review lands it: ready + auto-merge SQUASH through the queue.

① derived judgments (seat-measured on the fetched head, ⛔ not taken from the report):

  1. The partition keys on generatedness first, routing second — the diff adds read_not_driver_managed (reads NOT_DRIVER_MANAGED from scripts/regen-artifacts.mjs at run time via one node --input-type=module import; the regeneration command comes from the module's own ownerRunCommand, ⛔ nothing assembled in shell, no marker or file invented — triage's fence holds) and a class-3 branch; classes 1 and 2 print today's messages byte-for-byte (the self-test's 51 existing cases pass unchanged).
  2. Class 3 is two readings, discriminated by the marker pair in the conflicted file, ⛔ not the ledger's gen field — the header derives why (gen is an accounting field; keying on it would send seventeen ratchet ledgers to a regeneration their own entries forbid): marked ⇒ resolve by REGENERATION with the recorded generator command, plus the addendum's caveat answered in code — both index sides (:2: / :3:) stripped of their generated regions and diffed, the line count printed, 「⛔ Carry those lines over BEFORE you regenerate」 (the PR fix(spec): retire page.assignedProfiles and answer profiles: with the permission-set route #17835 shape); unmarked ⇒ resolve BY HAND, ⛔ do not regenerate in this merge.
  3. Self-test: bash scripts/pm/os-regen-merge.sh --self-test in a worktree at 1f8f50dd → exit 0, 79 cases (51 existing + 28 new); the new cases include the falsifiability pair (case 9b empties the ledger read and the defect reproduces: the NON-generated notice 0→1) and case 9c, which pins the REAL ledger (registry.ts declared, git check-attr still unspecified, a fabricated path absent as the control). A .git-less archive fails one case — an artefact of the seat's first run, not of the tree.
  4. Gates: the dev's --ran reconciliation reads 26 derived / 26 run / 0 NOT-MEASURED / 0 UNRUN with --repo objectstack-ai/objectstack asserted; the 8 local check:pm-* families exit 0; shellcheck is not a family in this tree (not installed, not invoked). Checks on the head at 2026-09-14T01:05Z: 33 — 19 green, 11 skipped, 3 running, 0 red.
  5. --pair 18089 on origin/main's reader → exit 0 at 2026-09-14T01:05Z (declaration legible in the fixed spelling, both carriers agree, no widening tell); Clause-②: no holds — no contract path.
  6. Scope held: one file; the skip-changeset label is the declaration (changeset-check has no path exemption) — the one write beyond the named budget, declared in the report and read back.

② semver: unchanged — PM tooling, nothing published.

③ boundary flags: none binding. Adjacent, not touched: #8360 (registry.ts conflicting at all), #17602 (the driver discarding a side); the dev's note that NOT_DRIVER_MANAGED records a routing disposition rather than a resolution recipe is a shape question for the ledger's owner — reported, not filed, nothing is wrong today.

Implemented-by: claude/issue-18047-os-regen-merge-generatedness-partition
Reviewed-by: session_01DAcomhvR9kKizeYgg89Vo8

Verdict: PASS — ready + auto-merge SQUASH by this seat.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants