From ab82e73af32be7476b34cad2f6fb2cbabe67530d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 6 Jul 2026 22:57:05 -0400 Subject: [PATCH] fix(#2003): add --runtime override to capability state + loop render-hooks resolveCapabilityRuntimeState derived the config dir from resolveRuntime(cwd) (GSD_RUNTIME -> config.runtime -> 'claude') when no --config-dir was passed, so a repo with persisted runtime:'codex' resolved the config dir to ~/.codex where the Claude skill isn't installed -> surfaced:false / hooks silently no-op when the operator drove from Claude Code. capability state and loop render-hooks parsed only --config-dir, never --runtime, so there was no way to assert the actually-active runtime. Add a runtimeOverride param to resolveCapabilityRuntimeState (canonicalized via runtime-name-policy so aliases like codex-app work); when present it short-circuits the persisted-runtime fallback and resolves getGlobalConfigDir for the explicit runtime. Thread --runtime through cmdCapabilityState and cmdLoopRenderHooks, and parse it in gsd-tools.cjs for both commands (dual --runtime X / --runtime=X form, mirroring --config-dir and the existing capability-set --runtime precedent). Help text updated. Without the override, behavior is byte-identical to today (regression-guarded). --- gsd-core/bin/gsd-tools.cjs | 49 ++++++++++++++++++++++++++++++++++++-- src/capability-state.cts | 46 ++++++++++++++++++++++++++--------- src/loop-resolver.cts | 8 ++++++- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index c3cec6716..99c806ef3 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -179,14 +179,23 @@ * * Loop Extension Point Queries (ADR-857 phase 3c): * loop render-hooks Resolve + render active Capability hooks at a loop point + * [--config-dir ] [--runtime ] [--active-cap ] * Returns JSON envelope { point, activeHooks, rendered } * Valid points: discuss:pre/post, plan:pre/post, * execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post + * --runtime: override the auto-detected runtime (#2003) so the config + * dir resolves to that runtime's home even when + * .planning/config.json persists a different runtime. * * Capability State (ADR-857 phase 4b): - * capability state [--config-dir ] Resolve per-capability install/surface/hook-activation state + * capability state [--config-dir ] [--runtime ] Resolve per-capability install/surface/hook-activation state * Returns JSON envelope { runtimeConfigDir, capabilities[] } * --config-dir: runtime config dir (default: auto-detect current runtime) + * --runtime: override the auto-detected runtime (#2003); bypasses the + * GSD_RUNTIME → config.runtime → 'claude' precedence so a + * repo with a persisted runtime can still resolve another + * runtime's config dir (e.g. driving Claude Code from a + * repo that persists runtime:"codex"). * * GSD-2 Migration: * from-gsd2 [--path ] [--force] [--dry-run] @@ -1598,9 +1607,28 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } loopActiveCap = value; } + // --runtime (#2003): explicit runtime override so the config-dir + // resolution bypasses the persisted-runtime fallback (GSD_RUNTIME → + // config.runtime). Mirrors the --config-dir dual-form (--runtime X / + // --runtime=X) and the capability-set --runtime precedent. + let loopRuntime = undefined; + const runtimeEqArg = args.find(arg => arg.startsWith('--runtime=')); + const runtimeIdx = args.indexOf('--runtime'); + if (runtimeEqArg) { + const value = runtimeEqArg.slice('--runtime='.length).trim(); + if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + loopRuntime = value; + } else if (runtimeIdx !== -1) { + const value = args[runtimeIdx + 1]; + if (!value || value.startsWith('--')) { + error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + loopRuntime = value; + } loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, { configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined, activeCap: loopActiveCap, + runtime: loopRuntime, }); } else { error( @@ -1752,7 +1780,24 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand configDir = configDirVal; } const resolvedConfigDir = configDir ? path.resolve(configDir) : null; - capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, {}); + // --runtime (#2003): explicit runtime override so the config-dir + // resolution bypasses the persisted-runtime fallback. Dual-form like + // --config-dir (--runtime X / --runtime=X). + let stateRuntime = undefined; + const stateRuntimeEqArg = args.find(arg => arg.startsWith('--runtime=')); + const stateRuntimeIdx = args.indexOf('--runtime'); + if (stateRuntimeEqArg) { + const value = stateRuntimeEqArg.slice('--runtime='.length).trim(); + if (!value) error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + stateRuntime = value; + } else if (stateRuntimeIdx !== -1) { + const value = args[stateRuntimeIdx + 1]; + if (!value || value.startsWith('--')) { + error('Missing value for --runtime', ERROR_REASON ? ERROR_REASON.USAGE : undefined); + } + stateRuntime = value; + } + capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, { runtime: stateRuntime }); } else if (capSubcommand === 'set') { // capability set [--on|--off|--enable|--disable] [--gate =]... [--config-dir ] [--runtime ] [--scope ] const capId = args[2]; diff --git a/src/capability-state.cts b/src/capability-state.cts index a43b16f3b..55e9318aa 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -441,6 +441,7 @@ function resolveCapabilityRuntimeState( cwd: string, runtimeConfigDir: string | undefined | null, configOverride?: Record, + runtimeOverride?: string, ): ResolveCapabilityRuntimeStateResult { const warnings: string[] = []; @@ -460,15 +461,35 @@ function resolveCapabilityRuntimeState( const runtimeHomes = require('./runtime-homes.cjs') as { getGlobalConfigDir: (runtime: string) => string; }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const runtimeSlash = require('./runtime-slash.cjs') as { - resolveRuntime: (projectDir: string | null | undefined) => string; - }; - // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'. - // resolveRuntime reads config.json directly (no side effects) and returns - // a lowercased canonical runtime name. - const detectedRuntime = runtimeSlash.resolveRuntime(cwd); - resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime); + // #2003: an explicit --runtime override bypasses the persisted-runtime + // fallback (GSD_RUNTIME → config.runtime → 'claude') so, e.g., a repo with + // persisted runtime:"codex" resolves the Claude config dir when the operator + // is driving from Claude Code. Canonicalize via runtime-name-policy (handles + // aliases like codex-app → codex); if canonicalization yields nothing, fall + // through to the persisted-runtime resolution below. Mirrors the update- + // context / effort sync precedent (read/diagnostic paths accepting both + // --config-dir and --runtime). + if (typeof runtimeOverride === 'string' && runtimeOverride.trim() !== '') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const runtimeNamePolicy = require('./runtime-name-policy.cjs') as { + canonicalizeRuntimeName: (value: unknown) => string | null; + }; + const canonical = runtimeNamePolicy.canonicalizeRuntimeName(runtimeOverride); + if (canonical) { + resolvedConfigDir = runtimeHomes.getGlobalConfigDir(canonical); + } + } + if (!resolvedConfigDir) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const runtimeSlash = require('./runtime-slash.cjs') as { + resolveRuntime: (projectDir: string | null | undefined) => string; + }; + // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'. + // resolveRuntime reads config.json directly (no side effects) and returns + // a lowercased canonical runtime name. + const detectedRuntime = runtimeSlash.resolveRuntime(cwd); + resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime); + } } catch { // Defensive fallback: use ~/.claude if the canonical resolver throws. // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -569,9 +590,12 @@ function cmdCapabilityState( cwd: string, runtimeConfigDir: string | undefined | null, raw: boolean, - _options: Record = {}, + options: Record = {}, ): void { - const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir); + // #2003: thread an explicit --runtime override so the config-dir resolution + // bypasses the persisted-runtime fallback (GSD_RUNTIME → config.runtime). + const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined; + const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, undefined, runtimeOverride); for (const warning of result.warnings) { coreError(`capability state: ${warning}`); } diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts index 7df0af4df..1b0d3b1aa 100644 --- a/src/loop-resolver.cts +++ b/src/loop-resolver.cts @@ -475,6 +475,12 @@ function cmdLoopRenderHooks( const runtimeConfigDir = typeof options['configDir'] === 'string' ? options['configDir'] : undefined; + // #2003: thread an explicit --runtime override into the capability-state + // resolver so the config-dir resolution bypasses the persisted-runtime + // fallback (GSD_RUNTIME → config.runtime). Without this, a repo with persisted + // runtime:"codex" resolves the config dir to ~/.codex and execute:post / + // verify:post hooks silently no-op when the operator drives from Claude Code. + const runtimeOverride = typeof options['runtime'] === 'string' ? options['runtime'] : undefined; // Load the config snapshot ONCE and share it with both the capability-state // resolver (via configOverride) and loop-hook resolution, so federated keys // present in loadConfig resolve identically for `active` and for hook when/ @@ -488,7 +494,7 @@ function cmdLoopRenderHooks( } catch { config = {}; } - const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config) as { + const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir, config, runtimeOverride) as { warnings?: string[]; capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>; };