diff --git a/CONTEXT.md b/CONTEXT.md index 4c09f79c6..a60ca7332 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -179,7 +179,7 @@ A Capability whose integration shape brings its own external process, service, o Standalone hook-surface writer module extracted from `bin/install.js` as ADR-857 phase 5f-1 (behavior-preserving relocation, no logic change). Owns: Cline rules-body/agents-md/pre-tool-use hook generation (`buildClineRulesBody`, `buildClineAgentsMdBody`, `buildClinePreToolUseHook`, `mergeGsdAgentsMd`, `writeClineArtifacts`); Cursor `hooks.json` lifecycle (`buildCursorHookEntry`, `isManagedCursorHookEntry`, `reconcileCursorHooksJson`, `writeCursorHooksJson`, `removeCursorHooksJson`); Copilot session-hook config (`buildCopilotHookConfig`, `writeCopilotHookConfig`); Codex hook-block and event management (`buildCodexHookBlock`, `rewriteLegacyCodexHookBlock`, `reconcileCodexHooksJsonEvent`, `reconcileCodexHooksJsonSessionStart`, `ensureCodexHooksJsonSessionStart`, `ensureCodexHooksJsonEvent`, `removeCodexHooksJsonEvent`, `removeCodexHooksJsonSessionStart`, `buildCodexHookWindowsShimIR`); and shared hook command helpers (`buildHookCommand`, `rewriteLegacyManagedNodeHookCommands`, `normalizeNodePath`, `resolveNodeRunner`). `bin/install.js` delegates to this module via thin wrappers and re-exports its functions unchanged so existing tests require no modification. Source: `src/runtime-hooks-surface.cts`. Built output: `gsd-core/bin/lib/runtime-hooks-surface.cjs`. ### Runtime Config Adapter Registry -Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. +Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `cursor-hooks-json` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Also exports `resolveInstallPlan(runtime)` — the ADR-58 `InstallPlan` capstone — which collects the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`) into one typed `InstallPlan` value consumed by `install()` and `finishInstall()` in `bin/install.js`. The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules and are not part of the plan; they are the execution adapters. Realizes both the adapter-selection and plan-collection halves of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. ### Claude Code Plugin Manifest Module Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). `hooks.json` covers all seven Claude Code lifecycle events: SessionStart, PreToolUse, PostToolUse, SubagentStop, Stop, PreCompact (all wired to context-monitor for context-headroom awareness), and FileChanged (matcher: `config.json` → config-reload, injects `additionalContext` when `.planning/config.json` changes mid-session). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. diff --git a/bin/install.js b/bin/install.js index 9e9c10635..55b0fcaae 100755 --- a/bin/install.js +++ b/bin/install.js @@ -37,7 +37,7 @@ const { applyWorktreeBaseRef, readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); -const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); // Canonical set of hook files shipped to users. Imported here so writeManifest() // records exactly the same set that build-hooks.js copies to hooks/dist/, making // the manifest and the installed hooks/ dir structurally identical. Avoids the @@ -9383,7 +9383,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const isHermes = runtime === 'hermes'; const isCodebuddy = runtime === 'codebuddy'; const isCline = runtime === 'cline'; - const configIntent = resolveRuntimeConfigIntent(runtime); + const plan = resolveInstallPlan(runtime); const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -10526,7 +10526,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { throw _earlyInstallErr; } - if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) { + if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) { // Capture pre-install snapshots before ANY GSD mutation // (#2760 fix 3). On post-write schema-validation failure OR any throw // during the mutation sequence (write failure, merge throw, etc.) we @@ -10905,7 +10905,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (configIntent.installSurface === 'copilot-instructions') { + if (plan.installSurface === 'copilot-instructions') { // Generate copilot-instructions.md const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'); const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); @@ -10935,7 +10935,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (configIntent.installSurface === 'cursor-hooks-json') { + if (plan.installSurface === 'cursor-hooks-json') { // #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse. // Hook scripts are copied to /hooks/ and referenced by hooks.json. const cursorHookResult = writeCursorHooksJson(targetDir, src, {}); @@ -10950,13 +10950,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (configIntent.installSurface === 'profile-marker-only') { + if (plan.installSurface === 'profile-marker-only') { // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed. persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (configIntent.installSurface === 'cline-rules') { + if (plan.installSurface === 'cline-rules') { // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live // at .clinerules/gsd.md and a PreToolUse lifecycle hook at // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md. @@ -10969,8 +10969,12 @@ function install(isGlobal, runtime = 'claude', options = {}) { } // Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs). - // Gemini and Antigravity use AfterTool instead of PostToolUse for post-tool hooks - const postToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse'; + // ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor. + // runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse. + // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check. + // A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe). + const _hookEventsDialect = plan.hookEvents; + const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse'; // #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot) // so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared // settings.json. Global installs and all other runtimes continue to use settings.json. @@ -11152,6 +11156,9 @@ function install(isGlobal, runtime = 'claude', options = {}) { isGlobal, targetDir, postToolEvent, + hookEvents: _hookEventsDialect, + extendedHookEvents: plan.extendedHookEvents, + hooksSurface: plan.hooksSurface, updateCheckCommand, contextMonitorCommand, promptGuardCommand, @@ -11276,9 +11283,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isWindsurf = runtime === 'windsurf'; const isTrae = runtime === 'trae'; const isCline = runtime === 'cline'; - const configIntent = resolveRuntimeConfigIntent(runtime); + const plan = resolveInstallPlan(runtime); - if (shouldInstallStatusline && configIntent.writesSharedSettings && !isOpencode) { + if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) { if (!isGlobal && !forceStatusline) { // Local installs skip statusLine by default: repo settings.json takes precedence over // profile-level settings.json in Claude Code, so writing here would silently clobber @@ -11304,7 +11311,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/ // cline either lack the surface or use a different config schema. const { shouldInstallBanner, bannerCommand } = bannerOpts; - if (shouldInstallBanner && settings && configIntent.writesSharedSettings && !isOpencode) { + if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) { if (!bannerCommand) { console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`); } else { @@ -11344,17 +11351,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // {type: 'command', command: null} items that the runtime hook schema // rejects at parse time. validateHookFields filters those out so the file // we write is always schema-valid. - if (settingsPath && settings && configIntent.writesSharedSettings) { + if (settingsPath && settings && plan.writesSharedSettings) { writeSettings(settingsPath, validateHookFields(settings)); } // Configure OpenCode permissions - if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) { + if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) { configureOpencodePermissions(isGlobal, configDir); } // Configure Kilo permissions - if (configIntent.finishPermissionWriter === 'kilo') { + if (plan.finishPermissionWriter === 'kilo') { configureKiloPermissions(isGlobal, configDir); } diff --git a/capabilities/antigravity/capability.json b/capabilities/antigravity/capability.json index a7627d316..ca14c4c25 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -40,6 +40,10 @@ "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/augment/capability.json b/capabilities/augment/capability.json index a43dc6380..591b86345 100644 --- a/capabilities/augment/capability.json +++ b/capabilities/augment/capability.json @@ -54,6 +54,10 @@ "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index 77e22ba3b..f7a3770bd 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -46,6 +46,10 @@ "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": ["SubagentStop", "Stop", "PreCompact", "FileChanged"] } } diff --git a/capabilities/cline/capability.json b/capabilities/cline/capability.json index fb21cb700..81dadc8ee 100644 --- a/capabilities/cline/capability.json +++ b/capabilities/cline/capability.json @@ -28,6 +28,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "cline-rules", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cline-rules", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/codebuddy/capability.json b/capabilities/codebuddy/capability.json index 2e357ce97..06f1d5999 100644 --- a/capabilities/codebuddy/capability.json +++ b/capabilities/codebuddy/capability.json @@ -54,6 +54,10 @@ "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/codex/capability.json b/capabilities/codex/capability.json index 42460508f..ef6c707c0 100644 --- a/capabilities/codex/capability.json +++ b/capabilities/codex/capability.json @@ -38,6 +38,10 @@ "hooksSurface": "codex-hooks-json", "hookEvents": "claude", "sandboxTier": "codex-agent-sandbox", - "supportTier": 1 + "supportTier": 1, + "installSurface": "codex-toml", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/copilot/capability.json b/capabilities/copilot/capability.json index 659b1dbd5..d8b03e3fd 100644 --- a/capabilities/copilot/capability.json +++ b/capabilities/copilot/capability.json @@ -37,6 +37,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "copilot-inline", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "copilot-instructions", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/cursor/capability.json b/capabilities/cursor/capability.json index 8ac974b60..e05a1e574 100644 --- a/capabilities/cursor/capability.json +++ b/capabilities/cursor/capability.json @@ -54,6 +54,10 @@ "hooksSurface": "cursor-hooks-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cursor-hooks-json", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/gemini/capability.json b/capabilities/gemini/capability.json index dac2fb18b..197a1e1a2 100644 --- a/capabilities/gemini/capability.json +++ b/capabilities/gemini/capability.json @@ -38,6 +38,10 @@ "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": ["BeforeAgent", "AfterAgent", "BeforeModel"] } } diff --git a/capabilities/hermes/capability.json b/capabilities/hermes/capability.json index b0140bb54..29bdfe327 100644 --- a/capabilities/hermes/capability.json +++ b/capabilities/hermes/capability.json @@ -38,6 +38,10 @@ "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/kilo/capability.json b/capabilities/kilo/capability.json index 32136736c..890c623d2 100644 --- a/capabilities/kilo/capability.json +++ b/capabilities/kilo/capability.json @@ -58,6 +58,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": false, + "permissionWriter": "kilo", + "extendedHookEvents": [] } } diff --git a/capabilities/kimi/capability.json b/capabilities/kimi/capability.json index 73f06ecb7..c9d225175 100644 --- a/capabilities/kimi/capability.json +++ b/capabilities/kimi/capability.json @@ -38,6 +38,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/opencode/capability.json b/capabilities/opencode/capability.json index 79676ef2c..a00c26281 100644 --- a/capabilities/opencode/capability.json +++ b/capabilities/opencode/capability.json @@ -53,6 +53,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": "opencode", + "extendedHookEvents": [] } } diff --git a/capabilities/qwen/capability.json b/capabilities/qwen/capability.json index 15f5ed553..bdc5e77df 100644 --- a/capabilities/qwen/capability.json +++ b/capabilities/qwen/capability.json @@ -38,6 +38,10 @@ "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": ["SubagentStop", "Stop", "PreCompact"] } } diff --git a/capabilities/trae/capability.json b/capabilities/trae/capability.json index 287723f84..53fc8962b 100644 --- a/capabilities/trae/capability.json +++ b/capabilities/trae/capability.json @@ -37,6 +37,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/capabilities/windsurf/capability.json b/capabilities/windsurf/capability.json index 4cfeb0ee6..a3e74028e 100644 --- a/capabilities/windsurf/capability.json +++ b/capabilities/windsurf/capability.json @@ -38,6 +38,10 @@ "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } diff --git a/docs/adr/1016-runtime-capability-descriptor.md b/docs/adr/1016-runtime-capability-descriptor.md index 1020cc009..a0f889c86 100644 --- a/docs/adr/1016-runtime-capability-descriptor.md +++ b/docs/adr/1016-runtime-capability-descriptor.md @@ -16,14 +16,14 @@ ADR-857 Branch 8 decided: host-CLI support becomes a `role: runtime` variant of Two enabling pieces already exist: -- **The descriptor schema is authored and validated.** `gen-capability-registry.cjs` `validateRuntimeBody` validates a `role: runtime` capability's `runtime: { configHome, configFormat, artifactLayout, commandStyle, hooksSurface, sandboxTier, supportTier }`, forbids feature-only fields (`skills`/`agents`/`steps`/`contributions`/`gates`/`hooks`), and the registry exposes a `runtimes` index (currently `{}` — no descriptor authored yet). -- **ADR-58 defines the `InstallPlan`** as a pure typed projection (placements + command text + config intentions → adapters execute) but it is **not materialized**; `runtime-config-adapter-registry.cts` realizes only the adapter-selection half. +- **The descriptor schema is authored and validated.** `gen-capability-registry.cjs` `validateRuntimeBody` validates a `role: runtime` capability's `runtime: { configHome, configFormat, artifactLayout, commandStyle, hooksSurface, hookEvents, sandboxTier, supportTier, installSurface, writesSharedSettings, permissionWriter, extendedHookEvents }`, forbids feature-only fields (`skills`/`agents`/`steps`/`contributions`/`gates`/`hooks`), and the registry exposes a `runtimes` index (currently `{}` — no descriptor authored yet). +- **ADR-58 defines the `InstallPlan`** as a pure typed projection (placements + command text + config intentions → adapters execute); it **is now materialized** — `runtime-config-adapter-registry.cts` realizes both the adapter-selection half and the plan-collection half via the exported `resolveInstallPlan(runtime)`, which `install()` and `finishInstall()` consume. -What is missing, and what this ADR fixes: only `configFormat` is a real closed enum (5 values); `commandStyle`/`hooksSurface`/`sandboxTier` are loose strings and `artifactLayout` is an unconstrained array. This ADR **closes the vocabulary for all six axes**, decides how the seven hard-case runtimes are absorbed as data, and fixes the staged migration that drives `install.js` from descriptors. +What is missing, and what this ADR fixes: only `configFormat` is a real closed enum (5 values); `commandStyle`/`hooksSurface`/`sandboxTier` are loose strings and `artifactLayout` is an unconstrained array. This ADR **closes the vocabulary for all eight original axes**, decides how the seven hard-case runtimes are absorbed as data, and fixes the staged migration that drives `install.js` from descriptors. Four additional axes — `installSurface`, `writesSharedSettings`, `permissionWriter`, and `extendedHookEvents` — were added to the descriptor and validator in the 5f-completion pass; they are documented in Decision 7a below. ## Decision -**Core principle:** every per-runtime difference is expressed as a value over a closed primitive vocabulary on six axes. A runtime that needs a shape no existing primitive expresses is supported by adding a first-party primitive (a new enum member + the code that honors it) — never by embedding arbitrary code or an open escape hatch in the descriptor. All 16 current runtimes MUST be expressible as data under the vocabulary below; any residue that genuinely cannot be is named explicitly as retained first-party code (§ Decision 8). +**Core principle:** every per-runtime difference is expressed as a value over a closed primitive vocabulary on twelve axes. A runtime that needs a shape no existing primitive expresses is supported by adding a first-party primitive (a new enum member + the code that honors it) — never by embedding arbitrary code or an open escape hatch in the descriptor. All 16 current runtimes MUST be expressible as data under the vocabulary below; any residue that genuinely cannot be is named explicitly as retained first-party code (§ Decision 8). ### 1. `configHome` — a structured value, not a path string @@ -95,17 +95,60 @@ hookEvents?: 'claude' // SessionStart/PreToolUse/PostToolUse `1` = fully tested first-party (claude, codex, antigravity); `2` = shipped, lower-tier (the other 13). None dropped. Drives the cross-runtime test matrix, not behavior. +### 7a. Install-surface axes — config-writing + the per-event hook SET (added in 5f completion) + +Four axes added to the descriptor (and to `gen-capability-registry.cjs` `validateRuntimeBody`) in the 5f-completion pass. Together they retire the last hardcoded per-runtime tables in `runtime-config-adapter-registry` and `applySettingsJsonHooks`. + +#### `installSurface` — closed enum (6 values) + +``` +installSurface: 'settings-json' | 'codex-toml' | 'copilot-instructions' + | 'cline-rules' | 'cursor-hooks-json' | 'profile-marker-only' +``` + +Selects which config-writing adapter `resolveRuntimeConfigIntent` (in `runtime-config-adapter-registry`) returns. Previously `runtime-config-adapter-registry` held a hand-kept `REGISTRY` table mapping runtime names to adapter types; `installSurface` in the descriptor is now the **single source of truth** — the `REGISTRY` table has been retired. + +#### `writesSharedSettings` — boolean + +Whether the runtime writes a shared `settings.json`. Replaces the former inline boolean per runtime in `runtime-config-adapter-registry`. Together with `installSurface` this fully parameterises the adapter-selection path. + +#### `permissionWriter` — `null | 'opencode' | 'kilo'` + +The finish-time permissions-sidecar writer (the JSONC file opencode and kilo require). Replaces the old `finishPermissionWriter` field in the `runtime-config-adapter-registry` `REGISTRY` table. All 14 runtimes that write no permissions sidecar carry `null`. + +#### `extendedHookEvents` — `string[]` over a closed event vocabulary + +The per-runtime set of **bonus lifecycle events** beyond the coarse `hookEvents` dialect. Vocabulary: + +``` +SubagentStop | Stop | PreCompact | FileChanged | BeforeAgent | AfterAgent | BeforeModel +``` + +Values per runtime: +- `claude` → `[SubagentStop, Stop, PreCompact, FileChanged]` +- `qwen` → `[SubagentStop, Stop, PreCompact]` +- `gemini` → `[BeforeAgent, AfterAgent, BeforeModel]` +- all 13 others → `[]` + +This replaces three hardcoded per-event guards in `applySettingsJsonHooks`: the `if (isQwen || runtime==='claude')` block (SubagentStop/Stop/PreCompact) and the `if (runtime==='claude')` block (FileChanged) and the `if (isGemini)` block (BeforeAgent/AfterAgent/BeforeModel). The loop now iterates `extendedHookEvents` for the active runtime; no per-runtime conditionals remain. + +**Relationship to `hookEvents`:** `hookEvents` (the coarse 2-value dialect — `'claude'` vs `'gemini'`) governs the *event-name vocabulary* the hook adapter emits. `extendedHookEvents` governs the *additional lifecycle events* each runtime registers beyond the base set. They are independent: antigravity carries `hookEvents: 'gemini'` (so its hook bodies use Gemini event names) but `extendedHookEvents: []` — it does **not** receive the per-agent Gemini events (`BeforeAgent`/`AfterAgent`/`BeforeModel`), which are gemini-only. + +#### `hooksSurface` is now load-bearing + +The `hooksSurface === 'none'` value now drives the settings-json hook-skip path in `applySettingsJsonHooks`, replacing the former `isOpencode || isKilo` boolean guards. This is not a new axis — `hooksSurface` was already Decision 5 — but it is now actively consumed (load-bearing) rather than advisory. + ### 8. Staged consumption — author registry-only, then drive install one axis at a time -The migration is staged the way phases 3–4 were (registry-only → consume incrementally → equivalence-proven no-op → retire the hardcoded branch). **Four of the six axes already live in dedicated modules** that `install.js` merely consumes, so driving them from the descriptor is per-axis and low-risk; the rest is staged behind a prerequisite and assembled last — **no big-bang `install()` rewrite**. +The migration is staged the way phases 3–4 were (registry-only → consume incrementally → equivalence-proven no-op → retire the hardcoded branch). **Four of the original eight axes already live in dedicated modules** that `install.js` merely consumes, so driving them from the descriptor is per-axis and low-risk; the rest is staged behind a prerequisite and assembled last — **no big-bang `install()` rewrite**. 1. **5a — author the 16 descriptors** (`capabilities//capability.json`, `role: runtime`) registry-only; nothing consumes them. The generator already validates them; the `runtimes` index populates. 2. **5b — drive `configHome`** ← descriptor (`runtime-homes.cts` already centralizes it; swap its switch for a descriptor lookup). Smallest blast radius. 3. **5c — drive `commandStyle`** ← descriptor (`runtime-slash.cts`, 2 values). Trivial. 4. **5d — drive `artifactLayout`** ← descriptor (`runtime-artifact-layout.cts`, ADR-3660; this is ADR-3660's Phase 2 / #3664 — the largest LOC reduction). 5. **5e — drive `configFormat`** ← descriptor (`runtime-config-adapter-registry.cts`) **and close the `ConverterName` enum** (Decision 3). Model-catalog routing stays orthogonal — referenced by descriptor `name`, not an axis; codex's install-time model-embedding is an implementation detail of its converter, not a 7th axis. -6. **5f — extract `hooksSurface` into its own module, then drive it** ← descriptor. `hooks-surface` is the one axis still scattered across `install.js`; its module extraction is a prerequisite, exactly as ADR-3660 was for `artifactLayout`. -7. **5g — materialize the `InstallPlan`** (ADR-58): collect the now-descriptor-driven per-axis projections into one typed value, so `install()` becomes *resolve chosen Runtime descriptor × active Feature Capabilities → `InstallPlan` → adapters execute*. This is **reachable by collection, not a rewrite** — the interleaving that made `install.js` look monolithic is already dissolved by 5b–5f *before* the plan is assembled. It is a phase-5 **deliverable**, not an aspiration. +6. **5f — extract `hooksSurface` into its own module, then drive it** ← descriptor. `hooks-surface` is the one axis still scattered across `install.js`; its module extraction is a prerequisite, exactly as ADR-3660 was for `artifactLayout`. **5f-completion (done):** `installSurface` drives the config-writing adapter in `runtime-config-adapter-registry` (#1055, retiring the hand-kept `REGISTRY` table); `extendedHookEvents` drives per-event hook registration in `applySettingsJsonHooks` (#1076, retiring the three hardcoded per-event guards); `hooksSurface === 'none'` is now load-bearing for the hook-skip path (replacing `isOpencode/isKilo` flags). `writesSharedSettings` and `permissionWriter` complete the adapter-selection and permissions-writer parameterisation (all four axes added to `validateRuntimeBody`). +7. **5g — materialize the `InstallPlan`** (ADR-58) — **DONE**: the install-level descriptor axes (`installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, `hooksSurface`) are collected into one typed `InstallPlan` value by the exported `resolveInstallPlan(runtime)` in `runtime-config-adapter-registry`. `install()` and `finishInstall()` in `bin/install.js` now route through it. The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules (`runtime-homes` / `runtime-artifact-layout` / `runtime-slash`) as the execution adapters — consistent with the "adapters execute" pattern. Phase 5 is fully materialized. Each rung is its own approved-enhancement + PR + equivalence proof (`sandbox-tier`, codex-only, is tiny and rides along in 5e/5g). **Irreducible first-party code, named not hidden:** the artifact converter *functions* remain first-party code, selected by the descriptor's closed `ConverterName` — the descriptor never embeds them. @@ -135,7 +178,7 @@ All 16 runtimes are authored through the same descriptor (dogfooding) — but th ## Out of scope -Authoring the 16 descriptors and the per-axis install cutovers (the impl phases); third-party runtime loading (its own additive ADR + trust gate); the per-feature loop-hook wiring (phase-6 cleanup); moving feature `*.enabled` keys out of the central config-schema (phase-6 cleanup). +Authoring the 16 descriptors and the per-axis install cutovers (the impl phases — note: 5f-completion install-surface drives are **done**, see Decision 7a; 5g `InstallPlan` materialization is **done**, see Decision 8 step 7); third-party runtime loading (its own additive ADR + trust gate); the per-feature loop-hook wiring (phase-6 cleanup); moving feature `*.enabled` keys out of the central config-schema (phase-6 cleanup). The runtime-descriptor phase (phases 5a–5g) is fully complete. - The **Connected Capability** contract (MCP-server / external-process / backend-provider contributions + the §7 trust/load gate) — future design, vehicle #956. - The **hook-firing spike** — proving `loop.render-hooks` → workflow execution end-to-end with a *host-computed aggregate* (a phase-6-flavored de-risk that should land **before** phase-5 build, since #956/#999 both depend on it). diff --git a/docs/adr/58-runtime-install-policy-module.md b/docs/adr/58-runtime-install-policy-module.md index 5cbe35794..a0556988f 100644 --- a/docs/adr/58-runtime-install-policy-module.md +++ b/docs/adr/58-runtime-install-policy-module.md @@ -46,6 +46,10 @@ The policy module resolves *intent* as data; adapters turn that intent into byte - The plan/adapter contract becomes a stability surface that must be held narrow; drift there reintroduces the very divergence this seam removes. - Rollout is incremental, not big-bang: this ADR establishes the boundary (#58); the explicit Runtime Adapter Registry lands next (#60); legacy helper retirement follows (#56); downstream cleanup in #57. +## Implementation (2026-06-11) + +The `InstallPlan` is realized as the exported `resolveInstallPlan(runtime)` in `runtime-config-adapter-registry` (co-located with adapter-selection, not a standalone module). It collects the install-level descriptor axes — `installSurface`, `writesSharedSettings`, `finishPermissionWriter`, `hookEvents`, `extendedHookEvents`, and `hooksSurface` — into one typed `InstallPlan` value. `install()` and `finishInstall()` in `bin/install.js` consume it directly. The spatial axes (`configHome`, `artifactLayout`, `commandStyle`) remain behind their self-resolving adapter modules (`runtime-homes`, `runtime-artifact-layout`, `runtime-slash`) as the execution adapters — consistent with this ADR's adapters-execute boundary. + ## References - ADR-0001 — Dispatch Policy Module (pure-policy-projects / thin-adapters-execute precedent). diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index f80139215..649eda3af 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -55,7 +55,11 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "audit": { @@ -143,7 +147,11 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "claude": { @@ -196,7 +204,16 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact", + "FileChanged" + ] } }, "cline": { @@ -231,7 +248,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "cline-rules", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cline-rules", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "codebuddy": { @@ -292,7 +313,11 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "codex": { @@ -337,7 +362,11 @@ const capabilities = { "hooksSurface": "codex-hooks-json", "hookEvents": "claude", "sandboxTier": "codex-agent-sandbox", - "supportTier": 1 + "supportTier": 1, + "installSurface": "codex-toml", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "copilot": { @@ -382,7 +411,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "copilot-inline", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "copilot-instructions", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "cursor": { @@ -443,7 +476,11 @@ const capabilities = { "hooksSurface": "cursor-hooks-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cursor-hooks-json", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "gemini": { @@ -488,7 +525,15 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "BeforeAgent", + "AfterAgent", + "BeforeModel" + ] } }, "graphify": { @@ -563,7 +608,11 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "intel": { @@ -658,7 +707,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": false, + "permissionWriter": "kilo", + "extendedHookEvents": [] } }, "kimi": { @@ -706,7 +759,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "opencode": { @@ -768,7 +825,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": "opencode", + "extendedHookEvents": [] } }, "qwen": { @@ -813,7 +874,15 @@ const capabilities = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact" + ] } }, "trae": { @@ -857,7 +926,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "ui": { @@ -987,7 +1060,11 @@ const capabilities = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } }; @@ -1210,7 +1287,11 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "augment": { @@ -1271,7 +1352,11 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "claude": { @@ -1324,7 +1409,16 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 1 + "supportTier": 1, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact", + "FileChanged" + ] } }, "cline": { @@ -1359,7 +1453,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "cline-rules", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cline-rules", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "codebuddy": { @@ -1420,7 +1518,11 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "codex": { @@ -1465,7 +1567,11 @@ const runtimes = { "hooksSurface": "codex-hooks-json", "hookEvents": "claude", "sandboxTier": "codex-agent-sandbox", - "supportTier": 1 + "supportTier": 1, + "installSurface": "codex-toml", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "copilot": { @@ -1510,7 +1616,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "copilot-inline", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "copilot-instructions", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "cursor": { @@ -1571,7 +1681,11 @@ const runtimes = { "hooksSurface": "cursor-hooks-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "cursor-hooks-json", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "gemini": { @@ -1616,7 +1730,15 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "gemini", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "BeforeAgent", + "AfterAgent", + "BeforeModel" + ] } }, "hermes": { @@ -1661,7 +1783,11 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [] } }, "kilo": { @@ -1728,7 +1854,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": false, + "permissionWriter": "kilo", + "extendedHookEvents": [] } }, "kimi": { @@ -1776,7 +1906,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "opencode": { @@ -1838,7 +1972,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": "opencode", + "extendedHookEvents": [] } }, "qwen": { @@ -1883,7 +2021,15 @@ const runtimes = { "hooksSurface": "settings-json", "hookEvents": "claude", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "settings-json", + "writesSharedSettings": true, + "permissionWriter": null, + "extendedHookEvents": [ + "SubagentStop", + "Stop", + "PreCompact" + ] } }, "trae": { @@ -1927,7 +2073,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } }, "windsurf": { @@ -1972,7 +2122,11 @@ const runtimes = { "commandStyle": "slash-hyphen", "hooksSurface": "none", "sandboxTier": "none", - "supportTier": 2 + "supportTier": 2, + "installSurface": "profile-marker-only", + "writesSharedSettings": false, + "permissionWriter": null, + "extendedHookEvents": [] } } }; diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index 901891208..f1d9f13b8 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -480,6 +480,25 @@ const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']); const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']); const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']); const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks']; +const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']); +const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']); +const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']); + +// GATE A: installSurface → allowed hooksSurface values (DEFECT.GENERATIVE-FIX: parity invariant) +// Derived from the actual pairings in the 16 real runtime descriptors. +const INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES = new Map([ + ['settings-json', new Set(['settings-json', 'none'])], + ['codex-toml', new Set(['codex-hooks-json'])], + ['copilot-instructions', new Set(['copilot-inline'])], + ['cline-rules', new Set(['cline-rules'])], + ['cursor-hooks-json', new Set(['cursor-hooks-json'])], + ['profile-marker-only', new Set(['none'])], +]); + +// GATE B: extended hook event families → required hookEvents value +// Gemini agent-events require hookEvents='gemini'; Claude-family events require hookEvents='claude'. +const GEMINI_AGENT_EVENTS = new Set(['BeforeAgent', 'AfterAgent', 'BeforeModel']); +const CLAUDE_FAMILY_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']); /** * Validate a runtime.configHome object per ADR-1016 Decision 1. @@ -747,6 +766,84 @@ function validateRuntimeBody(cap) { errors.push('runtime.supportTier must be 1 or 2 (got: ' + r.supportTier + ')'); } + // installSurface — required string in closed enum + if (!VALID_INSTALL_SURFACES.has(r.installSurface)) { + errors.push( + 'runtime.installSurface must be one of: ' + [...VALID_INSTALL_SURFACES].join(', ') + + ' (got: ' + JSON.stringify(r.installSurface) + ')', + ); + } + + // writesSharedSettings — required boolean + if (typeof r.writesSharedSettings !== 'boolean') { + errors.push( + 'runtime.writesSharedSettings must be a boolean (got: ' + JSON.stringify(r.writesSharedSettings) + ')', + ); + } + + // permissionWriter — required key; value must be null or a string in VALID_PERMISSION_WRITERS + if (!Object.prototype.hasOwnProperty.call(r, 'permissionWriter')) { + errors.push('runtime.permissionWriter is required (must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + ')'); + } else if (r.permissionWriter !== null && !VALID_PERMISSION_WRITERS.has(r.permissionWriter)) { + errors.push( + 'runtime.permissionWriter must be null or one of: ' + [...VALID_PERMISSION_WRITERS].join(', ') + + ' (got: ' + JSON.stringify(r.permissionWriter) + ')', + ); + } + + // extendedHookEvents — required array; every element must be in closed enum + if (!Array.isArray(r.extendedHookEvents)) { + errors.push( + 'runtime.extendedHookEvents must be an array (got: ' + JSON.stringify(r.extendedHookEvents) + ')', + ); + } else { + for (let i = 0; i < r.extendedHookEvents.length; i++) { + const ev = r.extendedHookEvents[i]; + if (typeof ev !== 'string' || !VALID_EXTENDED_HOOK_EVENTS.has(ev)) { + errors.push( + 'runtime.extendedHookEvents[' + i + '] must be one of: ' + [...VALID_EXTENDED_HOOK_EVENTS].join(', ') + + ' (got: ' + JSON.stringify(ev) + ')', + ); + } + } + } + + // GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX) + // Only check if both fields are valid strings (individual field validators above report type errors). + if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') { + const allowedHooksSurfaces = INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.get(r.installSurface); + if (allowedHooksSurfaces !== undefined && !allowedHooksSurfaces.has(r.hooksSurface)) { + errors.push( + 'runtime.hooksSurface "' + r.hooksSurface + '" is not valid for installSurface "' + r.installSurface + '"' + + ' — allowed: ' + [...allowedHooksSurfaces].join(', ') + + ' (src: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES in scripts/gen-capability-registry.cjs)', + ); + } + } + + // GATE B: extendedHookEvents ↔ hookEvents consistency (DEFECT.GENERATIVE-FIX) + // If extendedHookEvents contains Gemini agent-events, hookEvents must be 'gemini'. + // If it contains Claude-family events, hookEvents must be 'claude'. + // Empty extendedHookEvents imposes no constraint. + if (Array.isArray(r.extendedHookEvents) && r.extendedHookEvents.length > 0) { + const hasGeminiEvents = r.extendedHookEvents.some((ev) => GEMINI_AGENT_EVENTS.has(ev)); + const hasClaudeEvents = r.extendedHookEvents.some((ev) => CLAUDE_FAMILY_EVENTS.has(ev)); + if (hasGeminiEvents && r.hookEvents !== 'gemini') { + errors.push( + 'runtime.extendedHookEvents contains Gemini agent-events (' + + r.extendedHookEvents.filter((ev) => GEMINI_AGENT_EVENTS.has(ev)).join(', ') + + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "gemini"', + ); + } + if (hasClaudeEvents && r.hookEvents !== 'claude') { + errors.push( + 'runtime.extendedHookEvents contains Claude-family events (' + + r.extendedHookEvents.filter((ev) => CLAUDE_FAMILY_EVENTS.has(ev)).join(', ') + + ') but runtime.hookEvents is "' + r.hookEvents + '" — must be "claude"', + ); + } + } + return errors; } @@ -1551,26 +1648,13 @@ function runConsistencyGate(capabilityClusters, profileMembership, capMap) { // ─── ADR-857 phase 5e: configFormat ↔ installSurface parity gate ───────────── -// Paths are declared at top level; the actual require() call is deferred (lazy) so importing -// this generator on an unbuilt worktree doesn't fail at module load. Mirrors the pattern -// used for install-profiles.cjs and clusters.cjs above. -const RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH = path.join( - ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs', -); - -let _runtimeConfigAdapterMod = null; - -function getRuntimeConfigAdapterRegistry() { - if (!_runtimeConfigAdapterMod) { - _runtimeConfigAdapterMod = require(RUNTIME_CONFIG_ADAPTER_REGISTRY_PATH); - } - return _runtimeConfigAdapterMod; -} - // Map: installSurface → expected configFormat -// Derived from the pairing of runtime-config-adapter-registry.cjs (installSurface) -// and the capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map +// Derived from the pairing of capability.json descriptors (installSurface) +// and capability.json descriptors (configFormat). DEFECT.GENERATIVE-FIX: this map // is the single parity contract between the two generated surfaces. +// NOTE: both values come from the descriptor bodies in capMap — no dependency on +// runtime-config-adapter-registry.cjs, which now requires capability-registry.cjs +// (the file this gen-script produces), and thus must not be required here. const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ ['settings-json', 'settings-json'], ['codex-toml', 'toml'], @@ -1583,64 +1667,31 @@ const INSTALL_SURFACE_TO_CONFIG_FORMAT = new Map([ /** * ADR-857 phase 5e: configFormat ↔ installSurface parity gate. * - * For each runtime capability that also appears in INSTALL_SURFACES (i.e. is a - * known config-adapter runtime), assert that its capability.json configFormat - * matches the expected value derived from its installSurface. + * For each runtime capability that has an installSurface in its descriptor, + * assert that its configFormat matches the expected value derived from its + * installSurface. Both values are read directly from the capMap descriptor + * bodies — no dependency on runtime-config-adapter-registry.cjs. * * HARD gate — throws on mismatch (DEFECT.GENERATIVE-FIX: this invariant is * derived from two parallel generated surfaces and must fail loudly). - * SOFT skip — if the runtime-config-adapter-registry.cjs module is not loadable - * (unbuilt worktree), emits a warning to stderr and returns without throwing. * * @param {Map} capMap Fully-validated capability map. - * @returns {void} Throws on mismatch; returns normally on success or soft-skip. + * @returns {void} Throws on mismatch; returns normally on success. */ function runConfigFormatParityGate(capMap) { - let adapterMod; - try { - adapterMod = getRuntimeConfigAdapterRegistry(); - } catch (_err) { - // Module not loadable (unbuilt worktree) — soft-skip with warning - process.stderr.write( - '⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs not loadable ' + - '(run `npm run build` first)\n', - ); - return; - } - - // Check that REGISTRY is present and is an object-like registry - // (the .cjs exports resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES — - // not REGISTRY directly; we need to reconstruct per-runtime installSurface from the adapter). - // We use ALLOWED_CONFIG_RUNTIMES to know which runtimes are in the adapter, then resolve each. - const { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES: allowedRuntimes } = adapterMod; - - if (typeof resolveRuntimeConfigIntent !== 'function' || !(allowedRuntimes instanceof Set)) { - process.stderr.write( - '⚠ configFormat parity gate SKIPPED: runtime-config-adapter-registry.cjs missing expected exports\n', - ); - return; - } - - // Only check runtimes present in BOTH the capability registry and INSTALL_SURFACES + // Read installSurface directly from the descriptor bodies already loaded into + // capMap — eliminates the require cycle introduced when adapter-registry was + // changed to require capability-registry.cjs (ADR-857 phase 5g drive 2). for (const [capId, cap] of capMap) { if (cap.role !== 'runtime') continue; - if (!allowedRuntimes.has(capId)) continue; // grok etc. excluded — not in adapter const r = cap.runtime; if (!r || typeof r.configFormat !== 'string') continue; // already validated above - let intent; - try { - intent = resolveRuntimeConfigIntent(capId); - } catch (_err) { - // Should not happen (we checked allowedRuntimes.has(capId)), but be defensive - process.stderr.write( - '⚠ configFormat parity gate: could not resolve installSurface for "' + capId + '" — skipping\n', - ); - continue; - } + // Only check runtimes that have an installSurface (i.e. are config-adapter runtimes) + if (typeof r.installSurface !== 'string') continue; // grok etc. excluded — no installSurface - const installSurface = intent.installSurface; + const installSurface = r.installSurface; const expectedConfigFormat = INSTALL_SURFACE_TO_CONFIG_FORMAT.get(installSurface); if (expectedConfigFormat === undefined) { @@ -2223,6 +2274,12 @@ module.exports = { // ADR-857 phase 5e: configFormat ↔ installSurface parity gate runConfigFormatParityGate, INSTALL_SURFACE_TO_CONFIG_FORMAT, + // ADR-857 phase 5f: cross-field consistency gates + INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, + VALID_INSTALL_SURFACES, + VALID_EXTENDED_HOOK_EVENTS, + VALID_PERMISSION_WRITERS, + validateRuntimeBody, // FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters // so importing the generator on a fresh/unbuilt worktree doesn't fail at module load. get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; }, diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 1e579e369..bc7ed604c 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -1,5 +1,5 @@ { - "_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to \u2264 2, remove its entry entirely. New entries require justification in PR description.", + "_doc": "Baseline of modules currently exceeding the 2-test-file limit. Each entry locks in TODAY's exact test filenames as the allowlisted set (identity ratchet). Adding a NEW test file to a capped module fails (novel). Removing one requires pruning this list (stale, ratchet-down). When a cluster drops to ≤ 2, remove its entry entirely. New entries require justification in PR description.", "modules": { "audit": { "files": [ @@ -20,7 +20,8 @@ "config-field-docs.test.cjs", "config-get-default.test.cjs", "config-schema.property.test.cjs", - "config.test.cjs" + "config.test.cjs", + "enh-1055-config-intent-descriptor-drive.test.cjs" ], "issue": "TBD" }, @@ -130,6 +131,8 @@ "install": { "files": [ "bug-410-install-defaults-test-mode-guard.test.cjs", + "enh-1077-install-hook-events-dialect-drive.test.cjs", + "enh-1082-install-plan-capstone.test.cjs", "enh-776-install-gemini-hook-events.test.cjs", "install-minimal-hooks.test.cjs", "install-nested-layout.test.cjs", diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index bce78fd63..036234d13 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -1,9 +1,14 @@ 'use strict'; /** - * Runtime config adapter registry — explicit dispatch table for install-phase - * config mutations (issue #60), replacing inline `runtime === '...'` branching - * in bin/install.js. + * Runtime config adapter registry — dispatch table for install-phase config + * mutations (issue #60), replacing inline `runtime === '...'` branching in + * bin/install.js. + * + * ADR-857 phase 5g drive 2: The hand-kept REGISTRY const has been retired. + * Values are now read directly from the capability-registry.cjs descriptor + * (capabilities//capability.json runtime block) so a single source of + * truth drives all surfaces. * * Design notes: * - `installSurface` selects which config handler install() runs: @@ -22,6 +27,9 @@ * null → no dedicated permission writer. */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { runtimes } = require('./capability-registry.cjs') as { runtimes: Record | undefined }> }; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -36,6 +44,14 @@ type ConfigInstallSurface = type FinishPermissionWriter = 'opencode' | 'kilo' | null; +type HooksSurface = + | 'settings-json' + | 'codex-hooks-json' + | 'cursor-hooks-json' + | 'cline-rules' + | 'copilot-inline' + | 'none'; + interface RuntimeConfigIntent { runtime: string; installSurface: ConfigInstallSurface; @@ -43,41 +59,30 @@ interface RuntimeConfigIntent { finishPermissionWriter: FinishPermissionWriter; } -interface RegistryEntry { - installSurface: ConfigInstallSurface; - writesSharedSettings: boolean; - finishPermissionWriter: FinishPermissionWriter; +/** + * The full install plan for a runtime: config-intent axes PLUS the three + * hook axes that install() reads from the capability descriptor. + * ADR-857 phase 5g capstone — single seam for all install-level descriptor reads. + */ +interface InstallPlan extends RuntimeConfigIntent { + /** Hook event dialect: 'claude' | 'gemini' | undefined */ + hookEvents: string | undefined; + /** Extended hook event names registered beyond the core tool events (may be empty). */ + extendedHookEvents: string[]; + /** Which surface owns the hook registration for this runtime. */ + hooksSurface: HooksSurface; } -// --------------------------------------------------------------------------- -// Registry -// --------------------------------------------------------------------------- - -const REGISTRY: Record> = Object.freeze({ - claude: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - gemini: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - antigravity: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - augment: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - qwen: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - hermes: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - codebuddy: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), - opencode: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' } as const), - kilo: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' } as const), - codex: Object.freeze({ installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null } as const), - copilot: Object.freeze({ installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null } as const), - cline: Object.freeze({ installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null } as const), - cursor: Object.freeze({ installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null } as const), - windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), - trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), - kimi: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), -}); - // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- /** The complete set of 16 supported runtimes for config-adapter dispatch. */ -const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set(Object.keys(REGISTRY)); +const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set( + Object.entries(runtimes) + .filter(([, cap]) => cap && cap.runtime && typeof cap.runtime['installSurface'] === 'string') + .map(([id]) => id), +); /** All valid installSurface values. */ const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ @@ -98,16 +103,44 @@ const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ * @throws {TypeError} if runtime is not a known supported runtime. */ function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent { - if (!Object.hasOwn(REGISTRY, runtime)) { - throw new TypeError(`Unknown runtime for config adapter: ${runtime}`); - } - const entry = REGISTRY[runtime]; + const entry = runtimes[runtime]?.runtime; + if (!entry) throw new TypeError(`Unknown runtime for config adapter: ${runtime}`); + const permissionWriter = entry['permissionWriter']; return { runtime, - installSurface: entry.installSurface, - writesSharedSettings: entry.writesSharedSettings, - finishPermissionWriter: entry.finishPermissionWriter, + installSurface: entry['installSurface'] as ConfigInstallSurface, + writesSharedSettings: entry['writesSharedSettings'] as boolean, + finishPermissionWriter: permissionWriter == null ? null : permissionWriter as FinishPermissionWriter, }; } -export = { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; +/** + * Resolve the complete install plan for a given runtime. + * + * Composes the config-intent axes from resolveRuntimeConfigIntent PLUS the + * three hook axes (hookEvents / extendedHookEvents / hooksSurface) that + * install() previously read scattered from the capability registry. + * + * ADR-857 phase 5g capstone — single typed seam for all install-level + * descriptor reads. Returns a fresh object each call. + * + * @throws {TypeError} if runtime is not a known supported runtime. + */ +function resolveInstallPlan(runtime: string): InstallPlan { + const desc = runtimes[runtime]?.runtime; + if (!desc) throw new TypeError(`Unknown runtime for install plan: ${runtime}`); + const configIntent = resolveRuntimeConfigIntent(runtime); + return { + runtime, + installSurface: configIntent.installSurface, + writesSharedSettings: configIntent.writesSharedSettings, + finishPermissionWriter: configIntent.finishPermissionWriter, + hookEvents: desc['hookEvents'] as string | undefined, + extendedHookEvents: Array.isArray(desc['extendedHookEvents']) ? desc['extendedHookEvents'] as string[] : [], + hooksSurface: desc['hooksSurface'] != null + ? desc['hooksSurface'] as HooksSurface + : ((runtime === 'opencode' || runtime === 'kilo') ? 'none' : 'settings-json'), + }; +} + +export = { resolveRuntimeConfigIntent, resolveInstallPlan, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; diff --git a/src/runtime-hooks-surface.cts b/src/runtime-hooks-surface.cts index 1fc1a960e..f4f9035b9 100644 --- a/src/runtime-hooks-surface.cts +++ b/src/runtime-hooks-surface.cts @@ -1090,18 +1090,22 @@ function writeCopilotHookConfig(targetDir: string): string { // MUTATES `settings` by reference — registers all GSD-managed hook entries // into settings.hooks.* for runtimes that use a settings.json hook surface // (Claude Code, Gemini, Antigravity, Qwen Code, and others). -// Skipped entirely for opencode and kilo (which have their own hook surface). +// Skipped entirely for runtimes whose hooksSurface descriptor field is 'none' +// (opencode and kilo, which have their own hook surface). // // Extracted from the `if (!isOpencode && !isKilo) { … }` block inside -// install() (ADR-857 phase 5f-1b). No behavior change — all guards, -// event-name dialect branches, and idempotency checks are preserved verbatim. +// install() (ADR-857 phase 5f-1b). The hook-skip guard is now descriptor-driven +// (ADR-857 phase 5g drive 3): pass opts.hooksSurface from the runtime descriptor +// instead of deriving isOpencode/isKilo from the runtime name. // // @param settings - The settings object already read from disk. Mutated in place. // @param opts - Closure values the block read from install()'s scope. // runtime - runtime ID string (e.g. 'claude', 'gemini', 'qwen') +// hooksSurface - descriptor hooksSurface field ('settings-json'|'none'|…); if !== 'none', hooks are written // isGlobal - true for global installs // targetDir - absolute path to the runtime config dir -// postToolEvent - 'PostToolUse' | 'AfterTool' (pre-computed by caller) +// postToolEvent - 'PostToolUse' | 'AfterTool' (pre-computed by caller from descriptor) +// hookEvents - registry hookEvents dialect ('gemini'|'claude'|undefined) // updateCheckCommand - command string or null // contextMonitorCommand - command string or null // promptGuardCommand - command string or null @@ -1118,6 +1122,12 @@ interface ApplySettingsJsonHooksOpts { isGlobal: boolean; targetDir: string; postToolEvent: string; + /** ADR-857 phase 5f-2: hookEvents dialect from the registry descriptor ('gemini'|'claude'|undefined). */ + hookEvents?: string; + /** ADR-857 phase 5f-3: extended hook event names from the registry descriptor. */ + extendedHookEvents?: string[]; + /** ADR-857 phase 5g drive 3: hooksSurface from the runtime descriptor ('settings-json'|'none'|…). */ + hooksSurface?: string; updateCheckCommand: string | null; contextMonitorCommand: string | null; promptGuardCommand: string | null; @@ -1139,6 +1149,9 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) isGlobal, targetDir, postToolEvent, + hookEvents, + extendedHookEvents, + hooksSurface, updateCheckCommand, contextMonitorCommand, promptGuardCommand, @@ -1150,14 +1163,16 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) localShellCmd, } = opts; - // Derived from runtime — same as install() top-of-function declarations. - const isOpencode = runtime === 'opencode'; - const isKilo = runtime === 'kilo'; - const isGemini = runtime === 'gemini'; - const isQwen = runtime === 'qwen'; + // ADR-857 phase 5f-3: extended hook events are now driven by the registry + // descriptor field rather than hardcoded runtime-name checks. + const extendedEvents = Array.isArray(extendedHookEvents) ? extendedHookEvents : []; - // Configure SessionStart hook for update checking (skip for opencode / kilo) - if (!isOpencode && !isKilo) { + // ADR-857 phase 5g drive 3: hook-skip guard is driven by the hooksSurface + // descriptor field. Only runtimes with hooksSurface === 'settings-json' + // register settings.json hooks; runtimes with hooksSurface === 'none' + // (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff + // the old !isOpencode && !isKilo check. + if (hooksSurface !== 'none') { if (!settings.hooks) { settings.hooks = {}; } @@ -1235,8 +1250,10 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) } // Configure PreToolUse hook for prompt injection detection - // Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks - const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse'; + // ADR-857 phase 5f-2: drive dialect from opts.hookEvents (registry descriptor). + // hookEvents='gemini' → BeforeTool; all others → PreToolUse. + // Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} (same as old check). + const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse'; if (!settings.hooks[preToolEvent]) { settings.hooks[preToolEvent] = []; } @@ -1498,10 +1515,12 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) // user prompt text, not a tool invocation, so gsd-prompt-guard (which // exits unless tool_name is Write/Edit) would be a silent no-op. A // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. - if (isQwen || runtime === 'claude') { - const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code'; - // SubagentStop, Stop, PreCompact — route through the context monitor. + // SubagentStop, Stop, PreCompact — route through the context monitor. + // Guard is now descriptor-driven: only events present in extendedEvents are wired. + { + const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime; for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + if (!extendedEvents.includes(event)) continue; if (!settings.hooks[event]) { settings.hooks[event] = []; } @@ -1548,29 +1567,28 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) // Note: BeforeToolSelection is NOT wired. That event does not map to a // gsd hook use case at this time; deferred to a follow-on issue. // - // Guard: isGemini is derived from runtime at the top of this function. - if (isGemini) { - for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { - if (!Array.isArray(settings.hooks[geminiEvent])) { - settings.hooks[geminiEvent] = []; - } - const alreadyHasContextMonitor = settings.hooks[geminiEvent].some((entry: HookGroup) => - entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor')) - ); - if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { - settings.hooks[geminiEvent].push({ - hooks: [ - { - type: 'command', - command: contextMonitorCommand, - timeout: 10 - } - ] - }); - console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`); - } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { - console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`); - } + // Guard is now descriptor-driven: only events present in extendedEvents are wired. + for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) { + if (!extendedEvents.includes(geminiEvent)) continue; + if (!Array.isArray(settings.hooks[geminiEvent])) { + settings.hooks[geminiEvent] = []; + } + const alreadyHasContextMonitor = settings.hooks[geminiEvent].some((entry: HookGroup) => + entry.hooks && entry.hooks.some((h: HookEntry) => referencesHook(h as Record, 'gsd-context-monitor')) + ); + if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[geminiEvent].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`); + } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`); } } // ── end Gemini-only extended hook events ────────────────────────────────── @@ -1586,7 +1604,7 @@ function applySettingsJsonHooks(settings: any, opts: ApplySettingsJsonHooksOpts) // // Scoped to Claude Code only: Qwen Code's FileChanged support is not yet // verified; extend in a follow-on if empirically confirmed. - if (runtime === 'claude') { + if (extendedEvents.includes('FileChanged')) { if (!settings.hooks.FileChanged) { settings.hooks.FileChanged = []; } diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 26d0f07b8..3cc92014f 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -44,6 +44,12 @@ const { validateArtifactKindEntry, runConfigFormatParityGate, INSTALL_SURFACE_TO_CONFIG_FORMAT, + // ADR-857 phase 5f: cross-field consistency gates + INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, + VALID_INSTALL_SURFACES, + VALID_EXTENDED_HOOK_EVENTS, + VALID_PERMISSION_WRITERS, + validateRuntimeBody, } = require('../scripts/gen-capability-registry.cjs'); const ROOT = path.resolve(__dirname, '..'); @@ -1170,6 +1176,10 @@ describe('C3: role:runtime body validation', () => { hookEvents: 'claude', sandboxTier: 'none', supportTier: 2, + installSurface: 'cursor-hooks-json', + writesSharedSettings: false, + permissionWriter: null, + extendedHookEvents: [], }, }; @@ -2544,6 +2554,10 @@ function makeRuntimeCap(overrides) { hookEvents: 'claude', sandboxTier: 'none', supportTier: 1, + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: [], ...((overrides && overrides.runtime) ? overrides.runtime : {}), }, ...overrides, @@ -3433,8 +3447,22 @@ describe('ADR-857 phase 5e: validateArtifactKindEntry — ConverterName enum (FA // ─── 26. ADR-857 phase 5e: configFormat ↔ installSurface parity gate (Part A) ─ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => { - // Helper: build a minimal runtime capMap for parity tests - function makeRuntimeCapMap(runtimeId, configFormat) { + // Helper: build a minimal runtime capMap for parity tests. + // installSurface must be supplied for any runtime that should be checked by the gate; + // omit it (undefined) to simulate a runtime with no installSurface (gate skips it). + function makeRuntimeCapMap(runtimeId, configFormat, installSurface) { + const runtime = { + configHome: { kind: 'dot-home', name: '.' + runtimeId, env: [] }, + configFormat, + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'none', + sandboxTier: 'none', + supportTier: 1, + }; + if (installSurface !== undefined) { + runtime.installSurface = installSurface; + } const cap = { id: runtimeId, role: 'runtime', @@ -3442,15 +3470,7 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => description: 'Synthetic runtime for parity gate testing.', tier: 'core', requires: [], - runtime: { - configHome: { kind: 'dot-home', name: '.' + runtimeId, env: [] }, - configFormat, - artifactLayout: { global: [], local: [] }, - commandStyle: 'slash-hyphen', - hooksSurface: 'none', - sandboxTier: 'none', - supportTier: 1, - }, + runtime, }; return new Map([[runtimeId, cap]]); } @@ -3459,7 +3479,7 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => test('THROWS: claude with wrong configFormat "toml" (installSurface=settings-json → expected settings-json)', () => { // claude has installSurface=settings-json → expected configFormat=settings-json // Giving it configFormat=toml must trigger the HARD gate - const capMap = makeRuntimeCapMap('claude', 'toml'); + const capMap = makeRuntimeCapMap('claude', 'toml', 'settings-json'); assert.throws( () => runConfigFormatParityGate(capMap), (err) => { @@ -3478,7 +3498,7 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => }); test('THROWS: codex with wrong configFormat "settings-json" (installSurface=codex-toml → expected toml)', () => { - const capMap = makeRuntimeCapMap('codex', 'settings-json'); + const capMap = makeRuntimeCapMap('codex', 'settings-json', 'codex-toml'); assert.throws( () => runConfigFormatParityGate(capMap), (err) => { @@ -3533,9 +3553,12 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => ); }); - // Unknown runtimes (not in ALLOWED_CONFIG_RUNTIMES) are excluded from the gate - test('runtime capId not in adapter registry (e.g. "grok") is excluded from parity gate — does not throw', () => { - // 'grok' is not in the adapter registry → must be soft-skipped + // Runtimes with no installSurface in their descriptor are excluded from the gate. + // The gate reads installSurface from cap.runtime.installSurface (the descriptor level); + // if it is absent (typeof !== 'string'), the runtime is soft-skipped. + // NOTE: the gate no longer uses the adapter registry — it reads purely from the descriptor. + test('runtime with no installSurface in descriptor (e.g. hypothetical "grok") is excluded from parity gate — does not throw', () => { + // 'grok' has no installSurface → gate must soft-skip (typeof r.installSurface !== 'string') const grokCap = { id: 'grok', role: 'runtime', @@ -3545,18 +3568,161 @@ describe('ADR-857 phase 5e: configFormat ↔ installSurface parity gate', () => requires: [], runtime: { configHome: { kind: 'dot-home', name: '.grok', env: [] }, - configFormat: 'settings-json', // any value — gate should not check this + configFormat: 'settings-json', // any value — gate should not check this (no installSurface) artifactLayout: { global: [], local: [] }, commandStyle: 'slash-hyphen', hooksSurface: 'none', sandboxTier: 'none', supportTier: 2, + // intentionally no installSurface — gate must skip this entry }, }; const capMap = new Map([['grok', grokCap]]); assert.doesNotThrow( () => runConfigFormatParityGate(capMap), - 'Unknown runtimes not in the adapter registry must be excluded from the parity gate', + 'Runtimes with no installSurface in their descriptor must be excluded from the parity gate', ); }); }); + +// ─── 27. ADR-857 phase 5f: cross-field consistency gate rejection tests ──────── + +describe('ADR-857 phase 5f: cross-field consistency gate rejection tests (DEFECT.GENERATIVE-FIX)', () => { + // Helper: build a minimal VALID runtime cap for cross-field rejection tests. + // Override any field via the overrides object. + function makeValidRuntimeCap(overrides) { + const base = { + id: 'test-runtime', + role: 'runtime', + title: 'Test runtime', + description: 'Synthetic runtime for cross-field gate rejection testing.', + tier: 'core', + requires: [], + runtime: { + configHome: { kind: 'dot-home', name: '.test-runtime', env: [] }, + configFormat: 'settings-json', + artifactLayout: { global: [], local: [] }, + commandStyle: 'slash-hyphen', + hooksSurface: 'settings-json', + hookEvents: 'claude', + sandboxTier: 'none', + supportTier: 1, + installSurface: 'settings-json', + writesSharedSettings: true, + permissionWriter: null, + extendedHookEvents: [], + }, + }; + if (overrides && typeof overrides === 'object') { + for (const [k, v] of Object.entries(overrides)) { + if (k === 'runtime' && typeof v === 'object') { + Object.assign(base.runtime, v); + } else { + base[k] = v; + } + } + } + return base; + } + + test('REJECTS: installSurface not in VALID_INSTALL_SURFACES → throws validation error', () => { + const cap = makeValidRuntimeCap({ runtime: { installSurface: 'bogus-surface' } }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('installSurface') && e.includes('bogus-surface')), + 'Expected error about invalid installSurface, got: ' + JSON.stringify(errors), + ); + }); + + test('REJECTS: permissionWriter not null and not in {opencode,kilo} → throws validation error', () => { + const cap = makeValidRuntimeCap({ runtime: { permissionWriter: 'notarealwriter' } }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('permissionWriter') && e.includes('notarealwriter')), + 'Expected error about invalid permissionWriter, got: ' + JSON.stringify(errors), + ); + }); + + test('REJECTS: extendedHookEvents containing a bogus event ("SubagentStopTypo") → throws validation error', () => { + const cap = makeValidRuntimeCap({ runtime: { extendedHookEvents: ['SubagentStopTypo'] } }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('extendedHookEvents') && e.includes('SubagentStopTypo')), + 'Expected error about invalid extendedHookEvents entry, got: ' + JSON.stringify(errors), + ); + }); + + test('REJECTS: writesSharedSettings not a boolean → throws validation error', () => { + const cap = makeValidRuntimeCap({ runtime: { writesSharedSettings: 'yes' } }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('writesSharedSettings') && e.includes('"yes"')), + 'Expected error about writesSharedSettings not boolean, got: ' + JSON.stringify(errors), + ); + }); + + test('GATE A REJECTS: profile-marker-only + hooksSurface="settings-json" → validation error', () => { + // profile-marker-only installSurface only allows hooksSurface='none' + const cap = makeValidRuntimeCap({ + runtime: { + installSurface: 'profile-marker-only', + hooksSurface: 'settings-json', + configFormat: 'none', // correct for profile-marker-only + }, + }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('hooksSurface') && e.includes('profile-marker-only')), + 'Expected GATE A error for profile-marker-only + hooksSurface=settings-json, got: ' + JSON.stringify(errors), + ); + }); + + test('GATE B REJECTS: hookEvents="claude" + extendedHookEvents=["BeforeAgent"] → validation error', () => { + // BeforeAgent is a Gemini agent-event — requires hookEvents='gemini', not 'claude' + const cap = makeValidRuntimeCap({ + runtime: { + hookEvents: 'claude', + extendedHookEvents: ['BeforeAgent'], + }, + }); + const errors = validateRuntimeBody(cap); + assert.ok( + errors.some((e) => e.includes('BeforeAgent') && e.includes('"gemini"')), + 'Expected GATE B error for hookEvents=claude + extendedHookEvents=[BeforeAgent], got: ' + JSON.stringify(errors), + ); + }); + + // Verify the new constants are well-formed + test('INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES covers all 6 installSurface values', () => { + assert.ok(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES instanceof Map, 'Must be a Map'); + assert.strictEqual(INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.size, 6, 'Must cover 6 installSurface values'); + for (const installSurface of VALID_INSTALL_SURFACES) { + assert.ok( + INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES.has(installSurface), + 'INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES must include installSurface "' + installSurface + '"', + ); + } + }); + + test('VALID_EXTENDED_HOOK_EVENTS covers all 7 known extended events', () => { + assert.ok(VALID_EXTENDED_HOOK_EVENTS instanceof Set, 'Must be a Set'); + assert.strictEqual(VALID_EXTENDED_HOOK_EVENTS.size, 7, 'Must cover 7 extended hook events'); + for (const ev of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']) { + assert.ok(VALID_EXTENDED_HOOK_EVENTS.has(ev), 'Must include event "' + ev + '"'); + } + }); + + test('VALID_PERMISSION_WRITERS covers exactly {opencode, kilo}', () => { + assert.ok(VALID_PERMISSION_WRITERS instanceof Set, 'Must be a Set'); + assert.strictEqual(VALID_PERMISSION_WRITERS.size, 2, 'Must cover 2 permission writers'); + assert.ok(VALID_PERMISSION_WRITERS.has('opencode'), 'Must include opencode'); + assert.ok(VALID_PERMISSION_WRITERS.has('kilo'), 'Must include kilo'); + }); + + // Confirm the valid base fixture does NOT produce errors (sanity) + test('valid runtime fixture produces no validation errors', () => { + const cap = makeValidRuntimeCap({}); + const errors = validateRuntimeBody(cap); + assert.deepEqual(errors, [], 'Valid fixture must produce no errors, got: ' + JSON.stringify(errors)); + }); +}); diff --git a/tests/enh-1055-config-intent-descriptor-drive.test.cjs b/tests/enh-1055-config-intent-descriptor-drive.test.cjs new file mode 100644 index 000000000..3ac1b72a6 --- /dev/null +++ b/tests/enh-1055-config-intent-descriptor-drive.test.cjs @@ -0,0 +1,134 @@ +'use strict'; + +/** + * ADR-857 phase 5g drive 2: resolveRuntimeConfigIntent is now driven by the + * runtime capability descriptor (capability-registry.cjs) rather than a + * hand-kept REGISTRY const. + * + * This golden-master test pins the observable contract: the return shape and + * values must be identical to the pre-change behavior for all 16 runtimes. + * Purely behavioral — no source-grep. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + resolveRuntimeConfigIntent, + ALLOWED_CONFIG_RUNTIMES, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); + +// --------------------------------------------------------------------------- +// Frozen expected table (pre-change behavior — the contract being pinned) +// --------------------------------------------------------------------------- + +const EXPECTED = [ + { runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' }, + { runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' }, + { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, +]; + +// --------------------------------------------------------------------------- +// Test 1: Golden master — all 16 runtimes resolve to expected values +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: resolveRuntimeConfigIntent golden master', () => { + for (const row of EXPECTED) { + test(`${row.runtime} resolves to expected intent`, () => { + const intent = resolveRuntimeConfigIntent(row.runtime); + assert.deepStrictEqual(intent, { + runtime: row.runtime, + installSurface: row.installSurface, + writesSharedSettings: row.writesSharedSettings, + finishPermissionWriter: row.finishPermissionWriter, + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Test 3: Unknown runtime throws TypeError +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: unknown runtime throws TypeError', () => { + test('throws TypeError for "bogus-runtime"', () => { + assert.throws(() => resolveRuntimeConfigIntent('bogus-runtime'), TypeError); + }); + + test('throws TypeError for empty string', () => { + assert.throws(() => resolveRuntimeConfigIntent(''), TypeError); + }); + + test('throws TypeError for undefined', () => { + assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError); + }); + + test('throws TypeError for "__proto__"', () => { + assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// Test 4: ALLOWED_CONFIG_RUNTIMES contains all 16 expected runtimes +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: ALLOWED_CONFIG_RUNTIMES completeness', () => { + const EXPECTED_16 = new Set([ + 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', + 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', 'kimi', + ]); + + test('contains exactly the 16 expected runtimes', () => { + assert.deepStrictEqual(new Set(ALLOWED_CONFIG_RUNTIMES), EXPECTED_16); + }); + + test('has exactly 16 entries', () => { + assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16); + }); +}); + +// --------------------------------------------------------------------------- +// Test 5: Descriptor drive — the function reads from the descriptor, not +// a hardcoded local constant. This is proven indirectly: the golden master +// passes, meaning capability-registry.cjs (the live descriptor) matches the +// expected table. If the adapter had its own REGISTRY, a descriptor change +// would diverge silently; with drive, it cannot. +// --------------------------------------------------------------------------- + +describe('enh-1055 descriptor-drive: finishPermissionWriter passthrough', () => { + test('opencode → "opencode" (descriptor permissionWriter)', () => { + assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode'); + }); + + test('kilo → "kilo" (descriptor permissionWriter)', () => { + assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo'); + }); + + test('all other runtimes have finishPermissionWriter === null', () => { + const nullExpected = EXPECTED + .filter(r => r.finishPermissionWriter === null) + .map(r => r.runtime); + for (const runtime of nullExpected) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).finishPermissionWriter, + null, + `${runtime} should have finishPermissionWriter null`, + ); + } + }); +}); diff --git a/tests/enh-1076-extended-hook-events-drive.test.cjs b/tests/enh-1076-extended-hook-events-drive.test.cjs new file mode 100644 index 000000000..a7ae7799f --- /dev/null +++ b/tests/enh-1076-extended-hook-events-drive.test.cjs @@ -0,0 +1,427 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-3: extended hook event guards are driven by the + * extendedHookEvents descriptor field, not hardcoded runtime-name checks. + * + * Before this change: + * - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude') + * - FileChanged was wired only when (runtime === 'claude') + * - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini) + * + * After this change: + * - All three guard blocks are driven purely by extendedEvents.includes(eventName) + * - Any runtime (or arbitrary string) that passes the right extendedHookEvents + * array gets exactly those events registered, regardless of its runtime name. + * + * This suite proves descriptor-drive by calling applySettingsJsonHooks directly + * with a controlled extendedHookEvents array and asserting on settings.hooks. + * No source-grep; purely behavioral. + */ + +const { test, describe, before } = 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 REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** Idempotently ensure hooks/dist contains built .js files. */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +const { applySettingsJsonHooks } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Return all hook commands registered under an event key. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** True if any hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +/** + * Create a temporary directory with stub hook files so fs.existsSync guards pass. + * Returns the targetDir path. + */ +function createStubTargetDir() { + const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-')); + const hooksDir = path.join(tmpDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + // Stubs for the hooks applySettingsJsonHooks existsSync-checks + const stubs = [ + 'gsd-check-update.js', + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-read-guard.js', + 'gsd-read-injection-scanner.js', + 'gsd-config-reload.js', + 'gsd-workflow-guard.js', + 'gsd-worktree-path-guard.js', + 'gsd-validate-commit.sh', + 'gsd-session-state.sh', + 'gsd-phase-boundary.sh', + 'gsd-graphify-update.sh', + ]; + const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist'); + for (const stub of stubs) { + const dest = path.join(hooksDir, stub); + const distSrc = path.join(hooksDistDir, stub); + if (fs.existsSync(distSrc)) { + fs.copyFileSync(distSrc, dest); + } else { + // Minimal stub so existsSync passes + const ext = path.extname(stub); + fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } + return tmpDir; +} + +function cleanupDir(dir) { + cleanup(dir); +} + +/** + * Build the minimal opts bag for applySettingsJsonHooks. + * postToolEvent: 'PostToolUse' (default dialect). + * All commands: non-null strings so the "command truthy" guard passes. + */ +function buildOpts(targetDir, { runtime, extendedHookEvents }) { + const hookOpts = { platform: process.platform, runtime }; + const node = process.execPath; + return { + runtime, + isGlobal: true, + targetDir, + postToolEvent: 'PostToolUse', + hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents + extendedHookEvents, + updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`, + contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`, + promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`, + readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`, + readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`, + configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`, + hookOpts, + localCmd: () => null, + localShellCmd: () => null, + }; +} + +// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ───────── + +describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'Stop'), + `Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PreCompact is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'PreCompact'), + `Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('FileChanged is wired (descriptor-driven)', () => { + assert.ok( + hasHooksFor(settings, 'FileChanged'), + `Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ─────── + +describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'qwen', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop is wired', () => { + assert.ok(hasHooksFor(settings, 'SubagentStop')); + }); + + test('Stop is wired', () => { + assert.ok(hasHooksFor(settings, 'Stop')); + }); + + test('PreCompact is wired', () => { + assert.ok(hasHooksFor(settings, 'PreCompact')); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + `FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ─────────────── + +describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'gemini', + extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('BeforeAgent is wired', () => { + assert.ok( + hasHooksFor(settings, 'BeforeAgent'), + `Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('AfterAgent is wired', () => { + assert.ok(hasHooksFor(settings, 'AfterAgent')); + }); + + test('BeforeModel is wired', () => { + assert.ok(hasHooksFor(settings, 'BeforeModel')); + }); + + test('SubagentStop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'SubagentStop'), + false, + 'SubagentStop must NOT be wired for gemini shape' + ); + }); + + test('FileChanged is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual( + hasHooksFor(settings, 'FileChanged'), + false, + 'FileChanged must NOT be wired for gemini shape' + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 4: empty extendedHookEvents → none of the extended events ────────── + +describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // Use runtime='someruntime' to prove it's the descriptor, not the name, that matters + const opts = buildOpts(targetDir, { + runtime: 'someruntime', + extendedHookEvents: [], + }); + applySettingsJsonHooks(settings, opts); + }); + + const EXTENDED_EVENTS = [ + 'SubagentStop', 'Stop', 'PreCompact', 'FileChanged', + 'BeforeAgent', 'AfterAgent', 'BeforeModel', + ]; + + for (const event of EXTENDED_EVENTS) { + test(`${event} is NOT wired when extendedHookEvents is empty`, () => { + assert.strictEqual( + hasHooksFor(settings, event), + false, + `${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + } + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 5: descriptor-drive is runtime-name-agnostic ─────────────────────── +// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its +// extendedHookEvents. This could NEVER have worked under the old hardcoded check. +// Under the new descriptor-driven guard it MUST work. + +describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + const opts = buildOpts(targetDir, { + runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before + extendedHookEvents: ['SubagentStop'], + }); + applySettingsJsonHooks(settings, opts); + }); + + test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => { + assert.ok( + hasHooksFor(settings, 'SubagentStop'), + `SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('Stop is NOT wired (not in extendedHookEvents)', () => { + assert.strictEqual(hasHooksFor(settings, 'Stop'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ────────────────── +// +// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'. +// - hooksSurface:'none' → entire body is skipped; no hooks written +// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose +// name was previously hardcoded to skip, e.g. 'opencode') +// +// This proves the skip is driven by the descriptor field, not the runtime name. + +describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely. + const opts = { + ...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }), + hooksSurface: 'none', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart is NOT written when hooksSurface is "none"', () => { + assert.strictEqual( + hasHooksFor(settings, 'SessionStart'), + false, + `SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}` + ); + }); + + test('PostToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false); + }); + + test('PreToolUse is NOT written when hooksSurface is "none"', () => { + assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); + +describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => { + let targetDir; + let settings; + + before(() => { + targetDir = createStubTargetDir(); + settings = { hooks: {} }; + // 'opencode' previously was hardcoded to skip hooks; with descriptor drive it + // should write hooks whenever hooksSurface !== 'none'. + const opts = { + ...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }), + hooksSurface: 'settings-json', + }; + applySettingsJsonHooks(settings, opts); + }); + + test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => { + // ensureHooksDist() in before() guarantees hooks/dist is built, so the + // existsSync guards inside applySettingsJsonHooks pass and commands are registered. + assert.ok( + settings.hooks && typeof settings.hooks === 'object', + `settings.hooks must be initialized when hooksSurface="settings-json"`, + ); + assert.ok( + hasHooksFor(settings, 'SessionStart'), + `settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` + + `keys: ${JSON.stringify(Object.keys(settings.hooks))}`, + ); + }); + + test('cleanup', () => { + cleanupDir(targetDir); + }); +}); diff --git a/tests/enh-1077-install-hook-events-dialect-drive.test.cjs b/tests/enh-1077-install-hook-events-dialect-drive.test.cjs new file mode 100644 index 000000000..be9ddab85 --- /dev/null +++ b/tests/enh-1077-install-hook-events-dialect-drive.test.cjs @@ -0,0 +1,429 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor. + * + * Before this change, postToolEvent and preToolEvent were hardcoded strings + * derived from runtime-name checks: + * + * (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse' + * (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse' + * + * After phase 5f-2, both are driven by the registry descriptor's + * `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool; + * any other value (or missing) → PostToolUse/PreToolUse. + * + * Equivalence (i.e. identical observable behaviour for all runtimes): + * hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity} + * + * This suite asserts the equivalence and the registry-parity invariant: + * any runtime whose descriptor carries hookEvents='gemini' gets the + * AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse. + */ + +const { test, describe, before, 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 } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── hooks/dist build guard ─────────────────────────────────────────────────── +// +// hooks/dist/ is gitignored and only produced by `npm run build:hooks`. +// In CI the scoped/windows test jobs do NOT run build:hooks before running +// tests, so install() finds no hook files → event arrays come back empty → +// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion +// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs. + +const REPO_ROOT = path.resolve(__dirname, '..'); +const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); +const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); + +/** + * Idempotently ensure hooks/dist contains built .js files. + * Runs build-hooks.js only when the directory is absent or empty of .js files. + */ +function ensureHooksDist() { + if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { + execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + } +} + +before(() => { + ensureHooksDist(); +}); + +// ─── Registry lookup ────────────────────────────────────────────────────────── + +const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); +const registry = (() => { + try { return require(REGISTRY_PATH); } catch { return undefined; } +})(); + +/** + * Return the hookEvents dialect for a runtime ID from the live registry. + * Returns undefined when the registry is absent or the runtime has no descriptor. + */ +function registryHookEvents(runtimeId) { + return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Collect all hook commands registered under a settings event key. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +/** True if at least one hook is registered under eventName. */ +function hasHooksFor(settings, eventName) { + return hooksForEvent(settings, eventName).length > 0; +} + +// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ─────────────── +// +// Registry runtimes with hookEvents='gemini': gemini, antigravity + +describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => { + // ── gemini ── + + describe('gemini install uses AfterTool for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-gemini-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const geminiDir = path.join(tmpDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + const result = install(false, 'gemini'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms gemini hookEvents is "gemini"', () => { + // Parity assertion: if the registry changes, this test fails first. + const he = registryHookEvents('gemini'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for gemini must declare hookEvents="gemini"'); + } + }); + + test('gemini install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'gemini install must return a non-null settings object'); + }); + + test('gemini install registers at least one hook under AfterTool (post-tool)', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`); + }); + + test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── antigravity ── + + describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-antigrav-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const agDir = path.join(tmpDir, '.gemini', 'antigravity'); + fs.mkdirSync(agDir, { recursive: true }); + const result = install(false, 'antigravity'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms antigravity hookEvents is "gemini"', () => { + const he = registryHookEvents('antigravity'); + if (he !== undefined) { + assert.strictEqual(he, 'gemini', + 'Registry descriptor for antigravity must declare hookEvents="gemini"'); + } + }); + + test('antigravity install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'antigravity install must return a non-null settings object'); + }); + + test('antigravity install registers at least one hook under AfterTool', () => { + assert.ok(hasHooksFor(settings, 'AfterTool'), + `Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register context-monitor under PostToolUse', () => { + const cmds = hooksForEvent(settings, 'PostToolUse'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ────────────── +// +// Registry runtimes with hookEvents='claude': claude, augment + +describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => { + // ── claude ── + + describe('claude install uses PostToolUse for post-tool hooks', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms claude hookEvents is "claude"', () => { + const he = registryHookEvents('claude'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for claude must declare hookEvents="claude"'); + } + }); + + test('claude install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'claude install must return a non-null settings object'); + }); + + test('claude install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`); + }); + + test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); + + // ── augment ── + + describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-1077-augment-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const augDir = path.join(tmpDir, '.augment'); + fs.mkdirSync(augDir, { recursive: true }); + const result = install(false, 'augment'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('registry confirms augment hookEvents is "claude"', () => { + const he = registryHookEvents('augment'); + if (he !== undefined) { + assert.strictEqual(he, 'claude', + 'Registry descriptor for augment must declare hookEvents="claude"'); + } + }); + + test('augment install returns a settings object', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'augment install must return a non-null settings object'); + }); + + test('augment install registers at least one hook under PostToolUse', () => { + assert.ok(hasHooksFor(settings, 'PostToolUse'), + `Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register context-monitor under AfterTool', () => { + const cmds = hooksForEvent(settings, 'AfterTool'); + const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor')); + assert.strictEqual(hasMonitor, false, + `augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`); + }); + + test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => { + const cmds = hooksForEvent(settings, 'PreToolUse'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.ok(hasPromptGuard, + `Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`); + }); + + test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => { + const cmds = hooksForEvent(settings, 'BeforeTool'); + const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard')); + assert.strictEqual(hasPromptGuard, false, + `augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`); + }); + }); +}); + +// ─── Suite 3: Registry-parity invariant ────────────────────────────────────── +// +// For every runtime in the registry that exposes a settings.json surface +// (i.e. hookEvents is defined), assert that the installed hook dialect matches +// the registry value. This is the generative-fix parity assertion +// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the +// registry automatically requires a passing install test for that runtime. + +describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => { + test('all registry runtimes with hookEvents use the matching install dialect', () => { + if (!registry || !registry.runtimes) { + // Registry absent — skip parity check (equivalence still verified above) + return; + } + + // Runtimes that have settings.json surfaces and a hookEvents descriptor + const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy']; + + const failures = []; + + for (const runtimeId of SETTINGS_JSON_RUNTIMES) { + const he = registryHookEvents(runtimeId); + if (he === undefined) continue; // no hookEvents in descriptor — skip + + const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse'; + const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool'; + const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse'; + const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool'; + + const previousCwd = process.cwd(); + const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`); + try { + process.chdir(tmpDir); + const result = install(false, runtimeId); + const settings = result && result.settings; + if (!settings) continue; // non-settings-json surface, skip + + // Post-tool event assertions + const hasExpected = hasHooksFor(settings, expectedPostEvent); + const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent) + .some(c => c && c.includes('gsd-context-monitor')); + + if (!hasExpected) { + failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`); + } + if (hasUnexpected) { + failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`); + } + + // Pre-tool event assertions: prompt-guard must land under the dialect-correct key. + const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent); + const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard')); + const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent); + const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard')); + + if (!hasPromptGuardExpected) { + failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`); + } + if (hasPromptGuardUnexpected) { + failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`); + } + } finally { + process.chdir(previousCwd); + cleanup(tmpDir); + } + } + + assert.deepEqual(failures, [], + 'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' + + failures.join('\n')); + }); +}); diff --git a/tests/enh-1082-install-plan-capstone.test.cjs b/tests/enh-1082-install-plan-capstone.test.cjs new file mode 100644 index 000000000..a8862a8da --- /dev/null +++ b/tests/enh-1082-install-plan-capstone.test.cjs @@ -0,0 +1,222 @@ +'use strict'; + +/** + * Golden-master test for resolveInstallPlan — ADR-857 phase 5g capstone. + * + * Pins the exact InstallPlan shape for all 16 runtimes to guard against + * descriptor drift. Derived from actual resolveInstallPlan output at the time + * the seam was introduced (2026-06-11). Behavioral: calls the exported + * function and asserts on typed fields — no source-grep. + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); + +// --------------------------------------------------------------------------- +// Frozen golden-master table — derived from actual resolveInstallPlan output +// --------------------------------------------------------------------------- + +const EXPECTED = { + claude: { + runtime: 'claude', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'], + hooksSurface: 'settings-json', + }, + codex: { + runtime: 'codex', + installSurface: 'codex-toml', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'codex-hooks-json', + }, + antigravity: { + runtime: 'antigravity', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'gemini', + extendedHookEvents: [], + hooksSurface: 'settings-json', + }, + gemini: { + runtime: 'gemini', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'gemini', + extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'], + hooksSurface: 'settings-json', + }, + cursor: { + runtime: 'cursor', + installSurface: 'cursor-hooks-json', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'cursor-hooks-json', + }, + opencode: { + runtime: 'opencode', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: 'opencode', + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + }, + kilo: { + runtime: 'kilo', + installSurface: 'settings-json', + writesSharedSettings: false, + finishPermissionWriter: 'kilo', + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + }, + copilot: { + runtime: 'copilot', + installSurface: 'copilot-instructions', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'copilot-inline', + }, + augment: { + runtime: 'augment', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + }, + trae: { + runtime: 'trae', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + }, + qwen: { + runtime: 'qwen', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'], + hooksSurface: 'settings-json', + }, + hermes: { + runtime: 'hermes', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + }, + codebuddy: { + runtime: 'codebuddy', + installSurface: 'settings-json', + writesSharedSettings: true, + finishPermissionWriter: null, + hookEvents: 'claude', + extendedHookEvents: [], + hooksSurface: 'settings-json', + }, + cline: { + runtime: 'cline', + installSurface: 'cline-rules', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'cline-rules', + }, + kimi: { + runtime: 'kimi', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + }, + windsurf: { + runtime: 'windsurf', + installSurface: 'profile-marker-only', + writesSharedSettings: false, + finishPermissionWriter: null, + hookEvents: undefined, + extendedHookEvents: [], + hooksSurface: 'none', + }, +}; + +const ALL_RUNTIMES = Object.keys(EXPECTED); + +describe('resolveInstallPlan — ADR-857 phase 5g golden master', () => { + it('covers exactly 16 runtimes', () => { + assert.strictEqual(ALL_RUNTIMES.length, 16); + }); + + for (const runtime of ALL_RUNTIMES) { + it(`resolveInstallPlan('${runtime}') matches frozen plan`, () => { + const actual = resolveInstallPlan(runtime); + assert.deepStrictEqual(actual, EXPECTED[runtime], + `InstallPlan for '${runtime}' drifted from golden master`); + }); + } + + it('resolveInstallPlan throws TypeError for unknown runtime', () => { + assert.throws( + () => resolveInstallPlan('bogus'), + (err) => err instanceof TypeError && /bogus/.test(err.message), + ); + }); + + it('extendedHookEvents is always an array for every runtime', () => { + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + assert.ok(Array.isArray(plan.extendedHookEvents), + `${runtime}: extendedHookEvents should be an array`); + } + }); + + it('hooksSurface is always a non-empty string for every runtime', () => { + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + assert.strictEqual(typeof plan.hooksSurface, 'string', + `${runtime}: hooksSurface should be a string`); + assert.ok(plan.hooksSurface.length > 0, + `${runtime}: hooksSurface should not be empty`); + } + }); + + it('parity: resolveInstallPlan config-intent fields match resolveRuntimeConfigIntent', () => { + // Guard that resolveInstallPlan composes resolveRuntimeConfigIntent correctly — + // any drift between the two would silently break install(). + const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); + for (const runtime of ALL_RUNTIMES) { + const plan = resolveInstallPlan(runtime); + const intent = resolveRuntimeConfigIntent(runtime); + assert.strictEqual(plan.installSurface, intent.installSurface, + `${runtime}: installSurface mismatch between plan and intent`); + assert.strictEqual(plan.writesSharedSettings, intent.writesSharedSettings, + `${runtime}: writesSharedSettings mismatch`); + assert.strictEqual(plan.finishPermissionWriter, intent.finishPermissionWriter, + `${runtime}: finishPermissionWriter mismatch`); + } + }); +}); diff --git a/tests/issue-57-runtime-install-no-drift.test.cjs b/tests/issue-57-runtime-install-no-drift.test.cjs index 3725d42ce..b3abbf0a4 100644 --- a/tests/issue-57-runtime-install-no-drift.test.cjs +++ b/tests/issue-57-runtime-install-no-drift.test.cjs @@ -194,8 +194,8 @@ describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit 'bin/install.js no longer requires the runtime config adapter registry', ); assert.ok( - src.includes('resolveRuntimeConfigIntent('), - 'bin/install.js no longer dispatches config through resolveRuntimeConfigIntent', + src.includes('resolveInstallPlan('), + 'bin/install.js no longer dispatches config through resolveInstallPlan', ); }); });