File size: 25,935 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
"use strict";
/**
 * Capability State Resolver β€” ADR-857 phase 4b
 *
 * Unified capability-state resolver that composes the three toggle systems
 * (install profile, runtime surface, config activation) into one per-capability
 * view. The loop resolver consumes this state so workflow dispatch and the
 * `gsd-tools capability state` diagnostic share the same enablement answer.
 *
 * Exports (three things, mirroring loop-resolver):
 *   resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
 *     β†’ { capabilities: CapabilityStateEntry[] }
 *   cmdCapabilityState(cwd, runtimeConfigDir, raw, options) β€” I/O entry point
 *
 * resolveCapabilityState is DETERMINISTIC given (registry, installedSkills,
 * surfacedSkills, config) and β€” when `cwd` is provided β€” the project config
 * files at `cwd` (.planning/config.json etc). Pass `cwd: undefined` for a
 * pure, config-only resolution with no filesystem I/O.
 * cmdCapabilityState is the I/O handler.
 *
 * Dependencies (leaf modules only β€” no circular risk):
 *   - node:path
 *   - ./io.cjs                 (output, error)
 *   - ./capability-activation.cjs (_resolveActivationValue)
 *   - ./install-profiles.cjs   (readActiveProfile, loadSkillsManifest, resolveProfile)
 *   - ./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)
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_path_1 = __importDefault(require("node:path"));
const node_fs_1 = __importDefault(require("node:fs"));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const ioMod = require("./io.cjs");
const { output: coreOutput, error: coreError } = ioMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const activationMod = require("./capability-activation.cjs");
const { _resolveActivationValue } = activationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const configLoaderMod = require("./config-loader.cjs");
const { loadConfig } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const installProfilesMod = require("./install-profiles.cjs");
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const surfaceMod = require("./surface.cjs");
const { resolveSurface } = surfaceMod;
// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
function _isSafePropKey(key) {
    // Inline literal guards β€” CodeQL barrier pattern
    if (typeof key !== 'string')
        return false;
    if (key === '__proto__')
        return false;
    if (key === 'constructor')
        return false;
    if (key === 'prototype')
        return false;
    return true;
}
// ─── Pure resolver ─────────────────────────────────────────────────────────────
/**
 * Deterministic resolver: for each capability in the registry, produce the
 * three-dimension state view:
 *   1. installed  β€” does the install profile cover this capability?
 *   2. surfaced   β€” does the runtime surface enable this capability?
 *   3. hooks      β€” per-hook activation derived from config `when` keys.
 *
 * Determinism contract: given the same (registry, installedSkills,
 * surfacedSkills, config) and β€” when `cwd` is set β€” the same project config
 * files at `cwd`, the output is identical across calls. Pass `cwd: undefined`
 * for a pure, config-only resolution with no filesystem I/O.
 *
 * Never throws for malformed registry/hook entries β€” skips/defaults defensively.
 * An empty or missing capabilities object β†’ { capabilities: [] }.
 *
 * @param input.registry         The capability-registry.cjs module export.
 * @param input.installedSkills  Set<string> | '*' β€” from resolveProfile().skills.
 * @param input.surfacedSkills   Set<string> β€” from resolveSurface().skills.
 * @param input.config           Record from loadConfig(cwd).
 * @param input.cwd              Optional; when provided, enables raw .planning/config.json
 *                               fallback reads (levels 2+3 of _resolveActivationValue
 *                               precedence). Omit for a pure in-memory resolution.
 */
