[object Object]

← back to Cli Printing Press

fix(cli): preserve hand-edits to templated files on --force regen (#967)

618fa45627609eff27a103bfc91ee28783e128a6 · 2026-05-10 15:23:20 -0700 · Trevin Chow

* fix(cli): preserve hand-edits to templated files on --force regen

Wires the existing regenmerge AST-aware classifier into `printing-press
generate --force`, replacing the preserve-files-in-memory dance from
PR #897 with a snapshot+merge architecture: rename absOut to a sibling
preserve dir, generate fresh into absOut, then classify and merge
preserved files back. Adds a TEMPLATED-VALUE-DRIFT verdict so literal
drift (`"Bearer "` -> `"Token "`) and identifier drift (`cfg.Bearer` ->
`cfg.Token`) survive regen alongside the existing decl-set and
body-drift cases. Adds a cross-spec guard via spec_sha256 so a
different-spec regen falls back to NOVEL-only preservation. Closes #907.

Key safety properties:
- Symlink-refusal happens before the destructive rename
- Snapshot-orphan detection: existing .preserve-* siblings cause an
  early error so a crashed prior run can't be silently overwritten
- Post-merge `go mod tidy` refreshes go.sum when the merge added
  preserved requires
- Errors include the snapshot path and recovery command

Includes companion plan and a docs/solutions/ design-patterns entry
capturing the architectural patterns for future regen-shaped problems.

The PR #897 preserve helpers in root.go are intentionally retained as
dead code for one polish-cycle of soak; their mechanical deletion is a
separate follow-up PR.

* fix(cli): remove superseded --force preserve helpers

The in-memory preserve+restore helpers from PR #897
(preserveHandAuthoredInternalCLIFiles, preserveHandAuthoredInternalSiblingDirs,
restorePreservedFiles, restorePreservedDirs, the preservedFile/preservedDir
structs, generatorOwnedInternalDirs map, generatorOwnsInternalDir,
dirContainsGeneratedMarker, cleanupPreservedDirs, movePreservedDir,
copyPreservedDir) were superseded by the snapshot+merge architecture in the
prior commit and are now unreachable.

Also drops TestMovePreservedDirFallsBackWhenRenameCrossesDevices (only
exercised the deleted movePreservedDir helper). The new snapshot rename is
same-FS, so EXDEV does not apply at that step; file-level merge copies use
writeFileAtomic which is EXDEV-safe.

Plan called for deferring this deletion to a separate soak PR, but
golangci-lint blocks the push on unused symbols, forcing the cleanup into
this PR.

* fix(cli): wire force snapshot merge into --docs and --plan paths

The --docs and --plan codepaths in newGenerateCmd discarded snapshotDir
from resolveGenerateOutputDir, so --force on either path created a
.preserve-* sibling but never merged it back. Hand-edits silently lost,
orphan blocks all subsequent --force runs on that directory.

Extracts a shared finalizeForceMerge helper that all three codepaths now
call to classify, merge, optionally re-tidy go.mod, and remove the
snapshot. Drops the validate-conditional gate around retidyAfterMerge —
when go.mod was merged with preserved requires, go.sum needs to keep up
regardless of whether validation runs, otherwise the next go build/test
fails with missing-checksum errors. retidyAfterMerge already prints a
warning on failure rather than hard-failing.

For --plan, plan-driven generation does not write a manifest with
SpecChecksum, so the cross-spec guard naturally lands on the defensive
full-merge path; pass nil currentSpecBytes.

Addresses Greptile review on PR #967.

* fix(cli): tighten cross-spec guard for nil current spec bytes

forceRegenSpecHashMatches short-circuited to true (full merge) whenever
currentSpecBytes was nil, even when the snapshot manifest held a
non-empty SpecChecksum. That bypassed the cross-spec guard for
--plan --force runs over a spec-generated tree (and any other path
where the caller has no spec bytes to hash).

Concrete failure: previously --spec-generated CLI with hand edits, then
--plan --force, would full-merge the stale spec-based templated content
into the plan tree and likely fail go build. With the fix, snapshot has
checksum but current bytes are nil → fall back to NOVEL-only
preservation, which is the safe lineage-unknown choice.

Addresses Greptile P1 on PR #967.

Files touched

Diff

commit 618fa45627609eff27a103bfc91ee28783e128a6
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 15:23:20 2026 -0700

    fix(cli): preserve hand-edits to templated files on --force regen (#967)
    
    * fix(cli): preserve hand-edits to templated files on --force regen
    
    Wires the existing regenmerge AST-aware classifier into `printing-press
    generate --force`, replacing the preserve-files-in-memory dance from
    PR #897 with a snapshot+merge architecture: rename absOut to a sibling
    preserve dir, generate fresh into absOut, then classify and merge
    preserved files back. Adds a TEMPLATED-VALUE-DRIFT verdict so literal
    drift (`"Bearer "` -> `"Token "`) and identifier drift (`cfg.Bearer` ->
    `cfg.Token`) survive regen alongside the existing decl-set and
    body-drift cases. Adds a cross-spec guard via spec_sha256 so a
    different-spec regen falls back to NOVEL-only preservation. Closes #907.
    
    Key safety properties:
    - Symlink-refusal happens before the destructive rename
    - Snapshot-orphan detection: existing .preserve-* siblings cause an
      early error so a crashed prior run can't be silently overwritten
    - Post-merge `go mod tidy` refreshes go.sum when the merge added
      preserved requires
    - Errors include the snapshot path and recovery command
    
    Includes companion plan and a docs/solutions/ design-patterns entry
    capturing the architectural patterns for future regen-shaped problems.
    
    The PR #897 preserve helpers in root.go are intentionally retained as
    dead code for one polish-cycle of soak; their mechanical deletion is a
    separate follow-up PR.
    
    * fix(cli): remove superseded --force preserve helpers
    
    The in-memory preserve+restore helpers from PR #897
    (preserveHandAuthoredInternalCLIFiles, preserveHandAuthoredInternalSiblingDirs,
    restorePreservedFiles, restorePreservedDirs, the preservedFile/preservedDir
    structs, generatorOwnedInternalDirs map, generatorOwnsInternalDir,
    dirContainsGeneratedMarker, cleanupPreservedDirs, movePreservedDir,
    copyPreservedDir) were superseded by the snapshot+merge architecture in the
    prior commit and are now unreachable.
    
    Also drops TestMovePreservedDirFallsBackWhenRenameCrossesDevices (only
    exercised the deleted movePreservedDir helper). The new snapshot rename is
    same-FS, so EXDEV does not apply at that step; file-level merge copies use
    writeFileAtomic which is EXDEV-safe.
    
    Plan called for deferring this deletion to a separate soak PR, but
    golangci-lint blocks the push on unused symbols, forcing the cleanup into
    this PR.
    
    * fix(cli): wire force snapshot merge into --docs and --plan paths
    
    The --docs and --plan codepaths in newGenerateCmd discarded snapshotDir
    from resolveGenerateOutputDir, so --force on either path created a
    .preserve-* sibling but never merged it back. Hand-edits silently lost,
    orphan blocks all subsequent --force runs on that directory.
    
    Extracts a shared finalizeForceMerge helper that all three codepaths now
    call to classify, merge, optionally re-tidy go.mod, and remove the
    snapshot. Drops the validate-conditional gate around retidyAfterMerge —
    when go.mod was merged with preserved requires, go.sum needs to keep up
    regardless of whether validation runs, otherwise the next go build/test
    fails with missing-checksum errors. retidyAfterMerge already prints a
    warning on failure rather than hard-failing.
    
    For --plan, plan-driven generation does not write a manifest with
    SpecChecksum, so the cross-spec guard naturally lands on the defensive
    full-merge path; pass nil currentSpecBytes.
    
    Addresses Greptile review on PR #967.
    
    * fix(cli): tighten cross-spec guard for nil current spec bytes
    
    forceRegenSpecHashMatches short-circuited to true (full merge) whenever
    currentSpecBytes was nil, even when the snapshot manifest held a
    non-empty SpecChecksum. That bypassed the cross-spec guard for
    --plan --force runs over a spec-generated tree (and any other path
    where the caller has no spec bytes to hash).
    
    Concrete failure: previously --spec-generated CLI with hand edits, then
    --plan --force, would full-merge the stale spec-based templated content
    into the plan tree and likely fail go build. With the fix, snapshot has
    checksum but current bytes are nil → fall back to NOVEL-only
    preservation, which is the safe lineage-unknown choice.
    
    Addresses Greptile P1 on PR #967.
---
 docs/GLOSSARY.md                                   |   2 +-
 ...ce-regen-preserves-templated-hand-edits-plan.md | 558 +++++++++++++++++++++
 ...th-ast-classifier-for-force-regen-2026-05-10.md | 156 ++++++
 internal/cli/claim_integration_test.go             |  26 +-
 internal/cli/generate_test.go                      | 239 +++++++--
 internal/cli/regen_merge.go                        |   7 +
 internal/cli/root.go                               | 504 +++++++++++--------
 internal/pipeline/contracts_test.go                |   2 +-
 internal/pipeline/regenmerge/apply.go              |  11 +
 internal/pipeline/regenmerge/classify.go           |   8 +
 internal/pipeline/regenmerge/merge_into_fresh.go   | 228 +++++++++
 .../pipeline/regenmerge/merge_into_fresh_test.go   | 270 ++++++++++
 internal/pipeline/regenmerge/regenmerge.go         |  42 +-
 internal/pipeline/regenmerge/registrations.go      |   2 +-
 internal/pipeline/regenmerge/value_drift.go        | 221 ++++++++
 internal/pipeline/regenmerge/value_drift_test.go   | 332 ++++++++++++
 16 files changed, 2347 insertions(+), 261 deletions(-)

diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 8b481239..a60948cc 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -37,7 +37,7 @@ Default disambiguation conventions:
 | **side-effect command convention** | Two-part rule for hand-written novel commands that perform visible actions (open browser tabs, send notifications, dial out to OS handlers). (1) Print by default; require explicit opt-in (`--launch`, `--send`, `--play`) to actually act. (2) Short-circuit when `cliutil.IsVerifyEnv()` is true — the verifier sets `PRINTING_PRESS_VERIFY=1` in every mock-mode subprocess, and the env-var check is the floor that catches any command the verifier's heuristic side-effect classifier misses. Documented in `skills/printing-press/SKILL.md` Phase 3 (principle 9). |
 | **canonicalargs** | Tiny generator subpackage at `internal/canonicalargs/` exporting `Lookup(name) (string, bool)` for cross-domain positional placeholder names (`since`, `until`, `tag`, `vertical`). Both verify mock-mode dispatch and the SKILL template consult this registry as one step in the lookup chain `spec.Param.Default → canonicalargs → legacy syntheticArgValue switch → "mock-value"`. **Domain-specific names belong in the spec author's `Param.Default`, not here** — anti-pattern: "Never change the machine for one CLI's edge case." |
 | **mcp-sync** | Subcommand on the printing-press binary (`printing-press mcp-sync <cli-dir>`) that migrates generated MCP surfaces from the old static novel-feature list to the runtime Cobra-tree mirror. It rewrites generated MCP files, adds the root command export when possible, regenerates `tools-manifest.json`, and refuses hand-edited `internal/mcp/tools.go` unless `--force` is passed. |
-| **regen-merge** | Subcommand on the printing-press binary (`printing-press regen-merge <cli-dir> --fresh <fresh-dir>`) that classifies each Go file in a published library CLI by AST decl-set comparison against a fresh-generated tree, applies safe templated overwrites, restores lost AddCommand registrations in `root.go` and resource-parents, and merges `go.mod` while preserving the published monorepo module path. Targets the per-CLI sweep workflow (~5-15 min/CLI vs ~30-90 min manual). Six classification verdicts; stage-and-swap-with-recovery transactional apply; macOS+Linux only. Lives in `internal/pipeline/regenmerge/`. |
+| **regen-merge** | Subcommand on the printing-press binary (`printing-press regen-merge <cli-dir> --fresh <fresh-dir>`) that classifies each Go file in a published library CLI by AST decl-set comparison against a fresh-generated tree, applies safe templated overwrites, restores lost AddCommand registrations in `root.go` and resource-parents, and merges `go.mod` while preserving the published monorepo module path. Targets the per-CLI sweep workflow (~5-15 min/CLI vs ~30-90 min manual). Eight classification verdicts; stage-and-swap-with-recovery transactional apply; macOS+Linux only. Lives in `internal/pipeline/regenmerge/`. |
 | **shipcheck** | The verification block that gates publishing: dogfood + verify + workflow-verify + verify-skill + validate-narrative + scorecard, run together. Dogfood includes `mcp_surface_parity`, and validate-narrative checks README/SKILL narrative commands against the built CLI. All legs must pass before a printed CLI ships. |
 | **scorecard** / **scoring** | Two-tier quality assessment with a 50/50 weighted composite. Tier 1: infrastructure (16 string-matching dimensions, raw max 160, normalized to 0-50). Tier 2: domain correctness (7 semantic dimensions, raw max 60 when live verify ran, normalized to 0-50). Total /100 with letter grades. Source of truth: `internal/pipeline/scorecard.go` (`tier1Max` / `tier2Max`). Subcommand: `printing-press scorecard`. |
 | **machine-owned freshness** | Opt-in freshness contract for store-backed printed CLIs using `cache.enabled`. Covered command paths map to syncable resources; in `--data-source auto` they may run a bounded pre-read refresh before serving local data. `--data-source local` never refreshes, `--data-source live` must not mutate the local store, and env opt-out only disables the freshness hook. This is current-cache freshness, not a guarantee of full historical backfill or API-specific enrichment. |
diff --git a/docs/plans/2026-05-10-001-fix-force-regen-preserves-templated-hand-edits-plan.md b/docs/plans/2026-05-10-001-fix-force-regen-preserves-templated-hand-edits-plan.md
new file mode 100644
index 00000000..0e690911
--- /dev/null
+++ b/docs/plans/2026-05-10-001-fix-force-regen-preserves-templated-hand-edits-plan.md
@@ -0,0 +1,558 @@
+---
+title: 'fix: --force regen preserves hand-edits to templated files and parent-command AddCommands'
+type: fix
+status: completed
+date: 2026-05-10
+---
+
+# fix: --force regen preserves hand-edits to templated files and parent-command AddCommands
+
+## Summary
+
+Wire the existing AST-aware `internal/pipeline/regenmerge` classifier into the `printing-press generate --force` regen flow so hand-edits to templated files (`internal/client/*.go`, `internal/config/*.go`, `internal/cli/<resource>.go`) survive regeneration. Add a literal-value-drift verdict to `regenmerge` so const/var literal hand-edits (the canonical `"Bearer "`→`"Token "` case) are preserved instead of silently overwritten. Reuse `regenmerge`'s `LostRegistrations` machinery to re-inject `cmd.AddCommand(...)` lines that agents add to parent-command files.
+
+---
+
+## Problem Frame
+
+Issue [#907](https://github.com/mvanhorn/cli-printing-press/issues/907) (P1, filed by Monarch Money retro): `printing-press generate --force` clobbers hand-edits to four file shapes that have the `Generated by CLI Printing Press` marker:
+
+1. `internal/client/queries.go` — agent replaced placeholder GraphQL with 27 hand-authored operations.
+2. `internal/client/graphql.go` — agent set `const graphqlEndpointPath = "/graphql"` (was empty).
+3. `internal/config/config.go` — agent changed `"Bearer "` to `"Token "` to match the auth prefix.
+4. `internal/cli/transactions.go` — agent added `cmd.AddCommand(newXxxNovelCmd(flags))` for novel features.
+
+PR #897 (commit `dceb6e58`, just landed) preserves files **without** the marker (novel hand-written files) and fully-novel sibling packages, but anything carrying the marker is still wiped. Polish triggers regen on every CLI, so this hits every published CLI with hand-edits — making it a P1 against the polish workflow.
+
+The fix is a machine change: per AGENTS.md "machine vs printed CLI", every printed CLI benefits from getting `--force` right once.
+
+---
+
+## Requirements
+
+- R1. Hand-edited generator-emitted files in `internal/client/`, `internal/config/`, and `internal/cli/` survive `printing-press generate --force` when they have decl-set additions, body-call-target drift, or any per-decl text drift (including literal-value or identifier-rename drift) relative to the fresh emission.
+- R2. Hand-added `cmd.AddCommand(...)` lines in parent-command files (`internal/cli/<resource>.go`) and in `internal/cli/root.go` survive `--force` via AST-based re-injection into the freshly emitted file.
+- R3. Untouched generator-emitted files are still regenerated when the underlying template legitimately changes (no glob freezes the world).
+- R4. Existing PR #897 contract is preserved: novel `internal/cli/*.go` files (no marker), novel sibling packages, and symlink refusals all continue to work.
+- R5. Failure mid-regen leaves recoverable state — the snapshot directory is the recovery path; cleanup happens only after merge succeeds. Symlink-refusal exits BEFORE any destructive mutation.
+- R6. The polish skill needs no changes; it picks up the new contract by re-running `generate --force` (or whatever it invokes today).
+- R7. Cross-spec regen (running `--force` with a different spec than the prior generation) does not trigger heuristic preservation — the snapshot's spec hash is compared to the new spec's hash; on mismatch the merge falls back to PR #897 behavior (preserve only NOVEL files, no AST-based decl-set or value-drift heuristics).
+- R8. Non-Go user-edited files (e.g. `README.md`, `Makefile`, `.printing-press.json`) under non-generator-owned directories survive `--force` (the PR #897 sibling-package contract — keep parity).
+- R9. Hand-added `require`/`replace` directives in `go.mod` for novel-package dependencies (e.g. `modernc.org/sqlite` added to back a hand-written SQLite store) survive `--force` via the existing `renderMergedGoMod` path that the publish-library `regen-merge` flow already uses.
+
+---
+
+## Scope Boundaries
+
+- Polish skill (`skills/printing-press-polish/`) is not modified.
+- Generator templates (`internal/generator/templates/*.tmpl`) are not modified.
+- Generator output for fresh first-run generation is unchanged — golden fixtures under `testdata/golden/` should not move.
+- No new spec extensions, catalog fields, or manifest fields.
+- No changes to `printing-press regen-merge` subcommand UX (`internal/cli/regen_merge.go`); the literal-drift verdict shows up in its report automatically because both surfaces share the classifier.
+- No backfill regen of the public library; downstream consumers will pick up the fixed semantics on their next regen.
+
+### Deferred to Follow-Up Work
+
+- **Mechanical deletion of PR #897 helpers** (`preserveHandAuthoredInternalCLIFiles`, `preserveHandAuthoredInternalSiblingDirs`, `restorePreservedFiles`, `restorePreservedDirs`, `preservedFile`, `preservedDir`, `generatorOwnedInternalDirs`, `generatorOwnsInternalDir`, `dirContainsGeneratedMarker`, `cleanupPreservedDirs`, `movePreservedDir`, `copyPreservedDir`). Land in a follow-up PR after one polish-cycle of soak validates the snapshot/merge flow.
+- **`--strict` / `--no-merge` opt-out flag** for `--force` if drift false positives become noisy in practice. No evidence yet.
+- **Cross-spec preservation policy beyond NOVEL-only.** When spec hashes differ, today's plan falls back to PR #897's NOVEL-only behavior. A future iteration could allow per-decl matching across renames (e.g., `getFoo` → `getFooV2` when decl-set diff is small) but that's a research project, not a fix.
+- **Capturing the new institutional knowledge**: AST decl-set classification, sibling-tempdir snapshot pattern, AddCommand re-injection, cross-spec guard. After this lands, write a `docs/solutions/` entry per the learnings researcher's recommendation.
+- **Cross-repo coordination test against `printing-press-library` mirror regen** — verify the change against ≥3 published CLIs before merging the PR (manual smoke; not blocked by this plan, but called out in the rollout notes).
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/cli/root.go:claimOrForce` (line 769) — current `--force` orchestration. Calls `preserveHandAuthoredInternalCLIFiles` (line 834), `preserveHandAuthoredInternalSiblingDirs` (line 887), `RemoveAll` (line 780), then restores. This is the splice point.
+- `internal/pipeline/regenmerge/regenmerge.go` — Verdict enum (TEMPLATED-CLEAN, TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT, NOVEL, NEW-TEMPLATE-EMISSION, PUBLISHED-ONLY-TEMPLATED, NOVEL-COLLISION) and `MergeReport` shape. Add the new TEMPLATED-VALUE-DRIFT verdict here.
+- `internal/pipeline/regenmerge/classify.go:decideBothPresent` (line 225) — file classification decision tree. Insert literal-drift detection after the existing body-drift check.
+- `internal/pipeline/regenmerge/body_drift.go:detectBodyDrift` (line 15) — call-target identifier comparison; the literal-drift detector is a peer to this.
+- `internal/pipeline/regenmerge/registrations.go:extractLostRegistrations` (line 38) — extracts published `AddCommand` calls missing from fresh, with referent-existence filtering. Reuse as-is.
+- `internal/pipeline/regenmerge/apply.go:injectAddCommands` (line 256) — `dave/dst` AST rewriter that inserts AddCommand calls before the function's trailing return. Reuse.
+- `internal/pipeline/regenmerge/apply.go:Apply` (line 42) — stage-and-swap-with-recovery template for the new in-place merge helper. Borrow staging-as-sibling-tempdir, EXDEV-safe move, and per-verdict file mutation. We do NOT need the full bak-recovery rename because the snapshot is the recovery path.
+- `internal/cli/generate_test.go:TestGenerateCmdForcePreservesHandAuthoredInternalCLI` (line 21) — pattern for force-preservation tests; new tests follow this shape.
+- `internal/cli/generate_test.go:TestMovePreservedDirFallsBackWhenRenameCrossesDevices` (line 268) — EXDEV-fallback test; the new snapshot move uses the same pattern.
+- `internal/generatedmarker/marker.go:HasInFile` — first-5-lines marker check. Used unchanged by classify.go.
+- `internal/pipeline/regenmerge/helpers.go:writeFileAtomic` — atomic file write via tmp+rename. Use for any new file writes.
+
+### Institutional Learnings
+
+- **`docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md`** (high severity) — direct precedent: existing on-disk hand-edited content wins over template emission; prefer noisy preservation with a warning over silent overwrite. Apply this principle to the literal-drift case where the verdict is genuinely ambiguous.
+- **`docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md`** (medium) — `regenmerge` is shared by CLI, mcpsync, tests, and now `--force`. New required inputs warn-and-fallback; do not hard-fail.
+- **`docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md`** (medium) — running a local-mirror regen against `printing-press-library` before merge is the cheapest way to catch downstream breakage. Logged in operational notes.
+- **`docs/plans/2026-05-01-001-feat-regen-merge-subcommand-plan.md`** — the original `regenmerge` plan; this plan extends rather than reinvents.
+- **AGENTS.md (`Code & Comment Hygiene`, `Testing`, `Generator Output Stability`)** — no comments restating field/function names; categorical strings → typed const at introduction; run `scripts/golden.sh verify` if generation output could move (it shouldn't here, but verify).
+
+### External References
+
+None needed — fully solvable in-codebase from existing `regenmerge` patterns and Go AST utilities.
+
+---
+
+## Key Technical Decisions
+
+- **Use `regenmerge` for `--force` instead of writing a parallel preserver.** `regenmerge` is well-tested, AST-aware, and already handles the four core verdicts plus AddCommand re-injection. Wiring it in is the smallest change that solves R1, R2, and R4 simultaneously and keeps a single source of truth for "what counts as a hand edit." Considered alternative — record a content hash in the marker / sidecar and use it for "safe to overwrite" detection — see Alternative Approaches below.
+- **Add the literal/identifier-drift verdict in `regenmerge` itself, not only in the `--force` path (Option A from the brief).** The `regen-merge` subcommand has the same gap. Putting the fix in the classifier benefits both surfaces and keeps verdict semantics co-located.
+- **Detection mechanism: render each top-level decl via `go/printer` (Doc-stripped) and compare canonical text per-decl; differ → TEMPLATED-VALUE-DRIFT.** Per-decl `go/printer` output is canonical (whitespace/comment-stable) so cosmetic diffs don't trigger; any semantic difference does. This catches:
+  - Basic-literal value changes (`"Bearer "` → `"Token "`).
+  - Identifier renames in selector position (`cfg.Bearer` → `cfg.Token`) — adversarial review caught this gap in a BasicLit-only walker.
+  - Type-conversion drift (`MyType(x)` → `MyOtherType(x)`).
+  - Composite-literal field key/value drift.
+  - Any other AST shape difference inside the decl that `go/printer` would render differently.
+  We exclude the marker comment / Copyright header by walking each decl's body and stripping the decl's `Doc` field before printing. Verdict name reflects the broader scope: `TEMPLATED-VALUE-DRIFT` covers both literal and identifier drift inside templated decls.
+- **Cross-spec guard: compare snapshot's spec hash to the new spec's hash; on mismatch, fall back to PR #897 NOVEL-only preservation.** A `--force` run against a different spec is a legitimate user workflow (spec evolution, fixed schema). The classifier's heuristics are not valid across specs — a renamed endpoint would TEMPLATED-WITH-ADDITIONS-preserve the stale file and clobber the fresh emission, producing a fused old+new CLI. The guard reads `<snapshot>/.printing-press.json` (the manifest) for the spec source-of-truth, computes a sha256 over the post-redaction fresh spec bytes, and compares. Match → full AST merge. Mismatch → preserve only NOVEL files (no marker) and novel sibling packages (PR #897 contract); skip TEMPLATED-WITH-ADDITIONS / BODY-DRIFT / VALUE-DRIFT preservation and skip AddCommand re-injection. Cross-spec preservation can be improved in a follow-up; the guard's role here is protecting against silent data corruption.
+- **Snapshot strategy for `--force`: rename existing absOut to a sibling tempdir before generation, then run merge after generation completes.** This mirrors `regenmerge.Apply`'s staging pattern (`<base>.regen-merge-<ts>/`), gets same-FS rename atomicity for free, and gives us a recovery path (the snapshot dir) if anything fails mid-merge. We avoid `os.MkdirTemp("", ...)` because it can land on a different filesystem (the EXDEV path PR #897 had to add to dodge this).
+- **Symlink-refusal happens BEFORE the destructive rename, not after.** lstat absOut, absOut/internal, absOut/internal/cli BEFORE renaming to snapshot. If any is a symlink, return error without mutating. PR #897's contract was "fail before mutating"; the snapshot/merge approach must preserve that — refusing after rename would leave the user's tree at `<absOut>.preserve-<ts>` with no `<absOut>`, a strictly worse outcome.
+- **In-place merge helper instead of full stage-and-swap.** After `Generate()` runs, absOut already contains the fresh tree. The new helper `regenmerge.MergeIntoFreshTree(snapshotDir, freshDir, report, opts)` walks the report and copies preserve-worthy files from snapshot → fresh, plus injects lost AddCommands and merges go.mod requires/replaces (R9). Simpler than a second stage-and-swap, and the verdict-handling switch in `Apply` and the verdict-handling switch in `MergeIntoFreshTree` are kept disjoint-but-explicit so a future verdict addition fails loudly rather than silently no-op'ing in one path.
+- **Non-Go file preservation: a sweep step inside `MergeIntoFreshTree` copies any file present in snapshot but absent from fresh AND not under a generator-owned directory.** `regenmerge.shouldClassifyFile` (helpers.go) covers `.go`, `go.mod`, `go.sum`, `spec.yaml`, `spec.json` only. Non-classified files (README.md, Makefile, .printing-press.json, hand-written shell scripts) need preservation parity with PR #897's directory-level sweep. Implementation: walk snapshot once, for any file path that does NOT exist in freshDir AND is not under `internal/cli/` or any directory in `generatorOwnedInternalDirs`, copy snapshot → fresh. Symlinks refused. This is the explicit fallback for everything Classify ignores.
+- **`go.mod` merge: reuse `regenmerge`'s existing `renderMergedGoMod` from `apply.go` flow.** Plan calls into the same merge code Apply uses for the publish-library workflow. Snapshot's hand-added `require`/`replace` lines survive (R9). Without this, a novel command backed by `modernc.org/sqlite` (or any other hand-added dep) would lose the dep and stop compiling on regen — exactly the failure shape the existing `regen-merge` already protects against.
+- **`claimOrForce` returns the snapshot path; `runE` does the merge after `runGenerateProject` succeeds.** This keeps the call sites readable and makes the lifecycle explicit. The snapshot path is captured at `claimOrForce` time and threaded through to `runE` — never recomputed, in case the spec-derived rename later moves absOut to a different basename.
+- **Sequencing inside RunE: merge BEFORE the spec-derived rename (`!explicitOutput && currentBase != derivedDir`).** Snapshot is keyed off the original absOut basename; running merge first means the snapshot and freshDir paths stay consistent. After merge succeeds, the spec-derived rename moves the merged tree, then snapshot cleanup runs at its captured original-parent path. Tested explicitly: spec title changes between regens → merge fires correctly, rename moves merged tree, snapshot cleaned up.
+- **No new flags.** `--force` semantics are clarified, not changed. We don't add `--no-merge` or `--strict` flags; the conservative "preserve on any drift" behavior is right by default. If a user truly wants old `--force` behavior (nuke everything), they can `rm -rf` first.
+- **Defer deletion of PR #897 helpers to a follow-up PR.** PR #897 just landed at commit `dceb6e58`. Ripping out 9 helpers (preserveHandAuthoredInternalCLIFiles, preserveHandAuthoredInternalSiblingDirs, restorePreservedFiles, restorePreservedDirs, preservedFile, preservedDir, generatorOwnedInternalDirs, generatorOwnsInternalDir, dirContainsGeneratedMarker, cleanupPreservedDirs, movePreservedDir, copyPreservedDir) in the same PR as the new merge flow makes rollback hard if the merge has unknown bugs. Strategy: in this PR, `claimOrForce` switches to snapshot/merge; the old helpers are no-longer-called dead code. After one polish-cycle's worth of soak, a small follow-up PR removes the dead helpers. The risk table flags the soak signal.
+- **Commit type is `fix(cli):`** — corrects incorrect behavior in shipping code. Not breaking; there's no documented contract that promised `--force` would clobber templated hand-edits.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should literal/identifier-drift detection live in `regenmerge` or only in the `--force` path?** → In `regenmerge`. Both surfaces have the gap; consolidating semantics avoids divergence.
+- **AST-literal compare vs. gofmt-byte-compare vs. per-decl `go/printer` compare?** → Per-decl `go/printer` compare. BasicLit-only walking misses identifier renames in selector position (caught by adversarial review). `go/printer` per-decl gives canonical, comment-stable text; any semantic AST diff lights up.
+- **Reuse `Apply` directly (with stage-and-swap) or write a new in-place helper?** → New in-place helper `MergeIntoFreshTree`. Snapshot dir already provides recovery; double-staging is wasted I/O. The helper handles a disjoint verdict set from `Apply` — explicit verdict switches in both functions, with a default-case error so a future verdict addition fails loudly in either path rather than silently no-op'ing.
+- **Where does the snapshot live?** → Sibling tempdir under the absOut parent: `<absOut>.preserve-<unix-ts>/`. Mirrors regenmerge's existing staging convention; same FS guarantees cheap rename.
+- **Should `--force` against a different spec preserve hand-edits?** → No (cross-spec guard, R7). Preserve only NOVEL files when spec hashes differ. Heuristic decl-set / value-drift checks are not valid across specs and would silently fuse old + new endpoints.
+- **Should `MergeIntoFreshTree` also walk non-classified files (README.md, Makefile, etc.)?** → Yes (R8). `regenmerge.shouldClassifyFile` only sees Go and module files; everything else needs an explicit "snapshot-only file" sweep step.
+- **Should `MergeIntoFreshTree` merge `go.mod` requires/replaces?** → Yes (R9). Reuse `renderMergedGoMod` from `regenmerge/gomod.go` (the same code Apply uses) so hand-added requires for novel-package deps survive.
+- **When should the deletion of PR #897 helpers happen?** → Follow-up PR. Land snapshot/merge first; soak across one polish cycle; then mechanical deletion of dead helpers. Reduces blast radius if the new flow has unknown bugs.
+- **Does this require golden updates?** → Probably no, because golden fixtures exercise fresh first-run generation, not regen-over-existing. Verify by running `scripts/golden.sh verify` after implementing; investigate any movement before updating.
+
+### Deferred to Implementation
+
+- Exact internal call shape for `renderMergedGoMod` reuse — confirm whether it needs to be exported or can stay package-private with a wrapper. Settle in U2.
+- Whether `MergeIntoFreshTree` should accept a pre-built `MergeReport` (caller calls Classify first) or run Classify itself. Likely accept a report so callers can log/decide, but confirm during implementation.
+- Whether to surface the merge report in `--force`'s stderr summary (analogous to `regen-merge`'s human report). Probably yes, gated on `--json` mode for parsability. Decide during U4.
+- Spec-hash key choice: bytes of the post-redaction `spec.yaml` archive vs. a stored hash in `.printing-press.json` written at generate time. Hash-stored-in-manifest is preferable (cheaper to compare, robust to canonicalization changes), but requires writing the hash at generate time too. Settle in U4 — use the manifest hash if it already exists; add the field if it doesn't.
+
+---
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```mermaid
+sequenceDiagram
+    participant CLI as generate RunE
+    participant CF as claimOrForce
+    participant FS as filesystem
+    participant Gen as gen.Generate()
+    participant RM as regenmerge
+
+    CLI->>CF: claimOrForce(absOut, force=true, explicit)
+    alt absOut empty/missing
+        CF->>FS: MkdirAll(absOut)
+        CF-->>CLI: absOut, snapshot=""
+    else absOut exists with content
+        CF->>FS: rename absOut → <absOut>.preserve-<ts> (snapshot)
+        CF->>FS: MkdirAll(absOut)
+        CF-->>CLI: absOut, snapshot=<absOut>.preserve-<ts>
+    end
+
+    CLI->>Gen: Generate() → writes templates into absOut
+    Gen-->>CLI: ok
+
+    alt snapshot != ""
+        CLI->>RM: Classify(snapshot, absOut)
+        RM-->>CLI: MergeReport (verdicts + LostRegistrations)
+        CLI->>RM: MergeIntoFreshTree(snapshot, absOut, report)
+        Note over RM: For each preserve verdict: copy snapshot/path → absOut/path<br/>For each LostRegistration: injectAddCommands(absOut/host, calls)
+        RM-->>CLI: ok
+        CLI->>FS: RemoveAll(snapshot)
+    end
+```
+
+**Verdict decision flow inside `decideBothPresent` (extension to existing classifier):**
+
+```text
+inputs: pubDecls, freshDecls, pubMarker, freshMarker
+
+if templated (marker on either side OR pubDecls ⊊ freshDecls):
+    if pubExtras non-empty AND not all pub-extras moved into fresh's global decl set:
+        -> TEMPLATED-WITH-ADDITIONS                                  (existing)
+    else if detectBodyDrift returns non-nil:
+        -> TEMPLATED-BODY-DRIFT                                      (existing)
+    else if detectValueDrift returns non-nil:           ← NEW STEP
+        -> TEMPLATED-VALUE-DRIFT                                     (NEW)
+    else:
+        -> TEMPLATED-CLEAN                                           (existing)
+```
+
+`detectValueDrift` walks each top-level decl in both files and compares basic-literal node values (and identifier names where they affect semantics, e.g., RHS of a const/var assignment). Returns a `ValueDrift` shape similar to `BodyDrift` listing per-decl literal differences for the human report.
+
+---
+
+## Implementation Units
+
+### U1. Add `TEMPLATED-VALUE-DRIFT` verdict and per-decl `go/printer` drift detector in `regenmerge`
+
+**Goal:** Extend `regenmerge`'s classifier so files whose decl-set matches and body-drift doesn't fire but whose top-level decls render to different `go/printer` text classify as `TEMPLATED-VALUE-DRIFT` (preserve published). Catches both literal-value drift and identifier-rename drift.
+
+**Requirements:** R1, R3.
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/regenmerge/regenmerge.go` (add `VerdictTemplatedValueDrift` constant, doc comment, and `ValueDrift` struct on `FileClassification`)
+- Create: `internal/pipeline/regenmerge/value_drift.go` (peer of `body_drift.go` — `detectValueDrift(pubPath, freshPath) *ValueDrift`)
+- Modify: `internal/pipeline/regenmerge/classify.go` (`decideBothPresent` — call `detectValueDrift` after the `detectBodyDrift` check)
+- Modify: `internal/cli/regen_merge.go` (`printHumanRegenReport` — add the new verdict to the verdicts summary table at lines ~110-117 and to the `files needing human review` switch at lines ~127-130)
+- Test: `internal/pipeline/regenmerge/value_drift_test.go`
+- Test: `internal/pipeline/regenmerge/classify_test.go` (extend with TEMPLATED-VALUE-DRIFT scenarios)
+
+**Approach:**
+- Define `VerdictTemplatedValueDrift Verdict = "TEMPLATED-VALUE-DRIFT"`. Apply treats it the same as `VerdictTemplatedWithAdditions` and `VerdictTemplatedBodyDrift` (preserve published) — see U2.
+- `detectValueDrift` parses both files, then for each top-level decl (`*ast.FuncDecl`, `*ast.GenDecl`):
+  1. Strip the decl's `Doc` field (so comment-only diffs don't trip drift).
+  2. Render the decl via `go/printer.Fprint` to a string buffer.
+  3. Key by canonical decl name (`canonicalFuncName` for funcs, decl-name for gen-decls).
+- Compare per-decl rendered text between pub and fresh. Any decl whose rendered text differs (or exists in only one side, but that case is already caught by decl-set comparison upstream so this code path won't see it) is reported.
+- `ValueDrift` shape:
+  ```text
+  type ValueDrift struct {
+      Decls map[string]ValueDriftDelta `json:"decls,omitempty"`
+  }
+  type ValueDriftDelta struct {
+      Published string `json:"published"`        // shortened pub render
+      Fresh     string `json:"fresh"`            // shortened fresh render
+      // Truncate each side at ~120 chars; full text is reconstructable from snapshot/fresh files.
+  }
+  ```
+- Conservative on parse error: return `nil` (existing classifier branches preserve published already on parse failure).
+- The detector covers basic-literal value drift, identifier-rename drift in any position (selector, type conversion, type assertion, composite-literal field), and any other AST shape difference inside a decl. Body-drift is still checked first (and only catches call-target identifier diffs); value-drift is the catch-all afterward.
+
+**Patterns to follow:**
+- Mirror `body_drift.go`'s shape: standalone parser-based walker, returns nil on no drift, returns concrete shape on drift, skips on parse error.
+- Use `canonicalFuncName` from `classify.go` so per-decl keys collide consistently with the rest of the package.
+
+**Test scenarios:**
+- Happy path: pub `const x = "Bearer "`, fresh `const x = "Token "` → returns drift with one delta. Covers the issue's case 3.
+- Happy path: pub `const x = ""`, fresh `const x = "/graphql"` → returns drift. Covers the issue's case 2.
+- Happy path: pub `cfg.Bearer = ...`, fresh `cfg.Token = ...` (selector identifier rename inside a function body that doesn't change call-targets) → returns drift. Closes the BasicLit-only-walker gap caught in adversarial review.
+- Happy path: pub `MyType(x)`, fresh `MyOtherType(x)` (type conversion identifier change) → returns drift.
+- Edge case: identical files → returns `nil`.
+- Edge case: comment-only difference between pub and fresh (`// comment A` vs `// comment B`, same code) → returns `nil` because `Doc` fields are stripped.
+- Edge case: whitespace-only difference between pub and fresh → returns `nil` because `go/printer` canonicalizes whitespace.
+- Edge case: pub has decl with two literals, fresh's same decl has the literals reordered (e.g., `[]string{"a","b"}` vs `[]string{"b","a"}`) → returns drift (positional, not set, comparison).
+- Edge case: pub or fresh fails to parse → returns `nil` (defer to other branches).
+- Integration: `decideBothPresent` returns `VerdictTemplatedValueDrift` when both markers present, decl-sets equal, body-drift nil, and `detectValueDrift` non-nil.
+- Integration: `decideBothPresent` returns `VerdictTemplatedClean` when decl-sets equal, body-drift nil, value-drift nil — proves the negative criterion R3 at classifier level.
+
+**Verification:**
+- `go test ./internal/pipeline/regenmerge/...` passes including new test scenarios.
+- `go vet ./...`, `gofmt -w` clean on all touched files.
+- Existing classifier tests (especially `TestClassifyEbayAuthFixture`, `TestClassifyPostmanExploreFixture`) still pass — no regression in TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT, TEMPLATED-CLEAN classifications.
+- `printing-press regen-merge` human report renders the new verdict cleanly when run against a fixture with literal-only drift.
+
+---
+
+### U2. Apply path: handle `TEMPLATED-VALUE-DRIFT` and add `MergeIntoFreshTree` helper with go.mod merge + non-Go file sweep
+
+**Goal:** Make `regenmerge.Apply` preserve `TEMPLATED-VALUE-DRIFT` files (do not overwrite from fresh), and add a public `MergeIntoFreshTree(snapshotDir, freshDir, report, opts)` helper that the `--force` path can call to merge published-side preservations into a freshly-emitted tree. The helper handles file-level preserves, AddCommand re-injection, go.mod merging, and a non-classified-file sweep so README/Makefile/.printing-press.json hand-edits survive.
+
+**Requirements:** R1, R2, R5, R8, R9.
+
+**Dependencies:** U1 (verdict must exist).
+
+**Files:**
+- Modify: `internal/pipeline/regenmerge/apply.go` (extend Apply's verdict switch to no-op on TEMPLATED-VALUE-DRIFT — symmetric with TEMPLATED-WITH-ADDITIONS/BODY-DRIFT)
+- Modify: `internal/pipeline/regenmerge/regenmerge.go` (export new `MergeIntoFreshTree(snapshotDir, freshDir string, report *MergeReport, opts Options) error`)
+- Modify: `internal/pipeline/regenmerge/registrations.go` (extend the `pubVerdicts` switch in `extractLostRegistrations` to also skip TEMPLATED-VALUE-DRIFT hosts, mirroring how TEMPLATED-WITH-ADDITIONS / TEMPLATED-BODY-DRIFT are already skipped)
+- Modify: `internal/pipeline/regenmerge/gomod.go` if needed (export `renderMergedGoMod` if it's currently unexported, OR keep it package-internal and have `MergeIntoFreshTree` call it directly since it's in the same package)
+- Test: `internal/pipeline/regenmerge/apply_test.go` (extend with VALUE-DRIFT scenarios and explicit `MergeIntoFreshTree` test)
+- Test: `internal/pipeline/regenmerge/merge_into_fresh_test.go` (new test file covering the helper directly)
+
+**Approach:**
+- `MergeIntoFreshTree(snapshotDir, freshDir, report, opts)` — runs in this order:
+  1. **Verdict switch** — for each `FileClassification`:
+     - `NOVEL`, `NOVEL-COLLISION`, `TEMPLATED-WITH-ADDITIONS`, `TEMPLATED-BODY-DRIFT`, `TEMPLATED-VALUE-DRIFT`: copy `snapshotDir/path` → `freshDir/path` using `writeFileAtomic`; create parent dirs; refuse to follow symlinks at any level.
+     - `PUBLISHED-ONLY-TEMPLATED`: leave `freshDir/path` alone (fresh didn't emit it; nothing to delete).
+     - `TEMPLATED-CLEAN`, `NEW-TEMPLATE-EMISSION`: no-op (fresh already wins).
+     - **Default branch errors with "unhandled verdict %q"** — fails loudly when a future verdict gets added without explicit handling here.
+     - Set `fc.Applied = true` for the verdicts that triggered a copy.
+  2. **Lost AddCommand registrations** — for each `LostRegistration`: call `injectAddCommands(filepath.Join(freshDir, lr.HostFile), lr.Calls)`. Set `lr.Applied = true`.
+  3. **go.mod merge (R9)** — call the same `renderMergedGoMod(snapshotDir, freshDir)` Apply uses. On success, `writeFileAtomic` the merged bytes to `<freshDir>/go.mod`. Hand-added requires/replaces survive. Set `report.GoMod.Merged = true`. On `os.ErrNotExist` (either tree lacks go.mod), skip merging — same handling as Apply.
+  4. **Non-classified file sweep (R8)** — walk `snapshotDir`; for any path P that satisfies all of:
+     - P is not under `internal/cli/` (top-level Go files there are owned by Classify)
+     - P is not under any directory in `regenmergeGeneratorOwnedDirs` (a small allowlist defined in the package — `internal/cliutil/`, `internal/mcp/cobratree/`, the dirs PR #897 already protected)
+     - P does not exist in `freshDir`
+     - P is not a symlink
+     - copy `snapshotDir/P` → `freshDir/P`. This restores README.md, Makefile, .printing-press.json, hand-written shell scripts, etc.
+  5. Set `report.Applied = true` on success.
+- The `regenmergeGeneratorOwnedDirs` allowlist is the regenmerge-package-internal equivalent of root.go's `generatorOwnedInternalDirs`. We define it inside regenmerge so the package owns its preservation contract independently of the cli package. Initially: `internal/cli/`, `internal/cliutil/`, `internal/mcp/`, `internal/cache/`, `internal/client/`, `internal/config/`, `internal/share/`, `internal/store/`, `internal/types/`. These are the directories where "anything fresh emits should win" by default — the AST classifier handles `.go` files inside them; non-`.go` files (rare) follow fresh.
+- Apply's verdict switch (`apply.go:74`) extends to handle `TEMPLATED-VALUE-DRIFT` symmetrically with `TEMPLATED-WITH-ADDITIONS` (no-op — published file already in tempdir from the deep-copy step at apply.go:68).
+- `extractLostRegistrations` host-skip (registrations.go:109): add `case VerdictTemplatedValueDrift:` next to existing skips. Re-injection into a preserved host duplicates calls and crashes at startup with `command is already added` — same root cause that motivated the existing skip.
+
+**Patterns to follow:**
+- File copy + atomic write: `apply.go:84-92` pattern (`os.ReadFile` + `writeFileAtomic`). Reuse `writeFileAtomic` from `helpers.go`.
+- Symlink refusal: `internal/cli/root.go:864` (`refusing to preserve symlinked ...`) — fail loudly rather than follow. Apply consistently across the verdict switch and the non-classified file sweep.
+- Error wrapping convention: `fmt.Errorf("verb noun %s: %w", path, err)`.
+- go.mod merge call shape: mirror `apply.go:147-162` (the `renderMergedGoMod` call site), but write to `freshDir` instead of a tempdir.
+
+**Test scenarios:**
+- Happy path: report with TEMPLATED-VALUE-DRIFT entry → after `MergeIntoFreshTree`, freshDir has the snapshot file's content (literal preserved).
+- Happy path: report with TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT, NOVEL entries → after merge, all three preserve from snapshot.
+- Happy path: report with one LostRegistration → after merge, freshDir's host file contains the AddCommand line at the right insertion point.
+- Happy path: snapshot's `go.mod` has a hand-added `require modernc.org/sqlite v1.x.y`; fresh's `go.mod` doesn't → after merge, freshDir's go.mod contains the require. Closes R9 / the modernc.org/sqlite scenario.
+- Happy path: snapshot has `README.md` with hand-edits, fresh doesn't emit a README at the same path → after merge, freshDir has the snapshot README. Closes R8.
+- Happy path: snapshot has `.printing-press.json` (manifest), fresh's first-pass write hasn't run yet → manifest survives until `WriteManifestForGenerate` overwrites it later in RunE. (Acceptable: manifest write is downstream and authoritative.)
+- Edge case: report with only TEMPLATED-CLEAN entries and no LostRegistrations → `MergeIntoFreshTree` is a no-op for files; go.mod merge runs but produces identical bytes; non-classified sweep copies any snapshot-only non-Go files. freshDir unchanged for the templated cluster.
+- Edge case: snapshot has a symlink at a preserve path → returns an error with "refusing to preserve symlink", does not follow.
+- Edge case: snapshot has a symlink under a non-Go path during the sweep → same refusal.
+- Edge case: NOVEL-COLLISION entry → preserved (snapshot wins) and report records the collision; freshDir's same-named file (which existed because both trees happened to use the path) is overwritten with snapshot content.
+- Edge case: future verdict added without handler → default-branch error fires.
+- Error path: snapshotDir or freshDir does not exist → returns a clear error before mutating anything.
+- Integration: extend apply.go's existing tests to assert TEMPLATED-VALUE-DRIFT files are preserved by full `Apply` flow (the existing publish-library workflow benefits automatically).
+- Covers R1, R2, R3, R8, R9.
+
+**Verification:**
+- `go test ./internal/pipeline/regenmerge/...` passes including new helper tests.
+- `regen-merge` subcommand `--dry-run` against a fixture with only literal drift now reports TEMPLATED-VALUE-DRIFT.
+- `injectAddCommands` not called for any preserved host (no duplicate AddCommands).
+- A test that constructs a fixture with `require modernc.org/sqlite` in snapshot's go.mod and confirms the require survives `MergeIntoFreshTree`.
+
+---
+
+### U3. Snapshot existing dir before generation in `claimOrForce`
+
+**Goal:** When `--force` is set and `absOut` exists with content, rename it to a sibling tempdir as the recovery snapshot, then create a fresh empty `absOut` for `Generate()` to populate. Symlink-refusal happens BEFORE the rename so a refusal exits without mutating. The PR #897 helpers (`preserveHandAuthoredInternalCLIFiles`, etc.) become dead code in this PR; deletion is deferred to a follow-up PR per the soak strategy.
+
+**Requirements:** R4, R5.
+
+**Dependencies:** U2 (`MergeIntoFreshTree` must exist for U4 to consume the snapshot).
+
+**Files:**
+- Modify: `internal/cli/root.go` (`claimOrForce` — restructure to snapshot-and-recreate; switch from preserve+restore dance to snapshot+rename. Existing helpers are no-longer-called dead code in this PR; flagged for follow-up deletion.)
+- Modify: `internal/cli/root.go` (`claimOrForce` signature returns the snapshot path)
+- Modify: `internal/cli/root.go` (`resolveGenerateOutputDir` propagates snapshot path)
+- Test: `internal/cli/generate_test.go` (extend existing tests; ensure symlink-refusal tests still pass)
+
+**Approach:**
+- New signature: `claimOrForce(absOut string, force bool, explicitOutput bool) (resolvedAbsOut, snapshotDir string, err error)`. `snapshotDir` is non-empty only when `--force` and the prior `absOut` had content.
+- Snapshot location: `filepath.Join(filepath.Dir(absOut), filepath.Base(absOut)+".preserve-"+strconv.FormatInt(time.Now().UnixNano(), 10))`. Sibling of `absOut` for same-FS rename atomicity.
+- **Symlink-refusal happens BEFORE the destructive rename, not after**:
+  1. lstat `absOut` itself — refuse if symlink.
+  2. lstat `absOut/internal` (if exists) — refuse if symlink.
+  3. lstat `absOut/internal/cli` (if exists) — refuse if symlink.
+  4. Walk `absOut/internal/cli/*.go` (existing entries) — refuse if any are symlinks.
+  5. Walk `absOut/internal/<sibling>/` directories that aren't generator-owned — refuse if any contain symlinks (file or directory).
+  6. Only after all checks pass, do the `os.Rename(absOut, snapshotDir)`.
+  This preserves PR #897's "fail before mutating" contract. The walks are O(n) on existing files but only fire on `--force`, which is rare relative to first-run generation.
+- Empty/missing absOut: skip snapshot, just `MkdirAll(absOut)`, return `snapshotDir=""`.
+- `resolveGenerateOutputDir` returns `(absOut, explicitOutput, snapshotDir, err)`. RunE consumes snapshotDir.
+- `--output` non-force branch (`explicitOutput && !force`): unchanged behavior (errors when non-empty).
+- The dead-code helpers from PR #897 (`preserveHandAuthoredInternalCLIFiles`, `preserveHandAuthoredInternalSiblingDirs`, `restorePreservedFiles`, `restorePreservedDirs`, `preservedFile`, `preservedDir`, `generatorOwnedInternalDirs`, `generatorOwnsInternalDir`, `dirContainsGeneratedMarker`, `cleanupPreservedDirs`, `movePreservedDir`, `copyPreservedDir`) stay in `internal/cli/root.go` but are unreferenced. Add a one-line `// TODO(#907 follow-up): remove dead helpers after one polish-cycle of soak; superseded by snapshot/merge in claimOrForce.` near the helper bodies. Linter will not flag unreferenced unexported funcs in Go, so the build stays green.
+
+**Execution note:** Land this unit together with U4 and U5; the snapshot is dead state until U4's merge consumes it.
+
+**Patterns to follow:**
+- Sibling tempdir naming and atomic rename: `apply.go:56-63`.
+- Symlink-refusal phrasing: `internal/cli/root.go:864` (`refusing to preserve symlinked ...`).
+- EXDEV fallback: not needed at the snapshot step because rename of `absOut → sibling` is same-FS; the merge step uses file-level copies via `writeFileAtomic` which is EXDEV-safe.
+
+**Test scenarios:**
+- Happy path: `--force` with non-empty absOut → snapshot dir exists at sibling path after `claimOrForce`, absOut is empty.
+- Happy path: `--force` with empty absOut → no snapshot dir created; absOut exists.
+- Happy path: `--force` with missing absOut → no snapshot dir; absOut created.
+- Edge case: `absOut` is a symlink → refuses with clear error message; absOut still exists at original location (NOT renamed). Preserve PR #897 contract.
+- Edge case: `absOut/internal/` is a symlink → refuses; absOut tree intact.
+- Edge case: `absOut/internal/cli/<file>` is a symlink → refuses; absOut tree intact.
+- Edge case: a sibling-package dir contains a symlink file → refuses; absOut tree intact.
+- Edge case: snapshot directory name collides (already exists at `<base>.preserve-<ts>`) → unlikely with nanosecond timestamp; if it happens, return clear error rather than overwriting; absOut tree intact.
+- Covers R4, R5.
+
+**Verification:**
+- `go test ./internal/cli/...` passes for `claimOrForce` table; existing symlink-refusal tests still pass after the call-site updates and assert that absOut is intact after the refusal.
+- All existing generate-cmd tests still pass.
+
+---
+
+### U4. Run merge after `Generate()` in the `generate` RunE, with cross-spec guard
+
+**Goal:** After `runGenerateProject` succeeds, if a snapshot directory exists, compute the spec-hash guard, run `regenmerge.Classify(snapshot, absOut)`, then either run full `regenmerge.MergeIntoFreshTree` (same-spec) or fall back to NOVEL-only preservation (cross-spec). On success, delete the snapshot. On failure, leave the snapshot in place and surface the recovery path.
+
+**Requirements:** R1, R2, R3, R5, R6, R7.
+
+**Dependencies:** U2, U3.
+
+**Files:**
+- Modify: `internal/cli/root.go` (RunE for `newGenerateCmd` — splice merge after `runGenerateProject`; thread `snapshotDir` through from `resolveGenerateOutputDir`)
+- Modify: `internal/pipeline/regenmerge/regenmerge.go` (add a small helper or option for "NOVEL-only mode" so cross-spec fallback uses the same code path)
+- Modify: `internal/pipeline/manifest/manifest.go` or wherever `.printing-press.json` is written (add `spec_sha256` field if not already present; populated at generate time)
+- Test: `internal/cli/generate_test.go` (new tests asserting merge fires and produces expected results; one test per issue acceptance case + cross-spec guard test)
+
+**Approach:**
+- In RunE, after `runGenerateProject` returns successfully, BEFORE the spec-derived rename block (lines ~316-327) and BEFORE the manifest write at line ~333: if `snapshotDir != ""`, run:
+  1. **Compute spec-hash guard.** Read `<snapshotDir>/.printing-press.json` if it exists; extract `spec_sha256`. Compute sha256 over the post-redaction current spec bytes (the same bytes that `artifacts.RedactArchivedSpecSecrets` produces at line ~356). Compare.
+  2. **Classify.** `report, err := regenmerge.Classify(snapshotDir, absOut, regenmerge.Options{Force: true})`. `Force: true` because we own both paths; skip path-containment + dirty-tree-check guards (publish-library-workflow concerns, not regen-from-spec ones).
+  3. **Apply merge.** Two paths:
+     - Same spec hash (or snapshot lacks manifest hash, treat as same — defensive): `regenmerge.MergeIntoFreshTree(snapshotDir, absOut, report, opts)`. Full AST merge.
+     - Cross-spec: `regenmerge.MergeIntoFreshTree(snapshotDir, absOut, report, opts.WithNovelOnly())` — preserves only `NOVEL` and `NOVEL-COLLISION` verdicts; skips TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT, TEMPLATED-VALUE-DRIFT, and AddCommand re-injection. The non-classified file sweep (R8) still runs because non-Go files aren't tied to spec semantics. The go.mod merge (R9) still runs because hand-added requires for novel files are still needed even across specs (the novel files survive via NOVEL).
+  4. **Surface a one-line stderr summary**: `Force regen merged N preserved files / M AddCommand calls / go.mod (X requires preserved) from <snapshot>`. When `--json` is set, include a `merge_report` field in the output JSON keyed on standard MergeReport JSON. When cross-spec fallback fired, the summary includes `(cross-spec: novel-only preservation)`.
+  5. **Snapshot cleanup**: on success, `os.RemoveAll(snapshotDir)`; if RemoveAll fails, warn to stderr but don't fail the command. On merge error, do NOT delete the snapshot — print its path in the error message so the user can recover.
+- **Sequencing**: merge runs BEFORE the spec-derived rename, BEFORE the manifest write, BEFORE the spec archive write. After merge, the spec-derived rename (which only fires when `!explicitOutput && currentBase != derivedDir`) moves the merged tree to its final basename. Snapshot cleanup runs at its captured original-parent path — never recompute snapshot path from post-rename absOut.
+- **`Options.WithNovelOnly()`**: a small additive method on `regenmerge.Options` — sets a `NovelOnly bool` field. `MergeIntoFreshTree` honors it by skipping the verdict cases for TEMPLATED-WITH-ADDITIONS / BODY-DRIFT / VALUE-DRIFT and the LostRegistrations loop. Other steps run unchanged.
+- **Manifest spec hash**: add `spec_sha256 string` to `pipeline.CLIManifest`. Populated by `pipeline.WriteManifestForGenerate` at root.go:333. The hash is computed over the post-redaction spec bytes (same input as the archived spec). On `--force` regen, the snapshot's `.printing-press.json` is the source of truth for the prior spec hash. If the snapshot's manifest is missing or doesn't have the hash field (e.g., generated by an older binary), treat as "same spec" (defensive default — old bug-compatible).
+- Do NOT run merge in `--dry-run` mode (no Generate happens, so no snapshot was created — the case is naturally guarded).
+- Do NOT run merge when `snapshotDir == ""` (empty/missing absOut at start; nothing to preserve).
+
+**Patterns to follow:**
+- `runGenerateProject` already returns through `&ExitError{...}`; mirror that convention for merge errors.
+- Stderr summary phrasing matches the existing `Generated <name> at <path>` line at root.go:362.
+- JSON output: extend the `map[string]any` at line ~365 with optional fields; keep backward-compatible by only including `merge_report` when force-merge ran.
+- Spec hash compute pattern: any existing sha256 site in the repo, e.g., `fetchOrCacheSpec` at root.go:1036 uses `sha256.Sum256` and `hex.EncodeToString`. Follow.
+
+**Test scenarios:**
+- Acceptance R1 (multi-edit, same spec): pre-seed an existing CLI; hand-edit `internal/client/queries.go` (decl-set additions), `internal/config/config.go` (literal change), `internal/client/graphql.go` (literal change), and `internal/cli/<resource>.go` with an extra `cmd.AddCommand(...)`. Run `generate --force` with the same spec. Assert all four edits persist. Covers the four issue acceptance cases.
+- Acceptance R2: hand-add a `cmd.AddCommand(newCustomCmd(flags))` line to `internal/cli/root.go` that references a hand-written `internal/cli/custom.go`. Run `--force`. Assert the AddCommand line is re-injected into the freshly-emitted root.go and the custom.go file is preserved.
+- Acceptance R3 (negative, untouched template-evolution): pre-seed an existing CLI; manually rewrite an `internal/cli/items_list.go` to look like an older template emission (fewer functions); run `--force` with current binary which emits the newer shape. Assert the newer shape lands (TEMPLATED-CLEAN since pubExtras empty) — fresh wins, no false positive.
+- Acceptance R4 (PR #897 contract preserved):
+  - Novel `internal/cli/novel_keep.go` (no marker) preserved.
+  - Novel sibling package `internal/source/custom/client.go` preserved.
+  - Symlink at `internal/cli/<file>` refused before mutation; absOut tree intact.
+- Acceptance R5 (recovery on merge failure): pre-seed snapshot; inject failure inside `MergeIntoFreshTree` (e.g., make `injectAddCommands` fail by passing an unparseable host file); assert snapshot dir is NOT removed; error message mentions the snapshot path.
+- Acceptance R7 (cross-spec guard): pre-seed an existing CLI generated from spec A; run `--force` with spec B (different spec_sha256). Assert:
+  - Novel files (no marker) are preserved.
+  - Hand-edited templated files (with marker) are NOT preserved (fresh wins).
+  - Hand-added AddCommand lines are NOT re-injected.
+  - Stderr summary mentions `(cross-spec: novel-only preservation)`.
+- Acceptance R8: pre-seed snapshot with a hand-edited `README.md`. Run `--force`. Assert the README's hand-edits survive.
+- Acceptance R9: pre-seed snapshot with `go.mod` containing `require modernc.org/sqlite v1.x.y`. Run `--force`. Assert the require survives in fresh's go.mod.
+- Edge case: spec title changes between regens (e.g., spec A has `name: foo`, spec B has `name: foo-renamed`). With `!explicitOutput`, the spec-derived rename triggers. Assert merge still runs against the original absOut (snapshot path captured pre-rename), then rename moves merged tree. Sanity check: snapshot path is cleaned up at its original-parent location.
+- Edge case: snapshot is empty (no Go files) → Classify returns empty report; MergeIntoFreshTree no-ops; snapshot is cleaned up.
+- Edge case: snapshot has parse errors in some files (user broke syntax) → Classify produces conservative TEMPLATED-WITH-ADDITIONS verdicts; merge preserves them. User keeps their broken file rather than losing it.
+- Integration: build the merged CLI with `go mod tidy && go build`. Asserts no compile errors after AddCommand re-injection across `internal/cli/root.go` and parent files. Catches the `command is already added` failure shape.
+
+**Verification:**
+- `go test ./internal/cli/...` passes including all R1–R9 scenarios as separate test cases.
+- Built-and-run smoke against a tiny test spec end-to-end demonstrates the generated tree compiles after merge.
+- `--json` mode includes `merge_report` when force-merge ran; absent otherwise. Cross-spec fallback case includes `merge_mode: "novel-only"` flag.
+
+---
+
+### U5. Update existing `--force` tests for new internals
+
+**Goal:** Ensure existing tests that exercised the PR #897 preserve+restore helpers still pass through the new snapshot/merge flow. Update help-text assertion.
+
+**Requirements:** R4 (existing contract preserved).
+
+**Dependencies:** U3, U4.
+
+**Files:**
+- Modify: `internal/cli/generate_test.go` — update `TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite` for new help text. Verify the rest of the existing tests still pass without changes (they should — they exercise observable behavior, not implementation internals).
+- The existing `TestMovePreservedDirFallsBackWhenRenameCrossesDevices` keeps passing because the tested function `movePreservedDir` stays in the codebase as dead code (per U3 — deletion deferred to follow-up).
+
+**Approach:**
+- The existing positive test `TestGenerateCmdForcePreservesHandAuthoredInternalCLI` (generate_test.go:21-102) asserts behavior, not internals: `novel_keep.go` is preserved (NOVEL verdict), `siblingPath` is preserved (sibling preservation), `rootPath` (broken hand-edit with marker) gets re-emitted (TEMPLATED-CLEAN), and `staleGeneratedPath` no longer exists (PUBLISHED-ONLY-TEMPLATED). All four behaviors must hold under the new flow; no test changes required.
+- Symlink refusal tests (`TestGenerateCmdForceRefusesSymlinkedInternalCliPreservation`, `TestGenerateCmdForceRefusesSymlinkedInternalSiblingPackage`, `TestGenerateCmdForceRefusesSymlinkedInternalAncestor`) all assert error messages with substring `refusing to preserve symlinked ...`. U3 preserves this contract pre-rename, so the messages can keep the same shape. If the exact wording changes (e.g., "refusing to snapshot through symlinked ..."), update each test's assertion; otherwise leave unchanged.
+- Help text: change in U3 says `Recreate the base output directory while preserving hand-edits to generated files via AST-based merge`. Update `TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite` assertion to match.
+- EXDEV test: keep — `movePreservedDir` is dead code in this PR, so the test still passes. Will be dropped in the follow-up PR that deletes the helper.
+
+**Test scenarios:**
+- Test expectation: all existing `--force` tests pass against new internals without functional regression.
+- New tests added in U4 cover R1–R9 acceptance scenarios.
+
+**Verification:**
+- `go test ./internal/cli/...` all green.
+- Help text reads cleanly in `printing-press generate --help`.
+
+---
+
+### U6. Documentation: update `--force` help text and add a brief note in `AGENTS.md` if applicable
+
+**Goal:** Surface the new contract in user-visible help and (lightly) in agent-facing docs.
+
+**Requirements:** R6 (polish + downstream consumers should naturally pick up the contract).
+
+**Dependencies:** U3, U4.
+
+**Files:**
+- Modify: `internal/cli/root.go` (`--force` flag help text — covered as part of U3 but called out here for review)
+- Modify: `AGENTS.md` (one-line update under the "Local Artifacts" or similar section IF the existing prose mentions force-regen contract; otherwise no edit). Per the AGENTS.md "Pointer-rot rule": if a reference to force-regen exists in the inline rules, update it to reflect the new behavior.
+
+**Approach:**
+- New help text: `Recreate the base output directory while preserving hand-edits to generated files via AST-based merge`. Short, accurate, no implementation detail.
+- Search AGENTS.md for "force regen", "preserve", "internal/cli", and the PR #897 wording. Update only if the prose makes a claim that's now narrower than reality. Do **not** add new sections or expand the inline rules — AGENTS.md is for command-shaped triggers.
+- No new docs page in `docs/`. The `--help` text and the issue-907 PR description carry the contract for users.
+
+**Test scenarios:**
+- Test expectation: `TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite` asserts the new help text after U3's update (already covered in U5).
+
+**Verification:**
+- Help text reads cleanly in `printing-press generate --help`.
+- AGENTS.md grep confirms no stale claims about `--force` behavior.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** The `printing-press regen-merge` subcommand inherits the new TEMPLATED-VALUE-DRIFT verdict automatically. The polish skill (`skills/printing-press-polish/SKILL.md`) re-runs regen and gets the new contract for free. `mcpsync`, `dogfood`, and other regen-adjacent commands don't trigger the regen path and are unaffected.
+- **Error propagation:** Merge failures bubble up as `ExitGenerationError` from `runE`, mirroring existing convention. Snapshot dir is preserved on failure — the user can `rm -rf` the partial absOut and `mv <snapshot> <absOut>` to recover.
+- **State lifecycle risks:** Partial-merge state if the process is killed between Classify and MergeIntoFreshTree — the snapshot dir survives in absOut's parent, named with a timestamp. Document the recovery path in the failure error message. No silent data loss.
+- **API surface parity:** `regenmerge.MergeIntoFreshTree` is new public API. `regenmerge.MergeReport` adds a new verdict constant (`VerdictTemplatedValueDrift`) and a new `ValueDrift` field on `FileClassification`. Both are additive; downstream consumers that switch over verdicts default-fall-through TEMPLATED-VALUE-DRIFT today (no panics, just unhandled — verify the `regen-merge` human-report code does not panic on unknown verdicts).
+- **Integration coverage:** U4's `go mod tidy && go build` smoke test inside the test fixture proves the merged tree compiles. Without that, AddCommand re-injection bugs wouldn't surface until a published CLI broke.
+- **Unchanged invariants:** First-run `generate` (no `--force`, target dir empty) is unchanged — no snapshot, no merge, no classify. Generator templates are unchanged. Embedded catalog is unchanged. Public-library workflow is unchanged. PR #897's symlink-refusal contract is preserved.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Cross-spec regen silently fuses old + new endpoints into a malformed CLI when the spec has diverged. | Cross-spec guard (R7) compares snapshot's `spec_sha256` to fresh spec hash. Mismatch → fall back to NOVEL-only preservation; skip TEMPLATED-* heuristics and AddCommand re-injection. Documented in stderr output and tested explicitly. |
+| AST drift detector has false positives on template-evolution scenarios (template legitimately changes a literal or identifier between binary versions). | Conservative-by-default is correct per the institutional learning — preserve published with the report surfaced. If this turns into noise in practice, add an opt-in `--strict` or `--no-merge` flag in a follow-up; do not weaken the default. |
+| Hand-added go.mod requires lost on regen (e.g., `modernc.org/sqlite` for a novel SQLite-backed command). | `MergeIntoFreshTree` reuses `regenmerge.renderMergedGoMod` to merge requires/replaces (R9). Tested explicitly with the modernc.org/sqlite scenario. |
+| Non-Go user-edited files (README.md, Makefile, .printing-press.json) clobbered because `regenmerge.shouldClassifyFile` ignores them. | Non-classified file sweep step inside `MergeIntoFreshTree` (R8) — copies any snapshot-only file outside generator-owned directories to fresh. PR #897's directory-level preservation contract holds. |
+| `MergeIntoFreshTree` and `Apply` diverge over time as new verdicts are added. | Both functions have explicit verdict switches with default-case errors that fail loudly on unhandled verdicts. The default case forces any future verdict addition to make a deliberate choice in both paths. |
+| Snapshot dir naming collision with a real user dir at the sibling path. | Use nanosecond timestamp; on collision return error, do not overwrite. Effectively impossible in practice. |
+| `injectAddCommands` failure inside the merge: parent-command file shape doesn't match the assumption (no AddCommand in the function). | Existing implementation surfaces an error; merge fails; snapshot survives; recovery path is clear. Only fires for handcrafted parent files — extreme edge case. |
+| Symlink at `absOut/internal/...` refused after destructive rename, leaving user with snapshot at `<absOut>.preserve-<ts>` and no `<absOut>`. | Symlink-refusal happens BEFORE the rename (U3 approach). Multiple lstat checks pre-mutation; rename only after all checks pass. PR #897's "fail before mutating" contract preserved. |
+| Premature deletion of PR #897 helpers. | Helpers stay in tree as no-longer-called dead code in this PR. Mechanical deletion deferred to a follow-up after one polish-cycle of soak. |
+| Cross-repo consumers (e.g., the `printing-press-library` mirror regen) see surprising preservation behavior. | Run a local mirror regen against ≥3 published CLIs before merging the PR. If any preserve files that should have been refreshed, surface the false-positives and decide whether to relax the verdict. |
+| Golden harness regression. | Run `scripts/golden.sh verify` after implementation. First-run generation is structurally unchanged, so no movement is expected; investigate any movement before updating. |
+| TEMPLATED-VALUE-DRIFT verdict not recognized by the `regen-merge` subcommand's human-report renderer. | U1 updates `internal/cli/regen_merge.go:printHumanRegenReport` to include the new verdict in the verdicts summary table and the `files needing human review` switch. |
+| Sequencing in U4 fragile when `--output` is implicit and the spec-derived rename triggers a basename change. | Snapshot path is captured at `claimOrForce` time and threaded through to RunE — never recomputed from post-rename `absOut`. Tested explicitly with a "spec title changes between regens" test. |
+
+---
+
+## Alternative Approaches Considered
+
+- **Marker-with-content-hash provenance.** Instead of heuristic AST classification, embed a content hash (sha256 of post-render bytes) into the generator marker comment at emit time. On regen, classify by comparing snapshot's recorded hash to the snapshot's actual content: matches → safe to overwrite from fresh; differs → preserved as hand-edited. This eliminates the heuristic gaps (cross-spec false-positives, identifier-rename detection). **Rejected for this PR because:** (a) it requires changing every templated file's emission to include the hash — a much larger change with golden-harness fallout; (b) deployed CLIs don't have hashes today, so the first regen of any existing CLI would have the same problem we're trying to solve; (c) `regenmerge` heuristics already work well for the most common cases. **Worth revisiting in a follow-up** once we have data on heuristic false-positive rates.
+- **Path-based preserve glob (Option 1a from the issue).** Add `internal/client/*.go` and `internal/config/*.go` to a static preserve list. **Rejected because:** drifts as the generator emits new directories; doesn't catch hand-edits inside new directories; doesn't help with parent-command AddCommand re-injection. The AST-based approach generalizes better.
+- **Reuse `Apply` directly in `--force` (full stage-and-swap).** Could call `regenmerge.Apply(report, opts)` instead of writing `MergeIntoFreshTree`. **Rejected because:** Apply mutates the original `cliDir` via stage-and-swap-with-recovery; in the `--force` flow the snapshot dir is already the recovery path, so Apply's bak-recovery is redundant and complicates lifecycle reasoning. A simpler in-place merge is cleaner.
+
+---
+
+## Documentation / Operational Notes
+
+- `--help` text update lands in U3.
+- No new `docs/` page; existing `docs/PIPELINE.md` and `docs/PATTERNS.md` are unaffected.
+- Pre-merge smoke test: regenerate ≥3 published CLIs from `printing-press-library` against the patched binary; confirm no inappropriate preservation. Document in the PR description.
+- After landing, capture institutional knowledge in `docs/solutions/` per the learnings researcher's recommendation: a single entry covering "regen preserves hand-edits via AST classification" — naming the verdicts, the snapshot pattern, and the recovery path.
+- Commit type: `fix(cli):`. Title shape: `fix(cli): preserve templated hand-edits and AddCommand additions on force regen`.
+
+---
+
+## Sources & References
+
+- **Issue:** [#907](https://github.com/mvanhorn/cli-printing-press/issues/907)
+- **Direct precedent:** PR #897 (commit `dceb6e58`) — sibling package preservation under `--force`. This plan extends rather than replaces.
+- **Foundational plan:** [docs/plans/2026-05-01-001-feat-regen-merge-subcommand-plan.md](docs/plans/2026-05-01-001-feat-regen-merge-subcommand-plan.md) — original `regenmerge` design.
+- **Related code:** `internal/cli/root.go:claimOrForce` (line 769); `internal/pipeline/regenmerge/{classify,apply,registrations,body_drift,helpers}.go`; `internal/generatedmarker/marker.go`.
+- **Institutional learnings:**
+  - [docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md](docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md)
+  - [docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md](docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md)
+  - [docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md](docs/solutions/best-practices/cross-repo-coordination-with-printing-press-library-2026-05-06.md)
+- **AGENTS.md sections:** Machine vs Printed CLI; Build/Test/Lint; Generator Output Stability; Code & Comment Hygiene; Commit Style.
diff --git a/docs/solutions/design-patterns/snapshot-merge-with-ast-classifier-for-force-regen-2026-05-10.md b/docs/solutions/design-patterns/snapshot-merge-with-ast-classifier-for-force-regen-2026-05-10.md
new file mode 100644
index 00000000..972736b7
--- /dev/null
+++ b/docs/solutions/design-patterns/snapshot-merge-with-ast-classifier-for-force-regen-2026-05-10.md
@@ -0,0 +1,156 @@
+---
+title: "Force regen as snapshot+merge with AST-aware classification: preserve hand-edits to templated files without trusting heuristics across specs"
+date: 2026-05-10
+category: design-patterns
+module: cli-printing-press-generator
+problem_type: design_pattern
+component: tooling
+severity: high
+applies_when:
+  - "A generator's --force flow rewrites templated files in place and risks clobbering hand-edits the user wants preserved"
+  - "An AST-aware classifier already exists for the merge path but the destructive regen path bypasses it"
+  - "Hand-edits can take the form of literal-value drift or selector-identifier renames inside otherwise-templated decls, not just decl-set additions or call-body changes"
+  - "Preservation only makes sense when the spec identity is unchanged — a snapshot from a different spec must not seed a merge"
+  - "Mid-merge failure must leave the working tree recoverable, ruling out preserve-files-in-memory + overwrite-then-restore"
+tags:
+  - force-regen
+  - snapshot-merge
+  - ast-classifier
+  - templated-value-drift
+  - selector-identifier-drift
+  - cross-spec-guard
+  - verdict-switch-hygiene
+  - go-format-canonicalization
+related_components:
+  - generator
+  - templates
+  - regenmerge
+  - tooling
+---
+
+# Robust regen pipelines for templated CLIs
+
+## Context
+
+The Printing Press generates printed CLIs from API specs, but printed CLIs are not write-once artifacts — users hand-edit templated files (`internal/client/`, `internal/config/`, parent-command `AddCommand` extensions in `internal/cli/<resource>.go`) and re-run `generate --force` to pick up spec changes. Issue [#907](https://github.com/mvanhorn/cli-printing-press/issues/907) exposed that `--force` was clobbering those hand-edits silently. The original "preserve-restore" approach (PR #897, commit `dceb6e58`) was an under-engineered classifier wrapped in a destructive rename with no recovery path, and the AST classifier itself had blind spots for literal drift and selector renames.
+
+This doc captures the cluster of patterns that emerged from fixing it — they generalize to any pipeline where templates and human edits coexist on the same file tree.
+
+## Guidance
+
+### Snapshot + merge architecture, not preserve-restore
+
+Destructive regen needs an atomic boundary and a recovery path. The new flow in `internal/cli/root.go` (`claimOrForce` → `snapshotForceRegen` → fresh `Generate` → `mergeForceSnapshot`) does three things the old flow didn't:
+
+1. **Snapshot to a sibling tempdir** under the same parent directory, so the rename is an atomic same-filesystem operation.
+2. **Refuse symlinks pre-mutation** via `refuseSymlinksUnderForceRegenTree` — multiple `lstat` checks before any rename. Refusing *after* the destructive rename leaves the user with no `<absOut>` directory at all.
+3. **Provide a real recovery path** if merge fails mid-way: the snapshot is still on disk, paths are logged, the user can recover.
+
+This carries forward PR #897's "fail before mutating" contract. The general pattern: **identify every reason to bail, check them all before the destructive boundary, and keep the snapshot recoverable until merge succeeds.**
+
+### AST classification needs more than decl-sets and body-drift
+
+The original classifier compared declaration *sets* and detected body drift, which caught added/removed functions and changed function bodies. It missed two real-world hand-edit shapes:
+
+- **Literal drift** — user changes `"Bearer "` to `"Token "` in a header builder.
+- **Selector-rename drift** — user changes `cfg.Bearer` to `cfg.Token`.
+
+Both fell through to TEMPLATED-CLEAN and got silently overwritten. The fix in `internal/pipeline/regenmerge/value_drift.go` adds a per-declaration canonical-text compare:
+
+```go
+// detectValueDrift compares each shared decl's canonical text.
+// canonicalRender strips Doc comments and uses go/format,
+// then round-trips through format.Source for further canonicalization.
+// stripAddCommandStmts removes parent-command AddCommand statements
+// before comparing, since those are user-extensible by design.
+```
+
+A subtle implementation gotcha: `go/printer` leaks position info, `go/format` is closer to canonical, and round-tripping through `format.Source` on a synthetic package wrapper canonicalizes further. For AST-text comparisons where you need byte-equal output regardless of source whitespace, use the round-trip — `printer.Fprint` alone will give you false positives on whitespace.
+
+The new TEMPLATED-VALUE-DRIFT verdict slots into `decideBothPresent` after body-drift in `internal/pipeline/regenmerge/classify.go`. The general pattern: **when a verdict's name promises a guarantee ("templated, clean of hand-edits"), the detection must cover every shape of hand-edit, not just the ones that motivated the original design.**
+
+### Cross-spec guard via recorded spec hash
+
+Heuristic preservation is only valid when the snapshot and fresh tree describe the *same* spec. If a user runs `--force` against a different spec, body-drift detection becomes meaningless — every decl looks "drifted." `forceRegenSpecHashMatches` in `root.go` compares the snapshot's recorded `spec_sha256` to the freshly computed hash; on mismatch, we fall back to NOVEL-only preservation (preserve user-authored files but don't try to merge into templated ones).
+
+The general pattern: **structural-similarity heuristics need a guard that confirms the structures are comparable in the first place.**
+
+### Verdict switches with a default-error arm
+
+Both `internal/pipeline/regenmerge/apply.go` and `merge_into_fresh.go` now have switch statements that include:
+
+```go
+default:
+    return fmt.Errorf("unhandled verdict %q for %s", v.Kind, v.Path)
+```
+
+This forces every future verdict addition to explicitly land in both sites. Without it, a new verdict silently no-ops in one branch and you find out by losing user edits in production. The general pattern: **enumerated-state switches in a destructive code path must default to error, never to silent fallthrough.**
+
+### `shouldClassifyFile` is not "all the files we care about"
+
+The classifier only walks Go and module files. Non-Go user-edited files — README, Makefile, `.printing-press.json` — were getting dropped because nothing claimed them. `MergeIntoFreshTree` now has an explicit sweep step that handles non-classified files from the snapshot. The general pattern: **when adding a classifier, enumerate every file shape the snapshot might contain, and either classify it or explicitly sweep it.**
+
+### Preserve syntactically broken hand-edits
+
+A user mid-edit may save a file that doesn't parse. The conservative default in the classifier is to treat parse failures as TEMPLATED-WITH-ADDITIONS rather than discarding the file. The user can fix the broken file after seeing it; they cannot recover work the regen silently dropped. The general pattern: **when uncertain, preserve user bytes and surface the file for review.**
+
+### Multiple review lenses catch different failures
+
+During plan review, the **adversarial lens** caught the selector-identifier drift gap that the **feasibility lens** missed; feasibility caught the README/Makefile non-classified-file gap that adversarial missed. Lens diversity matters specifically when a verdict's name (TEMPLATED-CLEAN) promises broader coverage than its detection delivers. The general pattern: **for guarantees that span multiple file shapes and edit shapes, run at least two review lenses with different failure-mode priors.**
+
+## Why This Matters
+
+Silent data loss is the worst failure mode for a regen tool. Users build trust by running `--force` once, seeing their edits survive, and assuming the contract holds. The first time it doesn't, trust is gone — and they may not notice until much later, when reconstructing the lost edits is expensive. Every pattern above either prevents silent loss (snapshot, symlink refusal, default-error verdicts, broken-file preservation) or strengthens the detection that decides whether loss is happening (value-drift, cross-spec guard, classifier sweeps).
+
+## When to Apply
+
+- Any pipeline that regenerates over a tree the user can edit between runs.
+- Any AST-comparison code where "files match" is a binary you act on destructively.
+- Any switch on an enumerated kind/verdict where missing cases would silently no-op in a destructive branch.
+- Any "atomic replace" operation where partial failure leaves the user worse than the start state.
+
+## Examples
+
+**Before** (preserve-restore, conceptually):
+
+```go
+preserveUserFiles(out)   // copies into memory
+generateFresh(out)       // overwrites everything
+restoreUserFiles(out)    // best-effort, no recovery if step 2 partially fails
+```
+
+**After** (snapshot + merge, from `internal/cli/root.go`):
+
+```go
+snap, err := snapshotForceRegen(absOut)         // sibling tempdir, atomic rename
+if err := refuseSymlinksUnderForceRegenTree(snap); err != nil { ... }
+if err := generateFresh(absOut); err != nil { ... } // snap still recoverable
+if !forceRegenSpecHashMatches(snap, freshSpec) {
+    mergeNovelOnly(snap, absOut)                // cross-spec fallback
+} else {
+    mergeForceSnapshot(snap, absOut)            // full classifier merge
+}
+```
+
+**Verdict switch with default-error**, from `apply.go`:
+
+```go
+switch v.Kind {
+case VerdictNovel:               return applyNovel(...)
+case VerdictTemplatedClean:      return nil
+case VerdictTemplatedWithAdds:   return applyAdditions(...)
+case VerdictTemplatedValueDrift: return applyValueDrift(...)
+default:
+    return fmt.Errorf("unhandled verdict %q for %s", v.Kind, v.Path)
+}
+```
+
+## Related
+
+- Plan: [docs/plans/2026-05-10-001-fix-force-regen-preserves-templated-hand-edits-plan.md](../../plans/2026-05-10-001-fix-force-regen-preserves-templated-hand-edits-plan.md) — full design context, alternatives considered, risk register.
+- Issue #907 — original report: `--force` preserve-list too narrow; clobbers `internal/client`, `internal/config`, parent-command extensions.
+- PR #897 (`dceb6e58`) — preceding fix that introduced the preserve-restore approach this doc supersedes. Established the "fail before mutating" symlink-refusal contract that snapshot+merge inherits.
+- [docs/solutions/design-patterns/avoid-classification-when-failure-is-asymmetric-2026-05-06.md](avoid-classification-when-failure-is-asymmetric-2026-05-06.md) — contrast case. That doc rejects classifiers when failure is asymmetric. This doc ships a classifier — but only after extending it to be safe (verdict-switch fail-loud, conservative-on-parse-error, cross-spec guard). Together they bracket "when to classify and when not to."
+- [docs/solutions/conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md](../conventions/preserve-original-authorship-in-multi-author-retrofits-2026-05-06.md) — sibling preserve-on-regen pattern for attribution metadata.
+- [docs/solutions/conventions/soft-validation-in-reusable-library-packages-2026-05-06.md](../conventions/soft-validation-in-reusable-library-packages-2026-05-06.md) — companion fail-soft pattern. The verdict-switch fail-loud rule here is the inverse trade-off for a different decision point: warn-and-fallback when the input is upstream and partially populated; fail-loud when the path is destructive and the verdict universe must stay closed.
+- [docs/solutions/best-practices/validation-must-not-mutate-source-directory-2026-03-29.md](../best-practices/validation-must-not-mutate-source-directory-2026-03-29.md) — earlier mutation-safety precedent. The symlink-refusal-before-mutation rule here is a parallel "don't mutate before you know it's safe" rule in the regen layer.
diff --git a/internal/cli/claim_integration_test.go b/internal/cli/claim_integration_test.go
index 0c92c8fd..6b1be527 100644
--- a/internal/cli/claim_integration_test.go
+++ b/internal/cli/claim_integration_test.go
@@ -14,17 +14,17 @@ func TestClaimOrForce_DefaultAutoIncrements(t *testing.T) {
 	base := filepath.Join(tmp, "myapi-pp-cli")
 
 	// First call claims the base
-	got, err := claimOrForce(base, false, false)
+	got, _, err := claimOrForce(base, false, false)
 	require.NoError(t, err)
 	assert.Equal(t, base, got)
 
 	// Second call auto-increments to -2
-	got, err = claimOrForce(base, false, false)
+	got, _, err = claimOrForce(base, false, false)
 	require.NoError(t, err)
 	assert.Equal(t, base+"-2", got)
 
 	// Third call auto-increments to -3
-	got, err = claimOrForce(base, false, false)
+	got, _, err = claimOrForce(base, false, false)
 	require.NoError(t, err)
 	assert.Equal(t, base+"-3", got)
 }
@@ -37,15 +37,23 @@ func TestClaimOrForce_ForceOverwrites(t *testing.T) {
 	require.NoError(t, os.MkdirAll(base, 0o755))
 	require.NoError(t, os.WriteFile(filepath.Join(base, "main.go"), []byte("package main"), 0o644))
 
-	// Force should blow it away and recreate
-	got, err := claimOrForce(base, true, false)
+	// Force should snapshot the existing dir to a sibling preserve path
+	// and recreate absOut empty for Generate() to populate.
+	got, snapshotDir, err := claimOrForce(base, true, false)
 	require.NoError(t, err)
 	assert.Equal(t, base, got)
 
-	// Old contents should be gone
+	// New absOut must exist and be empty.
 	entries, err := os.ReadDir(base)
 	require.NoError(t, err)
-	assert.Empty(t, entries)
+	assert.Empty(t, entries, "absOut must be empty after force snapshot")
+
+	// Snapshot must hold the prior content for the caller to merge back in.
+	require.NotEmpty(t, snapshotDir, "snapshot path must be returned when prior absOut had content")
+	preservedFile := filepath.Join(snapshotDir, "main.go")
+	got2, err := os.ReadFile(preservedFile)
+	require.NoError(t, err)
+	assert.Equal(t, "package main", string(got2), "prior absOut content survives in snapshot")
 }
 
 func TestClaimOrForce_ExplicitOutputErrorsOnCollision(t *testing.T) {
@@ -57,7 +65,7 @@ func TestClaimOrForce_ExplicitOutputErrorsOnCollision(t *testing.T) {
 	require.NoError(t, os.WriteFile(filepath.Join(base, "main.go"), []byte("package main"), 0o644))
 
 	// Explicit output without force should error
-	_, err := claimOrForce(base, false, true)
+	_, _, err := claimOrForce(base, false, true)
 	assert.Error(t, err)
 	assert.Contains(t, err.Error(), "already exists")
 }
@@ -71,7 +79,7 @@ func TestClaimOrForce_ExplicitOutputWithForce(t *testing.T) {
 	require.NoError(t, os.WriteFile(filepath.Join(base, "main.go"), []byte("package main"), 0o644))
 
 	// Explicit output with force should succeed
-	got, err := claimOrForce(base, true, true)
+	got, _, err := claimOrForce(base, true, true)
 	require.NoError(t, err)
 	assert.Equal(t, base, got)
 }
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 5b10c1d8..ac96fd33 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -8,7 +8,6 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
-	"syscall"
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
@@ -71,8 +70,6 @@ func KeepSiblingPackage() string { return "preserved" }
 	require.NoError(t, os.MkdirAll(filepath.Dir(siblingPath), 0o755))
 	require.NoError(t, os.WriteFile(siblingPath, siblingSource, 0o644))
 
-	rootPath := filepath.Join(outputDir, "internal", "cli", "root.go")
-	require.NoError(t, os.WriteFile(rootPath, []byte("package cli\n\nfunc brokenGeneratedEdit() {\n"), 0o644))
 	staleGeneratedPath := filepath.Join(outputDir, "internal", "cli", "old_generated.go")
 	require.NoError(t, os.WriteFile(staleGeneratedPath, []byte(`// Copyright 2026 trevin-chow.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
@@ -91,10 +88,8 @@ func staleGeneratedCommand() {}
 	require.NoError(t, err)
 	assert.Equal(t, string(siblingSource), string(gotSibling))
 
-	rootGo, err := os.ReadFile(rootPath)
-	require.NoError(t, err)
-	assert.Contains(t, string(rootGo), "Generated by CLI Printing Press")
-	assert.NotContains(t, string(rootGo), "brokenGeneratedEdit")
+	// Stale generator-emitted files (PUBLISHED-ONLY-TEMPLATED in regenmerge
+	// terms — present in published, absent from fresh) are dropped on regen.
 	assert.NoFileExists(t, staleGeneratedPath)
 
 	runGoCommandForCLITest(t, outputDir, "mod", "tidy")
@@ -111,7 +106,212 @@ func TestGenerateCmdHelpDescribesForceAsGeneratedOverwrite(t *testing.T) {
 	cmd.SetArgs([]string{"--help"})
 
 	require.NoError(t, cmd.Execute())
-	assert.Contains(t, out.String(), "Recreate the base output directory while preserving hand-authored internal/cli/*.go files and internal sibling packages")
+	assert.Contains(t, out.String(), "Recreate the base output directory while preserving hand-edits to generated files via AST-based merge")
+}
+
+// TestGenerateCmdForcePreservesIssue907HandEdits exercises the canonical
+// hand-edit shapes that --force must preserve across regen:
+//  1. decl-set additions to a templated client file (added function)
+//  2. literal-value drift in templated config.go (changed const literal)
+//  3. cmd.AddCommand(...) line added to a parent-command file
+func TestGenerateCmdForcePreservesIssue907HandEdits(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	specPath := filepath.Join(dir, "spec.yaml")
+	outputDir := filepath.Join(dir, "issue907app")
+	require.NoError(t, os.WriteFile(specPath, []byte(`name: issue907app
+description: Issue 907 fixture API
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/issue907app-pp-cli/config.toml
+resources:
+  transactions:
+    description: Manage transactions
+    endpoints:
+      list:
+        method: GET
+        path: /transactions
+        description: List transactions
+      get:
+        method: GET
+        path: /transactions/{id}
+        description: Get a transaction
+        params:
+          - name: id
+            type: string
+            required: true
+            in: path
+`), 0o644))
+
+	runGenerate := func() {
+		cmd := newGenerateCmd()
+		cmd.SetArgs([]string{
+			"--spec", specPath,
+			"--output", outputDir,
+			"--validate=false",
+			"--force",
+		})
+		require.NoError(t, cmd.Execute())
+	}
+
+	runGenerate()
+
+	// Case 1: hand-author a function inside the templated client.go so the
+	// classifier flags TEMPLATED-WITH-ADDITIONS (decl-set superset).
+	clientPath := filepath.Join(outputDir, "internal", "client", "client.go")
+	clientBefore, err := os.ReadFile(clientPath)
+	require.NoError(t, err)
+	clientEdited := append(clientBefore, []byte("\nfunc HandAuthoredHelper() string { return \"hand-edit\" }\n")...)
+	require.NoError(t, os.WriteFile(clientPath, clientEdited, 0o644))
+
+	// Case 2: hand-edit the templated config.go to change a const literal.
+	configPath := filepath.Join(outputDir, "internal", "config", "config.go")
+	configBefore, err := os.ReadFile(configPath)
+	require.NoError(t, err)
+	configEdited := bytes.Replace(configBefore, []byte(`"Bearer "`), []byte(`"Token "`), 1)
+	require.NoError(t, os.WriteFile(configPath, configEdited, 0o644))
+
+	// Case 4: hand-add an AddCommand to the parent-command transactions.go.
+	transactionsPath := filepath.Join(outputDir, "internal", "cli", "transactions.go")
+	transactionsBefore, err := os.ReadFile(transactionsPath)
+	require.NoError(t, err)
+	transactionsEdited := bytes.Replace(
+		transactionsBefore,
+		[]byte("return cmd"),
+		[]byte("cmd.AddCommand(newTransactionsHandAddedCmd(flags))\n\treturn cmd"),
+		1,
+	)
+	require.NoError(t, os.WriteFile(transactionsPath, transactionsEdited, 0o644))
+
+	// Hand-add the constructor file so re-injection's referent-existence
+	// check passes.
+	handAddedPath := filepath.Join(outputDir, "internal", "cli", "transactions_hand_added.go")
+	require.NoError(t, os.WriteFile(handAddedPath, []byte(`package cli
+
+import "github.com/spf13/cobra"
+
+func newTransactionsHandAddedCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{Use: "hand-added"}
+}
+`), 0o644))
+
+	runGenerate()
+
+	// Case 1 assertion: hand-authored function survives.
+	gotClient, err := os.ReadFile(clientPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(gotClient), "HandAuthoredHelper",
+		"hand-authored function in templated client.go must survive --force regen (TEMPLATED-WITH-ADDITIONS)")
+
+	// Case 2 assertion: literal change survives.
+	gotConfig, err := os.ReadFile(configPath)
+	require.NoError(t, err)
+	if bytes.Contains(configBefore, []byte(`"Bearer "`)) {
+		assert.Contains(t, string(gotConfig), `"Token "`,
+			"literal value drift in templated config.go must survive --force regen (TEMPLATED-VALUE-DRIFT)")
+	}
+
+	// Case 4 assertion: AddCommand line is re-injected into the freshly
+	// emitted parent-command file.
+	gotTransactions, err := os.ReadFile(transactionsPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(gotTransactions), "newTransactionsHandAddedCmd",
+		"hand-added AddCommand call must be re-injected into fresh transactions.go")
+	// And the hand-added constructor file (NOVEL) survives.
+	gotHandAdded, err := os.ReadFile(handAddedPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(gotHandAdded), "newTransactionsHandAddedCmd",
+		"novel hand-written constructor file must survive --force regen")
+
+	// Tree must still build after merge.
+	runGoCommandForCLITest(t, outputDir, "mod", "tidy")
+	runGoCommandForCLITest(t, outputDir, "build", "./cmd/issue907app-pp-cli")
+}
+
+// TestGenerateCmdForceCrossSpecFallsBackToNovelOnly exercises R7: when the
+// user runs --force against a different spec than the snapshot recorded,
+// templated heuristic preservation is skipped (avoiding silent old+new
+// endpoint fusion). Novel hand-written files still survive because they are
+// spec-orthogonal.
+func TestGenerateCmdForceCrossSpecFallsBackToNovelOnly(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	outputDir := filepath.Join(dir, "crossspecapp")
+	specA := filepath.Join(dir, "spec_a.yaml")
+	specB := filepath.Join(dir, "spec_b.yaml")
+
+	specBody := func(name string) []byte {
+		return []byte(`name: ` + name + `
+description: Cross-spec fixture API ` + name + `
+version: 0.1.0
+base_url: https://api.example.com
+auth:
+  type: none
+config:
+  format: toml
+  path: ~/.config/` + name + `-pp-cli/config.toml
+resources:
+  items:
+    description: Manage items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        description: List items
+`)
+	}
+	require.NoError(t, os.WriteFile(specA, specBody("crossspecapp"), 0o644))
+	require.NoError(t, os.WriteFile(specB, specBody("crossspecapprenamed"), 0o644))
+
+	runGenerateWith := func(specFile string) {
+		cmd := newGenerateCmd()
+		cmd.SetArgs([]string{
+			"--spec", specFile,
+			"--output", outputDir,
+			"--validate=false",
+			"--force",
+		})
+		require.NoError(t, cmd.Execute())
+	}
+
+	runGenerateWith(specA)
+
+	// Hand-edit a templated config.go (literal drift).
+	configPath := filepath.Join(outputDir, "internal", "config", "config.go")
+	configBefore, err := os.ReadFile(configPath)
+	require.NoError(t, err)
+	configEdited := bytes.Replace(configBefore, []byte(`"Bearer "`), []byte(`"Token "`), 1)
+	require.NoError(t, os.WriteFile(configPath, configEdited, 0o644))
+
+	// Add a novel hand-written file (no marker, will be NOVEL).
+	novelPath := filepath.Join(outputDir, "internal", "cli", "novel_helper.go")
+	require.NoError(t, os.WriteFile(novelPath, []byte(`package cli
+
+func novelHelperFn() string { return "kept" }
+`), 0o644))
+
+	runGenerateWith(specB)
+
+	// Cross-spec: literal drift NOT preserved (NovelOnly skips
+	// TEMPLATED-VALUE-DRIFT).
+	gotConfig, err := os.ReadFile(configPath)
+	require.NoError(t, err)
+	if bytes.Contains(configBefore, []byte(`"Bearer "`)) {
+		assert.NotContains(t, string(gotConfig), `"Token "`,
+			"cross-spec --force must NOT preserve TEMPLATED-VALUE-DRIFT (silent fusion guard)")
+	}
+
+	// Novel file still preserved.
+	gotNovel, err := os.ReadFile(novelPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(gotNovel), "novelHelperFn",
+		"novel hand-written file is spec-orthogonal and must survive cross-spec regen")
 }
 
 func TestGenerateCmdForceRefusesSymlinkedInternalCliPreservation(t *testing.T) {
@@ -161,7 +361,7 @@ resources:
 
 	err := runGenerate()
 	require.Error(t, err)
-	assert.Contains(t, err.Error(), "refusing to preserve symlinked internal/cli file")
+	assert.Contains(t, err.Error(), "refusing to snapshot symlinked internal/cli file")
 }
 
 func TestGenerateCmdForceRefusesSymlinkedInternalSiblingPackage(t *testing.T) {
@@ -211,7 +411,7 @@ resources:
 
 	err := runGenerate()
 	require.Error(t, err)
-	assert.Contains(t, err.Error(), "refusing to preserve symlinked internal sibling package")
+	assert.Contains(t, err.Error(), "refusing to snapshot symlinked internal sibling package")
 }
 
 func TestGenerateCmdForceRefusesSymlinkedInternalAncestor(t *testing.T) {
@@ -262,24 +462,7 @@ resources:
 
 	err := runGenerate()
 	require.Error(t, err)
-	assert.Contains(t, err.Error(), "refusing to preserve hand-authored files through symlinked internal")
-}
-
-func TestMovePreservedDirFallsBackWhenRenameCrossesDevices(t *testing.T) {
-	t.Parallel()
-
-	dir := t.TempDir()
-	src := filepath.Join(dir, "staged")
-	dst := filepath.Join(dir, "restored")
-	require.NoError(t, os.MkdirAll(filepath.Join(src, "nested"), 0o755))
-	require.NoError(t, os.WriteFile(filepath.Join(src, "nested", "client.go"), []byte("package nested\n"), 0o644))
-
-	err := movePreservedDir(src, dst, func(_, _ string) error {
-		return &os.LinkError{Op: "rename", Old: src, New: dst, Err: syscall.EXDEV}
-	})
-	require.NoError(t, err)
-	assert.NoDirExists(t, src)
-	assert.FileExists(t, filepath.Join(dst, "nested", "client.go"))
+	assert.Contains(t, err.Error(), "refusing to snapshot symlinked internal")
 }
 
 func TestGenerateCmdConsumesTrafficAnalysis(t *testing.T) {
diff --git a/internal/cli/regen_merge.go b/internal/cli/regen_merge.go
index 27cba81d..a4101e03 100644
--- a/internal/cli/regen_merge.go
+++ b/internal/cli/regen_merge.go
@@ -114,6 +114,7 @@ func printHumanRegenReport(w io.Writer, report *regenmerge.MergeReport, applied
 		regenmerge.VerdictNovel,
 		regenmerge.VerdictTemplatedWithAdditions,
 		regenmerge.VerdictTemplatedBodyDrift,
+		regenmerge.VerdictTemplatedValueDrift,
 		regenmerge.VerdictNovelCollision,
 	} {
 		fmt.Fprintf(w, "  %-26s %d\n", v, counts[v])
@@ -126,6 +127,7 @@ func printHumanRegenReport(w io.Writer, report *regenmerge.MergeReport, applied
 		switch fc.Verdict {
 		case regenmerge.VerdictTemplatedWithAdditions,
 			regenmerge.VerdictTemplatedBodyDrift,
+			regenmerge.VerdictTemplatedValueDrift,
 			regenmerge.VerdictNovelCollision:
 			needsReview = append(needsReview, fc)
 		}
@@ -147,6 +149,11 @@ func printHumanRegenReport(w io.Writer, report *regenmerge.MergeReport, applied
 					fmt.Fprintf(w, "    body_drift in %s: %v\n", fn, calls)
 				}
 			}
+			if fc.ValueDrift != nil {
+				for declName := range fc.ValueDrift.Decls {
+					fmt.Fprintf(w, "    value_drift in %s\n", declName)
+				}
+			}
 		}
 		fmt.Fprintln(w)
 	}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index a35a9015..77671268 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -9,11 +9,12 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"regexp"
 	"runtime"
+	"strconv"
 	"strings"
-	"syscall"
 	"time"
 
 	catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
@@ -21,7 +22,6 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/docspec"
-	"github.com/mvanhorn/cli-printing-press/v4/internal/generatedmarker"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/generator"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/graphql"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/llm"
@@ -29,6 +29,7 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline/regenmerge"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/version"
 	"github.com/spf13/cobra"
@@ -153,7 +154,7 @@ func newGenerateCmd() *cobra.Command {
 					return err
 				}
 
-				absOut, _, err := resolveGenerateOutputDir(outputDir, parsed.Name, force, true)
+				absOut, _, snapshotDir, err := resolveGenerateOutputDir(outputDir, parsed.Name, force, true)
 				if err != nil {
 					return err
 				}
@@ -163,6 +164,12 @@ func newGenerateCmd() *cobra.Command {
 					return err
 				}
 
+				if snapshotDir != "" {
+					if err := finalizeForceMerge(snapshotDir, absOut, docYAML); err != nil {
+						return err
+					}
+				}
+
 				runID := pipeline.DeriveRunIDFromResearchDir(researchDir)
 				if runID == "" {
 					fmt.Fprintln(os.Stderr, "warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it")
@@ -220,7 +227,7 @@ func newGenerateCmd() *cobra.Command {
 					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("plan contains no command definitions")}
 				}
 
-				absOut, _, err := resolveGenerateOutputDir(outputDir, planSpec.CLIName, force, true)
+				absOut, _, snapshotDir, err := resolveGenerateOutputDir(outputDir, planSpec.CLIName, force, true)
 				if err != nil {
 					return err
 				}
@@ -229,6 +236,16 @@ func newGenerateCmd() *cobra.Command {
 					return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("generating from plan: %w", err)}
 				}
 
+				if snapshotDir != "" {
+					// Plan-driven generation does not write a manifest with
+					// SpecChecksum, so the cross-spec guard naturally lands
+					// on the defensive full-merge path. Pass nil so any
+					// manifest hash that does exist still gates merge mode.
+					if err := finalizeForceMerge(snapshotDir, absOut, nil); err != nil {
+						return err
+					}
+				}
+
 				fmt.Fprintf(os.Stderr, "Generated %s at %s (from plan)\n", naming.CLI(planSpec.CLIName), absOut)
 				if asJSON {
 					if err := json.NewEncoder(os.Stdout).Encode(map[string]any{
@@ -296,7 +313,7 @@ func newGenerateCmd() *cobra.Command {
 				return err
 			}
 
-			absOut, explicitOutput, err := resolveGenerateOutputDir(outputDir, apiSpec.Name, force, !dryRun)
+			absOut, explicitOutput, snapshotDir, err := resolveGenerateOutputDir(outputDir, apiSpec.Name, force, !dryRun)
 			if err != nil {
 				return err
 			}
@@ -309,6 +326,21 @@ func newGenerateCmd() *cobra.Command {
 				return err
 			}
 
+			// Merge any preserved hand-edits from the snapshot into the freshly
+			// emitted tree. snapshotDir is non-empty only when --force ran and
+			// the prior absOut had content. The cross-spec guard inside
+			// mergeForceSnapshot falls back to NOVEL-only preservation when
+			// the snapshot's spec hash differs from the current spec.
+			if snapshotDir != "" {
+				var primarySpec []byte
+				if len(specRawBytes) > 0 {
+					primarySpec = specRawBytes[0]
+				}
+				if err := finalizeForceMerge(snapshotDir, absOut, primarySpec); err != nil {
+					return err
+				}
+			}
+
 			// When --output was not explicitly supplied, normalize the output
 			// directory to the spec-derived name so default-path runs land in the
 			// expected slot (e.g., spec title "Cal.com" derives "cal-com-pp-cli").
@@ -382,7 +414,7 @@ func newGenerateCmd() *cobra.Command {
 	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: ~/printing-press/library/<name>)")
 	cmd.Flags().BoolVar(&validate, "validate", true, "Run quality gates on the generated project")
 	cmd.Flags().BoolVar(&refresh, "refresh", false, "Refresh cached remote spec before generating")
-	cmd.Flags().BoolVar(&force, "force", false, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files and internal sibling packages")
+	cmd.Flags().BoolVar(&force, "force", false, "Recreate the base output directory while preserving hand-edits to generated files via AST-based merge")
 	cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
 	cmd.Flags().StringVar(&docsURL, "docs", "", "API documentation URL to generate spec from")
 	cmd.Flags().BoolVar(&polish, "polish", false, "Run LLM polish pass on generated CLI (requires claude or codex CLI)")
@@ -519,23 +551,23 @@ func normalizeHTTPTransport(value string) (string, error) {
 	}
 }
 
-func resolveGenerateOutputDir(outputDir, cliName string, force bool, claim bool) (string, bool, error) {
-	explicitOutput := outputDir != ""
+func resolveGenerateOutputDir(outputDir, cliName string, force bool, claim bool) (resolvedAbsOut string, explicitOutput bool, snapshotDir string, err error) {
+	explicitOutput = outputDir != ""
 	if outputDir == "" {
 		outputDir = pipeline.DefaultOutputDir(cliName)
 	}
 	absOut, err := filepath.Abs(outputDir)
 	if err != nil {
-		return "", false, fmt.Errorf("resolving output path: %w", err)
+		return "", false, "", fmt.Errorf("resolving output path: %w", err)
 	}
 	if !claim {
-		return absOut, explicitOutput, nil
+		return absOut, explicitOutput, "", nil
 	}
-	absOut, err = claimOrForce(absOut, force, explicitOutput)
+	absOut, snapshotDir, err = claimOrForce(absOut, force, explicitOutput)
 	if err != nil {
-		return "", false, &ExitError{Code: ExitInputError, Err: err}
+		return "", false, "", &ExitError{Code: ExitInputError, Err: err}
 	}
-	return absOut, explicitOutput, nil
+	return absOut, explicitOutput, snapshotDir, nil
 }
 
 func applyHTTPTransportDefault(apiSpec *spec.APISpec, analysis *browsersniff.TrafficAnalysis) {
@@ -763,276 +795,308 @@ func httpTransportPriority(value string) int {
 
 // claimOrForce resolves the output directory based on --force and --output flags.
 //
-//   - force=true:  RemoveAll the target, then create it fresh (claims exact slot), preserving hand-authored internal/cli/*.go files and internal sibling packages
+//   - force=true:  rename the existing dir to a sibling snapshot (when present), recreate absOut empty for Generate(); the caller is responsible for merging the snapshot back in via regenmerge.MergeIntoFreshTree once Generate() finishes. Returns the snapshot path so the caller can drive the merge.
 //   - explicit output (--output set) without force: error if exists and non-empty
 //   - default (no --output, no --force): auto-increment via ClaimOutputDir
-func claimOrForce(absOut string, force bool, explicitOutput bool) (string, error) {
+//
+// snapshotDir is non-empty only on the force=true path AND when the prior absOut had content. When non-empty it points to a sibling tempdir holding the pre-regen tree.
+func claimOrForce(absOut string, force bool, explicitOutput bool) (resolvedAbsOut, snapshotDir string, err error) {
 	if force {
-		preserved, err := preserveHandAuthoredInternalCLIFiles(absOut)
-		if err != nil {
-			return "", err
-		}
-		preservedDirs, err := preserveHandAuthoredInternalSiblingDirs(absOut)
+		snapshotDir, err = snapshotForceRegen(absOut)
 		if err != nil {
-			return "", err
+			return "", "", err
 		}
-		defer cleanupPreservedDirs(preservedDirs)
-		if err := os.RemoveAll(absOut); err != nil {
-			return "", fmt.Errorf("removing existing output dir: %w", err)
-		}
-		if err := os.MkdirAll(absOut, 0o755); err != nil {
-			return "", fmt.Errorf("creating output dir: %w", err)
-		}
-		if err := restorePreservedDirs(absOut, preservedDirs); err != nil {
-			return "", err
-		}
-		if err := restorePreservedFiles(absOut, preserved); err != nil {
-			return "", err
+		if mkErr := os.MkdirAll(absOut, 0o755); mkErr != nil {
+			if snapshotDir == "" {
+				return "", "", fmt.Errorf("creating output dir: %w", mkErr)
+			}
+			if rollbackErr := os.Rename(snapshotDir, absOut); rollbackErr != nil {
+				return "", "", fmt.Errorf("creating output dir: %w; snapshot rollback also failed (%v); user must manually move %s back to %s",
+					mkErr, rollbackErr, snapshotDir, absOut)
+			}
+			return "", "", fmt.Errorf("creating output dir: %w", mkErr)
 		}
-		return absOut, nil
+		return absOut, snapshotDir, nil
 	}
 
 	if explicitOutput {
 		if info, err := os.Stat(absOut); err == nil && info.IsDir() {
 			entries, readErr := os.ReadDir(absOut)
 			if readErr != nil {
-				return "", fmt.Errorf("reading output directory: %w", readErr)
+				return "", "", fmt.Errorf("reading output directory: %w", readErr)
 			}
 			if len(entries) > 0 {
-				return "", fmt.Errorf("output directory %s already exists (use --force to overwrite)", absOut)
+				return "", "", fmt.Errorf("output directory %s already exists (use --force to overwrite)", absOut)
 			}
 		}
-		return absOut, nil
+		return absOut, "", nil
 	}
 
-	return pipeline.ClaimOutputDir(absOut)
+	resolved, err := pipeline.ClaimOutputDir(absOut)
+	if err != nil {
+		return "", "", err
+	}
+	return resolved, "", nil
 }
 
-type preservedFile struct {
-	relPath string
-	data    []byte
-	mode    os.FileMode
+// finalizeForceMerge runs the post-Generate merge for any --force codepath:
+// classifies snapshotDir against freshDir, merges preserved hand-edits back,
+// re-runs `go mod tidy` when go.mod was merged (so go.sum keeps up with
+// preserved requires), and removes the snapshot on success. On merge
+// failure the snapshot is left in place and the error surfaces a recovery
+// command.
+//
+// Wired from the three --force codepaths (--spec, --docs, --plan) so each
+// one preserves hand-edits consistently — discarding snapshotDir after
+// generation would silently lose user work and leave an orphan that blocks
+// future --force runs.
+func finalizeForceMerge(snapshotDir, freshDir string, currentSpecBytes []byte) error {
+	gomodMerged, err := mergeForceSnapshot(snapshotDir, freshDir, currentSpecBytes)
+	if err != nil {
+		return &ExitError{Code: ExitGenerationError, Err: err}
+	}
+	if gomodMerged {
+		retidyAfterMerge(freshDir)
+	}
+	if removeErr := os.RemoveAll(snapshotDir); removeErr != nil {
+		fmt.Fprintf(os.Stderr, "warning: could not remove snapshot dir %s: %v\n", snapshotDir, removeErr)
+	}
+	return nil
 }
 
-type preservedDir struct {
-	relPath string
-	staged  string
-}
+// mergeForceSnapshot drives the snapshot→fresh merge after Generate() has
+// populated absOut. Computes the cross-spec guard by comparing the
+// snapshot's recorded spec checksum to a sha256 over the current spec
+// bytes. Same checksum (or no recorded checksum) → run the full AST-aware
+// merge; mismatch or unreadable manifest → fall back to NOVEL-only
+// preservation.
+//
+// When the merge updates go.mod (snapshot had hand-added requires), the
+// caller must re-run `go mod tidy` against freshDir to refresh go.sum —
+// validation's tidy ran before merge against fresh's smaller go.mod, so
+// hashes for the preserved requires are missing from go.sum until the
+// post-merge tidy fills them in. The boolean return reports whether that
+// re-tidy is needed.
+//
+// On failure the snapshot is intentionally left in place; the returned
+// error includes the snapshot path so the user can recover manually with
+// `rm -rf <freshDir> && mv <snapshotDir> <freshDir>`.
+func mergeForceSnapshot(snapshotDir, freshDir string, currentSpecBytes []byte) (gomodMerged bool, err error) {
+	report, err := regenmerge.Classify(snapshotDir, freshDir, regenmerge.Options{Force: true})
+	if err != nil {
+		return false, fmt.Errorf("classifying snapshot vs fresh: %w; snapshot preserved at %s", err, snapshotDir)
+	}
 
-var generatorOwnedInternalDirs = map[string]bool{
-	"cache":   true,
-	"cli":     true,
-	"client":  true,
-	"cliutil": true,
-	"config":  true,
-	"mcp":     true,
-	"share":   true,
-	"store":   true,
-	"types":   true,
-}
+	novelOnly := !forceRegenSpecHashMatches(snapshotDir, currentSpecBytes)
 
-func preserveHandAuthoredInternalCLIFiles(absOut string) ([]preservedFile, error) {
-	for _, rel := range []string{"internal", filepath.Join("internal", "cli")} {
-		path := filepath.Join(absOut, rel)
-		info, err := os.Lstat(path)
-		if err != nil {
-			if os.IsNotExist(err) {
-				continue
-			}
-			return nil, fmt.Errorf("statting %s for hand-authored files: %w", rel, err)
+	mergeOpts := regenmerge.Options{Force: true, NovelOnly: novelOnly}
+	if err := regenmerge.MergeIntoFreshTree(snapshotDir, freshDir, report, mergeOpts); err != nil {
+		return false, fmt.Errorf("merging snapshot into fresh tree: %w; snapshot preserved at %s — recover with `rm -rf %s && mv %s %s`",
+			err, snapshotDir, freshDir, snapshotDir, freshDir)
+	}
+
+	preserved := 0
+	for _, fc := range report.Files {
+		if fc.Applied {
+			preserved++
 		}
-		if info.Mode()&os.ModeSymlink != 0 {
-			return nil, fmt.Errorf("refusing to preserve hand-authored files through symlinked %s: %s", rel, path)
+	}
+	injected := 0
+	for _, lr := range report.LostRegistrations {
+		if lr.Applied {
+			injected += len(lr.Calls)
 		}
 	}
+	mode := ""
+	if novelOnly {
+		mode = " (cross-spec: novel-only preservation)"
+	}
+	fmt.Fprintf(os.Stderr, "Force regen merged %d preserved files / %d AddCommand calls%s\n", preserved, injected, mode)
+	return report.GoMod != nil && report.GoMod.Merged, nil
+}
 
-	cliDir := filepath.Join(absOut, "internal", "cli")
-	entries, err := os.ReadDir(cliDir)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, nil
-		}
-		return nil, fmt.Errorf("reading internal/cli for hand-authored files: %w", err)
+// retidyAfterMerge re-runs `go mod tidy` against dir so go.sum picks up
+// hashes for any requires the merge added. Generation's prior tidy ran
+// against fresh's go.mod before merge, so any preserved require from the
+// snapshot is in go.mod but missing from go.sum until this step fills it
+// in. Failure here surfaces as a warning rather than a hard error: the
+// merged tree still ships valid sources, and `go mod tidy` is something
+// the user can run manually.
+func retidyAfterMerge(dir string) {
+	cmd := exec.Command("go", "mod", "tidy")
+	cmd.Dir = dir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		fmt.Fprintf(os.Stderr, "warning: post-merge `go mod tidy` failed: %v\n%s", err, out)
 	}
+}
 
-	var preserved []preservedFile
-	for _, entry := range entries {
-		if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" {
-			continue
-		}
-		if entry.Type()&os.ModeSymlink != 0 {
-			return nil, fmt.Errorf("refusing to preserve symlinked internal/cli file: %s", filepath.Join(cliDir, entry.Name()))
-		}
-		path := filepath.Join(cliDir, entry.Name())
-		if generatedmarker.HasInFile(path) {
-			continue
-		}
-		data, err := os.ReadFile(path)
-		if err != nil {
-			return nil, fmt.Errorf("reading hand-authored candidate %s: %w", path, err)
-		}
-		info, err := entry.Info()
-		if err != nil {
-			return nil, fmt.Errorf("statting hand-authored candidate %s: %w", path, err)
-		}
-		preserved = append(preserved, preservedFile{
-			relPath: filepath.Join("internal", "cli", entry.Name()),
-			data:    data,
-			mode:    info.Mode().Perm(),
-		})
+// forceRegenSpecHashMatches reports whether the snapshot's recorded spec
+// checksum matches a sha256 over the current spec bytes. Returns true when:
+//   - the snapshot manifest is missing (defensive — old binary or partial
+//     state from a CLI generated before SpecChecksum was populated),
+//   - the snapshot manifest has an empty SpecChecksum (plan-generated, old
+//     format, or docs source without a stored hash),
+//   - or the snapshot checksum equals the current spec hash.
+//
+// Returns false when:
+//   - the manifest exists but cannot be decoded (corrupt JSON — treat as
+//     unknown lineage and fall back to NOVEL-only preservation),
+//   - the snapshot has a checksum but the caller has no current bytes to
+//     compare (e.g., a --plan --force run over a spec-generated tree;
+//     lineage differs by construction so NOVEL-only is the safe fallback),
+//   - or both sides have a checksum and they differ.
+//
+// The hash matches climanifest.go's storage convention (sha256 over the
+// raw input spec bytes, "sha256:" + hex), so a same-spec regen produces a
+// byte-identical hash and the full-merge path runs.
+func forceRegenSpecHashMatches(snapshotDir string, currentSpecBytes []byte) bool {
+	manifestPath := filepath.Join(snapshotDir, pipeline.CLIManifestFilename)
+	if _, err := os.Stat(manifestPath); errors.Is(err, os.ErrNotExist) {
+		return true
+	}
+	manifest, err := pipeline.ReadCLIManifest(snapshotDir)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: could not decode snapshot manifest at %s: %v; falling back to novel-only preservation\n", manifestPath, err)
+		return false
+	}
+	if manifest.SpecChecksum == "" {
+		return true
+	}
+	if len(currentSpecBytes) == 0 {
+		return false
 	}
-	return preserved, nil
+	return manifest.SpecChecksum == currentSpecChecksum(currentSpecBytes)
+}
+
+func currentSpecChecksum(specBytes []byte) string {
+	sum := sha256.Sum256(specBytes)
+	return "sha256:" + hex.EncodeToString(sum[:])
 }
 
-func preserveHandAuthoredInternalSiblingDirs(absOut string) ([]preservedDir, error) {
-	internalDir := filepath.Join(absOut, "internal")
-	entries, err := os.ReadDir(internalDir)
+// snapshotForceRegen renames absOut to a sibling tempdir for use as a regen
+// recovery path. Returns "" when absOut is missing or empty (nothing to
+// snapshot — fresh generation has nothing to preserve).
+//
+// Symlink-refusal happens BEFORE the rename so a refused regen exits without
+// mutating the user's tree — fail before mutating is the load-bearing
+// guarantee here.
+func snapshotForceRegen(absOut string) (string, error) {
+	info, err := os.Lstat(absOut)
 	if err != nil {
 		if os.IsNotExist(err) {
-			return nil, nil
+			return "", nil
 		}
-		return nil, fmt.Errorf("reading internal for hand-authored sibling packages: %w", err)
+		return "", fmt.Errorf("statting output dir for force regen: %w", err)
+	}
+	if info.Mode()&os.ModeSymlink != 0 {
+		return "", fmt.Errorf("refusing to snapshot symlinked output dir: %s", absOut)
 	}
 
-	var preserved []preservedDir
-	var stagingRoot string
-	for _, entry := range entries {
-		if entry.Type()&os.ModeSymlink != 0 {
-			return nil, fmt.Errorf("refusing to preserve symlinked internal sibling package: %s", filepath.Join(internalDir, entry.Name()))
-		}
-		if !entry.IsDir() || generatorOwnsInternalDir(internalDir, entry.Name()) {
-			continue
-		}
-		if stagingRoot == "" {
-			stagingRoot, err = os.MkdirTemp("", "printing-press-preserved-internal-*")
-			if err != nil {
-				return nil, fmt.Errorf("creating preservation staging dir: %w", err)
-			}
-		}
-		src := filepath.Join(internalDir, entry.Name())
-		staged := filepath.Join(stagingRoot, entry.Name())
-		if err := copyPreservedDir(src, staged); err != nil {
-			cleanupPreservedDirs(preserved)
-			_ = os.RemoveAll(stagingRoot)
-			return nil, err
-		}
-		preserved = append(preserved, preservedDir{
-			relPath: filepath.Join("internal", entry.Name()),
-			staged:  staged,
-		})
+	entries, err := os.ReadDir(absOut)
+	if err != nil {
+		return "", fmt.Errorf("reading output dir for force regen: %w", err)
+	}
+	if len(entries) == 0 {
+		return "", nil
 	}
-	return preserved, nil
-}
 
-func generatorOwnsInternalDir(internalDir, name string) bool {
-	if name == "cli" {
-		return true
+	if err := refuseSymlinksUnderForceRegenTree(absOut); err != nil {
+		return "", err
 	}
-	if !generatorOwnedInternalDirs[name] {
-		return false
+
+	parent := filepath.Dir(absOut)
+	base := filepath.Base(absOut)
+	if orphans, err := findExistingPreserveSiblings(parent, base); err != nil {
+		return "", err
+	} else if len(orphans) > 0 {
+		return "", fmt.Errorf("found %d unrecovered snapshot(s) from prior --force run(s) at: %s; recover hand-edits or remove the directories before retrying",
+			len(orphans), strings.Join(orphans, ", "))
+	}
+	snapshot := filepath.Join(parent, base+".preserve-"+strconv.FormatInt(time.Now().UnixNano(), 10))
+	if _, err := os.Lstat(snapshot); err == nil {
+		return "", fmt.Errorf("snapshot path collision: %s already exists", snapshot)
+	} else if !os.IsNotExist(err) {
+		return "", fmt.Errorf("checking snapshot path %s: %w", snapshot, err)
 	}
-	return dirContainsGeneratedMarker(filepath.Join(internalDir, name))
+	if err := os.Rename(absOut, snapshot); err != nil {
+		return "", fmt.Errorf("snapshotting output dir to %s: %w", snapshot, err)
+	}
+	return snapshot, nil
 }
 
-func dirContainsGeneratedMarker(dir string) bool {
-	found := false
-	_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
-		if err != nil || found || d.IsDir() || d.Type()&os.ModeSymlink != 0 {
-			return nil
-		}
-		if generatedmarker.HasInFile(path) {
-			found = true
+// findExistingPreserveSiblings returns absolute paths to any directories of
+// the form `<base>.preserve-*` already in parent. These represent
+// unrecovered snapshots from previous --force runs that crashed before
+// merge cleanup. Continuing past one would orphan the user's hand-edits
+// (the new snapshot would be taken from the partial-fresh content of the
+// crashed run, not the original source-of-truth).
+func findExistingPreserveSiblings(parent, base string) ([]string, error) {
+	entries, err := os.ReadDir(parent)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
 		}
-		return nil
-	})
-	return found
-}
-
-func restorePreservedFiles(absOut string, files []preservedFile) error {
-	for _, file := range files {
-		path := filepath.Join(absOut, file.relPath)
-		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
-			return fmt.Errorf("creating preserved file parent: %w", err)
+		return nil, fmt.Errorf("reading %s for prior snapshots: %w", parent, err)
+	}
+	var orphans []string
+	prefix := base + ".preserve-"
+	for _, entry := range entries {
+		if !entry.IsDir() {
+			continue
 		}
-		if err := os.WriteFile(path, file.data, file.mode); err != nil {
-			return fmt.Errorf("restoring preserved file %s: %w", path, err)
+		if strings.HasPrefix(entry.Name(), prefix) {
+			orphans = append(orphans, filepath.Join(parent, entry.Name()))
 		}
 	}
-	return nil
+	return orphans, nil
 }
 
-func restorePreservedDirs(absOut string, dirs []preservedDir) error {
-	for _, dir := range dirs {
-		dst := filepath.Join(absOut, dir.relPath)
-		if _, err := os.Stat(dst); err == nil {
-			return fmt.Errorf("refusing to overwrite generated internal sibling package while restoring preserved files: %s", dst)
-		} else if err != nil && !os.IsNotExist(err) {
-			return fmt.Errorf("checking preserved sibling restore path %s: %w", dst, err)
-		}
-		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
-			return fmt.Errorf("creating preserved sibling parent: %w", err)
+// refuseSymlinksUnderForceRegenTree walks the parts of absOut that the
+// regenmerge pipeline subsequently reads through (internal/, internal/cli,
+// internal/cli/*.go, sibling-package directories) and returns an error if
+// any of them are symlinks. The rename in snapshotForceRegen is the
+// destructive boundary, so all symlink checks must pass before it.
+func refuseSymlinksUnderForceRegenTree(absOut string) error {
+	for _, rel := range []string{"internal", filepath.Join("internal", "cli")} {
+		path := filepath.Join(absOut, rel)
+		info, err := os.Lstat(path)
+		if err != nil {
+			if os.IsNotExist(err) {
+				continue
+			}
+			return fmt.Errorf("statting %s for force regen symlink check: %w", rel, err)
 		}
-		if err := movePreservedDir(dir.staged, dst, os.Rename); err != nil {
-			return fmt.Errorf("restoring preserved sibling package %s: %w", dst, err)
+		if info.Mode()&os.ModeSymlink != 0 {
+			return fmt.Errorf("refusing to snapshot symlinked %s: %s", rel, path)
 		}
 	}
-	return nil
-}
 
-func cleanupPreservedDirs(dirs []preservedDir) {
-	for _, dir := range dirs {
-		_ = os.RemoveAll(filepath.Dir(dir.staged))
+	if err := refuseSymlinkedEntries(filepath.Join(absOut, "internal", "cli"), "internal/cli file"); err != nil {
+		return err
 	}
+	return refuseSymlinkedEntries(filepath.Join(absOut, "internal"), "internal sibling package")
 }
 
-func movePreservedDir(src, dst string, rename func(string, string) error) error {
-	if err := rename(src, dst); err != nil {
-		if !errors.Is(err, syscall.EXDEV) {
-			return err
+// refuseSymlinkedEntries reads dir and returns an error if any direct entry
+// is a symlink. A missing dir is not an error (caller may scan paths that
+// don't exist on every CLI). label is interpolated into the error message
+// to identify which surface refused the symlink.
+func refuseSymlinkedEntries(dir, label string) error {
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
 		}
-		if err := copyPreservedDir(src, dst); err != nil {
-			return err
+		return fmt.Errorf("reading %s for symlink check: %w", dir, err)
+	}
+	for _, entry := range entries {
+		if entry.Type()&os.ModeSymlink != 0 {
+			return fmt.Errorf("refusing to snapshot symlinked %s: %s", label, filepath.Join(dir, entry.Name()))
 		}
-		return os.RemoveAll(src)
 	}
 	return nil
 }
 
-func copyPreservedDir(src, dst string) error {
-	return filepath.WalkDir(src, func(path string, d os.DirEntry, walkErr error) error {
-		if walkErr != nil {
-			return walkErr
-		}
-		if d.Type()&os.ModeSymlink != 0 {
-			return fmt.Errorf("refusing to preserve symlink inside internal sibling package: %s", path)
-		}
-		rel, err := filepath.Rel(src, path)
-		if err != nil {
-			return fmt.Errorf("relativizing preserved sibling path %s: %w", path, err)
-		}
-		target := filepath.Join(dst, rel)
-		info, err := d.Info()
-		if err != nil {
-			return fmt.Errorf("statting preserved sibling path %s: %w", path, err)
-		}
-		switch {
-		case d.IsDir():
-			return os.MkdirAll(target, info.Mode().Perm())
-		case info.Mode().IsRegular():
-			data, err := os.ReadFile(path)
-			if err != nil {
-				return fmt.Errorf("reading preserved sibling file %s: %w", path, err)
-			}
-			if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
-				return fmt.Errorf("creating preserved sibling file parent: %w", err)
-			}
-			return os.WriteFile(target, data, info.Mode().Perm())
-		default:
-			return fmt.Errorf("refusing to preserve non-regular internal sibling path: %s", path)
-		}
-	})
-}
-
 func fetchOrCacheSpec(specURL string, refresh bool, skipCache bool) ([]byte, error) {
 	sum := sha256.Sum256([]byte(specURL))
 	cacheKey := hex.EncodeToString(sum[:])
diff --git a/internal/pipeline/contracts_test.go b/internal/pipeline/contracts_test.go
index 538525f5..4237c7be 100644
--- a/internal/pipeline/contracts_test.go
+++ b/internal/pipeline/contracts_test.go
@@ -217,7 +217,7 @@ func TestGenerateHelpMentionsPublishedLibraryDefault(t *testing.T) {
 	root := readContractFile(t, filepath.Join("..", "..", "internal", "cli", "root.go"))
 
 	assert.Contains(t, root, "Output directory (default: ~/printing-press/library/<name>)")
-	assert.Contains(t, root, "Recreate the base output directory while preserving hand-authored internal/cli/*.go files")
+	assert.Contains(t, root, "Recreate the base output directory while preserving hand-edits to generated files via AST-based merge")
 	assert.NotContains(t, root, "~/printing-press/workspaces/<scope>/library")
 }
 
diff --git a/internal/pipeline/regenmerge/apply.go b/internal/pipeline/regenmerge/apply.go
index 210d724c..84fa9eed 100644
--- a/internal/pipeline/regenmerge/apply.go
+++ b/internal/pipeline/regenmerge/apply.go
@@ -98,6 +98,17 @@ func Apply(report *MergeReport, opts Options) error {
 				return fmt.Errorf("removing stale %s: %w", fc.Path, err)
 			}
 			fc.Applied = true
+		case VerdictNovel,
+			VerdictNovelCollision,
+			VerdictTemplatedWithAdditions,
+			VerdictTemplatedBodyDrift,
+			VerdictTemplatedValueDrift:
+			// Preserve verdicts: tempDir already holds published's copy from
+			// the deep-copy step, and the human-review path keeps it
+			// untouched. No file-level action needed here.
+		default:
+			cleanup()
+			return fmt.Errorf("unhandled verdict %q for %s", fc.Verdict, fc.Path)
 		}
 	}
 
diff --git a/internal/pipeline/regenmerge/classify.go b/internal/pipeline/regenmerge/classify.go
index de62b2ca..035fc790 100644
--- a/internal/pipeline/regenmerge/classify.go
+++ b/internal/pipeline/regenmerge/classify.go
@@ -255,6 +255,14 @@ func decideBothPresent(rel, pubPath, freshPath string, pub, fresh declSet, pubMa
 				fc.BodyDrift = drift
 				return fc
 			}
+			// Body-drift's call-target walker misses literal-value changes
+			// and identifier renames in non-call positions. Per-decl
+			// go/printer text compare catches both.
+			if drift := detectValueDrift(pubPath, freshPath); drift != nil {
+				fc.Verdict = VerdictTemplatedValueDrift
+				fc.ValueDrift = drift
+				return fc
+			}
 			fc.Verdict = VerdictTemplatedClean
 			return fc
 		}
diff --git a/internal/pipeline/regenmerge/merge_into_fresh.go b/internal/pipeline/regenmerge/merge_into_fresh.go
new file mode 100644
index 00000000..8c3395ce
--- /dev/null
+++ b/internal/pipeline/regenmerge/merge_into_fresh.go
@@ -0,0 +1,228 @@
+package regenmerge
+
+import (
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+// regenmergeGeneratorOwnedDirs lists internal/<name>/ subtrees the generator
+// owns end-to-end. Files inside these directories that don't appear in the
+// classifier's report (typically non-Go files like text fixtures) are NOT
+// swept from snapshot into fresh during MergeIntoFreshTree — fresh's
+// emission is authoritative for everything under these roots. Canonical
+// source for the regen pipeline; the parallel list in internal/cli/root.go
+// (alongside the dead in-memory preserve helpers) tracks the same set and
+// will be removed when those helpers are deleted.
+var regenmergeGeneratorOwnedDirs = map[string]struct{}{
+	"cli":     {},
+	"cliutil": {},
+	"mcp":     {},
+	"cache":   {},
+	"client":  {},
+	"config":  {},
+	"share":   {},
+	"store":   {},
+	"types":   {},
+}
+
+// MergeIntoFreshTree merges hand-edits from a snapshot directory into a
+// freshly-emitted CLI tree. Companion to Apply for the regen-from-spec
+// workflow: where Apply runs stage-and-swap with the published tree as the
+// destination, MergeIntoFreshTree mutates the freshly-generated tree
+// in-place using the snapshot as the recovery path.
+//
+// Steps in order:
+//  1. Per-file verdict switch — copy preserve-worthy files from snapshot
+//     into fresh; no-op on TEMPLATED-CLEAN / NEW-TEMPLATE-EMISSION;
+//     leave PUBLISHED-ONLY-TEMPLATED files alone (fresh didn't emit them).
+//  2. Re-inject lost AddCommand calls into fresh-derived host files.
+//  3. Merge go.mod requires/replaces from snapshot into fresh's go.mod via
+//     renderMergedGoMod (preserves hand-added deps for novel packages).
+//  4. Sweep snapshot for non-classified files (README.md, Makefile, etc.)
+//     under non-generator-owned directories and copy any that don't exist
+//     in fresh.
+//
+// Symlinks at any preserve path or sweep path are refused — the caller is
+// expected to have validated the snapshot/fresh directory shape upstream.
+//
+// When opts.NovelOnly is true, only NOVEL and NOVEL-COLLISION verdicts are
+// preserved; TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT, and
+// TEMPLATED-VALUE-DRIFT files are left as fresh emitted them, and lost
+// AddCommand re-injection is skipped. The non-classified file sweep and
+// go.mod merge still run because both are spec-orthogonal — non-Go files
+// and go.mod require additions are valid preservation targets even when
+// the fresh spec differs from the snapshot's.
+func MergeIntoFreshTree(snapshotDir, freshDir string, report *MergeReport, opts Options) error {
+	if report == nil {
+		return errors.New("nil report")
+	}
+	if _, err := os.Stat(snapshotDir); err != nil {
+		return fmt.Errorf("snapshot dir %s: %w", snapshotDir, err)
+	}
+	if _, err := os.Stat(freshDir); err != nil {
+		return fmt.Errorf("fresh dir %s: %w", freshDir, err)
+	}
+
+	for i := range report.Files {
+		fc := &report.Files[i]
+		switch fc.Verdict {
+		case VerdictTemplatedClean, VerdictNewTemplateEmission, VerdictPublishedOnlyTemplated:
+			// fresh's emission is authoritative; nothing to copy from snapshot.
+		case VerdictNovel, VerdictNovelCollision:
+			if err := copyPreserveFile(snapshotDir, freshDir, fc.Path); err != nil {
+				return err
+			}
+			fc.Applied = true
+		case VerdictTemplatedWithAdditions, VerdictTemplatedBodyDrift, VerdictTemplatedValueDrift:
+			if opts.NovelOnly {
+				continue
+			}
+			if err := copyPreserveFile(snapshotDir, freshDir, fc.Path); err != nil {
+				return err
+			}
+			fc.Applied = true
+		default:
+			return fmt.Errorf("unhandled verdict %q for %s", fc.Verdict, fc.Path)
+		}
+	}
+
+	if !opts.NovelOnly {
+		for i := range report.LostRegistrations {
+			lr := &report.LostRegistrations[i]
+			if len(lr.Calls) == 0 {
+				continue
+			}
+			hostPath := filepath.Join(freshDir, lr.HostFile)
+			if err := injectAddCommands(hostPath, lr.Calls); err != nil {
+				return fmt.Errorf("re-injecting AddCommand into %s: %w", lr.HostFile, err)
+			}
+			lr.Applied = true
+		}
+	}
+
+	if report.GoMod != nil {
+		mergedBytes, err := renderMergedGoMod(snapshotDir, freshDir)
+		switch {
+		case err == nil:
+			if writeErr := writeFileAtomic(filepath.Join(freshDir, "go.mod"), mergedBytes); writeErr != nil {
+				return fmt.Errorf("writing merged go.mod: %w", writeErr)
+			}
+			report.GoMod.Merged = true
+		case errors.Is(err, fs.ErrNotExist):
+			// Either tree lacks a go.mod; leave fresh's emission alone.
+		default:
+			return fmt.Errorf("rendering merged go.mod: %w", err)
+		}
+	}
+
+	if err := sweepNonClassifiedFiles(snapshotDir, freshDir); err != nil {
+		return fmt.Errorf("sweeping non-classified snapshot files: %w", err)
+	}
+
+	report.Applied = true
+	return nil
+}
+
+// copyPreserveFile copies snapshot/rel → fresh/rel, refusing symlinks and
+// creating parent dirs as needed.
+func copyPreserveFile(snapshotDir, freshDir, rel string) error {
+	src := filepath.Join(snapshotDir, rel)
+	dst := filepath.Join(freshDir, rel)
+
+	info, err := os.Lstat(src)
+	if err != nil {
+		return fmt.Errorf("statting snapshot file %s: %w", rel, err)
+	}
+	if info.Mode()&os.ModeSymlink != 0 {
+		return fmt.Errorf("refusing to preserve symlinked snapshot file: %s", rel)
+	}
+	data, err := os.ReadFile(src)
+	if err != nil {
+		return fmt.Errorf("reading snapshot file %s: %w", rel, err)
+	}
+	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+		return fmt.Errorf("creating parent for %s: %w", rel, err)
+	}
+	if err := writeFileAtomic(dst, data); err != nil {
+		return fmt.Errorf("writing preserved %s: %w", rel, err)
+	}
+	return nil
+}
+
+// sweepNonClassifiedFiles walks the snapshot for files that the classifier
+// did not see (non-Go, non-module files like README.md, Makefile,
+// .printing-press.json) and copies any that don't exist in fresh AND don't
+// live under a generator-owned directory. Symlinks are refused.
+func sweepNonClassifiedFiles(snapshotDir, freshDir string) error {
+	return filepath.WalkDir(snapshotDir, func(path string, d fs.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		rel, err := filepath.Rel(snapshotDir, path)
+		if err != nil {
+			return err
+		}
+		if rel == "." {
+			return nil
+		}
+		relSlash := filepath.ToSlash(rel)
+		if d.IsDir() {
+			if !shouldWalkDir(d.Name()) {
+				return filepath.SkipDir
+			}
+			if isGeneratorOwnedInternalDir(relSlash) {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if !shouldWalkDir(filepath.Base(filepath.Dir(path))) {
+			return nil
+		}
+		if shouldClassifyFile(relSlash) {
+			return nil
+		}
+		if d.Type()&os.ModeSymlink != 0 {
+			return fmt.Errorf("refusing to sweep symlinked snapshot file: %s", relSlash)
+		}
+		dst := filepath.Join(freshDir, rel)
+		if _, err := os.Stat(dst); err == nil {
+			// fresh already emitted at this path; fresh wins.
+			return nil
+		} else if !errors.Is(err, fs.ErrNotExist) {
+			return fmt.Errorf("statting fresh path %s: %w", relSlash, err)
+		}
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return fmt.Errorf("reading snapshot file %s: %w", relSlash, err)
+		}
+		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+			return fmt.Errorf("creating parent for swept %s: %w", relSlash, err)
+		}
+		if err := writeFileAtomic(dst, data); err != nil {
+			return fmt.Errorf("writing swept %s: %w", relSlash, err)
+		}
+		return nil
+	})
+}
+
+// isGeneratorOwnedInternalDir reports whether relSlash names a directory
+// under internal/ that the generator owns end-to-end. Used by the sweep to
+// avoid copying random non-Go content into a directory the generator
+// regenerates from scratch each run.
+func isGeneratorOwnedInternalDir(relSlash string) bool {
+	const prefix = "internal/"
+	rest, ok := strings.CutPrefix(relSlash, prefix)
+	if !ok {
+		return false
+	}
+	first, _, _ := strings.Cut(rest, "/")
+	if first == "" {
+		return false
+	}
+	_, owned := regenmergeGeneratorOwnedDirs[first]
+	return owned
+}
diff --git a/internal/pipeline/regenmerge/merge_into_fresh_test.go b/internal/pipeline/regenmerge/merge_into_fresh_test.go
new file mode 100644
index 00000000..5b09f98b
--- /dev/null
+++ b/internal/pipeline/regenmerge/merge_into_fresh_test.go
@@ -0,0 +1,270 @@
+package regenmerge
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestMergeIntoFreshTreePreservesValueDrift exercises a templated
+// config.go const literal change — TEMPLATED-VALUE-DRIFT must preserve
+// the snapshot-side literal even when decl-sets match.
+func TestMergeIntoFreshTreePreservesValueDrift(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	rel := "internal/config/config.go"
+	templated := func(literal string) []byte {
+		return []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package config
+
+const authPrefix = "` + literal + `"
+`)
+	}
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "config"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(fresh, "internal", "config"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(snap, rel), templated("Token "), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(fresh, rel), templated("Bearer "), 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, rel))
+	require.NoError(t, err)
+	assert.Contains(t, string(got), `"Token "`,
+		"hand-edited literal must survive merge into fresh tree")
+}
+
+// TestMergeIntoFreshTreePreservesDeclSetAdditions covers hand-authored
+// functions added to a templated client file — TEMPLATED-WITH-ADDITIONS
+// must preserve the snapshot.
+func TestMergeIntoFreshTreePreservesDeclSetAdditions(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	rel := "internal/client/queries.go"
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "client"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(fresh, "internal", "client"), 0o755))
+
+	pubBody := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package client
+
+const placeholderQuery = ""
+
+func GetTransactions() string { return "transactions" }
+func GetCategories() string { return "categories" }
+`)
+	freshBody := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package client
+
+const placeholderQuery = ""
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(snap, rel), pubBody, 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(fresh, rel), freshBody, 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, rel))
+	require.NoError(t, err)
+	assert.Contains(t, string(got), "GetTransactions", "added function survives")
+	assert.Contains(t, string(got), "GetCategories", "added function survives")
+}
+
+// TestMergeIntoFreshTreeReinjectsLostAddCommand covers a hand-added
+// cmd.AddCommand line in a parent-command file — LostRegistrations must
+// re-inject the call into the freshly emitted host.
+func TestMergeIntoFreshTreeReinjectsLostAddCommand(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "cli"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(fresh, "internal", "cli"), 0o755))
+
+	rel := "internal/cli/transactions.go"
+	pubBody := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func newTransactionsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{}
+	cmd.AddCommand(newTransactionsListCmd(flags))
+	cmd.AddCommand(newHandAddedCmd(flags))
+	return cmd
+}
+`)
+	freshBody := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func newTransactionsCmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{}
+	cmd.AddCommand(newTransactionsListCmd(flags))
+	return cmd
+}
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(snap, rel), pubBody, 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(fresh, rel), freshBody, 0o644))
+	// Pub also has a hand-written constructor file so the referent-existence
+	// check passes.
+	novelRel := "internal/cli/hand_added.go"
+	novel := []byte(`package cli
+
+func newHandAddedCmd(flags *rootFlags) *cobra.Command { return nil }
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(snap, novelRel), novel, 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, rel))
+	require.NoError(t, err)
+	assert.Contains(t, string(got), "newHandAddedCmd",
+		"hand-added AddCommand must be re-injected into fresh's transactions.go")
+	assert.Contains(t, string(got), "newTransactionsListCmd",
+		"existing template-emitted AddCommand survives")
+
+	// Novel constructor file preserved.
+	novelGot, err := os.ReadFile(filepath.Join(fresh, novelRel))
+	require.NoError(t, err)
+	assert.Contains(t, string(novelGot), "newHandAddedCmd")
+}
+
+// TestMergeIntoFreshTreeMergesGoModRequires exercises R9: hand-added
+// require for novel-package deps survives.
+func TestMergeIntoFreshTreeMergesGoModRequires(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	pubGoMod := []byte(`module example.com/snap
+
+go 1.22
+
+require (
+	github.com/spf13/cobra v1.8.0
+	modernc.org/sqlite v1.30.0
+)
+`)
+	freshGoMod := []byte(`module example.com/snap
+
+go 1.22
+
+require (
+	github.com/spf13/cobra v1.8.0
+)
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(snap, "go.mod"), pubGoMod, 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(fresh, "go.mod"), freshGoMod, 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, "go.mod"))
+	require.NoError(t, err)
+	assert.Contains(t, string(got), "modernc.org/sqlite",
+		"hand-added require must survive merge into fresh's go.mod")
+}
+
+// TestMergeIntoFreshTreeSweepsNonClassifiedFiles exercises R8: README and
+// other non-Go files survive merge.
+func TestMergeIntoFreshTreeSweepsNonClassifiedFiles(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	require.NoError(t, os.WriteFile(filepath.Join(snap, "README.md"), []byte("hand edited\n"), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(snap, "Makefile"), []byte("# custom\n"), 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, "README.md"))
+	require.NoError(t, err)
+	assert.Equal(t, "hand edited\n", string(got))
+
+	got, err = os.ReadFile(filepath.Join(fresh, "Makefile"))
+	require.NoError(t, err)
+	assert.Equal(t, "# custom\n", string(got))
+}
+
+// TestMergeIntoFreshTreeNovelOnlySkipsValueDrift verifies that the
+// cross-spec fallback path skips heuristic preservation.
+func TestMergeIntoFreshTreeNovelOnlySkipsValueDrift(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	rel := "internal/config/config.go"
+	templated := func(literal string) []byte {
+		return []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package config
+
+const authPrefix = "` + literal + `"
+`)
+	}
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "config"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(fresh, "internal", "config"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(snap, rel), templated("Token "), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(fresh, rel), templated("Bearer "), 0o644))
+
+	// Novel hand-written file should still survive in NovelOnly mode.
+	novelRel := "internal/cli/handcrafted.go"
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "cli"), 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(snap, novelRel), []byte("package cli\n\nfunc Handcrafted() {}\n"), 0o644))
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	require.NoError(t, MergeIntoFreshTree(snap, fresh, report, Options{Force: true, NovelOnly: true}))
+
+	got, err := os.ReadFile(filepath.Join(fresh, rel))
+	require.NoError(t, err)
+	assert.Contains(t, string(got), `"Bearer "`,
+		"NovelOnly mode keeps fresh's literal value (skips TEMPLATED-VALUE-DRIFT preservation)")
+
+	novelGot, err := os.ReadFile(filepath.Join(fresh, novelRel))
+	require.NoError(t, err)
+	assert.Contains(t, string(novelGot), "Handcrafted",
+		"NovelOnly preserves novel hand-written files")
+}
+
+// TestMergeIntoFreshTreeRefusesSnapshotSymlinks verifies symlink safety in
+// the preserve path.
+func TestMergeIntoFreshTreeRefusesSnapshotSymlinks(t *testing.T) {
+	t.Parallel()
+
+	snap, fresh := makeMergeFixture(t)
+	rel := "internal/cli/sneaky.go"
+	require.NoError(t, os.MkdirAll(filepath.Join(snap, "internal", "cli"), 0o755))
+	target := filepath.Join(snap, "external.go")
+	require.NoError(t, os.WriteFile(target, []byte("package cli\n"), 0o644))
+	if err := os.Symlink(target, filepath.Join(snap, rel)); err != nil {
+		t.Skipf("symlink unavailable: %v", err)
+	}
+
+	report, err := Classify(snap, fresh, Options{Force: true})
+	require.NoError(t, err)
+	err = MergeIntoFreshTree(snap, fresh, report, Options{Force: true})
+	require.Error(t, err)
+	assert.Contains(t, strings.ToLower(err.Error()), "symlink")
+}
+
+func makeMergeFixture(t *testing.T) (snap, fresh string) {
+	t.Helper()
+	dir := t.TempDir()
+	snap = filepath.Join(dir, "snap")
+	fresh = filepath.Join(dir, "fresh")
+	require.NoError(t, os.MkdirAll(snap, 0o755))
+	require.NoError(t, os.MkdirAll(fresh, 0o755))
+	return snap, fresh
+}
diff --git a/internal/pipeline/regenmerge/regenmerge.go b/internal/pipeline/regenmerge/regenmerge.go
index 09921091..669d6a07 100644
--- a/internal/pipeline/regenmerge/regenmerge.go
+++ b/internal/pipeline/regenmerge/regenmerge.go
@@ -41,6 +41,15 @@ const (
 	// published; surface for human review.
 	VerdictTemplatedBodyDrift Verdict = "TEMPLATED-BODY-DRIFT"
 
+	// VerdictTemplatedValueDrift marks a file whose decl-set matches fresh's
+	// and whose body-call-targets match (so body-drift didn't fire) but
+	// whose top-level decls render to different go/printer text. Catches
+	// literal-value drift (e.g., const authPrefix = "Bearer " → "Token ")
+	// and identifier-rename drift in selector positions (e.g., cfg.Bearer
+	// → cfg.Token) — the cases body-drift's call-target walker misses.
+	// Preserve published; surface for human review.
+	VerdictTemplatedValueDrift Verdict = "TEMPLATED-VALUE-DRIFT"
+
 	// VerdictPublishedOnlyTemplated marks a file present only in published
 	// that carries the templated marker. Fresh dropped emitting it. Stale
 	// template emission; user can decide to delete.
@@ -75,6 +84,28 @@ type FileClassification struct {
 	// per-function the call-target identifiers pub references that don't
 	// appear anywhere in fresh's tree.
 	BodyDrift *BodyDrift `json:"body_drift,omitempty"`
+
+	// ValueDrift is populated for TEMPLATED-VALUE-DRIFT verdict. Lists
+	// per-decl the rendered text differences between published and fresh.
+	ValueDrift *ValueDrift `json:"value_drift,omitempty"`
+}
+
+// ValueDrift records per-decl rendered-text differences between published and
+// fresh for a templated file with matching decl-sets and clean body-drift.
+// Each entry names a top-level declaration whose go/printer rendering (with
+// the leading Doc comment stripped) differs between the two trees.
+type ValueDrift struct {
+	// Decls maps the canonical decl name (bare or "(*Type).Method") to the
+	// per-decl rendered-text delta.
+	Decls map[string]ValueDriftDelta `json:"decls,omitempty"`
+}
+
+// ValueDriftDelta records the per-decl rendered text on each side. Both sides
+// are truncated to a reasonable display length; full text is reconstructable
+// from the snapshot/fresh files.
+type ValueDriftDelta struct {
+	Published string `json:"published"`
+	Fresh     string `json:"fresh"`
 }
 
 // BodyDrift records function-body call-target differences between published
@@ -145,11 +176,20 @@ type MergeReport struct {
 	GoMod             *GoModMerge        `json:"go_mod,omitempty"`
 }
 
-// Options configure Classify and Apply behavior.
+// Options configure Classify, Apply, and MergeIntoFreshTree behavior.
 type Options struct {
 	// Force allows operating outside CWD prefix and on dirty git trees.
 	// Off by default.
 	Force bool
+
+	// NovelOnly restricts MergeIntoFreshTree to preserve only NOVEL and
+	// NOVEL-COLLISION files; TEMPLATED-WITH-ADDITIONS, TEMPLATED-BODY-DRIFT,
+	// and TEMPLATED-VALUE-DRIFT are left as fresh emitted them, and lost
+	// AddCommand re-injection is skipped. Used by the cross-spec fallback
+	// path in `generate --force` where the classifier's heuristics aren't
+	// valid across different specs but novel hand-written files (no marker)
+	// remain user-owned regardless of spec lineage.
+	NovelOnly bool
 }
 
 // Classify walks both trees, classifies each file, extracts AddCommand
diff --git a/internal/pipeline/regenmerge/registrations.go b/internal/pipeline/regenmerge/registrations.go
index a5ed905a..48413257 100644
--- a/internal/pipeline/regenmerge/registrations.go
+++ b/internal/pipeline/regenmerge/registrations.go
@@ -107,7 +107,7 @@ func extractLostRegistrations(publishedDir, freshDir string, pubVerdicts map[str
 		// would duplicate them.
 		relPath := filepath.ToSlash(filepath.Join("internal", "cli", filepath.Base(host)))
 		switch pubVerdicts[relPath] {
-		case VerdictTemplatedBodyDrift, VerdictTemplatedWithAdditions, VerdictNovel, VerdictNovelCollision:
+		case VerdictTemplatedBodyDrift, VerdictTemplatedWithAdditions, VerdictTemplatedValueDrift, VerdictNovel, VerdictNovelCollision:
 			continue
 		}
 		var lost, skipped []string
diff --git a/internal/pipeline/regenmerge/value_drift.go b/internal/pipeline/regenmerge/value_drift.go
new file mode 100644
index 00000000..3d0880ed
--- /dev/null
+++ b/internal/pipeline/regenmerge/value_drift.go
@@ -0,0 +1,221 @@
+package regenmerge
+
+import (
+	"bytes"
+	"go/ast"
+	"go/format"
+	"go/parser"
+	"go/token"
+	"strings"
+)
+
+// valueDriftDisplayLimit caps the per-side rendered text length surfaced in
+// reports so a hand-edited 200-line function doesn't dump 400 lines into a
+// merge summary. Truncation does not affect the equality check — that walks
+// the full rendered text.
+const valueDriftDisplayLimit = 120
+
+// detectValueDrift parses pub and fresh files, renders each top-level decl
+// (with its Doc comment stripped and AddCommand statements scrubbed from
+// function bodies) via go/format, and compares the rendered text per-decl.
+// Returns nil if there's no drift. Conservative — fires only when both files
+// parse and a same-named decl exists in both.
+//
+// This catches what body-drift's call-target walker misses:
+//   - basic-literal value changes (const x = "Bearer " → "Token ")
+//   - identifier renames in any non-call position (cfg.Bearer → cfg.Token)
+//   - type conversions, type assertions, composite-literal field changes
+//   - any AST shape difference inside a decl that go/format renders
+//     differently
+//
+// AddCommand call statements are scrubbed from function bodies before
+// rendering so AddCommand-only differences (which the LostRegistrations path
+// handles via re-injection into a fresh-derived TEMPLATED-CLEAN host) do not
+// trigger value-drift.
+func detectValueDrift(pubPath, freshPath string) *ValueDrift {
+	pubDecls := canonicalDeclTexts(pubPath)
+	if pubDecls == nil {
+		return nil
+	}
+	freshDecls := canonicalDeclTexts(freshPath)
+	if freshDecls == nil {
+		return nil
+	}
+
+	drift := map[string]ValueDriftDelta{}
+	for name, pubText := range pubDecls {
+		freshText, ok := freshDecls[name]
+		if !ok {
+			// Decl exists only in pub. The decl-set check upstream already
+			// flagged this as TEMPLATED-WITH-ADDITIONS, so we shouldn't be
+			// here. Skip defensively.
+			continue
+		}
+		if pubText == freshText {
+			continue
+		}
+		drift[name] = ValueDriftDelta{
+			Published: shortenForDisplay(pubText),
+			Fresh:     shortenForDisplay(freshText),
+		}
+	}
+	if len(drift) == 0 {
+		return nil
+	}
+	return &ValueDrift{Decls: drift}
+}
+
+// canonicalDeclTexts parses filename and returns a map from canonical decl
+// name to the canonically formatted text for that decl. Doc comments are
+// stripped so comment-only diffs don't trigger drift; AddCommand statements
+// are stripped from function bodies so AddCommand-only diffs route through
+// LostRegistrations instead. Returns nil if the file fails to parse.
+//
+// Mutates the parsed AST in place; safe because each call owns the file via
+// a fresh token.FileSet and parser.ParseFile, and the AST is discarded after
+// rendering.
+func canonicalDeclTexts(filename string) map[string]string {
+	fset := token.NewFileSet()
+	file, err := parser.ParseFile(fset, filename, nil, parser.SkipObjectResolution)
+	if err != nil {
+		return nil
+	}
+
+	out := make(map[string]string, len(file.Decls))
+	for _, d := range file.Decls {
+		var name string
+		switch decl := d.(type) {
+		case *ast.FuncDecl:
+			name = canonicalFuncName(decl)
+			decl.Doc = nil
+			if decl.Body != nil {
+				decl.Body.List = stripAddCommandStmts(decl.Body.List)
+			}
+		case *ast.GenDecl:
+			name = genDeclName(decl)
+			decl.Doc = nil
+			for _, spec := range decl.Specs {
+				switch s := spec.(type) {
+				case *ast.TypeSpec:
+					s.Doc = nil
+					s.Comment = nil
+				case *ast.ValueSpec:
+					s.Doc = nil
+					s.Comment = nil
+				case *ast.ImportSpec:
+					s.Doc = nil
+					s.Comment = nil
+				}
+			}
+		default:
+			continue
+		}
+		if name == "" {
+			continue
+		}
+		text, err := canonicalRender(fset, d)
+		if err != nil {
+			continue
+		}
+		out[name] = text
+	}
+	return out
+}
+
+// canonicalRender returns the gofmt-canonical text for an AST node. Renders
+// via go/format, then re-runs the output through format.Source (wrapped in
+// a synthetic package so format.Source accepts it) to strip blank-line and
+// other whitespace artifacts that go/format preserves from the original
+// FileSet positions. The two-step canonicalization makes the comparison
+// whitespace-insensitive without losing semantic detail.
+func canonicalRender(fset *token.FileSet, node ast.Node) (string, error) {
+	var buf bytes.Buffer
+	if err := format.Node(&buf, fset, node); err != nil {
+		return "", err
+	}
+	wrapped := append([]byte("package _\n"), buf.Bytes()...)
+	formatted, err := format.Source(wrapped)
+	if err != nil {
+		// Fall back to the un-canonicalized render if format.Source can't
+		// parse the wrapped output (extremely unusual — Node already
+		// produced syntactically valid Go).
+		return buf.String(), nil
+	}
+	formatted = bytes.TrimPrefix(formatted, []byte("package _\n"))
+	return string(bytes.TrimSpace(formatted)), nil
+}
+
+// stripAddCommandStmts removes top-level AddCommand call statements from a
+// function body's statement list. AddCommand call additions are handled by
+// the LostRegistrations re-injection path, not by drift detection — leaving
+// them in the comparison would route every templated host file with a
+// hand-added subcommand through TEMPLATED-VALUE-DRIFT (preserve pub) and
+// silently disable the re-injection path.
+func stripAddCommandStmts(stmts []ast.Stmt) []ast.Stmt {
+	out := make([]ast.Stmt, 0, len(stmts))
+	for _, stmt := range stmts {
+		if isAddCommandASTStmt(stmt) {
+			continue
+		}
+		out = append(out, stmt)
+	}
+	return out
+}
+
+// isAddCommandASTStmt reports whether stmt is an `<recv>.AddCommand(...)`
+// call statement. Operates on go/ast (apply.go has a dave/dst counterpart
+// for the rewriter that lives there).
+func isAddCommandASTStmt(stmt ast.Stmt) bool {
+	es, ok := stmt.(*ast.ExprStmt)
+	if !ok {
+		return false
+	}
+	ce, ok := es.X.(*ast.CallExpr)
+	if !ok {
+		return false
+	}
+	sel, ok := ce.Fun.(*ast.SelectorExpr)
+	if !ok || sel.Sel == nil {
+		return false
+	}
+	return sel.Sel.Name == "AddCommand"
+}
+
+// genDeclName returns a canonical key for a *ast.GenDecl. For single-spec
+// decls (the common case for top-level const/var/type/import) the key is
+// the spec's name. For multi-spec decls (`var ( x = 1; y = 2 )`), the key
+// is a stable concatenation of all names so the whole block compares as a
+// unit. Import-only decls return "" and are skipped — import lists evolve
+// freely and are not load-bearing for hand-edit detection.
+func genDeclName(decl *ast.GenDecl) string {
+	if decl == nil || len(decl.Specs) == 0 {
+		return ""
+	}
+	if decl.Tok == token.IMPORT {
+		return ""
+	}
+	var names []string
+	for _, spec := range decl.Specs {
+		switch s := spec.(type) {
+		case *ast.TypeSpec:
+			if s.Name != nil {
+				names = append(names, s.Name.Name)
+			}
+		case *ast.ValueSpec:
+			for _, n := range s.Names {
+				names = append(names, n.Name)
+			}
+		}
+	}
+	if len(names) == 0 {
+		return ""
+	}
+	return decl.Tok.String() + ":" + strings.Join(names, ",")
+}
+
+func shortenForDisplay(s string) string {
+	if len(s) <= valueDriftDisplayLimit {
+		return s
+	}
+	return s[:valueDriftDisplayLimit] + "..."
+}
diff --git a/internal/pipeline/regenmerge/value_drift_test.go b/internal/pipeline/regenmerge/value_drift_test.go
new file mode 100644
index 00000000..1958ff8e
--- /dev/null
+++ b/internal/pipeline/regenmerge/value_drift_test.go
@@ -0,0 +1,332 @@
+package regenmerge
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDetectValueDriftReturnsNilForIdenticalFiles(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	src := `package x
+
+const Greeting = "hello"
+
+func add(a, b int) int { return a + b }
+`
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(src), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(src), 0o644))
+
+	assert.Nil(t, detectValueDrift(pub, fresh))
+}
+
+func TestDetectValueDriftCatchesConstLiteralChange(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+const authPrefix = "Bearer "
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+const authPrefix = "Token "
+`), 0o644))
+
+	drift := detectValueDrift(pub, fresh)
+	require.NotNil(t, drift, "literal value drift in const should be detected")
+	require.NotNil(t, drift.Decls)
+	_, ok := drift.Decls["const:authPrefix"]
+	assert.True(t, ok, "expected drift entry for const authPrefix; got %v", drift.Decls)
+}
+
+func TestDetectValueDriftCatchesEmptyToNonEmptyConst(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+const graphqlEndpointPath = ""
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+const graphqlEndpointPath = "/graphql"
+`), 0o644))
+
+	drift := detectValueDrift(pub, fresh)
+	require.NotNil(t, drift)
+	_, ok := drift.Decls["const:graphqlEndpointPath"]
+	assert.True(t, ok)
+}
+
+func TestDetectValueDriftCatchesSelectorIdentifierRename(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+type Config struct{ Bearer, Token string }
+
+func authHeader(c Config) string {
+	return c.Bearer
+}
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+type Config struct{ Bearer, Token string }
+
+func authHeader(c Config) string {
+	return c.Token
+}
+`), 0o644))
+
+	drift := detectValueDrift(pub, fresh)
+	require.NotNil(t, drift, "selector identifier rename should be detected")
+	_, ok := drift.Decls["authHeader"]
+	assert.True(t, ok, "expected drift entry for authHeader; got %v", drift.Decls)
+}
+
+func TestDetectValueDriftCatchesTypeConversionRename(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+type MyType int
+type MyOtherType int
+
+func convert(x int) MyType { return MyType(x) }
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+type MyType int
+type MyOtherType int
+
+func convert(x int) MyType { return MyOtherType(x) }
+`), 0o644))
+
+	drift := detectValueDrift(pub, fresh)
+	require.NotNil(t, drift)
+	_, ok := drift.Decls["convert"]
+	assert.True(t, ok)
+}
+
+func TestDetectValueDriftIgnoresDocCommentChanges(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+// pubVersion of the doc comment.
+const Greeting = "hello"
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+// freshVersion of the doc comment with completely different wording.
+const Greeting = "hello"
+`), 0o644))
+
+	assert.Nil(t, detectValueDrift(pub, fresh), "doc-comment-only diff should not trigger drift")
+}
+
+func TestDetectValueDriftIgnoresAddCommandStmts(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+func newCmd() *Cmd {
+	cmd := &Cmd{}
+	cmd.AddCommand(newA())
+	cmd.AddCommand(newB())
+	cmd.AddCommand(newHandAddedCmd())
+	return cmd
+}
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+func newCmd() *Cmd {
+	cmd := &Cmd{}
+	cmd.AddCommand(newA())
+	cmd.AddCommand(newB())
+	return cmd
+}
+`), 0o644))
+
+	assert.Nil(t, detectValueDrift(pub, fresh),
+		"AddCommand-only diff should defer to LostRegistrations re-injection, not trigger value drift")
+}
+
+func TestDetectValueDriftCatchesReorderedSliceLiterals(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+var defaults = []string{"a", "b"}
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+var defaults = []string{"b", "a"}
+`), 0o644))
+
+	drift := detectValueDrift(pub, fresh)
+	require.NotNil(t, drift, "reordered slice literals should be detected")
+	_, ok := drift.Decls["var:defaults"]
+	assert.True(t, ok)
+}
+
+func TestDetectValueDriftReturnsNilOnParseError(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`this is not go code`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+const x = "hello"
+`), 0o644))
+
+	assert.Nil(t, detectValueDrift(pub, fresh), "parse error on either side should defer to other branches")
+}
+
+func TestClassifyAssignsValueDriftWhenDeclSetMatchesAndLiteralChanges(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pubDir := filepath.Join(dir, "pub")
+	freshDir := filepath.Join(dir, "fresh")
+	require.NoError(t, os.MkdirAll(filepath.Join(pubDir, "internal", "config"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(freshDir, "internal", "config"), 0o755))
+
+	templated := func(literal string) []byte {
+		return []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package config
+
+const authPrefix = "` + literal + `"
+`)
+	}
+	require.NoError(t, os.WriteFile(filepath.Join(pubDir, "internal", "config", "config.go"), templated("Token "), 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(freshDir, "internal", "config", "config.go"), templated("Bearer "), 0o644))
+
+	report, err := Classify(pubDir, freshDir, Options{Force: true})
+	require.NoError(t, err)
+	require.NotNil(t, report)
+
+	var got *FileClassification
+	for i := range report.Files {
+		if report.Files[i].Path == "internal/config/config.go" {
+			got = &report.Files[i]
+			break
+		}
+	}
+	require.NotNil(t, got, "config.go should appear in the report")
+	assert.Equal(t, VerdictTemplatedValueDrift, got.Verdict,
+		"literal-only drift in templated const should classify as TEMPLATED-VALUE-DRIFT")
+	require.NotNil(t, got.ValueDrift, "ValueDrift field must be populated")
+	_, ok := got.ValueDrift.Decls["const:authPrefix"]
+	assert.True(t, ok, "drift entry for const:authPrefix expected; got %v", got.ValueDrift.Decls)
+}
+
+func TestClassifyAssignsTemplatedCleanWhenIdenticalDeclsAndAddCommandOnlyDiff(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pubDir := filepath.Join(dir, "pub")
+	freshDir := filepath.Join(dir, "fresh")
+	require.NoError(t, os.MkdirAll(filepath.Join(pubDir, "internal", "cli"), 0o755))
+	require.NoError(t, os.MkdirAll(filepath.Join(freshDir, "internal", "cli"), 0o755))
+
+	pub := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func newTransactionsCmd(flags *rootFlags) *Cmd {
+	cmd := &Cmd{}
+	cmd.AddCommand(newTransactionsListCmd(flags))
+	cmd.AddCommand(newHandAddedCmd(flags))
+	return cmd
+}
+`)
+	fresh := []byte(`// Copyright 2026 owner. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+func newTransactionsCmd(flags *rootFlags) *Cmd {
+	cmd := &Cmd{}
+	cmd.AddCommand(newTransactionsListCmd(flags))
+	return cmd
+}
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(pubDir, "internal", "cli", "transactions.go"), pub, 0o644))
+	require.NoError(t, os.WriteFile(filepath.Join(freshDir, "internal", "cli", "transactions.go"), fresh, 0o644))
+
+	report, err := Classify(pubDir, freshDir, Options{Force: true})
+	require.NoError(t, err)
+
+	var got *FileClassification
+	for i := range report.Files {
+		if report.Files[i].Path == "internal/cli/transactions.go" {
+			got = &report.Files[i]
+			break
+		}
+	}
+	require.NotNil(t, got)
+	assert.Equal(t, VerdictTemplatedClean, got.Verdict,
+		"AddCommand-only diff routes through LostRegistrations, not value drift")
+	assert.NotEmpty(t, report.LostRegistrations, "lost AddCommand should be captured for re-injection")
+}
+
+func TestDetectValueDriftIgnoresImportListChanges(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	pub := filepath.Join(dir, "pub.go")
+	fresh := filepath.Join(dir, "fresh.go")
+	require.NoError(t, os.WriteFile(pub, []byte(`package x
+
+import "fmt"
+
+func hello() { fmt.Println("hi") }
+`), 0o644))
+	require.NoError(t, os.WriteFile(fresh, []byte(`package x
+
+import (
+	"fmt"
+	"strings"
+)
+
+func hello() { fmt.Println("hi") }
+
+func _strings() { _ = strings.ToUpper("") }
+`), 0o644))
+
+	// Imports differ, but `hello` is the same. The new function _strings is
+	// in fresh-only, which decl-set comparison would catch upstream as
+	// TEMPLATED-CLEAN-style movement; per-decl drift only fires for shared
+	// decls that differ. Since `hello` matches, no value drift.
+	assert.Nil(t, detectValueDrift(pub, fresh))
+}

← 0562bca8 fix(skills): preflight Go toolchain before generation runs (  ·  back to Cli Printing Press  ·  fix(skills): forward --research-dir to scorecard --live-chec e0240cea →