Feat(installer): add phase 5 migration guardrails

This commit is contained in:
Tom Boucher
2026-05-11 10:44:57 -04:00
parent c046dbaacc
commit a1f00a8a0c
11 changed files with 167 additions and 38 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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` |

View File

@@ -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.

View File

@@ -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

View File

@@ -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 : '<unknown>');
}
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 : '<unknown>';
const relPath = action && typeof action.relPath === 'string' ? action.relPath : '<unknown>';
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,
};

View File

@@ -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);

View File

@@ -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',

View File

@@ -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',
});
}
}

View File

@@ -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',
},
];
},