Files
msd-core/docs/installer-migrations.md
0xdhx cc3ee301a7 fix(#2544): stage the CommonJS marker in GSD-owned dirs, not the config root (#2593)
* fix(#2544): stage the CommonJS marker in GSD-owned dirs, not the config root

installSharedHooksBundle wrote `{"type":"commonjs"}` over
<configRoot>/package.json unconditionally — no existence check, no merge,
no backup — on every install and every /gsd-update re-install. On the 11
affected runtimes that file is often user-owned; on OpenCode and Kilo it is
the documented place to declare local-plugin npm dependencies, so a user's
name/type/dependencies/scripts were destroyed on each run.

The uninstall path already read the file and unlinked it only on an exact
content match. That asymmetry was the defect: the discipline existed in the
codebase, it just was not applied on the write side.

Move the marker into the directories GSD creates and fills with its own .js
files — hooks/ (all shared-hooks runtimes, incl. Kimi's own root) and the
nativePlugin dir (plugins/ for OpenCode+Kilo, extensions/ for pi) — and stop
writing the config root entirely. New src/commonjs-marker.cts owns the marker
string plus one ownership predicate (absent / gsd-owned / foreign, fail-closed
on an unreadable file) shared by ensureCommonJsMarker and removeCommonJsMarker,
so install and uninstall cannot drift apart again.

Nothing else depended on the config-root marker: package identity is baked at
build time (#378/#498) and version resolution prefers gsd-core/VERSION and
already tolerates a missing root package.json (#1383) — Codex has installed
without one all along. A package.json in plugins/ or extensions/ is inert to
plugin discovery, which globs *.{ts,js} only (see installer-migration 006).

Uninstall retires the pre-fix config-root marker, so upgrading users are
cleaned up on removal, and still never touches a file it did not write.

* fix(#2544): point the changeset fragment at the filed PR

The fragment's `pr:` field is only knowable after `gh pr create` returns.

* fix(#2544): register commonjs-marker.cjs in the tsc-generated ESLint ignore set

bin/lib/commonjs-marker.cjs is tsc output (src/commonjs-marker.cts is the
linted source), so it belongs in the ADR-457 ignore list like its siblings.
Clears the lint-tests no-var failure and the repo-invariants
"linted xor ignored" migration-state test.

* fix(#2544): pin the kimi CommonJS marker to hooks/, not the ~/.kimi root

The UPGRADE 1 test still asserted the pre-#2544 marker location
(~/.kimi/package.json). The marker now lives inside ~/.kimi/hooks — the
directory GSD itself creates — matching the updated golden-install-parity
and install-tree fixtures. Also asserts the root marker is NOT written.

* fix(#2544): make the CommonJS marker write path non-fatal

Review round 2, Major 3 + Minor 1 + the stagedHooks nit.

ensureCommonJsMarker rethrew any non-EEXIST write error and neither call site
caught it, so EACCES on a read-only hooks/, EROFS, or ENOSPC aborted the whole
install with a raw stack trace. Every other marker interaction in the module is
best-effort — removeCommonJsMarker swallows unlink failures, classifyMarker
swallows read failures — and this was the write path, i.e. the one most likely
to fail on a locked-down config dir. It now returns a new 'failed' outcome and
both call sites warn and continue.

Sibling found while sweeping for the same defect class: fs.mkdirSync sat
OUTSIDE the try block, so an unwritable parent threw past the guard entirely.
Creating the directory is the same environmental hazard as writing into it, so
it moved inside.

Also in this file:

- The hooks marker is now gated on `stagedHooks && hooksOk`, not stagedHooks
  alone. stagedHooks is computed from the SOURCE listing before the copy loop,
  so it stays true when the copies land but verifyInstalled() then fails —
  marking a hooks/ GSD did not successfully populate claims an ownership the
  install did not earn.
- The uninstall rmdir of the native plugin dir is gated on GSD having actually
  removed something from it. Hoisting it out of the adapter-exists guard (so
  the marker-only case could prune) had silently widened it into deleting a
  user-created but empty plugins/ or extensions/ dir — the same "don't touch
  territory GSD didn't fill" principle this issue is about, inverted.
- Kimi's pre-#2544 marker at its native hook root (~/.kimi) is retired at the
  same call site that writes its replacement. That path is outside kimi's
  configDir, so installer-migration 007 structurally cannot reach it.

* fix(#2544): retire the stale config-root marker via installer-migration 007

Review round 2, Major 1 — the PR's headline claim was false for existing
installs. Upgraders kept BOTH markers: the new one under hooks/ and the stale
{"type":"commonjs"} at the config root, so their config root stayed pinned to
CommonJS and their dependency manifest stayed gone until they uninstalled.

The migration is unusual in one way, and it is the part worth reviewing: the
config-root marker was never recorded in gsd-file-manifest.json (writeManifest
records hooks/, agents/, commands/, scripts/ and the native plugin, never a root
package.json), so classifyArtifact answers 'unknown' for it and the planner's
own guard downgrades a remove-managed on an 'unknown' classification to
preserve-user. 007 therefore supplies the "purpose-built detector for an old
GSD-owned shape" that docs/installer-migrations.md#remove-managed sanctions —
exact content match, the same predicate removeCommonJsMarker has always used —
and declares the resulting classification on the action. A package.json with any
other content is left untouched, and there is deliberately no backup-and-remove
branch: a non-matching file here is not a patched GSD artifact, it is somebody
else's file.

Scope is all runtimes. The `runtimes` field is OMITTED rather than `[]`:
validateStringArray requires the field to be non-empty WHEN PRESENT, while the
runtime filter treats an empty array as "all" — so `runtimes: []` throws at plan
time and the migration never runs. The metadata test pins this.

Kimi is a deliberate carve-out, named in the migration's own header: its marker
lived at ~/.kimi, outside kimi's configDir, and migration relPaths are
structurally confined to configDir. It is retired by the installer instead.

Registration: shipped-migrations table, .gitignore for the emitted .cjs, the
EXPECTED_CHECKSUMS baseline, and the ESLint ignore set. That last one is not
copied from migration 006 by rote — 006 needs no entry because it imports
nothing, while 007 imports node builtins, so tsc emits its __importDefault
helper and the `var` in it trips no-var. This is the same lint gate that made
round 1 red.

* test(#2544): fault-injection and multi-runtime marker coverage

Review round 2, Major 2 + Minors 4 and 5.

Major 2 — CONTRIBUTING.md:514-531 is mandatory for install/uninstall flows and
the suite had no fs monkeypatching at all. Every branch now covered is one whose
doc comment claims it as the module's safety posture:

- classifyMarker non-ENOENT lstat error -> 'foreign' (the fail-closed rule),
  with an ENOENT control alongside it so the test discriminates rather than
  just asserting one side
- classifyMarker readFileSync throw -> 'foreign' (present-but-unreadable never
  downgrades to the permissive answer) — the fixture's bytes are exactly GSD's
  marker, so the test fails if the code ever answers on content it could not read
- a DIRECTORY at the marker path (CONTRIBUTING:521; the symlink case was already
  covered with a real symlink, the directory case needs no injection at all)
- the ensureCommonJsMarker TOCTOU EEXIST branch — the entire reason for flag:'wx'
- the new 'failed' outcome, for both writeFileSync (EACCES/EROFS/ENOSPC) and the
  mkdirSync that used to sit outside the guard
- removeCommonJsMarker unlink throw -> false

These save and restore fs methods in `finally` rather than using chmod 0o000,
which does not fault under root and would pass vacuously in root Docker and CI.

Minor 4 — uninstall was driven for opencode only. pi's extensions/ and both
kimi locations now have behavioral coverage, install and uninstall, each paired
with a user-authored-file case proving GSD leaves it alone.

Minor 5 — the stagedHooks gate had no assertion behind its stated reason.
A pre-existing, GSD-untouched hooks/ directory is now driven through a runtime
that declares skipSharedHooksInstall and asserted to stay marker-free, with its
user content intact.

Also regression-tests the uninstall rmdir gate from the previous commit: an
empty plugin dir GSD removed nothing from must survive.

* docs(#2544): correct stale marker prose, register the module, document the trade-off

Review round 2, Minors 2, 3 and 6.

Minor 2 — six files asserted the installed ROOT ships the synthetic marker.
None was load-bearing (all three walk-up consumers are VERSION-first with
try/catch and the marker never carried a `version`), but ADR-457:52 is the
rationale for keeping a generated module, so a future reader would mis-derive
the constraint from it. Each site is corrected to what is now true: the
installed tree carries no package.json with a .name at all, because the only
ones GSD stages are {"type":"commonjs"} markers and they now live in GSD's own
directories.

Two of the six needed more than a location swap. hooks/gsd-check-update-worker.js
and the platform-gate test both described `require('../package.json').name`
resolving to undefined; post-#2544 that require does not resolve at all, so the
history is kept accurate and the present-tense claim corrected rather than just
moved. And src/runtime-artifact-conversion.cts described the no-root-package.json
case as Codex-only — it is now every runtime, which strengthens that comment's
own argument for lazy resolution. The generated .cjs sibling needs no edit: it
is gitignored build output, not a tracked file.

Minor 3 — src/commonjs-marker.cts had no CONTEXT.md entry, unlike every peer
module, and CONTEXT.md is the #2 co-change partner of bin/install.js. Added,
including the fail-closed posture and the never-throws contract.

Minor 6 — the plugins//extensions/ marker shadows the config root for all .js
siblings, so an OpenCode/Kilo user's ESM plugin/*.js stays broken. That is
exactly what #2544's Fix section prescribed and it is disclosed in the PR body,
but the PR body is not documentation. It now lives in the OpenCode section of
docs/how-to/install-on-your-runtime.md, stated as a real constraint rather than
a pure improvement, with the .ts mitigation and a fallback for ESM plugins.

* test(#2544): attribute the CommonJS marker in the emitted-provenance rules

The differential emitted-attribution gate (#2723, landed on `next` after this
branch was cut) went red on the macOS shards once this PR rebased onto it. Two
distinct causes, both real gaps rather than noise:

1. `plugins/package.json` and `extensions/package.json` matched NO rule — the
   `native-plugin` rule covers `*.{js,cjs,mjs}` only, so the marker read as an
   unattributed emitted family.
2. `hooks/package.json` fell through to `hooks-built`, which attributes an
   emitted `hooks/<X>` to a repo source `hooks/<X>`. There is no
   `hooks/package.json` in the repo, so it resolved to a nonexistent path.

Cause 2 is exactly the failure already documented three lines above it for
Copilot's `gsd-session.json` — "a code literal, not a built script" — so the fix
follows that precedent rather than inventing one: `package.json` is excluded
from `hooks-built` the same way, and a dedicated `commonjs-marker` rule
attributes the family across all four roots it can appear in (both hooks roots
plus `plugins`/`extensions`) to the sources that actually emit it.

Deliberately a RULE, not an entry in tests/emitted-drift-ack.json. An ack is for
a one-off ripple and goes stale by design — the gate fails a stale ack precisely
so it cannot pre-clear the next change on that path. These markers are a
permanent part of the emitted tree from #2544 onward, so they need standing
attribution.

Verified by reproducing the CI failure locally with GSD_EMITTED_BASE: 3
provenance errors + 12 unattributed paths before, 35/35 green after.

* fix(#2544): route the #2717 hooks-surface marker helpers through commonjs-marker

#2717 landed a second copy of ensureCommonJsMarker/removeCommonJsMarkerIfGsdOwned
in src/runtime-hooks-surface.cts for the runtimes that stage .js hooks via
dedicated paths (cursor/windsurf/codex). That copy had drifted from this PR's
module on the two properties that matter:

  - ownership probe: `fs.existsSync` FOLLOWS symlinks and reports false for a
    DANGLING one, so a dangling package.json symlink classified as absent and
    the write went straight through it. Demonstrated: against the pre-fix copy,
    ensureCommonJsMarker() on a hooks/ dir holding a dangling package.json
    symlink returns true and creates {"type":"commonjs"} OUTSIDE that directory.
  - create: a plain writeFileSync leaves the classify->write window open, where
    commonjs-marker creates with flag:'wx' (O_EXCL).

Both helpers now delegate to src/commonjs-marker.cts, which is what this PR's
own docstring already claimed was the single place these rules are enforced.
Exported signatures are unchanged (still boolean), so bin/install.js and the
#2717 tests are unaffected.

The new subtest is the only coverage that fails if the duplicate is ever
reintroduced — the two implementations agree on every non-adversarial input, so
the existing suites pass against both.

* test(#2544): pin the stagedHooks gate on zcode, not windsurf

The Minor-5 coverage picked windsurf because hostBehaviors.skipSharedHooksInstall
kept it out of the shared hooks bundle, so GSD staged nothing into hooks/ and the
marker was correctly absent.

#2717 changed that premise: cursor/windsurf/codex now stage their .js hooks via
dedicated paths and get the marker beside those scripts. Measured on this tree,
windsurf stages 2 .js hooks and receives a marker — so the assertion was pinning
behaviour that is now wrong, not the gate it was written for.

ZCode is the durable choice: per #1821 it has hooksSurface:'none' AND no plugin
surface to spawn hooks, so GSD stages no .js there by either route (measured: 0
staged, no marker). The property under test is unchanged — a user-created hooks/
directory GSD never fills stays marker-free.

* test(#2544): use the shared cleanup helper in the migration test

Addresses the review's Major 1. The suppression's stated reason — "no helpers
import available" — was not correct: tests/helpers.cjs exports cleanup, and the
other test file added in this same PR imports it (tests/commonjs-marker.test.cjs).

The local reimplementation dropped two protections that are live on this repo's
windows-latest lane: the CWD guard (Windows cannot remove a directory that is the
current working directory) and the 20 x 250ms retry budget that absorbs the
deferred-scan handle Windows Defender holds on newly-written files.

Local function and suppression both removed; local/no-raw-rmsync-in-tests now
passes without one.

* test(#2544): expect hooks/package.json for the #2717 runtimes

The fresh-install contract table predates #2717, which stages cursor/windsurf/
codex .js hooks via dedicated paths and writes the CommonJS marker beside them.
All three therefore now receive hooks/package.json legitimately.

Measured on this tree: codex stages 3 .js hooks, cursor 6, windsurf 2 — each with
the marker; cline/copilot/trae/zcode stage none and get none, so their contracts
are unchanged.

* fix(#2544): gate the #2717 marker writes on having staged something

The three dedicated marker writers #2717 added ran unconditionally. Each one
mkdirs hooks/ up front and stages its scripts conditionally on the source
existing, so with an absent or empty hook source they created a directory,
filled it with nothing, and marked it as GSD's anyway.

That is the same write-into-someone-else's-territory this issue is about, and
installSharedHooksBundle already guards the identical case with `stagedHooks`.
The dedicated paths now carry the matching gate:

  - cursor / windsurf: `installedScripts.size > 0`
  - codex: a new `codexStagedHooks` flag. The enclosing guard only proves that
    hooks/dist EXISTS; it says nothing about whether any CODEX_HOOKS_TO_COPY
    entry landed.

Covered for cursor and windsurf by driving each writer against a src tree whose
hooks/ dir is empty. The codex leg is defensive and deliberately uncovered: its
trigger state needs a package tree where hooks/dist exists but holds none of the
allowlist, which is not constructible from a real checkout.

* test(#2544): scope the commonjs-marker sources per root

The rule declared one flat source list for every marker root, so
`extensions/package.json` was attributed to runtime-hooks-surface.cts (which
never writes there) and `.kimi/hooks/package.json` to install-engine.cts.

That is not merely untidy. emitted-diff.cjs accepts the FIRST satisfied source,
so a flat list containing bin/install.js let any change anywhere in that
13k-line file authorise marker drift for every root — the blanket escape hatch
this file's own agents-verbatim comment refuses for exactly the same reason.

Sources are now derived per root from ctx.rel. Note the rule ctx is
`{ rel, runtime }` and carries no `root`, so keying on ctx.root would have sent
every path down one branch silently.

* test(#2544): state precisely what the zcode assertion pins

The comment claimed the test pinned installSharedHooksBundle's `stagedHooks`
gate. It does not, and neither did the windsurf version it replaced: zcode
declares skipSharedHooksInstall, so the outer guard skips that helper entirely
and the gate is never evaluated. The test passes on the runtime exclusion.

What it does pin — the outcome a pre-existing, GSD-untouched hooks/ stays
marker-free — is still worth having, and is what the review asked for. The two
`staging zero hook scripts` tests are the ones that pin a real staged-nothing
gate. Comment corrected rather than left implying coverage that is not there.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-08-01 21:00:23 -04:00

32 KiB

Installer Migration Architecture

This document defines the migration layer for GSD installs and upgrades. It is for contributors who need to retire files, move install surfaces, rewrite runtime config, or preserve user data while changing how GSD is installed.

After reading this document, a contributor should be able to add a new installer migration without guessing which files are safe to remove or how to protect local user changes.

Problem

The installer already handles several upgrade behaviors:

  • replacing GSD-managed command, skill, agent, hook, and engine files
  • backing up locally modified managed files before replacement
  • preserving known user-owned artifacts
  • cleaning old hook files and hook registrations
  • rewriting runtime-specific configuration formats
  • rolling back some failed Codex installs

Those behaviors are currently distributed across install branches. That works for isolated fixes, but it makes feature retirement risky. A future change can remove a file from the package while leaving stale installed copies behind, or delete a user-created file because it happens to live inside a GSD-managed directory.

The migration layer exists to make upgrade behavior explicit, reviewed, and repeatable.

Design Goals

  1. Protect user data by default.
  2. Remove stale GSD-managed files when a feature is retired.
  3. Make destructive actions visible before they run.
  4. Record what happened so future installs do not re-run the same migration.
  5. Give each runtime the same safety model, even when the concrete files differ.
  6. Keep migration authoring small enough that contributors use it instead of adding another one-off cleanup block.

Non-Goals

  • This is not a general package manager.
  • This is not a database migration system.
  • This does not automatically infer every historical install layout.
  • This does not remove arbitrary user files.
  • This does not replace the existing install transforms in one step.

Terms

Managed file

A file that GSD installed and recorded in the install manifest. Managed files can be replaced automatically when unchanged. If changed locally, they must be backed up or merged.

User-owned file

A file created or maintained by a user workflow or by the user directly. These files must never be removed just because they sit under a GSD directory.

Unknown file

A file found under an install root that is not in the manifest and is not classified as user-owned. Unknown files are preserved unless a migration explicitly classifies them with evidence.

Migration

A versioned change set that can inspect the current install, produce a plan, and apply that plan after safety checks pass.

Plan

A list of proposed filesystem and config actions. A plan is safe to show to a user. It describes what will happen and why, without mutating disk.

Journal

A per-run record of applied actions and rollback data. It exists so failed installs can restore the pre-run state where possible.

State Files

The migration layer uses the existing file manifest and adds one install-state record.

File Manifest

The existing manifest remains the ownership baseline. It records the installed GSD version, install mode, and hashes for distribution-owned files.

The invariant is strict:

  • distribution-owned files are manifest-tracked
  • user-owned files are preserved and omitted from manifest hashes
  • a path cannot be both

Install State

The installer writes an install-state file next to the manifest.

Required fields:

{
  "schema": 1,
  "runtime": "codex",
  "scope": "global",
  "installed_version": "1.50.0",
  "install_mode": "full",
  "applied_migrations": [
    {
      "id": "2026-05-11-codex-hooks-layout",
      "package_version": "1.50.0",
      "checksum": "sha256:...",
      "applied_at": "2026-05-11T00:00:00.000Z"
    }
  ]
}

The checksum is calculated from the migration definition.

An already-applied migration is never re-run, so a drifted checksum is tolerated at runtime: it is collected in plan.checksumDrift and reconciled into install state on the next write, rather than aborting the user's upgrade (this unblocks upgrades — see issue #670).

The "shipped migration bodies are immutable" rule is enforced in CI by a committed checksum-baseline test in tests/installer-migrations.test.cjs. If you need to change the behaviour of a released migration, add a NEW fix-forward migration id instead of editing the shipped body.

Migration Record

Each migration exports a plain record plus pure planning logic.

Required fields:

module.exports = {
  id: '2026-05-11-runtime-layout-example',
  title: 'Move legacy commands into runtime skills',
  description: 'Move legacy runtime command files into the generated skill layout.',
  introducedIn: '1.50.0',
  runtimes: ['claude', 'codex', 'antigravity'],
  scopes: ['global', 'local'],
  destructive: true,
  plan(ctx) {
    return [];
  }
};

The Installer Migration Authoring Guard Module rejects records that omit id, title, description, introducedIn, scopes, destructive, or plan. runtimes remains optional only for migrations intentionally shared by every runtime, but scope must always be explicit so an author cannot accidentally broaden local/global behavior.

The plan(ctx) function receives an install context with runtime, scope, target directory, previous manifest, install state, package manifest, and filesystem helpers. It returns actions. It must not mutate disk.

Migrations may use helper predicates such as:

  • isManaged(relPath)
  • isUserOwned(relPath)
  • hashMatchesManifest(relPath)
  • exists(relPath)
  • readJson(relPath)
  • readToml(relPath)

Action Types

Migrations produce a small set of action types. The executor owns mutation, backup, rollback, and reporting.

remove-managed

Remove a path only when it is known to be GSD-managed and unchanged from the previous manifest, or when the migration provides a purpose-built detector for an old GSD-owned shape.

Authoring guardrail: every remove-managed action must include ownershipEvidence explaining the manifest entry, generated marker, or purpose-built detector that proves GSD ownership.

Use for retired hooks, old generated agents, deprecated command files, and stale runtime-specific generated artifacts.

backup-and-remove

Back up a managed path before removal because the file differs from the previous manifest. The user gets a clear report and can inspect the backup.

Use when a feature retires a managed file that users may have patched.

move-managed

Move a managed path to a new managed path. If the source was locally modified, the action becomes backup-and-move or a conflict.

Use for layout migrations such as command directories moving into skills.

rewrite-config

Rewrite a structured config file through a parser or existing structural helper. String replacement is only acceptable for narrowly-scoped marker blocks with tests for line-ending and ordering variations.

Use for runtime config, hook registrations, feature flags, and generated agent registration blocks.

The initial executor support is rewrite-json: a migration reads JSON through readJson(relPath), returns the next parsed value in the action, and may set deleteIfEmpty: true when the remaining structure is empty. The executor owns the disk write, journal entry, rollback snapshot, and runtime/scope filtering. Use this for legacy JSON config cleanup such as Codex hooks.json, where GSD can prove ownership of individual generated hook commands but not the whole file.

Authoring guardrail: every rewrite-json action must include ownershipEvidence, and the migration record must include runtimeContract citing docs/installer-migrations.md#runtime-configuration-contract-registry.

preserve-user

Declare that a path is user-owned and must survive surrounding directory replacement. This action is informational in dry-run output. During apply it becomes a copy-through/restore operation when baseline ownership is known; when ownership is not yet established, non-interactive apply must block until an interactive baseline migration records an explicit user choice.

Use for profile, preferences, hand-authored instructions, and future workflow outputs.

record-baseline

Record a manifest-managed file in the first-time baseline without mutating it. The executor writes a journal entry and install-state entry so later upgrades know the baseline scan completed.

Use only from the first-time baseline scanner.

baseline-preserve-user

Record a user-owned or unknown file discovered under a known install surface without mutating it. Unknown files default to this action unless they look like retired GSD-generated artifacts that need an explicit user choice.

Use only from the first-time baseline scanner.

prompt-user

Stop non-interactive destructive migration and ask in interactive mode. The prompt must present concrete choices such as preserve, back up, remove, or move. The default is preserve.

Use when classification is ambiguous and guessing could lose data.

Execution Flow

The installer runs migrations before materializing the new package payload.

  1. Build install context.
  2. Read prior manifest and install state.
  3. Build a pre-run snapshot for paths that may be touched.
  4. Discover pending migrations by runtime, scope, and applied state.
  5. Ask each pending migration for a plan.
  6. Merge plans and validate them.
  7. Print the plan in dry-run form.
  8. Apply safe non-interactive actions.
  9. Prompt or stop for ambiguous actions.
  10. Write the new package payload.
  11. Write the new manifest and install state.
  12. Report backups, preserved files, removed stale files, and skipped actions.

The Phase 4 install integration wires this flow into the normal install/update entry point for every supported runtime: Claude Code, Antigravity, Augment, Cline, CodeBuddy, Codex, Copilot, Cursor, Hermes Agent, Kilo, OpenCode, Qwen Code, Trae, and Windsurf. The installer invokes the same migration runner with baselineScan: true, reports the projected action rows, applies safe non-interactive actions before materialization, persists install state only after package materialization and finalization succeed, and fails before writing new package files when the runner returns blocked user-choice actions.

Phase 1-3 built the planning, apply, rollback, install-state, baseline, and migration-record mechanics. Those phases did not prove the normal install entry point across every runtime. Phase 4 owns that guardrail with an all-runtime install matrix that exercises safe managed cleanup and blocked user-choice artifacts for each runtime above.

If any apply step fails, the executor uses the journal to restore modified paths where possible. Rollback must never delete files that were not created or modified by the current installer run.

Dry Run

The migration runner supports a dry-run mode that prints the plan and exits without changes.

Dry-run output groups actions by risk:

  • will preserve
  • will replace unchanged managed files
  • will remove stale managed files
  • will back up locally modified files
  • needs user choice
  • blocked

The same planner powers dry-run and apply. There must not be a separate "preview-only" code path.

Safety Policy

Ownership

Never remove an unknown file. Unknown files are preserved unless a migration contains a specific detector proving the file is a stale GSD artifact.

Modification Detection

When a path is in the previous manifest:

  • hash match means unchanged managed file
  • hash mismatch means locally modified managed file
  • missing means already removed by the user and should stay removed unless a migration explicitly needs to recreate it

User-Owned Artifacts

User-owned artifacts are defined once and consumed by both preservation and manifest-writing code. Adding a user-owned artifact requires a regression test that proves it is preserved across reinstall and omitted from the manifest.

Config Files

Runtime config is mixed ownership. GSD may own marker blocks, generated agent sections, or hook entries, but it does not own the whole file unless the file was created as a GSD-only file. Config migrations should remove or rewrite only the owned portion.

Runtime Configuration Contract Registry

Last upstream documentation check: 2026-05-11. Kimi CLI was rechecked on 2026-06-07 against the MoonshotAI docs.

This registry is the source of truth for migrations that touch host runtime configuration. Each row records:

  • What: the GSD invocation, agent, skill, rule, hook, or config surface
  • Where: the global and local roots the installer targets
  • When: install, upgrade, uninstall, and migration touch points
  • Who: the ownership boundary for surrounding user config
  • Why: the upstream loader contract or current GSD compatibility shim

Migration authors must read the matching row before producing a rewrite-config, move-managed, or destructive cleanup action. If upstream docs change, update this registry, update docs/ARCHITECTURE.md, and add tests for the new shape before changing migration behavior.

Runtime What GSD installs Where GSD installs it Config ownership boundary Upstream contract snapshot
Claude Code Global skills in skills/gsd-*/SKILL.md; local slash commands in commands/gsd/*.md; agents in agents/gsd-*.md; hooks in hooks/; settings.json registrations Global CLAUDE_CONFIG_DIR or ~/.claude; local ./.claude GSD owns only generated skills, local commands, gsd-* agents, hook files, and GSD hook/statusLine entries in settings.json Slash commands, settings, hooks, subagents; docs not versioned, checked 2026-05-11
OpenCode Flat markdown commands in commands/gsd-*.md (plural — OpenCode discovers slash commands from commands/, not the legacy singular command/, #2329); agents in agents/gsd-*.md; config updates in opencode.json or opencode.jsonc Global OPENCODE_CONFIG_DIR, dirname(OPENCODE_CONFIG), XDG_CONFIG_HOME/opencode, or ~/.config/opencode; local ./.opencode GSD owns generated command/agent files and GSD entries in structured config only Config, Commands; docs published 2026-05, checked 2026-07-16
Kilo OpenCode-style flat markdown commands in command/gsd-*.md; agents in agents/gsd-*.md; config updates in kilo.json or kilo.jsonc Global KILO_CONFIG_DIR, dirname(KILO_CONFIG), XDG_CONFIG_HOME/kilo, or ~/.config/kilo; local ./.kilo GSD owns generated command/agent files and GSD entries in structured config only Custom subagents; docs not versioned, checked 2026-05-11
Kimi CLI Agent Skills in skills/gsd-*/SKILL.md; explicit custom agent YAML/prompt artifacts in agents/gsd.yaml, agents/gsd.md, and agents/subagents/gsd-*; gsd-core/ payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline Global KIMI_CONFIG_DIR, explicit --config-dir, or first-existing generic skills root: ~/.config/agents when ~/.config/agents/skills exists or no generic skills root exists yet, otherwise ~/.agents when ~/.agents/skills exists and ~/.config/agents/skills does not; KIMI_CONFIG_DIR and --config-dir are GSD write-location overrides and arbitrary roots require Kimi-side --skills-dir or extra_skill_dirs configuration for skill discovery; local --kimi --local is guarded and writes no project-level artifacts GSD owns only generated skills/gsd-*, agents/gsd.*, agents/subagents/gsd-*, installed gsd-core/ payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed gsd-local-patches/; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. Agent Skills, Agents and Subagents, Tools; docs checked 2026-06-07
Codex Skills in skills/gsd-*/SKILL.md; agents as source markdown plus per-agent TOML in agents/ (Codex auto-discovers each standalone agents/gsd-*.toml — that is the sole role-registration source, #2406); bare [agents] dispatch-tuning scalar and hooks in config.toml Global CODEX_HOME or ~/.codex; local ./.codex GSD owns generated skills, generated agent TOML, the managed bare [agents] scalar table (max_depth; no [agents.gsd-*] role sections — those were a duplicate registration removed in #2406), [features].hooks when added by GSD (canonical; legacy alias codex_hooks is recognized and migrated forward, #3566), and GSD hook entries Codex config schema, Codex developer docs; docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy codex_hooks recognized)
GitHub Copilot Skills in skills/gsd-*/SKILL.md; agents as .agent.md; repository instructions in copilot-instructions.md Global COPILOT_CONFIG_DIR, COPILOT_HOME, or ~/.copilot; local ./.github GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership Repository custom instructions, Copilot CLI custom instructions; GitHub Docs product docs, checked 2026-05-11
Antigravity Skills in skills/gsd-*/SKILL.md; agents in agents/; Gemini-style settings.json hooks when installed by GSD Global ANTIGRAVITY_CONFIG_DIR or ~/.gemini/antigravity; local ./.agents (canonical, #791) or ./.agent (legacy, recognized for backward-compat) GSD owns generated skills/agents/hooks and GSD settings entries only Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. Fresh installs write to .agents/ (the Google-Codelabs-documented form); existing .agent/ installs continue to be detected and served.
Cursor Skills in skills/gsd-*/SKILL.md; agents in agents/; rule references under rules/; lifecycle hooks via hooks.json (sessionStart + postToolUse, #777) Global CURSOR_CONFIG_DIR or ~/.cursor; local ./.cursor GSD owns generated skills/agents, GSD rule files or references, and GSD-managed hooks.json entries (sentinel gsd-managed:true); no statusline ownership Cursor rules; Cursor hooks; docs not versioned, checked 2026-06-07
Windsurf / Devin Desktop Local slash-command workflows in workflows/gsd-*.md; no custom-agent artifact surface Local workflow directory ./.windsurf/workflows; global workflow install is intentionally a no-op GSD owns generated local workflow files only; no hook/statusline ownership Windsurf workflows are the documented / command surface. Workspace workflows live under .windsurf/workflows/*.md; global workflow locations are outside GSD's normal user-owned runtime config directory and are not written by the GSD installer.
Augment Code Skills in skills/gsd-*/SKILL.md; agents in agents/ Global AUGMENT_CONFIG_DIR or ~/.augment; local ./.augment GSD owns generated skills/agents only; no hook/statusline ownership Augment Agent Skills, Augment IDE skills; IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11
Trae Skills in skills/gsd-*/SKILL.md; agents in agents/; rule references under rules/ Global TRAE_CONFIG_DIR or ~/.trae; local ./.trae GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership Public Trae docs expose AI settings and .rules announcements, but no stable skills/config API was found as of 2026-05-11; migrations must treat this row as source-limited
Qwen Code Claude-compatible skills in skills/gsd-*/SKILL.md; agents in agents/; optional common hook/settings integration through GSD Global QWEN_CONFIG_DIR or ~/.qwen; local ./.qwen GSD owns generated skills/agents/hooks and GSD settings entries only Qwen commands and skills; docs last updated 2026-05-06
Hermes Agent Category skills under skills/gsd/ with DESCRIPTION.md plus nested gsd-*/SKILL.md; agents in agents/; optional common hook/settings integration through GSD Global HERMES_HOME or ~/.hermes; local ./.hermes GSD owns generated skills/gsd/ category content, generated agents, and GSD settings entries only Hermes configuration, Hermes skills, working with skills; docs checked 2026-05-11
CodeBuddy Skills in skills/gsd-*/SKILL.md; agents in agents/; optional common hook/settings integration through GSD Global CODEBUDDY_CONFIG_DIR or ~/.codebuddy; local ./.codebuddy GSD owns generated skills/agents/hooks and GSD settings entries only CodeBuddy CLI skills, CodeBuddy IDE skills; docs checked 2026-05-11
pi A single native extension at extensions/gsd.js (registers /gsd + the gsd_invoke tool programmatically); the shared hooks/ + hooks/lib/ bundle the extension spawns as bounded subprocesses; the gsd-core/ payload. No commands/agents/skills surface (pluginOnlyInstall) Global ~/.pi/agent; local ./.pi GSD owns only the generated extension file, the installed hooks//hooks/lib/ bundle, and the gsd-core/ payload. GSD writes no pi config: configFormat: "none", hooksSurface: "none", writesSharedSettings: false — ~/.pi/agent/settings.json is entirely user-owned and must never be rewritten, including its extensions array. Other users' extensions in extensions/ are unknown files and are preserved pi extension loader: discoverExtensionsInDir() scans <agentDir>/extensions/ and keeps only names passing isExtensionFile() (.ts/.js); accepted files load through jiti, which handles CommonJS and ESM alike, so the suffix — not the module format — is what gates discovery. Explicit paths in settings.json bypass the filter. Source read 2026-07-20 against @earendil-works/pi-coding-agent 0.80.10 (#2470)
Cline Rule-based integration via .clinerules for current installer output Global CLINE_CONFIG_DIR or ~/.cline; local project root .clinerules GSD owns the generated .clinerules file only when it created or manifest-tracked it; no hooks/statusline ownership Cline rules; docs prefer .clinerules/ directory and still detect legacy rule files, checked 2026-05-11

Registry Authoring Rules

  • Use structured parsers for config files whenever the runtime provides JSON, JSONC, TOML, or YAML. Marker-block rewrites need line-ending and ordering tests.
  • Do not claim ownership of a mixed config file. Own only generated entries, generated files, and explicit marker blocks.
  • Preserve unknown user config, even when it sits inside a GSD-managed runtime root.
  • Add or update the upstream snapshot date and version note when a runtime's docs, CLI schema, or loader behavior changes.
  • Treat source-limited rows as high-risk. A migration that rewrites those runtimes needs either a new primary source or an installer-level probe with tests.

Rollback

Before applying a migration, the executor records enough data to restore:

  • file bytes before overwrite
  • directory membership before removing generated directories
  • config bytes before structured rewrite
  • paths created by the current run
  • temporary files created by atomic writes

Rollback is best-effort but must be loud when incomplete.

First-Time Baseline Migration

The first migration should classify an existing install rather than attempt to fix every historical layout.

It should:

  1. read the current manifest if present
  2. scan known runtime install surfaces
  3. classify files as managed, user-owned, or unknown
  4. report stale GSD-looking files that are not in the current manifest
  5. offer actions for ambiguous files instead of deleting them
  6. write install state after successful classification

The Phase 3 implementation adds a gated baseline migration record, 2026-05-11-first-time-baseline-scan. The runner passes baselineScan: true when the installer wants this first-time scan. Without that flag, discovery is safe for normal migration runs and the baseline record plans no actions.

The baseline action contract is:

  • record-baseline for manifest-managed files
  • baseline-preserve-user for known user-owned files and unknown files that do not look like stale GSD-generated artifacts
  • prompt-user for stale GSD-looking artifacts that are not manifest-proven

This baseline is the escape hatch for old installs that predate full migration tracking. It gives the user a reviewable redistribution/removal plan without requiring the installer to infer every past release transition perfectly.

Authoring Workflow

When a feature removes or moves install artifacts, the PR must include:

  1. a migration record
  2. tests for dry-run plan output
  3. tests for apply behavior
  4. tests for locally modified managed files
  5. tests for user-owned files near the changed path
  6. an update to release notes if the migration affects user-visible install behavior

The author must answer these questions in the migration file:

  • What old artifact or config shape is being retired?
  • How do we prove it is GSD-owned?
  • What happens if the user modified it?
  • What happens if it is missing?
  • What runtime and scope does it affect?
  • Is the action safe in non-interactive install?

Test Matrix

Every migration runner change should cover:

  • fresh install with no prior state
  • reinstall with matching manifest
  • upgrade with pending migration
  • locally modified managed file
  • unknown file under a GSD directory
  • user-owned file under a wiped directory
  • failed apply with rollback
  • global and local install scopes when applicable
  • Windows path separators when paths are serialized
  • CRLF input when config files are rewritten

Implementation Sequence

  1. Extract install ownership helpers around the manifest and user-owned artifact list.
  2. Add install-state read/write helpers.
  3. Add migration record discovery and checksum calculation.
  4. Add planner-only dry-run support.
  5. Add executor with journaled file actions.
  6. Port orphaned hook/file cleanup into the first explicit migration.
  7. Port one structured config rewrite into the migration runner.
  8. Add the baseline classifier for existing installs.
  9. Make new install-affecting PRs require migrations when artifacts are moved, renamed, or retired.

This sequence keeps the first implementation small: the existing installer continues to materialize files, while the migration runner takes ownership of cleanup, classification, and reviewable destructive changes.

Shipped Migrations

Each row corresponds to one migration record in src/installer-migrations/.

ID File Introduced In Scopes Destructive Summary
2026-05-11-first-time-baseline-scan 000-first-time-baseline.cts 1.50.0 global, local No Records classification baseline for existing installs before destructive migrations run.
2026-05-11-legacy-orphan-files 001-legacy-orphan-files.cts 1.50.0 global, local Yes Removes manifest-managed legacy orphan hook files (hooks/gsd-notify.sh, hooks/statusline.js) retired by the installer.
2026-05-11-codex-legacy-hooks-json 002-codex-legacy-hooks-json.cts 1.50.0 global, local Yes Removes legacy GSD hook registrations from Codex hooks.json after the config.toml migration.
2026-06-02-rename-get-shit-done-to-gsd-core 003-rename-get-shit-done-to-gsd-core.cts 1.2.0 global, local Yes Removes managed files from the stale get-shit-done/ runtime directory after the rename to gsd-core/ (#604). User-added files are preserved; emptied directories may remain (framework limitation).
2026-06-09-prune-stale-pristine-get-shit-done 004-prune-stale-pristine-snapshots.cts 1.4.3 global, local Yes Removes stale gsd-pristine/get-shit-done/ snapshot files left behind by migration 003, which caused false verify-reapply-patches failures (#934).
2026-07-17-opencode-baseline-commands-dir 005-opencode-baseline-commands-dir.cts 1.7.0 global, local No Baselines pre-existing files under OpenCode's commands/ (plural) directory during the first-time scan. #2329 moved OpenCode command materialization to commands/, but 000's RUNTIME_SURFACES.opencode is a shipped, immutable body that still only names the legacy command/ alias, so this fix-forward migration widens the scanned surface. OpenCode only; Kilo is unaffected.
2026-07-20-pi-extension-cjs-to-js 006-pi-extension-cjs-to-js.cts 1.7.1 global, local Yes Removes the stale extensions/gsd.cjs left by pre-#2470 pi installs. pi's extension auto-discovery (isExtensionFile()) accepts only .ts/.js, so the .cjs file was never loaded and /gsd never registered; #2470 renamed the installed artifact to extensions/gsd.js, orphaning the old path. Locally modified copies are backed up rather than deleted; an unmanifested gsd.cjs is preserved as a user file. pi only.
2026-07-28-retire-config-root-commonjs-marker 007-retire-config-root-commonjs-marker.cts 1.8.0 global, local Yes Removes <configRoot>/package.json when it is exactly the {"type":"commonjs"} marker pre-#2544 installs wrote there. #2544 moved that marker into the directories GSD fills (hooks/, and the native plugin dir), so an upgraded install would otherwise keep both and stay pinned to CommonJS at a config root GSD no longer writes. Ownership is proven by exact content match, not the manifest (the marker was never manifest-recorded) — a package.json with any other content is left untouched, with no backup-and-remove branch. All runtimes; kimi's root marker lives outside configDir and is retired by the installer instead.

Prior Art

The design borrows from established upgrade systems:

  • Flyway versioned migrations: ordered, once-only changes tracked by checksum.
  • Flyway dry runs: preview planned mutations before applying them.
  • Liquibase changesets and preconditions: declarative changes gated by current system state.
  • Debian conffile policy: preserve local configuration and distinguish package ownership from user ownership.
  • npm lifecycle scripts: useful as packaging context, but not sufficient as the migration mechanism because uninstall and upgrade context are limited.