@opengsd/gsd-core 1.5.0-rc.4 → 1.5.0-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-phase-researcher.md +1 -1
  3. package/agents/gsd-planner.md +4 -0
  4. package/agents/gsd-project-researcher.md +1 -1
  5. package/agents/gsd-verifier.md +45 -13
  6. package/bin/install.js +3 -1
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +49 -48
  9. package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
  10. package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
  11. package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
  12. package/gsd-core/bin/lib/capability-activation.cjs +37 -10
  13. package/gsd-core/bin/lib/capability-registry.cjs +2 -0
  14. package/gsd-core/bin/lib/capability-state.cjs +80 -30
  15. package/gsd-core/bin/lib/capability-writer.cjs +5 -4
  16. package/gsd-core/bin/lib/check-command-router.cjs +50 -51
  17. package/gsd-core/bin/lib/commands.cjs +20 -2
  18. package/gsd-core/bin/lib/config-loader.cjs +3 -4
  19. package/gsd-core/bin/lib/config-schema.cjs +1 -1
  20. package/gsd-core/bin/lib/config-types.cjs +2 -1
  21. package/gsd-core/bin/lib/config.cjs +5 -2
  22. package/gsd-core/bin/lib/docs.cjs +14 -2
  23. package/gsd-core/bin/lib/frontmatter.cjs +2 -2
  24. package/gsd-core/bin/lib/gap-checker.cjs +5 -2
  25. package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
  26. package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
  27. package/gsd-core/bin/lib/graphify.cjs +7 -31
  28. package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
  29. package/gsd-core/bin/lib/init.cjs +30 -4
  30. package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
  31. package/gsd-core/bin/lib/intel.cjs +28 -34
  32. package/gsd-core/bin/lib/io.cjs +2 -4
  33. package/gsd-core/bin/lib/learnings.cjs +2 -2
  34. package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
  35. package/gsd-core/bin/lib/milestone.cjs +13 -5
  36. package/gsd-core/bin/lib/model-resolver.cjs +3 -4
  37. package/gsd-core/bin/lib/phase-id.cjs +2 -4
  38. package/gsd-core/bin/lib/phase-locator.cjs +3 -6
  39. package/gsd-core/bin/lib/phase.cjs +18 -7
  40. package/gsd-core/bin/lib/probe-core.cjs +33 -11
  41. package/gsd-core/bin/lib/profile-output.cjs +5 -2
  42. package/gsd-core/bin/lib/prohibition-enforcement.cjs +485 -0
  43. package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
  44. package/gsd-core/bin/lib/roadmap-parser.cjs +3 -5
  45. package/gsd-core/bin/lib/roadmap.cjs +9 -4
  46. package/gsd-core/bin/lib/state.cjs +11 -2
  47. package/gsd-core/bin/lib/task-command-router.cjs +2 -2
  48. package/gsd-core/bin/lib/template.cjs +11 -2
  49. package/gsd-core/bin/lib/uat.cjs +8 -2
  50. package/gsd-core/bin/lib/verification.cjs +8 -5
  51. package/gsd-core/bin/lib/verify.cjs +311 -4
  52. package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
  53. package/gsd-core/bin/lib/workstream.cjs +8 -2
  54. package/gsd-core/bin/lib/worktree-safety.cjs +37 -2
  55. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  56. package/gsd-core/references/planner-antipatterns.md +46 -0
  57. package/gsd-core/references/planning-config.md +5 -1
  58. package/gsd-core/references/prohibition-probe.md +51 -2
  59. package/gsd-core/templates/verification-report.md +16 -3
  60. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
  61. package/gsd-core/workflows/execute-phase.md +5 -1
  62. package/gsd-core/workflows/quick.md +3 -0
  63. package/gsd-core/workflows/settings-advanced.md +5 -5
  64. package/gsd-core/workflows/settings-integrations.md +5 -5
  65. package/gsd-core/workflows/spec-phase.md +25 -2
  66. package/gsd-core/workflows/verify-phase.md +22 -7
  67. package/package.json +2 -2
  68. package/scripts/gen-capability-registry.cjs +27 -1
  69. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  70. package/scripts/research-profiles.cjs +2 -2
  71. package/gsd-core/bin/lib/core.cjs +0 -345
