Feat(installer): add phase 5 migration guardrails
This commit is contained in:
5
.changeset/phase-five-installer-migration-guardrails.md
Normal file
5
.changeset/phase-five-installer-migration-guardrails.md
Normal 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.
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
102
get-shit-done/bin/lib/installer-migration-authoring.cjs
Normal file
102
get-shit-done/bin/lib/installer-migration-authoring.cjs
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user