From a1f00a8a0c62632d0339ca481dd8cafbd2a6daef Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 11 May 2026 10:44:57 -0400 Subject: [PATCH] Feat(installer): add phase 5 migration guardrails --- ...ase-five-installer-migration-guardrails.md | 5 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- docs/adr/0008-installer-migration-module.md | 9 ++ docs/installer-migrations.md | 15 +++ .../bin/lib/installer-migration-authoring.cjs | 102 ++++++++++++++++++ .../bin/lib/installer-migrations.cjs | 38 +++---- .../000-first-time-baseline.cjs | 27 ++--- .../001-legacy-orphan-files.cjs | 1 + .../002-codex-legacy-hooks-json.cjs | 1 + 11 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 .changeset/phase-five-installer-migration-guardrails.md create mode 100644 get-shit-done/bin/lib/installer-migration-authoring.cjs diff --git a/.changeset/phase-five-installer-migration-guardrails.md b/.changeset/phase-five-installer-migration-guardrails.md new file mode 100644 index 000000000..c72220de2 --- /dev/null +++ b/.changeset/phase-five-installer-migration-guardrails.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 0 +--- +**Installer migrations now enforce authoring guardrails** — migration records require explicit metadata, scopes, ownership evidence, and runtime-contract citations before planned cleanup can run. diff --git a/CONTEXT.md b/CONTEXT.md index d3c780b97..ea0f8ece3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -64,6 +64,9 @@ Module owning SDK-to-`get-shit-done-cc` compatibility policy: legacy asset disco ### Runtime-Global Skills Policy Module Module owning runtime-aware global skills directory policy for SDK query surfaces. Resolves runtime-global skills bases/skill paths from runtime + env precedence, renders display paths for warnings/manifests, and reports unsupported runtimes with no skills directory. +### Installer Migration Authoring Guard Module +Module owning validation for Installer Migration Module records and planned actions. It enforces migration metadata, explicit install scopes, ownership evidence for destructive/config actions, and runtime contract citations for runtime config rewrites before a migration can enter planning or apply. + ### MVP Mode Phase-level planning mode that frames work as a vertical slice (UI → API → DB) of one user-visible capability instead of horizontal layers. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index c2bcdbc84..78300673d 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -277,6 +277,7 @@ "init-command-router.cjs", "init.cjs", "install-profiles.cjs", + "installer-migration-authoring.cjs", "installer-migration-report.cjs", "installer-migrations.cjs", "intel.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9a50be43a..783bc0d09 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -359,7 +359,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (52 shipped) +## CLI Modules (53 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -385,6 +385,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | | `install-profiles.cjs` | Install profile allowlist + skill staging for `--minimal` install (#2762); single source of truth for which `gsd-*` skills/agents land in runtime config dirs | +| `installer-migration-authoring.cjs` | Installer migration authoring guardrails for record metadata, explicit scopes, ownership evidence, and runtime contract citations | | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | diff --git a/docs/adr/0008-installer-migration-module.md b/docs/adr/0008-installer-migration-module.md index 25509d37a..f6b569f4a 100644 --- a/docs/adr/0008-installer-migration-module.md +++ b/docs/adr/0008-installer-migration-module.md @@ -48,3 +48,12 @@ snapshot before migration work proceeds. The first implementation should extract manifest/user-owned helpers, add install-state persistence, add migration planning, and port one existing orphan cleanup into the migration runner. It should not rewrite every runtime installer branch in the first pass. The detailed module contract lives in `docs/installer-migrations.md`. + +## Amendment (2026-05-11): Authoring guard enforcement + +The Installer Migration Authoring Guard Module validates migration records and +planned actions before planning can proceed. Records must declare title, +description, introduction version, explicit install scopes, destructive status, +and a plan function. Destructive or config-rewrite actions must include +ownership evidence, and runtime config rewrites must cite the runtime +configuration contract registry. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 2428805e6..4ea17ad86 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -135,6 +135,7 @@ 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', 'gemini'], scopes: ['global', 'local'], @@ -145,6 +146,12 @@ module.exports = { }; ``` +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. @@ -169,6 +176,10 @@ 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. @@ -203,6 +214,10 @@ 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 diff --git a/get-shit-done/bin/lib/installer-migration-authoring.cjs b/get-shit-done/bin/lib/installer-migration-authoring.cjs new file mode 100644 index 000000000..24c23b791 --- /dev/null +++ b/get-shit-done/bin/lib/installer-migration-authoring.cjs @@ -0,0 +1,102 @@ +'use strict'; + +function requireNonEmptyString(record, field, source) { + if (typeof record[field] !== 'string' || record[field].trim() === '') { + throw new Error(`migration record must include a non-empty ${field}: ${source}`); + } +} + +function validateStringArray(record, field, source) { + if (record[field] === undefined) return; + if ( + !Array.isArray(record[field]) || + record[field].length === 0 || + record[field].some((value) => typeof value !== 'string' || value.trim() === '') + ) { + throw new Error(`migration record ${field} must be a non-empty string array when provided: ${source}`); + } +} + +function requireStringArray(record, field, source) { + if ( + !Array.isArray(record[field]) || + record[field].length === 0 || + record[field].some((value) => typeof value !== 'string' || value.trim() === '') + ) { + throw new Error(`migration record ${field} must be a non-empty string array: ${source}`); + } +} + +function recordSource(record, fallback) { + return fallback || (record && typeof record.id === 'string' && record.id.trim() ? record.id : ''); +} + +function validateInstallerMigrationRecord(record, source) { + const displaySource = recordSource(record, source); + if (!record || typeof record !== 'object') { + throw new Error(`migration record must export an object: ${displaySource}`); + } + + // Authoring contract follows docs/installer-migrations.md#authoring-workflow + // and docs/adr/0008-installer-migration-module.md#decision. + requireNonEmptyString(record, 'id', displaySource); + requireNonEmptyString(record, 'title', displaySource); + requireNonEmptyString(record, 'description', displaySource); + requireNonEmptyString(record, 'introducedIn', displaySource); + if (typeof record.destructive !== 'boolean') { + throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`); + } + validateStringArray(record, 'runtimes', displaySource); + requireStringArray(record, 'scopes', displaySource); + if (typeof record.plan !== 'function') { + throw new Error(`migration record must include a plan function: ${displaySource}`); + } + + return record; +} + +function actionSource(migration, action) { + const migrationId = migration && typeof migration.id === 'string' ? migration.id : ''; + const relPath = action && typeof action.relPath === 'string' ? action.relPath : ''; + return `${migrationId} ${relPath}`; +} + +function requireActionEvidence(action, field, migration) { + if (typeof action[field] !== 'string' || action[field].trim() === '') { + throw new Error(`migration action ${action.type} must include ${field}: ${actionSource(migration, action)}`); + } +} + +function validateInstallerMigrationActions(actions, migration) { + if (!Array.isArray(actions)) { + throw new Error(`migration ${migration.id} plan must return an array`); + } + + for (const action of actions) { + if (!action || typeof action !== 'object') { + throw new Error(`migration action must be an object: ${migration.id}`); + } + if (typeof action.type !== 'string' || action.type.trim() === '') { + throw new Error(`migration action must include a non-empty type: ${migration.id}`); + } + if (typeof action.relPath !== 'string' || action.relPath.trim() === '') { + throw new Error(`migration action ${action.type} must include a non-empty relPath: ${migration.id}`); + } + // Ownership and runtime-contract evidence are required by + // docs/installer-migrations.md#action-types and + // docs/adr/0008-installer-migration-module.md#runtime-contract-decision. + if (action.type === 'remove-managed' || action.type === 'rewrite-json') { + requireActionEvidence(action, 'ownershipEvidence', migration); + } + if (action.type === 'rewrite-json' && (typeof migration.runtimeContract !== 'string' || migration.runtimeContract.trim() === '')) { + throw new Error(`migration action rewrite-json requires migration runtimeContract: ${actionSource(migration, action)}`); + } + } + + return actions; +} + +module.exports = { + validateInstallerMigrationActions, + validateInstallerMigrationRecord, +}; diff --git a/get-shit-done/bin/lib/installer-migrations.cjs b/get-shit-done/bin/lib/installer-migrations.cjs index f497907b4..ebbcf302f 100644 --- a/get-shit-done/bin/lib/installer-migrations.cjs +++ b/get-shit-done/bin/lib/installer-migrations.cjs @@ -3,6 +3,10 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const { + validateInstallerMigrationActions, + validateInstallerMigrationRecord, +} = require('./installer-migration-authoring.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const INSTALL_STATE_NAME = 'gsd-install-state.json'; @@ -171,19 +175,6 @@ function migrationMatchesContext(migration, { runtime, scope }) { return true; } -function validateMigrationRecord(record, source) { - if (!record || typeof record !== 'object') { - throw new Error(`migration record must export an object: ${source}`); - } - if (typeof record.id !== 'string' || record.id.trim() === '') { - throw new Error(`migration record must include a non-empty id: ${source}`); - } - if (typeof record.plan !== 'function') { - throw new Error(`migration record must include a plan function: ${source}`); - } - return record; -} - function discoverInstallerMigrations({ migrationsDir }) { if (!migrationsDir || !fs.existsSync(migrationsDir)) return []; return fs.readdirSync(migrationsDir, { withFileTypes: true }) @@ -196,7 +187,9 @@ function discoverInstallerMigrations({ migrationsDir }) { delete require.cache[require.resolve(source)]; const exported = require(source); const records = Array.isArray(exported) ? exported : [exported]; - return records.map((record) => validateMigrationRecord({ ...record, checksum: record.checksum || checksum }, source)); + return records.map((record) => + validateInstallerMigrationRecord({ ...record, checksum: record.checksum || checksum }, source) + ); }); } @@ -302,8 +295,11 @@ function planInstallerMigrations({ const manifest = readInstallManifest(configDir); const state = readInstallState(configDir); - const scopedMigrations = migrations.filter((migration) => - migration && migrationMatchesContext(migration, { runtime, scope }) + const validatedMigrations = migrations.map((migration) => + validateInstallerMigrationRecord(migration) + ); + const scopedMigrations = validatedMigrations.filter((migration) => + migrationMatchesContext(migration, { runtime, scope }) ); const applied = appliedMigrationEntries(state); assertAppliedMigrationChecksums(applied, scopedMigrations); @@ -320,12 +316,6 @@ function planInstallerMigrations({ }; for (const migration of pending) { - if (typeof migration.id !== 'string' || migration.id.trim() === '') { - throw new Error('migration id must be a non-empty string'); - } - if (typeof migration.plan !== 'function') { - throw new Error(`migration ${migration.id} must provide a plan function`); - } const plannedActions = migration.plan({ configDir, runtime, @@ -337,9 +327,7 @@ function planInstallerMigrations({ classifyArtifact: classify, readJson: (relPath) => readJson(configDir, relPath), }); - if (!Array.isArray(plannedActions)) { - throw new Error(`migration ${migration.id} plan must return an array`); - } + validateInstallerMigrationActions(plannedActions, migration); const checksum = migrationChecksum(migration); for (const rawAction of plannedActions) { const relPath = normalizeRelPath(rawAction.relPath); diff --git a/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs b/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs index ddb93e4f4..cbc31d6c4 100644 --- a/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs +++ b/get-shit-done/bin/lib/installer-migrations/000-first-time-baseline.cjs @@ -151,6 +151,21 @@ module.exports = { const actions = []; for (const relPath of scanBaselineFiles(configDir, runtime)) { + // docs/installer-migrations.md#baseline-preserve-user keeps user-owned + // artifacts out of destructive migration flow; classify later only when + // ownership is not already known. + if (isUserOwnedBaselinePath(relPath)) { + actions.push({ + type: 'baseline-preserve-user', + relPath, + reason: 'known user-owned artifact preserved by first-time migration baseline', + classification: 'user-owned', + originalHash: null, + currentHash: null, + }); + continue; + } + const artifact = classifyArtifact(relPath); if (artifact.classification === 'managed-pristine' || artifact.classification === 'managed-modified') { actions.push({ @@ -174,18 +189,6 @@ module.exports = { continue; } - if (isUserOwnedBaselinePath(relPath)) { - actions.push({ - type: 'baseline-preserve-user', - relPath, - reason: 'known user-owned artifact preserved by first-time migration baseline', - classification: 'user-owned', - originalHash: null, - currentHash, - }); - continue; - } - if (isStaleGsdLookingPath(relPath)) { actions.push({ type: 'prompt-user', diff --git a/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs index b8868288a..0cb751478 100644 --- a/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs +++ b/get-shit-done/bin/lib/installer-migrations/001-legacy-orphan-files.cjs @@ -25,6 +25,7 @@ module.exports = { type: 'remove-managed', relPath, reason: 'legacy orphan hook file retired by installer migration', + ownershipEvidence: 'legacy hook path is manifest-managed in gsd-file-manifest.json', }); } } diff --git a/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs b/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs index 6a7d9fe1d..f431a20e2 100644 --- a/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs +++ b/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs @@ -74,6 +74,7 @@ module.exports = { value: pruned.value, deleteIfEmpty: true, reason: 'legacy Codex hooks.json GSD registration retired by installer migration', + ownershipEvidence: 'pruned command matches generated GSD hook command under the install hooks directory', }, ]; },