@@ -0,0 +1,143 @@
1
+ "use strict";
2
+ /**
3
+ * Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters).
4
+ *
5
+ * Owns:
6
+ * - getAgentsDir(runtime?): string
7
+ * - checkAgentsInstalled(runtime?): AgentsInstalledResult
8
+ *
9
+ * The core.cjs re-export spine was retired in epic #1267; callers import
10
+ * these symbols from agent-install-check.cjs directly.
11
+ */
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ const node_fs_1 = __importDefault(require("node:fs"));
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ const modelProfiles = require("./model-profiles.cjs");
19
+ const { MODEL_PROFILES } = modelProfiles;
20
+ const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
21
+ /**
22
+ * Resolve the agents directory for the given runtime.
23
+ *
24
+ * Priority:
25
+ * 1. GSD_AGENTS_DIR env var (explicit override, any runtime)
26
+ * 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
27
+ * This is correct for both repo runs and real installs (the runtime config dir's
28
+ * agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
29
+ * 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
30
+ *
31
+ * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
32
+ */
33
+ function getAgentsDir(runtime) {
34
+ if (process.env['GSD_AGENTS_DIR']) {
35
+ return process.env['GSD_AGENTS_DIR'];
36
+ }
37
+ const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
38
+ if (resolved === 'claude') {
39
+ return node_path_1.default.join(__dirname, '..', '..', '..', 'agents');
40
+ }
41
+ return node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(resolved), 'agents');
42
+ }
43
+ /**
44
+ * Check which GSD agents are installed on disk.
45
+ *
46
+ * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
47
+ */
48
+ function checkAgentsInstalled(runtime) {
49
+ const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
50
+ const agentsDir = getAgentsDir(resolvedRuntime);
51
+ const expectedAgents = Object.keys(MODEL_PROFILES);
52
+ const installed = [];
53
+ const missing = [];
54
+ if (!node_fs_1.default.existsSync(agentsDir)) {
55
+ return {
56
+ agents_installed: false,
57
+ missing_agents: expectedAgents,
58
+ installed_agents: [],
59
+ incomplete_agents: [],
60
+ agents_dir: agentsDir,
61
+ agent_runtime: resolvedRuntime,
62
+ };
63
+ }
64
+ for (const agent of expectedAgents) {
65
+ const agentFile = node_path_1.default.join(agentsDir, `${agent}.md`);
66
+ const agentFileCopilot = node_path_1.default.join(agentsDir, `${agent}.agent.md`);
67
+ const agentFileCodex = node_path_1.default.join(agentsDir, `${agent}.toml`);
68
+ const agentFileKimiYaml = node_path_1.default.join(agentsDir, 'subagents', `${agent}.yaml`);
69
+ const agentFileKimiPrompt = node_path_1.default.join(agentsDir, 'subagents', `${agent}.md`);
70
+ const kimiAgentInstalled = resolvedRuntime === 'kimi' &&
71
+ node_fs_1.default.existsSync(agentFileKimiYaml) &&
72
+ node_fs_1.default.existsSync(agentFileKimiPrompt);
73
+ if (node_fs_1.default.existsSync(agentFile) ||
74
+ node_fs_1.default.existsSync(agentFileCopilot) ||
75
+ node_fs_1.default.existsSync(agentFileCodex) ||
76
+ kimiAgentInstalled) {
77
+ installed.push(agent);
78
+ }
79
+ else {
80
+ missing.push(agent);
81
+ }
82
+ }
83
+ // ── Manifest-backed completeness check ──────────────────────────────────────
84
+ // If a gsd-file-manifest.json exists alongside the agents dir (parent dir),
85
+ // verify that every manifest-tracked file for each expected agent is present
86
+ // on disk. Missing manifest-tracked files indicate an incomplete install even
87
+ // when the plain presence check above passed (e.g. .md present, .toml absent).
88
+ // If no manifest is found the check is a no-op (graceful for claude/bundled).
89
+ const incomplete = [];
90
+ const manifestPath = node_path_1.default.join(node_path_1.default.dirname(agentsDir), 'gsd-file-manifest.json');
91
+ let manifestFiles = {};
92
+ try {
93
+ const raw = node_fs_1.default.readFileSync(manifestPath, 'utf8');
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed !== null &&
96
+ typeof parsed === 'object' &&
97
+ 'files' in parsed &&
98
+ typeof parsed['files'] === 'object' &&
99
+ parsed['files'] !== null) {
100
+ manifestFiles = parsed['files'];
101
+ }
102
+ }
103
+ catch {
104
+ // No manifest or unreadable — completeness check is skipped
105
+ }
106
+ if (Object.keys(manifestFiles).length > 0) {
107
+ for (const agent of expectedAgents) {
108
+ // Find all manifest keys that belong to this agent:
109
+ // key must be "agents/<agentName>.<ext>" with no further path segments.
110
+ const agentPrefix = `agents/${agent}.`;
111
+ const agentManifestKeys = Object.keys(manifestFiles).filter(key => {
112
+ if (!key.startsWith(agentPrefix))
113
+ return false;
114
+ const rest = key.slice(agentPrefix.length);
115
+ // rest must be a bare extension (no slashes, non-empty)
116
+ return rest.length > 0 && !rest.includes('/');
117
+ });
118
+ if (agentManifestKeys.length === 0) {
119
+ // Agent not tracked in manifest — skip completeness check for this agent
120
+ continue;
121
+ }
122
+ const allPresent = agentManifestKeys.every(key => {
123
+ const basename = key.slice('agents/'.length);
124
+ return node_fs_1.default.existsSync(node_path_1.default.join(agentsDir, basename));
125
+ });
126
+ if (!allPresent) {
127
+ incomplete.push(agent);
128
+ }
129
+ }
130
+ }
131
+ return {
132
+ agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0,
133
+ missing_agents: missing,
134
+ installed_agents: installed,
135
+ incomplete_agents: incomplete,
136
+ agents_dir: agentsDir,
137
+ agent_runtime: resolvedRuntime,
138
+ };
139
+ }
140
+ module.exports = {
141
+ getAgentsDir,
142
+ checkAgentsInstalled,
143
+ };
@@ -24,7 +24,7 @@
24
24
  * inline case arms which each required only their own module).
