Migration Runbook
On this page
Use this when bringing a downstream project into sync with the
claude-quickstart template — a first migration onto the deterministic-first
layout, or an ongoing re-sync after a template update. It has two parts: the
engine-driven happy path (Part 1), and the mature-repo hazards the engine
cannot know about because they live outside the manifest’s reconciliation
surface (Part 2). The engine makes the synced surface safe; Part 2 is everything
else.
Part 1 — The engine-driven flow (the happy path)
-
Bring the engine into sync first. The check-docs engine is the versioned
checkdocscrate (docs/adrs/adr-002-checkdocs-engine-crate.adoc), not copy-pasted source. Depend on it by git tag inxtask/Cargo.toml:checkdocs = { git = "https://gitlab.com/gadhs/templates/claude-quickstart.git", tag = "checkdocs-vYYYY.N" }On a first add, paste that into
xtask/Cargo.tomlandcargo build(orcargo fetch) pulls it — there is nothing toupdateyet. On a later engine bump, change only thetag =value, thencargo update -p checkdocs. The tag matches the engine’sENGINE_VERSION.On a first migration only, hand-copy the engine SEAM — and ONLY the seam — once: the three thin wrappers
xtask/src/cmd/{check_docs.rs, validate.rs, audit_claude_md.rs}(plus theirmod.rsvariants +main.rsmatch arms), the rootCargo.toml[workspace.lints]managed region, and thecheckdocs = { git, tag }dep line above. Each wrapper is ~15 lines mapping the crate’sOutcometo an exit code. Do NOT bulk-copy the template’sxtask/— a mature repo’s own commands, dependencies, and lint overrides live there, and a wholesale copy clobbers them.xtaskis per-project source (not synced), so this is a one-time pull; thereafter engine updates are tag bumps.NOTEThree surfaces, three delivery mechanisms — don’t conflate them:
-
Engine logic (
checkdocs) — pinned by git tag (checkdocs-v2026.10); updated by atag =bump. -
Synced content (rules, standards pages, hooks, tool-configs, the CLAUDE.md preamble) — fetched LIVE from the template’s
mainand reconciled bycheck-docs --fix; it tracks latest, NOT the pinned tag. -
Per-project
xtasksource (the thin wrappers above) — a one-time hand-copy; neither tagged nor synced.
So the engine tag (
v2026.10) and the manifest’smanifest_version(v2026.15) are INDEPENDENT axes — the tag is the engine code,manifest_versionis the synced-content stamp; they advance on different schedules and are expected to differ. -
-
Run the report.
cargo xtask check-docs. Read it. The 3-state exit contract: 0 = in sync; 3 = advisory (an activesync-overridesentry, or a nothing-verified offline/transition run — not blocking); 1 = a real violation (drift, a neutered hook, missing mandatory content, an expired/unknown override, or a manifest/handshake error). -
Reconcile.
cargo xtask check-docs --fix --yes --allow-exec.--allow-execis REQUIRED for the git-hook entries (the engine refuses to write executable artifacts described by a remote manifest without it).--fixexits 0 after a clean repair of the byte-synced + managed-region entries — with one first-migration nuance: a.claude/CLAUDE.mdlacking theclaude-quickstart:managedmarkers is afull_restore_on_missing_markers = falseentry, so--fixCANNOT insert the markers. Because the preamble entry carriesmissing_markers_severity = "advisory", check-docs (engine v2026.9+) reports it as a non-blocking advisory nudge (exit 3), not a blocking violation — add the markers by hand once (see Thin the CLAUDE.md below) to start receiving preamble syncs. (An engine pinned before v2026.9 still treats it as a blocking violation — bump the tag.) Review withgit diff, then commit (chore: sync universal standards to template vYYYY.N).
Part 2 — Mature-repo hazards the engine cannot know
(a) .gitignore may hide new .claude/ artifacts
A mature downstream’s .gitignore may ignore more than the shipped
.claude/settings.local.json. After --fix, confirm the new files are actually
staged:
cargo xtask check-docs --fix --yes --allow-exec
git status --porcelain .claude/ ; git diff --cached --stat
If .claude/rules/ (or any synced path) is missing from the staged set, an
ignore rule is swallowing it — un-ignore / force-add it.
(b) Retiring .claude/docs/ is dangerous — grep the WHOLE repo first
.claude/docs/ is retired (docs/adrs/adr-001-antora-distribution.adoc). Before rm -rf .claude/docs/, grep the
ENTIRE repo (code AND docs) for residual references, and confirm each retired
file’s prose actually landed in docs/modules/ROOT/pages/*.adoc:
grep -rn '\.claude/docs' . # references anywhere (code, CI, docs)
Audit a unique content tail of each retired file against its new ROOT page — the engine does NOT verify hand-migrated prose (see (i)).
(c) Hooks are wholly replaced (full-restore) — re-add project content outside the markers
The three git-hook entries set full_restore_on_missing_markers = true, so
--fix rewrites the ENTIRE hook file from canonical. The splice/restore only
ever touches bytes BETWEEN the # >>> claude-quickstart:managed >>> /
# <<< claude-quickstart:managed <<< markers. Any project-specific hook logic
(reseed, asset build, extra security/perf checks) MUST live OUTSIDE the markers
(after the closing marker line); content there is never touched. Re-add it after
the first --fix if the downstream had customized a hook.
(d) Nested-engine + visibility contract differences
-
The engine returns a typed
Outcome; only the thincheck-docswrapper maps it to a process exit code (noprocess::exitinside the engine — it composes). If you call the engine from your own tooling, map theOutcomeyourself; do not expect it to exit the process. -
The visibility-exception expiry in
validatefails CLOSED: when the date command is unavailable its "today" is9999-12-31, so everyexception_expiresreads as expired and a PRIVATE repo is refused. This is the OPPOSITE ofcheck-docs, whose missing-date sentinel0000-00-00fails OPEN (no override expires). A private downstream must ensuredate(or PowerShell on Windows) is available in CI, or its visibility gate will refuse to pass.
(e) Measure the FULL clippy surface across ALL feature views first
The [workspace.lints] block denies the pedantic AND cargo groups plus an
explicit list. Before estimating migration effort, measure clippy across every
feature combination, not the single default view:
cargo clippy --all-targets --all-features -- -D warnings
# plus each meaningful feature combo your crates expose
A default-view-only count badly under-estimates the work.
(f) Macro-generated code × pedantic lints explodes — scope an allow at the generation site
Pedantic lints fire inside derive/macro expansions you do not author. Do NOT edit
or document generated code; place a scoped, reason-bearing allow at the generation
site (the module/item that invokes the macro), mirroring the xtask crate-root
carve-out:
#[allow(clippy::some_pedantic_lint, reason = "macro-generated; not our source")]
(g) The test carve-out does NOT reach integration-test crates
clippy.toml sets only allow-unwrap-in-tests / allow-expect-in-tests, and a
per-lib #![cfg_attr(test, allow(…))] covers that lib’s unit tests. A SEPARATE
integration-test crate (tests/) gets the full denied set — indexing_slicing,
arithmetic_side_effects, missing_docs_in_private_items,
let_underscore_must_use, etc. Add the carve-out at the top of each integration
crate as needed; do not assume the unit-test relaxation extends to it.
(h) Config-critical universal files carry project content — diff before overwrite
These are immutable-hashed and --fix will OVERWRITE them: rust-toolchain.toml
(a project may carry an extra target, e.g. wasm32), .config/nextest.toml
(concurrency / test-threads / profiles), and .gitattributes (LFS rules a
downstream added). git diff each before accepting the restore. If a divergence is
intentional, record a .claude/sync-overrides.toml entry (the run then reports
exit 3 advisory instead of clobbering on every sync).
(i) Hand-migrated prose has un-gateable fidelity risk — name it
Moving .claude/docs/.md prose into Antora docs/modules/ROOT/pages/.adoc is
byte-for-byte UNVERIFIABLE by the engine: it gates synced files, not the
operator’s hand-migrated ROOT pages. Fidelity here is a manual review
responsibility, not a gated one — read the old and new side by side.
(j) A git dependency on checkdocs may trip cargo-deny
Adding the checkdocs = { git = … } dep introduces a git source. If your deny.toml
sets [sources] unknown-git = "deny" (a hardened posture), cargo deny check sources
FAILS until you allow it:
[sources]
allow-git = ["https://gitlab.com/gadhs/templates/claude-quickstart.git"]
With the template default (unknown-git = "warn") you get a warning, not a failure —
but allow-listing the source silences it cleanly.
Thin the CLAUDE.md
A migrating project’s .claude/CLAUDE.md is usually bloated. Cut it to the
canonical structure in CLAUDE.md Skeleton:
-
Remove restated rule prose (it lives in
.claude/rules/*). -
Remove duplicated standards text (it lives in
docs/modules/standards/*). -
Remove status / TODO / next-steps logs (they live in GitLab work items).
-
Keep only the managed preamble + the project-owned section bodies.
-
Leave the
claude-quickstart:managedpreamble markers intact (they re-sync).
Then cargo xtask audit-claude-md should report no duplicated-rule headers and the
file under the size soft-limit. (The gate’s logic now lives in the checkdocs crate,
so it arrives with the tag pin + the thin audit_claude_md.rs wrapper from the seam
copy — no hand-port of ~345 lines.)
The e2e / perf gates after migration
cargo xtask e2e and cargo xtask perf ship as stubs that exit 4
(NOT_CONFIGURED) until you wire a suite. The pre-push hook soft-skips a
not-configured suite, so a UI-less service or a fresh scaffold passes its own
gate. Two equally-valid choices for a project with no such suite:
-
keep the stub (it exits 4 → soft-skipped), or
-
delete the subcommand from
xtask(clap then exits2→ ALSO soft-skipped).
Either way pre-push passes; a REAL e2e/perf failure exits via its own non-2/non-4
code and still blocks. Wire a real suite by editing xtask/src/cmd/{e2e,perf}.rs.