function resolveCapabilityState(input) {
    const { registry, installedSkills, surfacedSkills, config, cwd } = input;
    // Guard: registry missing capabilities
    if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
        return { capabilities: [] };
    }
    const capabilitiesRaw = registry['capabilities'];
    if (!capabilitiesRaw || typeof capabilitiesRaw !== 'object' || Array.isArray(capabilitiesRaw)) {
        return { capabilities: [] };
    }
    const capabilitiesMap = capabilitiesRaw;
    const results = [];
    for (const capId of Object.keys(capabilitiesMap)) {
        // Prototype-pollution guard on capability id
        if (!_isSafePropKey(capId))
            continue;
        const cap = capabilitiesMap[capId];
        if (!cap || typeof cap !== 'object' || Array.isArray(cap))
            continue;
        const capObj = cap;
        // Extract tier
        const tier = typeof capObj['tier'] === 'string' ? capObj['tier'] : 'unknown';
        // Extract skills array
        const skillsRaw = capObj['skills'];
        const skills = Array.isArray(skillsRaw)
            ? skillsRaw.filter((s) => typeof s === 'string')
            : [];
        // ── installed ──────────────────────────────────────────────────────────────
        // Empty-skills cap β†’ vacuously installed (no skills to be absent).
        // installedSkills === '*' β†’ installed = true for every cap.
        let installed;
        if (installedSkills === '*') {
            installed = true;
        }
        else if (skills.length === 0) {
            installed = true; // vacuous: no skills required
        }
        else {
            installed = skills.every((s) => installedSkills.has(s));
        }
        // ── surfaced ───────────────────────────────────────────────────────────────
        // Empty-skills cap β†’ vacuously surfaced.
        let surfaced;
        if (skills.length === 0) {
            surfaced = true; // vacuous
        }
        else {
            surfaced = skills.every((s) => surfacedSkills.has(s));
        }
        const enabled = installed && surfaced;
        // ── per-capability config activation ──────────────────────────────────────
        // Resolve the capability's own activationKey (if present). This is the
        // config-level toggle that gates the whole capability β€” separate from the
        // per-hook `when` keys that gate individual hooks. When activationKey is
        // absent, configActivation defaults to true (no config gate on the cap).
        // active = enabled && configActivation  (enabled unchanged: installed && surfaced)
        const activationKey = typeof capObj['activationKey'] === 'string' && capObj['activationKey'].length > 0
            ? capObj['activationKey']
            : undefined;
        const configActivation = activationKey !== undefined
            ? _resolveActivationValue(activationKey, config, cwd, registry)
            : true;
        const active = enabled && configActivation;
        // ── hooks ──────────────────────────────────────────────────────────────────
        // Collect from steps, gates, contributions. Each may have a `when` key.
        // Activation semantics (mirrors loop-resolver.isActive exactly):
        //   - No `when` field present (undefined/null) β†’ unconditional, active=true
        //   - Non-empty string `when` β†’ resolve via _resolveActivationValue
        //   - Present-but-empty-string or non-string `when` β†’ malformed, active=false
        // The original `when` value is carried through to the output for visibility.
        const hooks = [];
        function processHooks(arr, kind) {
            for (const hookRaw of arr) {
                if (!hookRaw || typeof hookRaw !== 'object' || Array.isArray(hookRaw))
                    continue;
                const h = hookRaw;
                const point = typeof h['point'] === 'string' ? h['point'] : '';
                // Carry the raw `when` value through for visibility
                const whenRaw = h['when'];
                let configured;
                if (whenRaw === undefined || whenRaw === null) {
                    // No `when` field β†’ unconditional, always active
                    configured = true;
                }
                else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
                    // Non-empty string `when` β†’ resolve via _resolveActivationValue
                    configured = _resolveActivationValue(whenRaw, config, cwd, registry);
                }
                else {
                    // Present-but-empty-string or non-string `when` β†’ malformed, inactive
                    // (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` β†’ false)
                    configured = false;
                }
                // Hook active = capability-level active AND hook's own config gate.
                // The capability's `active` constant (= enabled && configActivation) is
                // used here so that a config-disabled capability (active=false) cannot
                // produce active hooks even when the hook's own `when` is unconditional
                // (configured=true). The capability gate cascades to all its hooks.
                hooks.push({ point, kind, when: whenRaw, configured, active: active && configured });
            }
        }
        const stepsRaw = capObj['steps'];
        const gatesRaw = capObj['gates'];
        const contributionsRaw = capObj['contributions'];
        processHooks(Array.isArray(stepsRaw) ? stepsRaw : [], 'step');
        processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
        processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
        results.push({ id: capId, tier, skills, installed, surfaced, enabled, active, hooks });
    }
    // Deterministic sort by id for stable output across calls
    results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
    return { capabilities: results };
}
// ─── I/O command handler ───────────────────────────────────────────────────────
/**
 * Derive the commands/gsd path from __dirname (which resolves to
 * gsd-core/bin/lib/ at runtime). The source tree is:
 *   <repo>/gsd-core/bin/lib/capability-state.cjs
 *   <repo>/commands/gsd/*.md
 * So we walk up three levels: lib/ β†’ bin/ β†’ gsd-core/ β†’ <repo>/, then
 * into commands/gsd/.
 */