25
25
  */
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
- const core = require("./core.cjs");
27
+ const io = require("./io.cjs");
28
28
  // ─── routeAuditUat ────────────────────────────────────────────────────────────
29
29
  function routeAuditUat({ args, cwd, raw, error, _uat }) {
30
30
  // Suppress unused-variable warnings for args/error — this command has no
@@ -42,16 +42,16 @@ function routeAuditOpen({ args, cwd, raw, error, _audit, _core }) {
42
42
  void error;
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
44
44
  const a = _audit ?? require('./audit.cjs');
45
- const c = _core ?? core;
45
+ const c = _core ?? io;
46
46
  const wantJson = args.includes('--json');
47
47
  const result = a.auditOpenArtifacts(cwd);
48
48
  if (wantJson) {
49
- // core.output JSON-stringifies its first arg; pass the object directly.
49
+ // io.output JSON-stringifies its first arg; pass the object directly.
50
50
  c.output(result, raw);
51
51
  }
52
52
  else {
53
53
  // Human-readable report must bypass JSON encoding — use the rawValue
54
- // form (third arg) which core.output emits verbatim.
54
+ // form (third arg) which io.output emits verbatim.
55
55
  c.output(null, true, a.formatAuditReport(result));
56
56
  }
57
57
  }
@@ -55,32 +55,59 @@ function _readRawConfigKey(filePath, dotKey) {
55
55
  return { found: false, value: undefined };
56
56
  }
57
57
  }
