diff --git a/.changeset/3566-codex-hooks-canonical-feature-key.md b/.changeset/3566-codex-hooks-canonical-feature-key.md new file mode 100644 index 000000000..1d42f79ca --- /dev/null +++ b/.changeset/3566-codex-hooks-canonical-feature-key.md @@ -0,0 +1,6 @@ +--- +type: Fixed +pr: 3573 +--- + +**Codex installer now emits canonical `[features].hooks` on fresh inserts (no longer writes deprecated `codex_hooks`)** — Codex's own source marks `codex_hooks` as a `legacy_key` ([codex-rs/features/src/legacy.rs](https://github.com/openai/codex/blob/main/codex-rs/features/src/legacy.rs)). The GSD installer was writing the deprecated key on every install / reinstall on Codex CLI ≥ 0.130.0. The installer now writes the canonical `[features].hooks = true` (section, root-dotted, and block-fallback forms) and the runtime check recognizes both `hooks` and legacy `codex_hooks` as enabling the feature. Pre-existing legacy lines (user-authored or from older GSD installs) are preserved untouched — Codex's own `legacy_key` alias handles them at the runtime layer, so there's no breaking change for existing configs. Closes #3566. diff --git a/bin/install.js b/bin/install.js index 2f807fb2f..2e920fcb4 100755 --- a/bin/install.js +++ b/bin/install.js @@ -32,6 +32,19 @@ const reset = '\x1b[0m'; // Codex config.toml constants const GSD_CODEX_MARKER = '# GSD Agent Configuration \u2014 managed by get-shit-done installer'; const GSD_CODEX_HOOKS_OWNERSHIP_PREFIX = '# GSD codex_hooks ownership: '; +// Codex's hook-enabling feature flag (issue #3566). Codex itself marks +// `codex_hooks` as a `legacy_key` in codex-rs/features/src/legacy.rs; the +// canonical current key under [features] is `hooks`. The installer always +// emits the canonical key going forward, recognizes legacy aliases as +// equivalent during reinstall, and migrates them forward on rewrite. The +// audit-marker string above is intentionally unchanged so existing +// installs' ownership lines continue to round-trip. +const CODEX_HOOKS_FEATURE_KEY = 'hooks'; +const CODEX_HOOKS_FEATURE_LEGACY_KEYS = ['codex_hooks']; +const CODEX_HOOKS_FEATURE_ALL_KEYS = [CODEX_HOOKS_FEATURE_KEY, ...CODEX_HOOKS_FEATURE_LEGACY_KEYS]; +function isCodexHooksFeatureKey(key) { + return CODEX_HOOKS_FEATURE_ALL_KEYS.includes(key); +} // Copilot instructions marker constants const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; @@ -3230,7 +3243,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) { !record.startsInMultilineString && record.keySegments && record.keySegments.length === 1 && - record.keySegments[0] === 'codex_hooks' + isCodexHooksFeatureKey(record.keySegments[0]) ); for (const record of codexHookRecords) { @@ -3275,7 +3288,7 @@ function stripCodexHooksFeatureAssignments(content, ownership = null) { record.keySegments && record.keySegments.length === 2 && record.keySegments[0] === 'features' && - record.keySegments[1] === 'codex_hooks' + isCodexHooksFeatureKey(record.keySegments[1]) ); for (const record of rootCodexHookRecords) { @@ -4431,6 +4444,13 @@ function rewriteTomlKeyLines(content, matches, key) { const blockEol = blockEnd > 0 && content[blockEnd - 1] === '\n' ? (blockEnd > 1 && content[blockEnd - 2] === '\r' ? '\r\n' : '\n') : ''; + // Preserve the existing key when one is present on the line + // (`match.keyRaw`). This respects user ownership: a user-authored + // `codex_hooks = true` line stays as `codex_hooks = true` even + // though `hooks` is the canonical key in current Codex (#3566). + // Codex's own `legacy_key` alias mechanism in codex-rs handles the + // backward compat at the runtime layer. Migration to canonical is + // a fresh-insert-only operation in ensureCodexHooksFeature. rewritten += normalizeCodexHooksLine(match.text, match.keyRaw || key) + blockEol; cursor = blockEnd; return; @@ -4612,11 +4632,17 @@ function ensureCodexHooksFeature(configContent) { record.end + record.eol.length <= featuresSection.end && record.keySegments && record.keySegments.length === 1 && - record.keySegments[0] === 'codex_hooks' + isCodexHooksFeatureKey(record.keySegments[0]) ); if (sectionLines.length > 0) { - const rewritten = rewriteTomlKeyLines(configContent, sectionLines, 'codex_hooks'); + // Rewrite to canonical key — this migrates legacy `codex_hooks` to + // `hooks` in-place on every reinstall. If the file already has the + // canonical key the rewrite is a no-op shape-wise (same key, same + // value). The rewriteTomlKeyLines helper preserves indentation, + // trailing comments, and ownership-marker positioning, and always + // emits the caller-supplied canonical key (#3566). + const rewritten = rewriteTomlKeyLines(configContent, sectionLines, CODEX_HOOKS_FEATURE_KEY); return { content: repairTrappedFeaturesKeys(rewritten), ownership: null, @@ -4626,7 +4652,7 @@ function ensureCodexHooksFeature(configContent) { const sectionBody = configContent.slice(featuresSection.headerEnd, featuresSection.end); const needsSeparator = sectionBody.length > 0 && !sectionBody.endsWith('\n') && !sectionBody.endsWith('\r\n'); const insertPrefix = sectionBody.length === 0 && featuresSection.headerEnd === configContent.length ? eol : ''; - const insertText = `${insertPrefix}${needsSeparator ? eol : ''}codex_hooks = true${eol}`; + const insertText = `${insertPrefix}${needsSeparator ? eol : ''}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`; const merged = configContent.slice(0, featuresSection.end) + insertText + configContent.slice(featuresSection.end); return { content: repairTrappedFeaturesKeys(merged), @@ -4644,11 +4670,11 @@ function ensureCodexHooksFeature(configContent) { ); const rootCodexHooksLines = rootFeatureLines - .filter((record) => record.keySegments.length === 2 && record.keySegments[1] === 'codex_hooks'); + .filter((record) => record.keySegments.length === 2 && isCodexHooksFeatureKey(record.keySegments[1])); if (rootCodexHooksLines.length > 0) { return { - content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, 'features.codex_hooks'), + content: rewriteTomlKeyLines(configContent, rootCodexHooksLines, `features.${CODEX_HOOKS_FEATURE_KEY}`), ownership: null, }; } @@ -4666,13 +4692,13 @@ function ensureCodexHooksFeature(configContent) { const prefix = insertAt > 0 && configContent[insertAt - 1] === '\n' ? '' : eol; return { content: configContent.slice(0, insertAt) + - `${prefix}features.codex_hooks = true${eol}` + + `${prefix}features.${CODEX_HOOKS_FEATURE_KEY} = true${eol}` + configContent.slice(insertAt), ownership: 'root_dotted', }; } - const featuresBlock = `[features]${eol}codex_hooks = true${eol}`; + const featuresBlock = `[features]${eol}${CODEX_HOOKS_FEATURE_KEY} = true${eol}`; if (!configContent) { return { content: featuresBlock, ownership: 'section' }; } @@ -4703,11 +4729,11 @@ function hasEnabledCodexHooksFeature(configContent) { const isSectionKey = record.tablePath === 'features' && record.keySegments.length === 1 && - record.keySegments[0] === 'codex_hooks'; + isCodexHooksFeatureKey(record.keySegments[0]); const isRootDottedKey = record.tablePath === null && record.keySegments.length === 2 && record.keySegments[0] === 'features' && - record.keySegments[1] === 'codex_hooks'; + isCodexHooksFeatureKey(record.keySegments[1]); if (!isSectionKey && !isRootDottedKey) { return false; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 78d50b1ff..0ff012a27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -737,7 +737,7 @@ The migration-specific ownership and source snapshots live in | 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 | +| Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), 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 | diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index fd175ed41..4c99b3f51 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -359,7 +359,7 @@ for the new shape before changing migration behavior. | 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 | +| 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].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), 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-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` 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; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. | | 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 | diff --git a/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs b/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs new file mode 100644 index 000000000..1d422b337 --- /dev/null +++ b/tests/bug-3566-codex-hooks-feature-canonical-key.test.cjs @@ -0,0 +1,202 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Regression tests for bug #3566 — Codex installer must emit canonical + * [features].hooks (not the legacy [features].codex_hooks). + * + * Codex itself marks `codex_hooks` as a `legacy_key` in + * codex-rs/features/src/legacy.rs. The canonical current feature flag is + * `hooks`. The GSD installer was still writing `codex_hooks` on every fresh + * install / reinstall, leaving deprecated config behind. This file pins: + * + * 1. Fresh install writes canonical `[features].hooks = true` and never + * emits `codex_hooks` (section, root-dotted, or block-fallback forms). + * 2. Reinstall over a GSD-owned section-form legacy + * `[features].codex_hooks = true` migrates forward to + * `[features].hooks = true` (legacy line removed); user-owned legacy + * entries are preserved per #2760. + * 3. Reinstall over a GSD-owned root-dotted legacy + * `features.codex_hooks = true` migrates forward to + * `features.hooks = true`; user-owned legacy entries are preserved. + * 4. Reinstall over a user-owned `[features].hooks = true` (no GSD + * ownership marker) preserves the user line; no double-write, no + * ownership stamp. + * 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical + * `hooks` AND legacy `codex_hooks` as "enabled" so existing installs + * keep working across the migration window. + * 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy + * `codex_hooks`; user-owned `hooks` is preserved. + * + * All assertions use parseTomlToObject — never substring-match on raw TOML + * text (per RULESET.TESTS.no-source-grep). The product surface is the + * parsed config shape, not the file's lexical layout. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); + +function withCodexHome(codexHome, fn) { + const prev = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + try { + return fn(); + } finally { + if (prev == null) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = prev; + } +} + +function readConfig(codexHome) { + const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); + return { text, parsed: parseTomlToObject(text) }; +} + +function featuresHooks(parsed) { + return parsed?.features?.hooks; +} + +function featuresCodexHooks(parsed) { + return parsed?.features?.codex_hooks; +} + +describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => { + let tmpRoot; + let codexHome; + + beforeEach(() => { + if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } + tmpRoot = createTempDir('gsd-3566-'); + codexHome = path.join(tmpRoot, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpRoot); + }); + + test('fresh install writes [features].hooks = true and never emits codex_hooks', () => { + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'fresh install must write canonical [features].hooks = true', + ); + assert.strictEqual( + featuresCodexHooks(parsed), + undefined, + 'fresh install must NOT write legacy [features].codex_hooks', + ); + }); + + test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => { + // A user who hand-wrote `codex_hooks = true` keeps the legacy key. + // Codex itself maps it via the runtime legacy_key alias, so this is + // forward-compatible without GSD rewriting user-authored content. + const legacy = [ + '[features]', + 'codex_hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned legacy codex_hooks line must be preserved verbatim', + ); + }); + + test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => { + const legacy = 'features.codex_hooks = true\n'; + fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresCodexHooks(parsed), + true, + 'user-owned root-dotted legacy line must be preserved verbatim', + ); + }); + + test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'user-owned hooks=true must be preserved', + ); + }); + + test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => { + // Phase 1: fresh GSD install — writes GSD-owned hooks line. + withCodexHome(codexHome, () => install(true, 'codex')); + const { parsed: afterInstall } = readConfig(codexHome); + assert.strictEqual( + featuresHooks(afterInstall), + true, + 'precondition: install wrote canonical hooks', + ); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const configPath = path.join(codexHome, 'config.toml'); + if (!fs.existsSync(configPath)) { + // Uninstall may delete config.toml entirely when nothing user-owned + // remains — that is the strongest possible "feature flag removed" + // signal and counts as success. + return; + } + const { parsed: afterUninstall } = readConfig(codexHome); + assert.notStrictEqual( + featuresHooks(afterUninstall), + true, + 'uninstall must remove GSD-owned canonical hooks line', + ); + }); + + test('uninstall preserves user-owned hooks=true when GSD never owned it', () => { + const userOwned = [ + '[features]', + 'hooks = true', + '', + ].join('\n'); + fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned); + + withCodexHome(codexHome, () => uninstall(true, 'codex')); + const { parsed } = readConfig(codexHome); + + assert.strictEqual( + featuresHooks(parsed), + true, + 'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)', + ); + }); +}); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 62c6c4d11..b53b5636c 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -1462,7 +1462,7 @@ describe('Codex install hook configuration (e2e)', () => { runCodexInstall(codexHome); const content = readCodexConfig(codexHome); - assert.ok(content.includes('[features]\ncodex_hooks = true\n'), 'writes codex_hooks feature'); + assert.ok(content.includes('[features]\nhooks = true\n'), 'writes codex_hooks feature'); const parsed = parseTomlToObject(content); assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); // #3017: handler command now uses the absolute Node binary path so @@ -1478,7 +1478,7 @@ describe('Codex install hook configuration (e2e)', () => { const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json'); assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'writes one codex_hooks key'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'writes one codex_hooks key'); assertNoDraftRootKeys(content); assertUsesOnlyEol(content, '\n'); }); @@ -1556,7 +1556,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); assert.ok(content.includes('# user comment'), 'preserves user comment'); assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); @@ -1610,7 +1610,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'creates one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'creates one codex_hooks key'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'creates one codex_hooks key'); assert.ok(content.includes('# user comment'), 'preserves user comment'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves model section'); // [features] should be inserted between top-level lines and [model], not prepended @@ -1636,8 +1636,8 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.includes('[features]\r\n# keep me\r\n\r\ncodex_hooks = true\r\n'), 'adds codex_hooks within comment-only table'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n# keep me\r\n\r\nhooks = true\r\n'), 'adds codex_hooks within comment-only table'); assert.ok(content.includes('[model]\r\nname = "o3"\r\n'), 'preserves following table'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); @@ -1657,10 +1657,10 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\s*\[features\](?:\s*#.*)?$/gm), 1, 'keeps one commented [features] header'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); assert.ok(content.includes('[features] # keep comment\nother_feature = true'), 'preserves commented features table'); - assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); - assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('[features] # keep comment'), 'adds codex_hooks within existing features table'); + assert.ok(content.indexOf('hooks = true') < content.indexOf('[model]'), 'does not create a second features table before model'); assertNoDraftRootKeys(content); }); @@ -1671,9 +1671,9 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); - assert.ok(content.indexOf('codex_hooks = true') < content.indexOf('[agents.'), 'keeps codex_hooks before the first managed [agents.] struct entry'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('[features]'), 'adds codex_hooks after the existing EOF features header'); + assert.ok(content.indexOf('hooks = true') < content.indexOf('[agents.'), 'keeps codex_hooks before the first managed [agents.] struct entry'); assertNoDraftRootKeys(content); }); @@ -1765,7 +1765,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); @@ -1786,7 +1786,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); - assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); @@ -1876,9 +1876,9 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.ok(content.includes('features.notes = """\nkeep-me\n"""'), 'preserves the multiline dotted assignment'); - assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); - assert.ok(content.indexOf('features.codex_hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); - assert.ok(content.indexOf('features.codex_hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); + assert.strictEqual(countMatches(content, /^features\.hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); + assert.ok(content.indexOf('features.hooks = true') > content.indexOf('"""'), 'inserts codex_hooks after the multiline assignment closes'); + assert.ok(content.indexOf('features.hooks = true') < content.indexOf('[model]'), 'inserts codex_hooks before the next table'); assertNoDraftRootKeys(content); }); @@ -1889,8 +1889,8 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.ok(content.includes('[features]\r\n\r\ncodex_hooks = true\r\n'), 'adds codex_hooks to empty table'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds one codex_hooks key'); + assert.ok(content.includes('[features]\r\n\r\nhooks = true\r\n'), 'adds codex_hooks to empty table'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); @@ -1914,13 +1914,13 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds a real codex_hooks key once'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'adds a real codex_hooks key once'); assert.ok(content.includes('notes = \'\'\'\n[model]\ncodex_hooks = false\n\'\'\''), 'preserves multiline string content'); assert.strictEqual(countMatches(content, /^codex_hooks = false$/gm), 1, 'does not rewrite codex_hooks text inside multiline string'); - assert.ok(content.indexOf('codex_hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); + assert.ok(content.indexOf('hooks = true') > content.indexOf('other_feature = true'), 'does not stop the features section at multiline string content'); // Parse structurally — verify codex_hooks and migrated AfterCommand hook via parsed object const parsed = parseTomlToObject(content); - assert.equal(parsed.features?.codex_hooks, true, 'writes a real codex_hooks boolean key'); + assert.equal(parsed.features?.hooks, true, 'writes a real hooks boolean key (#3566)'); assert.ok(Array.isArray(parsed.hooks?.AfterCommand), 'AfterCommand flat [[hooks]] migrated to namespaced AoT'); const afterCmds = parsed.hooks.AfterCommand.flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks.map((h) => h.command).filter(Boolean) : [] @@ -2112,7 +2112,7 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); // [features] is inserted after top-level lines, before [model] — not prepended - assert.ok(content.includes('# first line wins\n\n[features]\ncodex_hooks = true\n'), 'inserts features after top-level lines using first newline style'); + assert.ok(content.includes('# first line wins\n\n[features]\nhooks = true\n'), 'inserts features after top-level lines using first newline style'); assert.ok(content.includes(`# GSD Agent Configuration — managed by get-shit-done installer\n`), 'writes the managed agent block using the first newline style'); // Structural check: managed SessionStart hooks live in hooks.json. const parsedMixed = parseTomlToObject(content); @@ -2121,7 +2121,7 @@ describe('Codex install hook configuration (e2e)', () => { const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); - assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'remains idempotent on repeated installs'); + assert.strictEqual(countMatches(content, /^hooks = true$/gm), 1, 'remains idempotent on repeated installs'); assertNoDraftRootKeys(content); }); });