function _resolveCommandsGsdDir() {
    // __dirname = gsd-core/bin/lib/
    const repoRoot = node_path_1.default.resolve(__dirname, '..', '..', '..');
    return node_path_1.default.join(repoRoot, 'commands', 'gsd');
}
/**
 * Build a skill dependency manifest from an INSTALLED runtime's skills directory.
 *
 * In an installed runtime (e.g. Codex at ~/.codex), gsd skills live as
 * configDir/skills/gsd-STEM/SKILL.md. There is no commands/gsd source tree.
 * This function scans that installed layout and builds the same
 * Map shape that loadSkillsManifest produces from sources.
 *
 * Stem extraction: a directory named gsd-secure-phase maps to stem secure-phase.
 * Only directories whose names start with gsd- are included so user-created
 * skills (without the gsd- prefix) are not accidentally pulled in.
 *
 * The requires: field is parsed via the shared parseRequires helper (the same
 * parser loadSkillsManifest uses), so the two paths cannot drift.
 *
 * Returns an empty Map when the skills dir does not exist.
 */
function _loadInstalledSkillsManifest(configDir) {
    const manifest = new Map();
    const skillsDir = node_path_1.default.join(configDir, 'skills');
    if (!node_fs_1.default.existsSync(skillsDir))
        return manifest;
    let entries;
    try {
        entries = node_fs_1.default.readdirSync(skillsDir, { withFileTypes: true });
    }
    catch {
        return manifest;
    }
    for (const entry of entries) {
        if (!entry.isDirectory())
            continue;
        if (!entry.name.startsWith('gsd-'))
            continue;
        // Strip the 'gsd-' prefix to get the skill stem
        const stem = entry.name.slice(4); // 'gsd-'.length === 4
        if (!stem)
            continue;
        const skillMdPath = node_path_1.default.join(skillsDir, entry.name, 'SKILL.md');
        // Parity with loadSkillsManifest: a stem exists only when its artifact
        // file is present. loadSkillsManifest registers a stem per .md FILE (and
        // tolerates an unreadable file as []), but never invents a stem for which
        // no file exists. Mirror that here: a stale gsd-<stem>/ directory with no
        // SKILL.md must NOT register the stem β€” otherwise the capability would be
        // wrongly reported surfaced/enabled and a verify:post hook would render
        // for a skill that cannot run.
        if (!node_fs_1.default.existsSync(skillMdPath))
            continue;
        let content = '';
        try {
            content = node_fs_1.default.readFileSync(skillMdPath, 'utf8');
        }
        catch {
            // SKILL.md present but unreadable β€” register with no deps (parity with
            // loadSkillsManifest's readFileSync catch branch).
        }
        // Parse requires: via the SAME shared parser loadSkillsManifest uses, so
        // installed-runtime dependency resolution can never silently diverge from
        // the source-tree path (single source of truth β€” no duplicated regex).
        manifest.set(stem, content ? parseRequires(content) : []);
        // Mirror loadSkillsManifest's Map shape: it always sets a companion
        // `_calls_agents_<stem>` key. Installed SKILL.md bodies carry no
        // recoverable agent-call refs, so [] (the no-agents case) keeps the two
        // manifest shapes identical and prevents undefined-vs-[] drift for any
        // consumer that reads the agent-refs companion key.
        manifest.set(`_calls_agents_${stem}`, []);
    }
    return manifest;
}
/**
 * Resolve the skill dependency manifest for capability-state resolution.
 *
 * Resolution order (fixes #1160 β€” installed-runtime capability surface):
 *   1. If commandsGsdDir exists, load from source (repo-checkout behavior).
 *   2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
 *
 * In an installed runtime the commands/gsd source tree is absent; only the
 * skills/ layout exists. Returning an empty manifest caused resolveSurface to
 * materialise the full-sentinel to an empty Set, making every capability appear
 * unsurfaced even when the skill was physically installed.
 */