58
- function _resolveActivationValue(dotKey, config, cwd, registry) {
58
+ /**
59
+ * Resolve the raw value for a dotted config key using the four-level precedence
60
+ * walk. Returns { found, value } with the RAW value (not coerced to boolean),
61
+ * so callers can decide how to interpret the value (boolean gate vs. raw config
62
+ * value for numeric/string settings like security_asvs_level).
63
+ *
64
+ * Precedence (mirrors _resolveActivationValue):
65
+ * 1. loadConfig result (config arg) — guarded nested-lookup.
66
+ * 2. Workstream config.json at planningDir(cwd)/config.json.
67
+ * 3. Root config.json at planningRoot(cwd)/config.json (only if path differs).
68
+ * 4. registry.configSchema[dotKey].default — schema default.
69
+ * 5. Absent → { found: false, value: undefined }.
70
+ */
71
+ function resolveConfigKey(dotKey, opts) {
72
+ const { config, cwd, registry } = opts;
73
+ // Level 1: loadConfig result
59
74
  const fromConfig = _getNestedConfigValue(config, dotKey);
60
75
  if (fromConfig.found)
61
- return Boolean(fromConfig.value);
76
+ return { found: true, value: fromConfig.value };
77
+ // Level 2 + 3: raw config.json files (only when cwd is available)
62
78
  if (cwd) {
63
79
  const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
64
80
  const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
65
81
  const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
66
82
  if (fromWs.found)
67
- return Boolean(fromWs.value);
83
+ return { found: true, value: fromWs.value };
68
84
  if (wsConfigPath !== rootConfigPath) {
69
85
  const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
70
86
  if (fromRoot.found)
71
- return Boolean(fromRoot.value);
87
+ return { found: true, value: fromRoot.value };
72
88
  }
73
89
  }
74
- const schemaEntry = registry['configSchema']?.[dotKey];
75
- if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
76
- const def = schemaEntry['default'];
77
- if (def !== undefined)
78
- return Boolean(def);
90
+ // Level 4: registry configSchema default
91
+ const schemaMap = registry['configSchema'];
92
+ if (schemaMap && typeof schemaMap === 'object' && !Array.isArray(schemaMap)
93
+ && Object.prototype.hasOwnProperty.call(schemaMap, dotKey)) {
94
+ const schemaEntry = schemaMap[dotKey];
95
+ if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
96
+ const def = schemaEntry['default'];
97
+ if (def !== undefined)
98
+ return { found: true, value: def };
99
+ }
79
100
  }
80
- return false;
101
+ // Level 5: absent
102
+ return { found: false, value: undefined };
103
+ }
104
+ function _resolveActivationValue(dotKey, config, cwd, registry) {
105
+ const r = resolveConfigKey(dotKey, { config, cwd, registry });
106
+ return r.found ? Boolean(r.value) : false;
81
107
  }
82
108
  module.exports = {
83
109
  _getNestedConfigValue,
84
110
  _readRawConfigKey,
85
111
  _resolveActivationValue,
112
+ resolveConfigKey,
86
113
  };
@@ -762,6 +762,7 @@ const capabilities = {
762
762
  "graphify"
763
763
  ],
764
764
  "agents": [],
