From 3943146484994e50412aa5f5ef851919be1ff5f7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 10 May 2026 23:08:35 -0400 Subject: [PATCH 1/3] feat: migrate legacy codex hooks cleanup --- bin/install.js | 96 ++----- docs/ARCHITECTURE.md | 84 ++++-- docs/adr/0008-installer-migration-module.md | 17 ++ docs/installer-migrations.md | 64 ++++- .../bin/lib/installer-migrations.cjs | 212 +++++++++++++-- .../001-legacy-orphan-files.cjs | 4 + .../002-codex-legacy-hooks-json.cjs | 80 ++++++ ...codex-legacy-hooks-json-migration.test.cjs | 26 +- tests/installer-migrations.test.cjs | 246 +++++++++++++++++- 9 files changed, 708 insertions(+), 121 deletions(-) create mode 100644 get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs diff --git a/bin/install.js b/bin/install.js index bdfa4e242..7cc17d601 100755 --- a/bin/install.js +++ b/bin/install.js @@ -777,76 +777,6 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner) { return { content: updated, changed }; } -function isManagedCodexHookCommand(command, targetDir) { - if (typeof command !== 'string') return false; - if (typeof targetDir !== 'string' || targetDir.length === 0) return false; - const normalizedCommand = command.replace(/\\/g, '/'); - const managedHooksDir = `${path.join(targetDir, 'hooks').replace(/\\/g, '/')}/`; - if (!normalizedCommand.includes(managedHooksDir)) return false; - return /(^|[\\/\s"'])(gsd-check-update\.js|gsd-update-check\.js)(?=$|[\s"'])/.test(normalizedCommand); -} - -function pruneGsdManagedHooksJsonValue(value, targetDir) { - if (Array.isArray(value)) { - let changed = false; - const next = []; - for (const item of value) { - const pruned = pruneGsdManagedHooksJsonValue(item, targetDir); - if (pruned.changed) changed = true; - if (!isStructurallyEmpty(pruned.value)) next.push(pruned.value); - else changed = true; - } - return { value: next, changed }; - } - - if (value && typeof value === 'object') { - if (isManagedCodexHookCommand(value.command, targetDir)) { - return { value: null, changed: true }; - } - - let changed = false; - const next = {}; - for (const [key, child] of Object.entries(value)) { - const pruned = pruneGsdManagedHooksJsonValue(child, targetDir); - if (pruned.changed) changed = true; - if (!isStructurallyEmpty(pruned.value)) next[key] = pruned.value; - else changed = true; - } - return { value: next, changed }; - } - - return { value, changed: false }; -} - -function isStructurallyEmpty(value) { - if (value === null || value === undefined) return true; - if (Array.isArray(value)) return value.length === 0; - return typeof value === 'object' && Object.keys(value).length === 0; -} - -function cleanupLegacyCodexHooksJson(targetDir) { - const hooksPath = path.join(targetDir, 'hooks.json'); - if (!fs.existsSync(hooksPath)) return { changed: false, removedFile: false }; - - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); - } catch { - return { changed: false, removedFile: false, skipped: 'invalid_json' }; - } - - const pruned = pruneGsdManagedHooksJsonValue(parsed, targetDir); - if (!pruned.changed) return { changed: false, removedFile: false }; - - if (isStructurallyEmpty(pruned.value)) { - fs.unlinkSync(hooksPath); - return { changed: true, removedFile: true }; - } - - atomicWriteFileSync(hooksPath, JSON.stringify(pruned.value, null, 2) + '\n', 'utf8'); - return { changed: true, removedFile: false }; -} - /** * Build a hook command path using forward slashes for cross-platform compatibility. * On Windows, $HOME is not expanded by cmd.exe/PowerShell, so we use the actual path. @@ -7620,9 +7550,6 @@ function install(isGlobal, runtime = 'claude') { isGlobal, }); - // Run manifest-backed cleanup migrations before package materialization. - runInstallerMigrations({ configDir: targetDir }); - // #3245 — Codex idempotent rollback. Capture pre-install state of ALL // directories and files GSD will mutate so that any post-install validation // failure (config.toml schema check, write failure, etc.) can revert the @@ -7653,6 +7580,13 @@ function install(isGlobal, runtime = 'claude') { // Map — content snapshot of each pre-existing gsd-* agent file. const codexPreInstallAgentContents = new Map(); let codexPreInstallVersionBytes = null; + let installerMigrationResult = null; + const rollbackInstallerMigrations = () => { + if (!installerMigrationResult || typeof installerMigrationResult.rollback !== 'function') return; + const rollback = installerMigrationResult.rollback; + installerMigrationResult = null; + rollback(); + }; if (isCodex && !isMinimalMode(installMode)) { const _preSkillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(_preSkillsDir)) { @@ -7708,6 +7642,7 @@ function install(isGlobal, runtime = 'claude') { // The full restoreCodexSnapshot() (defined inside the config block) additionally // handles config.toml, which is not yet touched at this point in the pipeline. const _codexPreConfigRollback = !isCodex || isMinimalMode(installMode) ? null : () => { + rollbackInstallerMigrations(); // skills/gsd-* — pass 1: restore snapshot entries (may be absent if deleted mid-install). const _earlySkillsDir = path.join(targetDir, 'skills'); for (const skillName of codexPreInstallSkillNames) { @@ -7784,6 +7719,15 @@ function install(isGlobal, runtime = 'claude') { _earlyCleanTmpFiles(targetDir); }; + // Run manifest-backed cleanup migrations after rollback snapshots exist and + // before package materialization. Codex rollback paths invoke the migration + // rollback handle if a later install step fails. + installerMigrationResult = runInstallerMigrations({ + configDir: targetDir, + runtime, + scope: isGlobal ? 'global' : 'local', + }); + // #3245 CR finding 2 — wrap the pre-config install operations in a try/catch so // that ANY throw between snapshot capture and the Codex config block triggers rollback. // Non-Codex paths are unaffected (_codexPreConfigRollback is null for them). @@ -8445,6 +8389,7 @@ function install(isGlobal, runtime = 'claude') { // existence checks. Safe to call before any snapshots are captured (variables // default to empty Set / null). Does NOT touch non-gsd-* user content. const restoreCodexSnapshot = () => { + rollbackInstallerMigrations(); // 1. config.toml if (codexConfigPreInstallSnapshot !== null) { try { fs.writeFileSync(codexConfigPathPreInstall, codexConfigPreInstallSnapshot); } @@ -8708,10 +8653,6 @@ function install(isGlobal, runtime = 'claude') { throw wrapped; } console.log(` ${green}✓${reset} Configured Codex hooks (SessionStart)`); - const legacyHooksCleanup = cleanupLegacyCodexHooksJson(targetDir); - if (legacyHooksCleanup.changed) { - console.log(` ${green}✓${reset} Removed legacy GSD hooks.json entries`); - } } catch (e) { // #2760 — schema-validation and write failures must be loud and fatal // so the user is never left with a config Codex refuses to load (or no @@ -10804,7 +10745,6 @@ if (process.env.GSD_TEST_MODE) { rewriteLegacyManagedNodeHookCommands, buildCodexHookBlock, rewriteLegacyCodexHookBlock, - cleanupLegacyCodexHooksJson, }; } else { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e726f335b..989fb8fe0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -484,7 +484,8 @@ UI-SPEC.md (per phase) ─────────────────── ``` ~/.claude/ # Claude Code (global install) -├── commands/gsd/*.md # Slash commands (authoritative roster: docs/INVENTORY.md) +├── skills/gsd-*/SKILL.md # Global skills (authoritative roster: docs/INVENTORY.md) +├── commands/gsd/*.md # Local Claude installs use slash commands instead of global skills ├── get-shit-done/ │ ├── bin/gsd-tools.cjs # CLI utility │ ├── bin/lib/*.cjs # Domain modules (authoritative roster: docs/INVENTORY.md) @@ -500,12 +501,20 @@ UI-SPEC.md (per phase) ─────────────────── Equivalent paths for other runtimes: -- **OpenCode:** `~/.config/opencode/` or `~/.opencode/` -- **Kilo:** `~/.config/kilo/` or `~/.kilo/` -- **Gemini CLI:** `~/.gemini/` -- **Codex:** `~/.codex/` (uses skills instead of commands) -- **Copilot:** `~/.github/` -- **Antigravity:** `~/.gemini/antigravity/` (global) or `./.agent/` (local) +- **OpenCode:** `~/.config/opencode/` global or `./.opencode/` local +- **Kilo:** `~/.config/kilo/` global or `./.kilo/` local +- **Gemini CLI:** `~/.gemini/` global or `./.gemini/` local +- **Codex:** `~/.codex/` global or `./.codex/` local +- **Copilot:** `~/.copilot/` global or `./.github/` local +- **Antigravity:** `~/.gemini/antigravity/` global or `./.agent/` local +- **Cursor:** `~/.cursor/` global or `./.cursor/` local +- **Windsurf:** `~/.codeium/windsurf/` global or `./.windsurf/` local +- **Augment Code:** `~/.augment/` global or `./.augment/` local +- **Trae:** `~/.trae/` global or `./.trae/` local +- **Qwen Code:** `~/.qwen/` global or `./.qwen/` local +- **Hermes Agent:** `~/.hermes/` global or `./.hermes/` local +- **CodeBuddy:** `~/.codebuddy/` global or `./.codebuddy/` local +- **Cline:** `~/.cline/` global or project-root `.clinerules` local ### Project Files (`.planning/`) @@ -587,11 +596,11 @@ verification. ## Installer Architecture -The installer (`bin/install.js`, ~3,000 lines) handles: +The installer (`bin/install.js`, ~10,700 lines) handles: -1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--trae`, `--cline`, `--augment`, `--all`) +1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`) 2. **Location selection** — Global (`--global`) or local (`--local`) -3. **File deployment** — Copies commands, workflows, references, templates, agents, hooks +3. **File deployment** — Copies commands, skills, workflows, references, templates, agents, and hooks 4. **Runtime adaptation** — Transforms file content per runtime: - Claude Code: Uses as-is - OpenCode: Converts commands/agents to OpenCode-compatible flat command + subagent format @@ -600,7 +609,12 @@ The installer (`bin/install.js`, ~3,000 lines) handles: - Copilot: Maps tool names (Read→read, Bash→execute, etc.) - Gemini: Adjusts hook event names (`AfterTool` instead of `PostToolUse`) - Antigravity: Skills-first with Google model equivalents + - Cursor: Skills-first with Cursor rule references + - Windsurf: Skills-first with Windsurf rule references - Trae: Skills-first install to `~/.trae` / `./.trae` with no `settings.json` or hook integration + - Qwen Code: Skills-first with Qwen-branded path and prompt rewrites + - Hermes Agent: Category-based skills under `skills/gsd/` + - CodeBuddy: Skills-first with CodeBuddy path and prompt rewrites - Cline: Writes `.clinerules` for rule-based integration - Augment Code: Skills-first with full skill conversion and config management 5. **Path normalization** — Replaces `~/.claude/` paths with runtime-specific paths @@ -708,20 +722,46 @@ The researcher → planner → executor pipeline includes a supply-chain gate ag GSD supports multiple AI coding runtimes through a unified command/workflow architecture: +### Runtime Install Contract Matrix -| Runtime | Command Format | Agent System | Config Location | -| ------------ | -------------- | ---------------- | ------------------------ | -| Claude Code | `/gsd-command` | Task spawning | `~/.claude/` | -| OpenCode | `/gsd-command` | Subagent mode | `~/.config/opencode/` | -| Kilo | `/gsd-command` | Subagent mode | `~/.config/kilo/` | -| Gemini CLI | `/gsd-command` | Task spawning | `~/.gemini/` | -| Codex | `$gsd-command` | Skills | `~/.codex/` | -| Copilot | `/gsd-command` | Agent delegation | `~/.github/` | -| Antigravity | Skills | Skills | `~/.gemini/antigravity/` | -| Trae | Skills | Skills | `~/.trae/` | -| Cline | Rules | Rules | `.clinerules` | -| Augment Code | Skills | Skills | Augment config | +This matrix describes the runtime surfaces the installer materializes today. +The migration-specific ownership and source snapshots live in +[Installer Migrations](installer-migrations.md#runtime-configuration-contract-registry). +| Runtime | Global root | Local root | Invocation surface | Agent surface | Config and hooks | +| --- | --- | --- | --- | --- | --- | +| Claude Code | `~/.claude` | `./.claude` | Global `skills/gsd-*/SKILL.md`; local `commands/gsd/*.md` | `agents/gsd-*.md` | `settings.json` hook and statusLine entries | +| OpenCode | `~/.config/opencode` | `./.opencode` | `command/gsd-*.md` | `agents/gsd-*.md` | `opencode.json` or `opencode.jsonc`; no GSD hooks | +| Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks | +| Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | +| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].codex_hooks`, and hook tables | +| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline | +| Antigravity | `~/.gemini/antigravity` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | +| Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Augment Code | `~/.augment` | `./.augment` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | No GSD hooks or statusline | +| Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | +| Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/DESCRIPTION.md` plus `skills/gsd/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| Cline | `~/.cline` | project root | `.clinerules` | Rules only | No GSD hooks or statusline | + +### Upstream Contract Sources + +Runtime install expectations are checked against primary documentation where +available. The current source snapshot is 2026-05-11: + +- Claude Code: Anthropic slash commands, settings, hooks, and subagents docs. +- OpenCode and Kilo: OpenCode config docs and Kilo custom subagent docs. +- Gemini CLI and Qwen Code: command/config docs; Qwen command docs were last + updated 2026-05-06. +- Codex: OpenAI Codex docs and `config-schema.json`; the installer also carries + Codex 0.124.0 compatibility for agent table shape. +- Copilot, Cursor, Cline, Augment, Hermes, and CodeBuddy: vendor docs for + custom instructions, rules, skills, or config. +- Antigravity, Windsurf, and Trae: source-limited rows. The installer documents + current compatibility shims, and migrations must refresh those sources before + rewriting their config. ### Abstraction Points diff --git a/docs/adr/0008-installer-migration-module.md b/docs/adr/0008-installer-migration-module.md index dfeaf49cf..10883f311 100644 --- a/docs/adr/0008-installer-migration-module.md +++ b/docs/adr/0008-installer-migration-module.md @@ -15,6 +15,22 @@ We decided to introduce an explicit Installer Migration Module for install-time - Require migrations to plan first, then apply through a shared executor that owns backup, rollback, and reporting. - Default ambiguous or unknown files to preserve; destructive changes need managed-file evidence or explicit user choice. - Support dry-run output using the same planner used by apply mode. +- Treat the runtime configuration contract registry in `docs/installer-migrations.md` as the source of truth for migrations that touch host runtime config. + +## Runtime Contract Decision + +Every migration that rewrites runtime config, moves an invocation surface, or +retires a generated runtime artifact must cite the registry row in +`docs/installer-migrations.md`. If the migration changes where a runtime loads +commands, skills, agents, hooks, or rules, the PR must update both the registry +and `docs/ARCHITECTURE.md`. + +The registry records what GSD installs, where it installs it, when migrations +may touch it, who owns the surrounding config, and why the shape matches the +host runtime. When upstream docs do not publish an API or docs version, the +checked date is the drift sentinel. A later upstream docs or CLI release that +changes command, skill, agent, hook, or rule loading requires a new registry +snapshot before migration work proceeds. ## Consequences @@ -23,6 +39,7 @@ We decided to introduce an explicit Installer Migration Module for install-time - Locally modified managed files get a consistent backup path before removal or replacement. - Future rollback work can become runtime-neutral instead of Codex-specific. - Migration authors must define ownership evidence, conflict behavior, runtime scope, and non-interactive behavior. +- Migration authors must also define which runtime contract they are relying on and whether the upstream documentation is versioned. - The installer gains another state file, so tests must cover missing, legacy, and checksum-mismatch state. ## Scope diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index e175ec734..d8a54b635 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -195,11 +195,20 @@ 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. + ### preserve-user Declare that a path is user-owned and must survive surrounding directory -replacement. This action is informational in dry-run output and becomes a -copy-through or restore operation during apply. +replacement. This action is informational in dry-run output and blocks +non-interactive apply until a later interactive baseline migration can ask for +an explicit user choice. Use for profile, preferences, hand-authored instructions, and future workflow outputs. @@ -279,6 +288,57 @@ 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. + +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](https://docs.anthropic.com/en/docs/claude-code/slash-commands), [settings](https://docs.anthropic.com/en/docs/claude-code/settings), [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks), [subagents](https://docs.anthropic.com/en/docs/claude-code/sub-agents); docs not versioned, checked 2026-05-11 | +| OpenCode | Flat markdown commands in `command/gsd-*.md`; 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](https://opencode.ai/docs/config/); docs published 2026-05, checked 2026-05-11 | +| 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](https://docs.kilo.ai/docs/customize/custom-subagents); docs not versioned, checked 2026-05-11 | +| Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 | +| Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].codex_hooks` when added by GSD, and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-11; installer compatibility sentinel: Codex 0.124.0 agent table shape | +| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-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 `./.agent` | 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; GSD uses the Gemini-compatible settings contract as a compatibility shim | +| Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 | +| Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules | +| 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](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/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](https://qwenlm.github.io/qwen-code-docs/en/users/features/commands/); 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](https://hermes-agent.nousresearch.com/docs/user-guide/configuration), [Hermes skills](https://hermes-agent.nousresearch.com/docs/zh-Hans/user-guide/features/skills), [working with skills](https://hermes-agent.nousresearch.com/docs/guides/work-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](https://www.codebuddy.ai/docs/cli/skills), [CodeBuddy IDE skills](https://www.codebuddy.ai/docs/ide/Features/Skills); docs checked 2026-05-11 | +| 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](https://docs.cline.bot/customization/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: diff --git a/get-shit-done/bin/lib/installer-migrations.cjs b/get-shit-done/bin/lib/installer-migrations.cjs index fba3f81eb..ac2c6a243 100644 --- a/get-shit-done/bin/lib/installer-migrations.cjs +++ b/get-shit-done/bin/lib/installer-migrations.cjs @@ -12,6 +12,10 @@ function sha256File(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } +function sha256Text(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + function readJsonIfPresent(filePath, fallback) { if (!fs.existsSync(filePath)) return fallback; try { @@ -47,10 +51,22 @@ function readInstallState(configDir) { function writeInstallState(configDir, state) { fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n', 'utf8'); + writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n'); return state; } +function readJson(configDir, relPath) { + const { fullPath } = ensureInsideConfig(configDir, relPath); + if (!fs.existsSync(fullPath)) { + return { exists: false, value: null, error: null }; + } + try { + return { exists: true, value: JSON.parse(fs.readFileSync(fullPath, 'utf8')), error: null }; + } catch (error) { + return { exists: true, value: null, error }; + } +} + function normalizeRelPath(relPath) { if (typeof relPath !== 'string' || relPath.trim() === '') { throw new Error('migration action relPath must be a non-empty string'); @@ -87,6 +103,56 @@ function appliedMigrationIds(state) { ); } +function appliedMigrationEntries(state) { + const entries = new Map(); + for (const entry of state.appliedMigrations) { + if (entry && typeof entry.id === 'string' && !entries.has(entry.id)) { + entries.set(entry.id, entry); + } + } + return entries; +} + +function migrationChecksum(migration) { + if (typeof migration.checksum === 'string' && migration.checksum) return migration.checksum; + const serializable = { + id: migration.id, + title: migration.title || null, + description: migration.description || null, + introducedIn: migration.introducedIn || null, + runtimes: migration.runtimes || null, + scopes: migration.scopes || null, + destructive: migration.destructive === true, + runtimeContract: migration.runtimeContract || null, + plan: typeof migration.plan === 'function' ? migration.plan.toString() : null, + }; + return `sha256:${sha256Text(JSON.stringify(serializable))}`; +} + +function assertAppliedMigrationChecksums(state, migrations) { + const applied = appliedMigrationEntries(state); + for (const migration of migrations) { + const entry = applied.get(migration.id); + if (!entry || !entry.checksum) continue; + const checksum = migrationChecksum(migration); + if (entry.checksum !== checksum) { + throw new Error( + `applied migration checksum changed for ${migration.id}; create a new fix-forward migration id` + ); + } + } +} + +function migrationMatchesContext(migration, { runtime, scope }) { + if (Array.isArray(migration.runtimes) && migration.runtimes.length > 0) { + if (!runtime || !migration.runtimes.includes(runtime)) return false; + } + if (Array.isArray(migration.scopes) && migration.scopes.length > 0) { + if (!scope || !migration.scopes.includes(scope)) return false; + } + return true; +} + function validateMigrationRecord(record, source) { if (!record || typeof record !== 'object') { throw new Error(`migration record must export an object: ${source}`); @@ -108,10 +174,11 @@ function discoverInstallerMigrations({ migrationsDir }) { .sort() .flatMap((fileName) => { const source = path.join(migrationsDir, fileName); + const checksum = `sha256:${sha256File(source)}`; delete require.cache[require.resolve(source)]; const exported = require(source); const records = Array.isArray(exported) ? exported : [exported]; - return records.map((record) => validateMigrationRecord(record, source)); + return records.map((record) => validateMigrationRecord({ ...record, checksum: record.checksum || checksum }, source)); }); } @@ -129,14 +196,44 @@ function ensureInsideConfig(configDir, relPath) { return { normalized, fullPath }; } -function planInstallerMigrations({ configDir, migrations, now = () => new Date().toISOString() }) { +function isStructurallyEmpty(value) { + if (value === null || value === undefined) return true; + if (Array.isArray(value)) return value.length === 0; + return typeof value === 'object' && Object.keys(value).length === 0; +} + +function writeFileAtomicSync(filePath, content) { + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + try { + fs.writeFileSync(tmpPath, content, 'utf8'); + fs.renameSync(tmpPath, filePath); + } catch (error) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch { + // best-effort cleanup only; preserve the original write failure + } + throw error; + } +} + +function journalAction(action, status, extras = {}) { + const { value, ...safeAction } = action; + return { ...safeAction, ...extras, status }; +} + +function planInstallerMigrations({ configDir, runtime = null, scope = null, migrations, now = () => new Date().toISOString() }) { if (!configDir) throw new Error('configDir is required'); if (!Array.isArray(migrations)) throw new Error('migrations must be an array'); const manifest = readInstallManifest(configDir); const state = readInstallState(configDir); + const scopedMigrations = migrations.filter((migration) => + migration && migrationMatchesContext(migration, { runtime, scope }) + ); + assertAppliedMigrationChecksums(state, scopedMigrations); const applied = appliedMigrationIds(state); - const pending = migrations.filter((migration) => migration && !applied.has(migration.id)); + const pending = scopedMigrations.filter((migration) => !applied.has(migration.id)); const actions = []; const blocked = []; const classifications = new Map(); @@ -157,10 +254,13 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date() } const plannedActions = migration.plan({ configDir, + runtime, + scope, manifest, state, now, classifyArtifact: classify, + readJson: (relPath) => readJson(configDir, relPath), }); if (!Array.isArray(plannedActions)) { throw new Error(`migration ${migration.id} plan must return an array`); @@ -177,6 +277,7 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date() } const action = { migrationId: migration.id, + migrationChecksum: migrationChecksum(migration), type: protectedType, relPath, reason: rawAction.reason || migration.description || '', @@ -190,7 +291,11 @@ function planInstallerMigrations({ configDir, migrations, now = () => new Date() if (action.type === 'backup-and-remove') { action.backupRelPath = path.posix.join('gsd-migration-backups', migration.id, relPath); } - if (action.classification === 'unknown') blocked.push(action); + if (action.type === 'rewrite-json') { + action.value = rawAction.value; + action.deleteIfEmpty = rawAction.deleteIfEmpty === true; + } + if (action.classification === 'unknown' && action.type !== 'rewrite-json') blocked.push(action); actions.push(action); } } @@ -209,6 +314,54 @@ function uniqueActionMigrationIds(actions) { return [...new Set(actions.map((action) => action.migrationId).filter(Boolean))]; } +function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, previousInstallStateBytes }) { + const failures = []; + for (const action of [...journal.actions].reverse()) { + if (!action.rollbackRelPath) continue; + const rollbackPath = path.join(configDir, action.rollbackRelPath); + const dest = path.join(configDir, action.relPath); + try { + if (fs.existsSync(rollbackPath)) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(rollbackPath, dest); + } + } catch (error) { + failures.push({ relPath: action.relPath, error: error.message }); + } + if (action.backupRelPath) { + try { + fs.rmSync(path.join(configDir, action.backupRelPath), { force: true }); + } catch { + // backup cleanup is best-effort; preserve restore failures above + } + } + } + + try { + if (previousInstallStateBytes === null) { + fs.rmSync(path.join(configDir, INSTALL_STATE_NAME), { force: true }); + } else { + fs.mkdirSync(configDir, { recursive: true }); + writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), previousInstallStateBytes); + } + } catch (error) { + failures.push({ relPath: INSTALL_STATE_NAME, error: error.message }); + } + + try { + fs.rmSync(journalPath, { force: true }); + fs.rmSync(rollbackRoot, { recursive: true, force: true }); + } catch { + // journal cleanup is best-effort; the rollback above is the safety-critical part + } + + if (failures.length > 0) { + const error = new Error('migration rollback incomplete'); + error.rollbackFailures = failures; + throw error; + } +} + function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().toISOString() }) { if (!configDir) throw new Error('configDir is required'); if (!plan || !Array.isArray(plan.actions)) throw new Error('plan with actions is required'); @@ -228,20 +381,20 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t actions: [], }; const rollback = []; + const installStatePath = path.join(configDir, INSTALL_STATE_NAME); + const previousInstallStateBytes = fs.existsSync(installStatePath) + ? fs.readFileSync(installStatePath) + : null; try { for (const action of plan.actions) { - if (action.type === 'preserve-user') { - journal.actions.push({ ...action, status: 'preserved' }); - continue; - } - if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove') { + if (action.type !== 'remove-managed' && action.type !== 'backup-and-remove' && action.type !== 'rewrite-json') { throw new Error(`unsupported migration action type: ${action.type}`); } const { normalized, fullPath } = ensureInsideConfig(configDir, action.relPath); if (!fs.existsSync(fullPath)) { - journal.actions.push({ ...action, status: 'missing' }); + journal.actions.push(journalAction(action, 'missing')); continue; } @@ -250,14 +403,34 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t fs.copyFileSync(fullPath, rollbackPath); rollback.push({ relPath: normalized, rollbackPath }); + if (action.type === 'rewrite-json') { + if (action.deleteIfEmpty && isStructurallyEmpty(action.value)) { + fs.rmSync(fullPath, { force: true }); + journal.actions.push(journalAction(action, 'removed', { + rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), + })); + } else { + writeFileAtomicSync(fullPath, JSON.stringify(action.value, null, 2) + '\n'); + journal.actions.push(journalAction(action, 'rewritten', { + rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), + })); + } + continue; + } + if (action.type === 'backup-and-remove') { const backupRelPath = action.backupRelPath || path.posix.join('gsd-migration-backups', action.migrationId, normalized); const backupPath = path.join(configDir, backupRelPath); fs.mkdirSync(path.dirname(backupPath), { recursive: true }); fs.copyFileSync(fullPath, backupPath); - journal.actions.push({ ...action, backupRelPath, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' }); + journal.actions.push(journalAction(action, 'removed', { + backupRelPath, + rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), + })); } else { - journal.actions.push({ ...action, rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), status: 'removed' }); + journal.actions.push(journalAction(action, 'removed', { + rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized), + })); } fs.rmSync(fullPath, { force: true }); } @@ -270,7 +443,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t const nextApplied = [...state.appliedMigrations]; for (const id of journal.appliedMigrationIds) { if (!applied.has(id)) { - nextApplied.push({ id, appliedAt, journal: journalRelPath }); + const action = plan.actions.find((candidate) => candidate.migrationId === id); + nextApplied.push({ + id, + appliedAt, + journal: journalRelPath, + checksum: action && action.migrationChecksum ? action.migrationChecksum : null, + }); } } writeInstallState(configDir, { @@ -281,6 +460,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t return { appliedMigrationIds: journal.appliedMigrationIds, journalRelPath, + rollback: () => rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollbackRoot, previousInstallStateBytes }), }; } catch (error) { const rollbackFailures = []; @@ -309,11 +489,13 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t function runInstallerMigrations({ configDir, + runtime = null, + scope = null, migrationsDir = DEFAULT_MIGRATIONS_DIR, migrations = discoverInstallerMigrations({ migrationsDir }), now = () => new Date().toISOString(), } = {}) { - const plan = planInstallerMigrations({ configDir, migrations, now }); + const plan = planInstallerMigrations({ configDir, runtime, scope, migrations, now }); if (plan.actions.length === 0) { return { appliedMigrationIds: [], 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 2540d901d..d216bb366 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 @@ -7,7 +7,11 @@ const LEGACY_ORPHAN_FILES = [ module.exports = { id: '2026-05-11-legacy-orphan-files', + title: 'Remove manifest-managed legacy orphan hook files', description: 'Remove legacy orphan hook files that are still manifest-managed.', + introducedIn: '1.50.0', + scopes: ['global', 'local'], + destructive: true, plan: ({ classifyArtifact }) => { const actions = []; for (const relPath of LEGACY_ORPHAN_FILES) { 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 new file mode 100644 index 000000000..6a7d9fe1d --- /dev/null +++ b/get-shit-done/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs @@ -0,0 +1,80 @@ +'use strict'; + +const path = require('path'); + +function isStructurallyEmpty(value) { + if (value === null || value === undefined) return true; + if (Array.isArray(value)) return value.length === 0; + if (typeof value !== 'object') return false; + for (const _key in value) return false; + return true; +} + +function isManagedCodexHookCommand(command, configDir) { + if (typeof command !== 'string') return false; + if (typeof configDir !== 'string' || configDir.length === 0) return false; + const normalizedCommand = command.replace(/\\/g, '/'); + const managedHooksDir = `${path.join(configDir, 'hooks').replace(/\\/g, '/')}/`; + if (!normalizedCommand.includes(managedHooksDir)) return false; + return /(^|[\\/\s"'])(gsd-check-update\.js|gsd-update-check\.js)(?=$|[\s"'])/.test(normalizedCommand); +} + +function pruneLegacyCodexHooksJsonValue(value, configDir) { + if (Array.isArray(value)) { + let changed = false; + const next = []; + for (const item of value) { + const pruned = pruneLegacyCodexHooksJsonValue(item, configDir); + if (pruned.changed) changed = true; + if (!isStructurallyEmpty(pruned.value)) next.push(pruned.value); + else changed = true; + } + return { value: next, changed }; + } + + if (value && typeof value === 'object') { + if (isManagedCodexHookCommand(value.command, configDir)) { + return { value: null, changed: true }; + } + + let changed = false; + const next = {}; + for (const [key, child] of Object.entries(value)) { + const pruned = pruneLegacyCodexHooksJsonValue(child, configDir); + if (pruned.changed) changed = true; + if (!isStructurallyEmpty(pruned.value)) next[key] = pruned.value; + else changed = true; + } + return { value: next, changed }; + } + + return { value, changed: false }; +} + +module.exports = { + id: '2026-05-11-codex-legacy-hooks-json', + title: 'Remove legacy Codex hooks.json GSD hook registrations', + description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.', + introducedIn: '1.50.0', + runtimes: ['codex'], + scopes: ['global', 'local'], + destructive: true, + runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry Codex row', + plan: ({ configDir, readJson }) => { + const hooksJson = readJson('hooks.json'); + if (!hooksJson.exists || hooksJson.error) return []; + + const pruned = pruneLegacyCodexHooksJsonValue(hooksJson.value, configDir); + if (!pruned.changed) return []; + + return [ + { + type: 'rewrite-json', + relPath: 'hooks.json', + value: pruned.value, + deleteIfEmpty: true, + reason: 'legacy Codex hooks.json GSD registration retired by installer migration', + }, + ]; + }, +}; diff --git a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs b/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs index 7bb0a3768..b88c15831 100644 --- a/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs +++ b/tests/bug-3357-codex-legacy-hooks-json-migration.test.cjs @@ -15,7 +15,9 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { install, parseTomlToObject } = require('../bin/install.js'); +const installModule = require('../bin/install.js'); +const { readInstallState } = require('../get-shit-done/bin/lib/installer-migrations.cjs'); +const { install, parseTomlToObject } = installModule; const { createTempDir, cleanup } = require('./helpers.cjs'); function withCodexHome(codexHome, fn) { @@ -67,6 +69,7 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc }); afterEach(() => { + delete installModule.__codexSchemaValidator; cleanup(tmpRoot); }); @@ -104,4 +107,25 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc ]); assert.equal(tomlGsdHookCount(codexHome), 1); }); + + test('restores migrated hooks.json and install state when later Codex validation fails', () => { + const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2); + fs.writeFileSync(path.join(codexHome, 'hooks.json'), before); + + installModule.__codexSchemaValidator = () => ({ + ok: false, + reason: 'forced migration rollback test', + }); + + assert.throws( + () => withCodexHome(codexHome, () => install(true, 'codex')), + /forced migration rollback test/ + ); + + assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before); + assert.equal( + readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'), + false + ); + }); }); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index 8796dd328..6ba99a390 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -8,6 +8,7 @@ const crypto = require('crypto'); const { applyInstallerMigrationPlan, discoverInstallerMigrations, + INSTALL_STATE_NAME, planInstallerMigrations, readInstallState, runInstallerMigrations, @@ -45,6 +46,28 @@ function writeManifest(root, files) { ); } +function legacyCodexHook(configDir) { + return { + hooks: [ + { + type: 'command', + command: `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`, + }, + ], + }; +} + +function userHook(command) { + return { + hooks: [ + { + type: 'command', + command, + }, + ], + }; +} + test('plans a pending migration against an unchanged managed file', () => { const configDir = createTempInstall(); try { @@ -73,7 +96,17 @@ test('plans a pending migration against an unchanged managed file', () => { assert.deepEqual(plan.pendingMigrationIds, ['2026-05-11-remove-old-hook']); assert.equal(plan.blocked.length, 0); - assert.deepEqual(plan.actions, [ + assert.equal(plan.actions.length, 1); + assert.deepEqual( + { + migrationId: plan.actions[0].migrationId, + type: plan.actions[0].type, + relPath: plan.actions[0].relPath, + reason: plan.actions[0].reason, + classification: plan.actions[0].classification, + originalHash: plan.actions[0].originalHash, + currentHash: plan.actions[0].currentHash, + }, { migrationId: '2026-05-11-remove-old-hook', type: 'remove-managed', @@ -82,8 +115,9 @@ test('plans a pending migration against an unchanged managed file', () => { classification: 'managed-pristine', originalHash: sha256('managed hook\n'), currentHash: sha256('managed hook\n'), - }, - ]); + } + ); + assert.match(plan.actions[0].migrationChecksum, /^sha256:/); } finally { cleanup(configDir); } @@ -203,6 +237,7 @@ test('applies an unblocked plan with a journal and install-state update', () => const state = readInstallState(configDir); assert.deepEqual(state.appliedMigrations.map((entry) => entry.id), ['2026-05-11-remove-old-hook']); + assert.match(state.appliedMigrations[0].checksum, /^sha256:/); } finally { cleanup(configDir); } @@ -317,6 +352,70 @@ test('reports rollback restore failures instead of swallowing them', () => { } }); +test('rejects executable preserve-user actions because preservation blocks non-interactive apply', () => { + const configDir = createTempInstall(); + try { + writeManifest(configDir, {}); + + assert.throws( + () => applyInstallerMigrationPlan({ + configDir, + plan: { + blocked: [], + actions: [ + { + migrationId: '2026-05-11-preserve-user', + type: 'preserve-user', + relPath: 'hooks/custom-user-hook.js', + reason: 'unknown user hook', + classification: 'unknown', + originalHash: null, + currentHash: sha256('user hook\n'), + }, + ], + }, + }), + /unsupported migration action type: preserve-user/ + ); + } finally { + cleanup(configDir); + } +}); + +test('keeps prior install state intact when a state write fails mid-write', () => { + const configDir = createTempInstall(); + const originalWriteFileSync = fs.writeFileSync; + try { + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [{ id: 'already-safe', appliedAt: '2026-05-11T00:00:00.000Z' }], + }); + + fs.writeFileSync = (filePath, content, ...rest) => { + if (path.basename(filePath).startsWith(`${INSTALL_STATE_NAME}.tmp-`)) { + throw new Error('simulated temp state write failure'); + } + return originalWriteFileSync(filePath, content, ...rest); + }; + + assert.throws( + () => writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [{ id: 'new-migration', appliedAt: '2026-05-11T00:00:01.000Z' }], + }), + /simulated temp state write failure/ + ); + } finally { + fs.writeFileSync = originalWriteFileSync; + } + + try { + assert.deepEqual(readInstallState(configDir).appliedMigrations.map((entry) => entry.id), ['already-safe']); + } finally { + cleanup(configDir); + } +}); + test('skips migration records already present in install state', () => { const configDir = createTempInstall(); try { @@ -354,6 +453,83 @@ test('skips migration records already present in install state', () => { } }); +test('refuses to plan an already-applied migration whose checksum changed', () => { + const configDir = createTempInstall(); + try { + writeManifest(configDir, {}); + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [ + { + id: '2026-05-11-remove-old-hook', + checksum: 'sha256:old-definition', + appliedAt: '2026-05-11T00:00:00.000Z', + journal: 'gsd-migration-journal/prior.json', + }, + ], + }); + + assert.throws( + () => planInstallerMigrations({ + configDir, + migrations: [ + { + id: '2026-05-11-remove-old-hook', + checksum: 'sha256:new-definition', + description: 'Remove retired hook', + plan: () => [], + }, + ], + }), + /applied migration checksum changed/ + ); + } finally { + cleanup(configDir); + } +}); + +test('ignores checksum drift for applied migrations outside the active runtime scope', () => { + const configDir = createTempInstall(); + try { + writeManifest(configDir, {}); + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [ + { + id: '2026-05-11-codex-only', + checksum: 'sha256:old-definition', + appliedAt: '2026-05-11T00:00:00.000Z', + journal: 'gsd-migration-journal/prior.json', + }, + ], + }); + + const plan = planInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + migrations: [ + { + id: '2026-05-11-codex-only', + checksum: 'sha256:new-definition', + runtimes: ['codex'], + scopes: ['global'], + description: 'Codex-only migration', + plan: () => { + throw new Error('out-of-scope migration planner must not run'); + }, + }, + ], + }); + + assert.deepEqual(plan.pendingMigrationIds, []); + assert.deepEqual(plan.actions, []); + assert.deepEqual(plan.blocked, []); + } finally { + cleanup(configDir); + } +}); + test('discovers migration records from a directory in filename order', () => { const configDir = createTempInstall(); try { @@ -418,6 +594,7 @@ test('runs discovered installer migrations against manifest-managed legacy orpha const result = runInstallerMigrations({ configDir, + scope: 'global', now: () => '2026-05-11T00:00:05.000Z', }); @@ -429,3 +606,66 @@ test('runs discovered installer migrations against manifest-managed legacy orpha cleanup(configDir); } }); + +test('runs a Codex legacy hooks.json cleanup migration without removing user hooks', () => { + const configDir = createTempInstall(); + try { + writeFile( + configDir, + 'hooks.json', + JSON.stringify({ + SessionStart: [ + legacyCodexHook(configDir), + userHook('node "/Users/example/bin/user-hook.js"'), + userHook('node "/Users/example/bin/gsd-check-update.js"'), + ], + }, null, 2) + ); + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'codex', + scope: 'global', + now: () => '2026-05-11T00:00:06.000Z', + }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8')); + const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command); + + assert.deepEqual(commands, [ + 'node "/Users/example/bin/user-hook.js"', + 'node "/Users/example/bin/gsd-check-update.js"', + ]); + assert.ok(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json')); + } finally { + cleanup(configDir); + } +}); + +test('skips runtime-specific migration records for other runtimes', () => { + const configDir = createTempInstall(); + try { + writeFile( + configDir, + 'hooks.json', + JSON.stringify({ + SessionStart: [legacyCodexHook(configDir)], + }, null, 2) + ); + writeManifest(configDir, {}); + + const result = runInstallerMigrations({ + configDir, + runtime: 'claude', + scope: 'global', + now: () => '2026-05-11T00:00:07.000Z', + }); + + const hooksJson = JSON.parse(fs.readFileSync(path.join(configDir, 'hooks.json'), 'utf8')); + assert.equal(hooksJson.SessionStart[0].hooks[0].command, `node "${path.join(configDir, 'hooks', 'gsd-check-update.js')}"`); + assert.equal(result.appliedMigrationIds.includes('2026-05-11-codex-legacy-hooks-json'), false); + } finally { + cleanup(configDir); + } +}); From 9889d0a9aa569072ba59a4885feccef526b1247e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 10 May 2026 23:10:10 -0400 Subject: [PATCH 2/3] docs: add changeset for installer migration phase two --- .changeset/gentle-jays-zip.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gentle-jays-zip.md diff --git a/.changeset/gentle-jays-zip.md b/.changeset/gentle-jays-zip.md new file mode 100644 index 000000000..82c581f8e --- /dev/null +++ b/.changeset/gentle-jays-zip.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 3399 +--- +**Installer migrations now handle legacy Codex hooks cleanup transactionally** - GSD-owned hooks.json entries are removed through the migration runner with runtime filtering, rollback, and checksum drift protection. From 5002d51d92ce5a8bc9343655d42a50d9a220ff3b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 11 May 2026 14:59:33 -0400 Subject: [PATCH 3/3] Docs(installer): reword Antigravity migration contract note --- docs/installer-migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index d8a54b635..c9701c4c3 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -314,7 +314,7 @@ for the new shape before changing migration behavior. | Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].codex_hooks` when added by GSD, and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-11; installer compatibility sentinel: Codex 0.124.0 agent table shape | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-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 `./.agent` | 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; GSD uses the Gemini-compatible settings contract as a compatibility shim | +| 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 `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Checked 2026-05-11 against available Antigravity install/config material; this row records GSD's Gemini-compatible settings contract as the compatibility baseline for installer migrations. | | Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 | | Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules | | 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](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 |