function _resolveManifest(commandsGsdDir, configDir) {
    if (node_fs_1.default.existsSync(commandsGsdDir)) {
        return loadSkillsManifest(commandsGsdDir);
    }
    return _loadInstalledSkillsManifest(configDir);
}
/**
 * Command entry point: resolve install profile, surface, and config; compute
 * capability state; emit the envelope via io.output.
 *
 * Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
 *
 * runtimeConfigDir resolution (when not provided or empty):
 *   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
 * remains useful β€” degraded to the best available state β€” but the caller can
 * detect that the state is not fully resolved.
 *
 *   Legitimate "no marker β†’ default full profile" is NOT a warning.
 *   A thrown error during profile/surface resolution IS a warning.
 *
 * @param cwd              Project root directory
 * @param runtimeConfigDir Runtime config directory (e.g. ~/.claude). May be
 *                         empty/undefined β€” falls back to auto-detection.
 *                         Providing a value without a next token (e.g. the flag
 *                         is last in argv with no following value) should be
 *                         caught by the caller before invoking this function.
 * @param raw              Whether to emit raw JSON (io.output raw mode)
 * @param _options         Reserved for future use
 */
function resolveCapabilityRuntimeState(cwd, runtimeConfigDir, configOverride) {
    const warnings = [];
    // Resolve runtimeConfigDir using the canonical runtime-homes resolver.
    // 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 = runtimeConfigDir || '';
    if (!resolvedConfigDir) {
        try {
            // eslint-disable-next-line @typescript-eslint/no-require-imports
            const runtimeHomes = require('./runtime-homes.cjs');
            // eslint-disable-next-line @typescript-eslint/no-require-imports
            const runtimeSlash = require('./runtime-slash.cjs');
            // 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
            const os = require('node:os');
            resolvedConfigDir = node_path_1.default.join(os.homedir(), '.claude');
        }
    }
    // ── Load registry (ADR-857 phase 4c) ────────────────────────────────────────
    // Load BEFORE resolveProfile and resolveSurface so both calls receive the
    // registry and capability-contributed skills are reflected in installed/surfaced.
    // No-op today (UI capability is tier:full β†’ only adds to 'full', which returns
    // '*' regardless) but cutover-ready for future tier:core/standard capabilities.
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const registry = require('./capability-registry.cjs');
    // ── Resolve installed skills (from install profile) ──────────────────────────
    // Distinguish "no profile marker β†’ default full" (legitimate) from a thrown
    // error (surface as a warning and degrade gracefully β€” do NOT silently report
    // installedSkills='*' as if the install profile were truly unlimited).
    let installedSkills;
    try {
        const commandsGsdDir = _resolveCommandsGsdDir();
        // Fix #1160: use _resolveManifest so installed-runtime layouts (where
        // commands/gsd is absent) fall back to <configDir>/skills/gsd-*/SKILL.md.
        const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
        const profileName = readActiveProfile(resolvedConfigDir) ?? 'full';
        const resolvedInstall = resolveProfile({
            modes: profileName.split(',').map((s) => s.trim()),
            manifest,
            registry,
        });
        installedSkills = resolvedInstall.skills;
    }
    catch (err) {
        // Genuine resolution failure β€” surface it so the caller is not misled.
        const msg = err instanceof Error ? err.message : String(err);
        warnings.push(`profile-resolution failed: ${msg}`);
        // Degrade to empty set (not '*') so installed=false is reported accurately.
        installedSkills = new Set();
    }
    // ── Resolve surfaced skills (from runtime surface) ────────────────────────────
    let surfacedSkills;
    try {
        const commandsGsdDir = _resolveCommandsGsdDir();
        // Fix #1160: use _resolveManifest so installed-runtime layouts (where
        // commands/gsd is absent) fall back to <configDir>/skills/gsd-*/SKILL.md.
        const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
        const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry);
        // resolveSurface returns { name, skills: Set<string>, agents: Set<string> }
        // (always a concrete Set β€” full profile is materialized)
        surfacedSkills = surfaceResult.skills instanceof Set
            ? surfaceResult.skills
            : new Set();
    }
    catch (err) {
        // Genuine surface resolution failure β€” surface it so the caller is not misled.
        const msg = err instanceof Error ? err.message : String(err);
        warnings.push(`surface-resolution failed: ${msg}`);
        surfacedSkills = new Set();
    }
    // ── Load config ───────────────────────────────────────────────────────────────
    // When the caller already holds a loadConfig snapshot (e.g. cmdLoopRenderHooks),
    // accept it via configOverride so capability `active` and hook resolution
    // share the SAME config object β€” single snapshot, no TOCTOU window.
    let config;
    if (configOverride !== undefined) {
        config = configOverride;
    }
    else {
        try {
            config = loadConfig(cwd);
        }
        catch {
            config = {};
        }
    }
    // ── Resolve state ────────────────────────────────────────────────────────────
    const result = resolveCapabilityState({
        registry,
        installedSkills,
        surfacedSkills,
        config,
        cwd,
    });
    return {
        runtimeConfigDir: resolvedConfigDir,
        warnings,
        capabilities: result.capabilities,
    };
}
function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
    const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
    for (const warning of result.warnings) {
        coreError(`capability state: ${warning}`);
    }
    // Build envelope β€” include warnings array only when non-empty so the nominal
    // path keeps the output clean and callers can check `warnings` for degraded state.
    const envelope = {
        runtimeConfigDir: result.runtimeConfigDir,
        capabilities: result.capabilities,
    };
    if (result.warnings.length > 0) {
        envelope.warnings = result.warnings;
    }
    coreOutput(envelope, raw);
}
/**
 * Convenience predicate: returns true if the capability identified by `capId`
 * is active (installed && surfaced && config-enabled) in the current runtime
 * environment at `cwd`.
 *
 * Internally calls `resolveCapabilityRuntimeState(cwd, undefined)` and returns
 * the `active` field of the matching CapabilityStateEntry.
 * Returns `false` when the capability is not found in the registry.
 *
 * @param capId  Capability identifier (e.g. 'graphify', 'intel')
 * @param cwd    Project root directory for config resolution
 */
function isCapabilityActive(capId, cwd) {
    const result = resolveCapabilityRuntimeState(cwd, undefined);
    const entry = result.capabilities.find((c) => c.id === capId);
    return entry !== undefined ? entry.active : false;
}
module.exports = {
    resolveCapabilityState,
    resolveCapabilityRuntimeState,
    isCapabilityActive,
    cmdCapabilityState,
    // Exported for tests
    _resolveCommandsGsdDir,
    _loadInstalledSkillsManifest,
    _resolveManifest,
    _isSafePropKey,
};