* refactor(#1306): gate graphify on isCapabilityActive (tri-state), not config-only graphify's command gate moves from the config-only isGraphifyEnabled to the shared isCapabilityActive('graphify', cwd) — so graphify is off unless installed AND surfaced AND graphify.enabled. Fixes a latent claude-hardcoding in the resolver: resolveCapabilityRuntimeState now detects the active runtime via resolveRuntime(cwd) (GSD_RUNTIME -> config.runtime -> 'claude') so non-Claude runtimes (Codex/Cursor) read their own surface, not ~/.claude. Hermetic regression test proves config-on+unsurfaced -> disabled; cross-runtime test proves GSD_RUNTIME=codex honors CODEX_HOME. Gate fails closed on every error path. Part of #1302. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1306): add changeset for graphify tri-state gate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
* - ./surface.cjs (resolveSurface)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
* - ./runtime-homes.cjs (getGlobalConfigDir — for runtimeConfigDir auto-detection)
|
||||
* - ./runtime-slash.cjs (resolveRuntime — GSD_RUNTIME > config.runtime > 'claude' precedence)
|
||||
* - capability-registry.cjs (loaded at call time)
|
||||
*/
|
||||
|
||||
@@ -399,11 +400,15 @@ function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string
|
||||
* Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
|
||||
*
|
||||
* runtimeConfigDir resolution (when not provided or empty):
|
||||
* Uses the canonical getGlobalConfigDir from runtime-homes.cjs to detect the
|
||||
* active runtime's config dir — the same resolver used by install.js. This
|
||||
* correctly handles all supported runtimes (claude, codex, cursor, gemini,
|
||||
* opencode, grok, etc.) and their env-var overrides. Defaults to claude
|
||||
* (falls back to ~/.claude) if the resolver throws.
|
||||
* Detects the active runtime via the canonical precedence:
|
||||
* process.env.GSD_RUNTIME → config.runtime → 'claude'
|
||||
* (using resolveRuntime() from runtime-slash.cjs, the same precedence used
|
||||
* by profile-output.cjs and the rest of the runtime resolution chain).
|
||||
* Then calls getGlobalConfigDir(detectedRuntime) from runtime-homes.cjs —
|
||||
* the same resolver used by install.js. This correctly handles all supported
|
||||
* runtimes (claude, codex, cursor, gemini, opencode, grok, etc.) and their
|
||||
* env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_CONFIG_DIR, …).
|
||||
* Defaults to ~/.claude if either resolver throws.
|
||||
*
|
||||
* Failure surfacing: genuine resolution failures (manifest/profile/surface
|
||||
* errors) are reported in the `warnings` array in the envelope. The output
|
||||
@@ -429,9 +434,12 @@ function resolveCapabilityRuntimeState(
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
|
||||
// When not provided, getGlobalConfigDir(runtime) is called with 'claude'
|
||||
// as the default runtime — the same fallback as install.js. The canonical
|
||||
// resolver handles all env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME,
|
||||
// When not provided, the active runtime is detected via the canonical
|
||||
// precedence: process.env.GSD_RUNTIME → config.runtime → 'claude'
|
||||
// (mirrors resolveRuntime() from runtime-slash.cjs and the precedence used
|
||||
// by profile-output.cjs and the rest of the runtime resolution chain).
|
||||
// getGlobalConfigDir(detectedRuntime) is then called, which honours the
|
||||
// runtime-specific env-var override (CLAUDE_CONFIG_DIR, CODEX_HOME,
|
||||
// CURSOR_CONFIG_DIR, GROK_AGENTS_HOME, etc.) correctly and without
|
||||
// fabricating env vars that don't exist upstream.
|
||||
let resolvedConfigDir: string = runtimeConfigDir || '';
|
||||
@@ -441,13 +449,15 @@ function resolveCapabilityRuntimeState(
|
||||
const runtimeHomes = require('./runtime-homes.cjs') as {
|
||||
getGlobalConfigDir: (runtime: string) => string;
|
||||
};
|
||||
// Delegate runtime detection entirely to getGlobalConfigDir: calling it
|
||||
// with 'claude' causes it to check CLAUDE_CONFIG_DIR first, falling back
|
||||
// to ~/.claude. The canonical resolver already encodes the correct env-var
|
||||
// precedence for each runtime — we do not re-implement that logic here.
|
||||
// For non-claude runtimes, the caller should pass --config-dir explicitly
|
||||
// (or set the runtime-specific env var, which getGlobalConfigDir honors).
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir('claude');
|
||||
// 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
|
||||
|
||||
@@ -11,32 +11,11 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateMod = require('./capability-state.cjs');
|
||||
const { isCapabilityActive } = capabilityStateMod;
|
||||
|
||||
/**
|
||||
* Check whether graphify is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no graphify key, or on error).
|
||||
*/
|
||||
function isGraphifyEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return false;
|
||||
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'graphify' in config &&
|
||||
config.graphify &&
|
||||
typeof config.graphify === 'object' &&
|
||||
'enabled' in config.graphify &&
|
||||
(config.graphify as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface DisabledResponse {
|
||||
disabled: true;
|
||||
@@ -396,7 +375,7 @@ function countCommitsBetween(cwd: string, from: string, to: string): number | nu
|
||||
*/
|
||||
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
@@ -435,7 +414,7 @@ function graphifyQuery(cwd: string, term: string, options: { budget?: number | n
|
||||
*/
|
||||
function graphifyStatus(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
if (!fs.existsSync(graphPath)) {
|
||||
@@ -498,7 +477,7 @@ function graphifyStatus(cwd: string): unknown {
|
||||
*/
|
||||
function graphifyDiff(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
|
||||
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
|
||||
@@ -554,7 +533,7 @@ function graphifyDiff(cwd: string): unknown {
|
||||
*/
|
||||
function graphifyBuild(cwd: string): unknown {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
|
||||
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
|
||||
|
||||
const installed = checkGraphifyInstalled();
|
||||
if (!installed.installed) return { error: installed.message };
|
||||
@@ -619,7 +598,6 @@ function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
|
||||
|
||||
export = {
|
||||
// Config gate
|
||||
isGraphifyEnabled,
|
||||
disabledResponse,
|
||||
// Subprocess
|
||||
execGraphify,
|
||||
|
||||
Reference in New Issue
Block a user