765
+ "activationKey": "graphify.enabled",
765
766
  "config": {
766
767
  "graphify.enabled": {
767
768
  "type": "boolean",
@@ -845,6 +846,7 @@ const capabilities = {
845
846
  },
846
847
  "skills": [],
847
848
  "agents": [],
849
+ "activationKey": "intel.enabled",
848
850
  "config": {
849
851
  "intel.enabled": {
850
852
  "type": "boolean",
@@ -18,14 +18,15 @@
18
18
  * pure, config-only resolution with no filesystem I/O.
19
19
  * cmdCapabilityState is the I/O handler.
20
20
  *
21
- * Dependencies (leaf modules only — no core.cjs circular risk):
21
+ * Dependencies (leaf modules only — no circular risk):
22
22
  * - node:path
23
- * - ./core.cjs (output, error)
23
+ * - ./io.cjs (output, error)
24
24
  * - ./capability-activation.cjs (_resolveActivationValue)
25
25
  * - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
26
26
  * - ./surface.cjs (resolveSurface)
27
27
  * - ./config-loader.cjs (loadConfig)
28
28
  * - ./runtime-homes.cjs (getGlobalConfigDir — for runtimeConfigDir auto-detection)
29
+ * - ./runtime-slash.cjs (resolveRuntime — GSD_RUNTIME > config.runtime > 'claude' precedence)
29
30
  * - capability-registry.cjs (loaded at call time)
30
31
  */
31
32
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -34,8 +35,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
34
35
  const node_path_1 = __importDefault(require("node:path"));
35
36
  const node_fs_1 = __importDefault(require("node:fs"));
36
37
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
- const core = require("./core.cjs");
38
- const { output: coreOutput, error: coreError } = core;
38
+ const ioMod = require("./io.cjs");
39
+ const { output: coreOutput, error: coreError } = ioMod;
39
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
41
  const activationMod = require("./capability-activation.cjs");
41
42
  const { _resolveActivationValue } = activationMod;
@@ -135,6 +136,19 @@ function resolveCapabilityState(input) {
135
136
  surfaced = skills.every((s) => surfacedSkills.has(s));
136
137
  }
137
138
  const enabled = installed && surfaced;
139
+ // ── per-capability config activation ──────────────────────────────────────
140
+ // Resolve the capability's own activationKey (if present). This is the
141
+ // config-level toggle that gates the whole capability — separate from the
142
+ // per-hook `when` keys that gate individual hooks. When activationKey is
143
+ // absent, configActivation defaults to true (no config gate on the cap).
144
+ // active = enabled && configActivation (enabled unchanged: installed && surfaced)
145
+ const activationKey = typeof capObj['activationKey'] === 'string' && capObj['activationKey'].length > 0
146
+ ? capObj['activationKey']
147
+ : undefined;
148
+ const configActivation = activationKey !== undefined
149
+ ? _resolveActivationValue(activationKey, config, cwd, registry)
150
+ : true;
151
+ const active = enabled && configActivation;
138
152
  // ── hooks ──────────────────────────────────────────────────────────────────
139
153
  // Collect from steps, gates, contributions. Each may have a `when` key.
140
154
  // Activation semantics (mirrors loop-resolver.isActive exactly):
@@ -165,7 +179,12 @@ function resolveCapabilityState(input) {
165
179
  // (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
166
180
  configured = false;
167
181
  }
168
- hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
182
+ // Hook active = capability-level active AND hook's own config gate.
183
+ // The capability's `active` constant (= enabled && configActivation) is
184
+ // used here so that a config-disabled capability (active=false) cannot
185
+ // produce active hooks even when the hook's own `when` is unconditional
186
+ // (configured=true). The capability gate cascades to all its hooks.
187
+ hooks.push({ point, kind, when: whenRaw, configured, active: active && configured });
169
188
  }
170
189
  }
171
190
  const stepsRaw = capObj['steps'];
@@ -174,7 +193,7 @@ function resolveCapabilityState(input) {
174
193
  processHooks(Array.isArray(stepsRaw) ? stepsRaw : [], 'step');
175
194
  processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
176
195
  processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
177
- results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
196
+ results.push({ id: capId, tier, skills, installed, surfaced, enabled, active, hooks });
178
197
  }
179
198
  // Deterministic sort by id for stable output across calls
180
199
  results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
@@ -283,16 +302,20 @@ function _resolveManifest(commandsGsdDir, configDir) {
283
302
  }
284
303
  /**
285
304
  * Command entry point: resolve install profile, surface, and config; compute
286
- * capability state; emit the envelope via core.output.
305
+ * capability state; emit the envelope via io.output.
287
306
  *
288
307
  * Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
289
308
  *
290
309
  * runtimeConfigDir resolution (when not provided or empty):
291
- * Uses the canonical getGlobalConfigDir from runtime-homes.cjs to detect the
292
- * active runtime's config dir — the same resolver used by install.js. This
293
- * correctly handles all supported runtimes (claude, codex, cursor, gemini,
294
- * opencode, grok, etc.) and their env-var overrides. Defaults to claude
295
- * (falls back to ~/.claude) if the resolver throws.
310
+ * Detects the active runtime via the canonical precedence:
311
+ * process.env.GSD_RUNTIME → config.runtime → 'claude'
312
+ * (using resolveRuntime() from runtime-slash.cjs, the same precedence used
313
+ * by profile-output.cjs and the rest of the runtime resolution chain).
314
+ * Then calls getGlobalConfigDir(detectedRuntime) from runtime-homes.cjs —
315
+ * the same resolver used by install.js. This correctly handles all supported
316
+ * runtimes (claude, codex, cursor, gemini, opencode, grok, etc.) and their
317
+ * env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_CONFIG_DIR, …).
318
+ * Defaults to ~/.claude if either resolver throws.
296
319
  *
297
320
  * Failure surfacing: genuine resolution failures (manifest/profile/surface
298
321
  * errors) are reported in the `warnings` array in the envelope. The output
@@ -308,15 +331,18 @@ function _resolveManifest(commandsGsdDir, configDir) {
308
331
  * Providing a value without a next token (e.g. the flag
309
332
  * is last in argv with no following value) should be
310
333
  * caught by the caller before invoking this function.
311
- * @param raw Whether to emit raw JSON (core.output raw mode)
334
+ * @param raw Whether to emit raw JSON (io.output raw mode)
312
335
  * @param _options Reserved for future use
313
336
  */
314
- function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
337
+ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
315
338
  const warnings = [];
316
339
  // Resolve runtimeConfigDir using the canonical runtime-homes resolver.
317
- // When not provided, getGlobalConfigDir(runtime) is called with 'claude'
318
- // as the default runtime — the same fallback as install.js. The canonical
319
- // resolver handles all env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME,
340
+ // When not provided, the active runtime is detected via the canonical
341
+ // precedence: process.env.GSD_RUNTIME → config.runtime → 'claude'
342
+ // (mirrors resolveRuntime() from runtime-slash.cjs and the precedence used
343
+ // by profile-output.cjs and the rest of the runtime resolution chain).
344
+ // getGlobalConfigDir(detectedRuntime) is then called, which honours the
345
+ // runtime-specific env-var override (CLAUDE_CONFIG_DIR, CODEX_HOME,
320
346
  // CURSOR_CONFIG_DIR, GROK_AGENTS_HOME, etc.) correctly and without
321
347
  // fabricating env vars that don't exist upstream.
322
348
  let resolvedConfigDir = runtimeConfigDir || '';
@@ -324,13 +350,13 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
324
350
  try {
325
351
  // eslint-disable-next-line @typescript-eslint/no-require-imports
326
352
  const runtimeHomes = require('./runtime-homes.cjs');
327
- // Delegate runtime detection entirely to getGlobalConfigDir: calling it
328
- // with 'claude' causes it to check CLAUDE_CONFIG_DIR first, falling back
329
- // to ~/.claude. The canonical resolver already encodes the correct env-var
330
- // precedence for each runtime — we do not re-implement that logic here.
331
- // For non-claude runtimes, the caller should pass --config-dir explicitly
332
- // (or set the runtime-specific env var, which getGlobalConfigDir honors).
333
- resolvedConfigDir = runtimeHomes.getGlobalConfigDir('claude');
353
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
354
+ const runtimeSlash = require('./runtime-slash.cjs');
355
+ // Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
356
+ // resolveRuntime reads config.json directly (no side effects) and returns
357
+ // a lowercased canonical runtime name.
358
+ const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
359
+ resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
334
360
  }
335
361
  catch {
336
362
  // Defensive fallback: use ~/.claude if the canonical resolver throws.
@@ -392,12 +418,20 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
392
418
  surfacedSkills = new Set();
393
419
  }
394
420
  // ── Load config ───────────────────────────────────────────────────────────────
421
+ // When the caller already holds a loadConfig snapshot (e.g. cmdLoopRenderHooks),
422
+ // accept it via configOverride so capability `active` and hook resolution
423
+ // share the SAME config object — single snapshot, no TOCTOU window.
395
424
  let config;
396
- try {
397
- config = loadConfig(cwd);
425
+ if (configOverride !== undefined) {
426
+ config = configOverride;
398
427
  }
399
- catch {
400
- config = {};
428
+ else {
429
+ try {
430
+ config = loadConfig(cwd);
431
+ }
432
+ catch {
433
+ config = {};
434
+ }
401
435
  }
402
436
  // ── Resolve state ────────────────────────────────────────────────────────────
403
437
  const result = resolveCapabilityState({
@@ -410,8 +444,6 @@ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
410
444
  return {
411
445
  runtimeConfigDir: resolvedConfigDir,
412
446
  warnings,
413
- registry,
414
- config,
415
447
  capabilities: result.capabilities,
416
448
  };
417
449
  }
@@ -431,9 +463,27 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
431
463
  }
432
464
  coreOutput(envelope, raw);
433
465
  }
466
+ /**
467
+ * Convenience predicate: returns true if the capability identified by `capId`
468
+ * is active (installed && surfaced && config-enabled) in the current runtime
469
+ * environment at `cwd`.
470
+ *
471
+ * Internally calls `resolveCapabilityRuntimeState(cwd, undefined)` and returns
472
+ * the `active` field of the matching CapabilityStateEntry.
473
+ * Returns `false` when the capability is not found in the registry.
474
+ *
475
+ * @param capId Capability identifier (e.g. 'graphify', 'intel')
476
+ * @param cwd Project root directory for config resolution
477
+ */
478
+ function isCapabilityActive(capId, cwd) {
479
+ const result = resolveCapabilityRuntimeState(cwd, undefined);
480
+ const entry = result.capabilities.find((c) => c.id === capId);
481
+ return entry !== undefined ? entry.active : false;
482
+ }
434
483
  module.exports = {
435
484
  resolveCapabilityState,
436
485
  resolveCapabilityRuntimeState,
486
+ isCapabilityActive,
437
487
  cmdCapabilityState,
438
488
  // Exported for tests
439
489
  _resolveCommandsGsdDir,
@@ -14,7 +14,7 @@
14
14
  * - re-resolve: always calls resolveCapabilityRuntimeState for the return value
15
15
  *
16
16
  * Dependencies (leaf modules only — no circular risk):
17
- * - ./core.cjs (output, error)
17
+ * - ./io.cjs (output, error)
18
18
  * - ./capability-state.cjs (resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir)
19
19
  * - ./surface.cjs (readSurface, writeSurface, applySurface)
20
20
  * - ./install-profiles.cjs (readActiveProfile)
@@ -23,8 +23,8 @@
23
23
  * - capability-registry.cjs (loaded at call time)
24
24
  */
25
25
  // eslint-disable-next-line @typescript-eslint/no-require-imports
26
- const core = require("./core.cjs");
27
- const { output: coreOutput, error: coreError } = core;
26
+ const ioMod = require("./io.cjs");
27
+ const { output: coreOutput, error: coreError } = ioMod;
28
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
29
  const capabilityStateMod = require("./capability-state.cjs");
30
30
  const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod;
@@ -71,7 +71,8 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
71
71
  const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
72
72
  const resolvedConfigDir = before.runtimeConfigDir;
73
73
  // ── Load registry ─────────────────────────────────────────────────────────
74
- const registry = before.registry;
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const registry = require('./capability-registry.cjs');
75
76
  const capabilitiesMap = (registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
76
77
  ? registry['capabilities']
77
78
  : {});