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).
This commit is contained in:
Tom Boucher
2026-07-06 22:57:05 -04:00
parent 6a15ab9345
commit ab82e73af3
3 changed files with 89 additions and 14 deletions

View File

@@ -441,6 +441,7 @@ function resolveCapabilityRuntimeState(
cwd: string,
runtimeConfigDir: string | undefined | null,
configOverride?: Record<string, unknown>,
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<string, unknown> = {},
options: Record<string, unknown> = {},
): 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}`);
}

View File

@@ -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 }>;
};