feat(#1082): complete phase 5 — descriptor-drive all install surfaces + materialize the InstallPlan — ADR-857/1016/58 (#1080)
* feat(#1077): phase 5f-2 — drive the hookEvents dialect (PostToolUse/AfterTool) from the descriptor postToolEvent (bin/install.js) and preToolEvent (applySettingsJsonHooks in runtime-hooks-surface.cts) now select the event-name dialect from registry.runtimes[id].runtime.hookEvents instead of the hardcoded (runtime === 'gemini' || runtime === 'antigravity') check: hookEvents === 'gemini' → AfterTool/BeforeTool; else → PostToolUse/PreToolUse. hookEvents threaded into the applySettingsJsonHooks opts bag. Equivalence-preserving (Codex-verified): hookEvents 'gemini' is exactly {gemini, antigravity}, 'claude' the rest; undefined → claude dialect (matches the old else). The per-event SET guards (isQwen||claude → SubagentStop/Stop/PreCompact; runtime==='claude' → FileChanged; isGemini → Gemini agent-events) stay HARDCODED — hookEvents (2-value) is too coarse to drive them (the event set differs within hookEvents='claude'); per-event-set drive tracked in #1076. Registry-parity test (enh-1077): asserts BOTH post-tool (AfterTool/PostToolUse) AND pre-tool (BeforeTool/PreToolUse) dialects are a pure function of hookEvents, for gemini/antigravity/claude/augment — non-vacuous (catches a broken hookEvents thread). Closes #1077 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1077): build hooks/dist in before() so dialect-drive test passes in scoped CI hooks/dist is gitignored and absent in scoped/windows CI jobs that do not pre-run build:hooks. Without it, install() finds no hook files and all AfterTool/BeforeTool/PostToolUse/PreToolUse event arrays come back empty, failing every hook-presence assertion. Added an idempotent ensureHooksDist() called in a top-level before() — mirrors the pattern from bug-376-claude-js-hook-gsd-rewriter.test.cjs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1055): add installSurface/writesSharedSettings/permissionWriter/extendedHookEvents to runtime descriptors Purely additive: four new fields on all 16 runtime capability.json descriptors, validator extended with three new closed-vocab sets, registry regenerated. Test fixtures (VALID_RUNTIME_CAP and makeRuntimeCap) updated to include the new required fields so all 255 capability-registry tests continue to pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): drive per-event hook guards from extendedHookEvents descriptor Replace hardcoded runtime-name checks (isQwen||runtime==='claude', runtime==='claude', isGemini) in applySettingsJsonHooks with a single descriptor-driven extendedEvents array derived from the new opts field. Remove isQwen and isGemini derivations (no remaining uses after the three guard blocks are migrated). Wire extendedHookEvents from the capability registry in bin/install.js call site. Add behavioral regression test (enh-1076-extended-hook-events-drive.test.cjs) confirming the drive is purely descriptor-based and runtime-name-agnostic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1055): drive resolveRuntimeConfigIntent from the runtime descriptor; retire hand-kept REGISTRY - Rewrites src/runtime-config-adapter-registry.cts to require capability-registry.cjs and read installSurface / writesSharedSettings / permissionWriter from runtimes[id].runtime; deletes the hand-kept REGISTRY const (ADR-857 phase 5g drive 2). - ALLOWED_CONFIG_RUNTIMES is now derived from descriptor entries that have installSurface. - Fixes the configFormat parity gate in scripts/gen-capability-registry.cjs to read installSurface directly from capMap descriptor bodies, breaking the require cycle (adapter now requires the generated registry; gen-script must not require the adapter). - Adds golden-master test tests/enh-1055-config-intent-descriptor-drive.test.cjs (41 tests) pinning all 16 runtimes' return shapes and the TypeError-on-unknown contract. - Updates scripts/lint-test-file-count.allowlist.json (config module, +1 file). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): make hooksSurface descriptor load-bearing for the settings-json hook-skip - Adds hooksSurface?: string to ApplySettingsJsonHooksOpts and destructuring in applySettingsJsonHooks (src/runtime-hooks-surface.cts). - Replaces the hardcoded !isOpencode && !isKilo hook-skip guard with hooksSurface !== 'none'; removes the now-unused isOpencode/isKilo derivations (ADR-857 phase 5g drive 3). - Passes hooksSurface from the runtime descriptor at the applySettingsJsonHooks call site in bin/install.js using the established _capabilityRegistry?.runtimes?.[runtime]?.runtime?.hooksSurface idiom. - Extends tests/enh-1076-extended-hook-events-drive.test.cjs with two new suites proving: (a) hooksSurface:'none' writes no hooks regardless of runtime name; (b) hooksSurface:'settings-json' writes hooks even for 'opencode' (previously hardcoded to skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record installSurface/writesSharedSettings/permissionWriter/extendedHookEvents descriptor axes in ADR-1016 Add Decision 7a documenting the four axes added in the 5f-completion pass, update axis counts from "six" to "twelve", note 5f-completion drives as done in Decision 8's ladder, update Out of scope to reflect #1055/#1076 are done and 5g (InstallPlan capstone) remains the only open phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#1055): parity gate must fire on configFormat↔installSurface mismatch (read installSurface at the descriptor level) The test fixture makeRuntimeCapMap did not include installSurface in the runtime object, so the gate's typeof r.installSurface !== 'string' guard always skipped the entry and never threw. Added installSurface as an optional third parameter to makeRuntimeCapMap and passed the correct installSurface values ('settings-json' for claude, 'codex-toml' for codex) to the two THROWS tests. The gate implementation already reads r.installSurface correctly from the descriptor level. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1076): add installSurface↔hooksSurface + extendedHookEvents↔hookEvents consistency gates with rejection tests GATE A: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES map in validateRuntimeBody enforces that a runtime's hooksSurface is valid for its installSurface (e.g. profile-marker-only only allows none, codex-toml only allows codex-hooks-json). Derived from the 16 real runtime descriptors. GATE B: validateRuntimeBody checks that if extendedHookEvents contains Gemini agent-events (BeforeAgent/AfterAgent/BeforeModel), hookEvents must be 'gemini'; if it contains Claude-family events (SubagentStop/Stop/PreCompact/FileChanged), hookEvents must be 'claude'. Added 10 rejection tests in suite 27 covering each gate + each new field validator. All 16 real runtimes satisfy both gates (verified before coding). Exports: INSTALL_SURFACE_TO_ALLOWED_HOOKS_SURFACES, VALID_INSTALL_SURFACES, VALID_EXTENDED_HOOK_EVENTS, VALID_PERMISSION_WRITERS, validateRuntimeBody. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1076): strengthen hooksSurface-drive assertions; defensive hooksSurface fallback; drop vacuous dup 1. bin/install.js: add explicit literal fallback for hooksSurface when the committed capability registry fails to load (opencode/kilo → 'none', all others → 'settings-json'). The descriptor is always the source of truth in normal operation. 2. enh-1076 Suite 7: change SessionStart assertion from key-presence (hasOwnProperty) to at least-one-command (hasHooksFor), so the test fails if hooks are initialized-but-empty. ensureHooksDist() in before() guarantees hook files exist. 3. enh-1055 Test 2: remove vacuous duplicate suite that re-asserted intent.runtime === row.runtime already fully covered by Test 1's deepStrictEqual over all four fields. 4. capability-registry.test.cjs: fix stale comments in the grok-skip test that claimed the parity gate uses the adapter registry; gate reads purely from the descriptor (installSurface absent → typeof r.installSurface !== 'string' → soft-skip). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#1082): materialize the InstallPlan — collect install-level descriptor axes into resolveInstallPlan; route install()/finishInstall() through it (ADR-58/5g) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: record 5g InstallPlan materialization (ADR-58 Accepted, ADR-1016 phase-5 complete) ADR-1016 Decision 8 step 7 updated to DONE: resolveInstallPlan(runtime) in runtime-config-adapter-registry collects install-level descriptor axes into the typed InstallPlan consumed by install()/finishInstall(). Out-of-scope section updated: 5g capstone is complete, phase 5 fully materialized. ADR-1016 line ~20 updated: InstallPlan IS now materialized (both halves). ADR-58 Implementation note added (2026-06-11): realized in runtime-config-adapter-registry (co-located with adapter-selection). CONTEXT.md Runtime Config Adapter Registry entry extended to document resolveInstallPlan and both-halves realization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1082): update install drift guard to the resolveInstallPlan seam (5g) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <targetDir>/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);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
"hooksSurface": "settings-json",
|
||||
"hookEvents": "gemini",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 1
|
||||
"supportTier": 1,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"hooksSurface": "settings-json",
|
||||
"hookEvents": "claude",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "cline-rules",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "cline-rules",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
"hooksSurface": "settings-json",
|
||||
"hookEvents": "claude",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "copilot-inline",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "copilot-instructions",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"hooksSurface": "settings-json",
|
||||
"hookEvents": "claude",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "none",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": "kilo",
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "none",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "none",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "settings-json",
|
||||
"writesSharedSettings": true,
|
||||
"permissionWriter": "opencode",
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "none",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
"commandStyle": "slash-hyphen",
|
||||
"hooksSurface": "none",
|
||||
"sandboxTier": "none",
|
||||
"supportTier": 2
|
||||
"supportTier": 2,
|
||||
"installSurface": "profile-marker-only",
|
||||
"writesSharedSettings": false,
|
||||
"permissionWriter": null,
|
||||
"extendedHookEvents": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<runtime>/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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<string, object>} 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; },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<id>/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<string, { runtime: Record<string, unknown> | 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<string, Readonly<RegistryEntry>> = 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<string> = new Set(Object.keys(REGISTRY));
|
||||
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = 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<ConfigInstallSurface> = Object.freeze([
|
||||
@@ -98,16 +103,44 @@ const INSTALL_SURFACES: ReadonlyArray<ConfigInstallSurface> = 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 };
|
||||
|
||||
@@ -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<string, unknown>, '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<string, unknown>, '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 = [];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
134
tests/enh-1055-config-intent-descriptor-drive.test.cjs
Normal file
134
tests/enh-1055-config-intent-descriptor-drive.test.cjs
Normal file
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
427
tests/enh-1076-extended-hook-events-drive.test.cjs
Normal file
427
tests/enh-1076-extended-hook-events-drive.test.cjs
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
429
tests/enh-1077-install-hook-events-dialect-drive.test.cjs
Normal file
429
tests/enh-1077-install-hook-events-dialect-drive.test.cjs
Normal file
@@ -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'));
|
||||
});
|
||||
});
|
||||
222
tests/enh-1082-install-plan-capstone.test.cjs
Normal file
222
tests/enh-1082-install-plan-capstone.test.cjs
Normal file
@@ -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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user