From e9ee7e9ba661340a6305b2fb0d7820da2fd2da6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 15 Jun 2026 22:13:20 -0400 Subject: [PATCH] feat(#1305): per-capability active tri-state + isCapabilityActive in Capability State Resolver (#1311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#1305): add per-capability active tri-state + isCapabilityActive to the Capability State Resolver CapabilityStateEntry gains active = enabled && configActivation, where configActivation resolves the capability's optional activationKey via the shared _resolveActivationValue (absent activationKey -> true). enabled stays installed && surfaced (unchanged). Each hook's active now also cascades the capability config gate (active && configured). Adds isCapabilityActive(capId, cwd) — a thin convenience over resolveCapabilityRuntimeState. cmdCapabilityState emits active per capability. No consumer cutover yet (graphify/intel: #1306/#1307; loop-resolver: #1310). Part of #1302. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#1305): add changeset for capability active tri-state Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/sharp-geese-glide.md | 5 + CONTEXT.md | 2 +- docs/ARCHITECTURE.md | 2 +- src/capability-state.cts | 54 +++++- tests/capability-state.test.cjs | 303 ++++++++++++++++++++++++++++++-- 5 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 .changeset/sharp-geese-glide.md diff --git a/.changeset/sharp-geese-glide.md b/.changeset/sharp-geese-glide.md new file mode 100644 index 000000000..5d207cb60 --- /dev/null +++ b/.changeset/sharp-geese-glide.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1311 +--- +**Capability state now reports a tri-state `active`** — `gsd-tools capability state` adds an `active` field per capability (installed && surfaced && config-enabled), alongside the existing `enabled` (installed && surfaced). Internal `isCapabilityActive(capId, cwd)` lets consumers honor the single resolved on/off answer. (#1311) diff --git a/CONTEXT.md b/CONTEXT.md index de55b1fab..686ea3076 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -170,7 +170,7 @@ ADR-857 phase 3b seam that merges capability-declared config slices into the `lo A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing. ### Capability State Resolver -ADR-857 phase 4b/6 unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view consumed by workflow hook rendering. Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `resolveCapabilityRuntimeState(cwd, runtimeConfigDir)` (I/O resolver shared by workflow dispatch and diagnostics); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O output entry point). CLI surface: `gsd-tools capability state [--config-dir ]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, enabled, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `enabled = installed && surfaced`, and `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, configured, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (`configured` resolves `when`; `active = enabled && configured`). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. +ADR-857 phase 4b/6 unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view consumed by workflow hook rendering. Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `resolveCapabilityRuntimeState(cwd, runtimeConfigDir)` (I/O resolver shared by workflow dispatch and diagnostics); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O output entry point); `isCapabilityActive(capId, cwd): boolean` (convenience predicate — calls `resolveCapabilityRuntimeState`, finds the entry, returns `entry.active`; `false` when capability not found). CLI surface: `gsd-tools capability state [--config-dir ]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, enabled, active, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `enabled = installed && surfaced` (unchanged — install+surface toggle only), `active = enabled && configActivation` (tri-state deepening: configActivation resolves the capability's `activationKey` via `_resolveActivationValue`; absent `activationKey` → configActivation=true, so `active===enabled` for ungated capabilities), and `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, configured, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (`configured` resolves `when`; `active = enabled && configured`). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. ### Capability State Writer The write-side mirror of the Capability State Resolver. Takes a desired capability state — per-capability `enabled` plus per-hook `gates` — and projects it onto the substrates: `enabled` drives the runtime surface (`.gsd-surface.json`) as the capability on/off switch; `gates` drive the federated config keys (`config.json` `workflow.*`) for hook-level granularity; the install profile (`.gsd-profile`) is a read-only floor it never writes. Writes the surface once and config once (atomic per substrate), then re-runs the resolver and reports divergence (assert-and-report) — so 'off means off' holds as a write-time invariant rather than by caller discipline. Source of truth: `src/capability-writer.cts`; the surface and config writers become its internal adapters. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d9607c92a..8282310de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,7 +375,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; consumes resolved Capability State, filters `byLoopPoint` by capability enablement plus config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks [--config-dir ]` | -| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir ]` | +| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, I/O `cmdCapabilityState`, and convenience predicate `isCapabilityActive(capId, cwd)`; `gsd-tools capability state [--config-dir ]` emits `{ runtimeConfigDir, capabilities[] }` where each entry carries `enabled` (installed && surfaced) and `active` (enabled && configActivation via the capability's `activationKey`; absent key → active===enabled) | | `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry | | `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry | | `intel-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-4, last first-party cutover); extracted from the `case 'intel':` arm in `gsd-tools.cjs`; `routeIntelCommand` → all 9 intel subcommands via lazy `require('./intel.cjs')`; preserves non-raw `timeAgo` transform on `status.files[*].updated_at`; discovered via `commandFamilies` in the capability registry | diff --git a/src/capability-state.cts b/src/capability-state.cts index dc0c58475..8dadeb24c 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -89,6 +89,18 @@ interface CapabilityStateEntry { surfaced: boolean; /** True when the capability is both installed and surfaced. */ enabled: boolean; + /** + * True when the capability is enabled AND its config activation resolves to + * true. Config activation is determined by resolving the capability's + * `activationKey` (a dotted config key, e.g. `graphify.enabled`) via + * `_resolveActivationValue`. When `activationKey` is absent, configActivation + * defaults to `true` — the capability has no config gate. + * + * active = enabled && configActivation + * + * Note: `enabled` stays exactly `installed && surfaced` (unchanged). + */ + active: boolean; /** Resolved hook activation state across steps, gates, and contributions */ hooks: HookEntry[]; } @@ -211,6 +223,20 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa 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: boolean = 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): @@ -242,7 +268,12 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa // (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false) configured = false; } - hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured }); + // 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 }); } } @@ -254,7 +285,7 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate'); processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution'); - results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks }); + results.push({ id: capId, tier, skills, installed, surfaced, enabled, active, hooks }); } // Deterministic sort by id for stable output across calls @@ -533,9 +564,28 @@ function cmdCapabilityState( 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: string, cwd: string): boolean { + const result = resolveCapabilityRuntimeState(cwd, undefined); + const entry = result.capabilities.find((c) => c.id === capId); + return entry !== undefined ? entry.active : false; +} + export = { resolveCapabilityState, resolveCapabilityRuntimeState, + isCapabilityActive, cmdCapabilityState, // Exported for tests _resolveCommandsGsdDir, diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index acde0af36..cbac166eb 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -19,6 +19,7 @@ const { cleanup } = require('./helpers.cjs'); const { resolveCapabilityState, + isCapabilityActive, _isSafePropKey, _loadInstalledSkillsManifest, _resolveManifest, @@ -31,7 +32,7 @@ const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); /** * Build a minimal synthetic registry for a single capability with the given - * skills, steps, gates, contributions, and configSchema entries. + * skills, steps, gates, contributions, configSchema, and optional activationKey. */ function makeRegistry({ id = 'test-cap', @@ -41,18 +42,23 @@ function makeRegistry({ gates = [], contributions = [], configSchema = {}, + activationKey = undefined, } = {}) { + const capEntry = { + id, + tier, + skills, + steps, + gates, + contributions, + config: {}, + }; + if (activationKey !== undefined) { + capEntry.activationKey = activationKey; + } return { capabilities: { - [id]: { - id, - tier, - skills, - steps, - gates, - contributions, - config: {}, - }, + [id]: capEntry, }, configSchema, }; @@ -535,6 +541,64 @@ describe('resolveCapabilityState — hook activation details', () => { assert.strictEqual(hook.when, 42, 'original non-string when value must be preserved'); assert.strictEqual(hook.active, false, 'non-string when → inactive'); }); + + // ── configActivation cascade to hooks ──────────────────────────────────────── + // Bug: a config-disabled capability (active=false) with an unconditional hook + // (no `when`, configured=true) was wrongly yielding hook.active=true because + // the hook loop used `enabled && configured` instead of `active && configured`. + // Fix: hook.active = active(capability) && configured. + + test('config-disabled capability with unconditional hook → hook.active=false, configured=true', () => { + // The capability activationKey resolves false → capability active=false. + // The hook has no `when` → configured=true (unconditional). + // Before the fix: hook.active = enabled(true) && configured(true) = true ← BUG + // After the fix: hook.active = active(false) && configured(true) = false ← correct + const registry = makeRegistry({ + id: 'test-cap', + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + steps: [{ point: 'plan:pre', ref: { skill: 'my-skill' } }], // no `when` → unconditional + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: false } }, // capability's configActivation = false + }); + const cap = result.capabilities[0]; + assert.ok(cap, 'capability must be present'); + assert.strictEqual(cap.enabled, true, 'enabled stays true: installed && surfaced'); + assert.strictEqual(cap.active, false, 'capability active=false: configActivation off'); + const hook = cap.hooks.find((h) => h.kind === 'step'); + assert.ok(hook, 'unconditional step hook must be present'); + assert.strictEqual(hook.when, undefined, 'hook has no when field (unconditional)'); + assert.strictEqual(hook.configured, true, 'hook configured=true: hook own gate is open'); + assert.strictEqual(hook.active, false, 'hook active=false: capability configActivation cascades to hook'); + }); + + test('config-ENABLED capability with unconditional hook → hook.active=true (control)', () => { + // Same setup as above but activationKey resolves true → capability active=true. + // hook.active should follow configured as before. + const registry = makeRegistry({ + id: 'test-cap', + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + steps: [{ point: 'plan:pre', ref: { skill: 'my-skill' } }], // no `when` + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: true } }, // capability's configActivation = true + }); + const cap = result.capabilities[0]; + assert.ok(cap, 'capability must be present'); + assert.strictEqual(cap.active, true, 'capability active=true: configActivation on'); + const hook = cap.hooks.find((h) => h.kind === 'step'); + assert.ok(hook, 'unconditional step hook must be present'); + assert.strictEqual(hook.configured, true, 'hook configured=true'); + assert.strictEqual(hook.active, true, 'hook active=true: both capability and hook gate open'); + }); }); // ─── resolveCapabilityState — determinism ───────────────────────────────────── @@ -1177,3 +1241,222 @@ describe('regressions: installed-runtime capability surface (#1160)', () => { }); }); + +// ─── resolveCapabilityState — per-capability active field ──────────────────── + +describe('resolveCapabilityState — per-capability active (Phase 2)', () => { + // Boundary: installed && surfaced && config-enabled → active=true + test('active=true when installed && surfaced && activationKey resolves true', () => { + const registry = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + configSchema: { 'myfeature.enabled': { default: false } }, + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: true } }, + }); + const cap = result.capabilities[0]; + assert.ok(cap, 'capability must be present'); + assert.strictEqual(cap.installed, true); + assert.strictEqual(cap.surfaced, true); + assert.strictEqual(cap.enabled, true, 'enabled = installed && surfaced (unchanged)'); + assert.strictEqual(cap.active, true, 'active = enabled && config-enabled'); + }); + + // Boundary: installed && surfaced && config-DISABLED → active=false (the key case) + test('active=false when installed && surfaced but activationKey resolves false', () => { + const registry = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + configSchema: { 'myfeature.enabled': { default: false } }, + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: false } }, + }); + const cap = result.capabilities[0]; + assert.ok(cap); + assert.strictEqual(cap.enabled, true, 'enabled still true: installed && surfaced unchanged'); + assert.strictEqual(cap.active, false, 'active=false: config gate is off'); + }); + + // Boundary: config-enabled but NOT surfaced → active=false + test('active=false when config-enabled but not surfaced (enabled=false)', () => { + const registry = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(), // NOT surfaced + config: { myfeature: { enabled: true } }, + }); + const cap = result.capabilities[0]; + assert.ok(cap); + assert.strictEqual(cap.surfaced, false); + assert.strictEqual(cap.enabled, false, 'enabled=false: not surfaced'); + assert.strictEqual(cap.active, false, 'active=false: enabled is false'); + }); + + // Boundary: NOT installed → active=false regardless of config + test('active=false when not installed (installed=false)', () => { + const registry = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + }); + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(), // NOT installed + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: true } }, + }); + const cap = result.capabilities[0]; + assert.ok(cap); + assert.strictEqual(cap.installed, false); + assert.strictEqual(cap.enabled, false); + assert.strictEqual(cap.active, false, 'active=false: not installed'); + }); + + // Boundary: no activationKey → active === enabled (no config gate) + test('active=enabled when capability has no activationKey', () => { + // No activationKey: configActivation defaults to true so active = enabled + const registry = makeRegistry({ + skills: ['my-skill'], + // No activationKey + }); + // installed && surfaced → enabled=true → active=true + const resultEnabled = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: {}, + }); + const capEnabled = resultEnabled.capabilities[0]; + assert.ok(capEnabled); + assert.strictEqual(capEnabled.enabled, true); + assert.strictEqual(capEnabled.active, true, 'active=true when no activationKey and enabled'); + + // NOT surfaced → enabled=false → active=false + const resultDisabled = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(), // not surfaced + config: {}, + }); + const capDisabled = resultDisabled.capabilities[0]; + assert.ok(capDisabled); + assert.strictEqual(capDisabled.enabled, false); + assert.strictEqual(capDisabled.active, false, 'active=false when no activationKey and not enabled'); + }); + + // enabled field semantics unchanged: still installed && surfaced only + test('enabled stays installed && surfaced regardless of activationKey', () => { + const registry = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + }); + // installed && surfaced but config disables it + const result = resolveCapabilityState({ + registry, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: { myfeature: { enabled: false } }, + }); + const cap = result.capabilities[0]; + assert.ok(cap); + // enabled must still be true (installed && surfaced — config doesn't affect it) + assert.strictEqual(cap.enabled, true, 'enabled = installed && surfaced (config does not affect enabled)'); + assert.strictEqual(cap.active, false, 'active reflects config gate'); + }); + + // activationKey defaults to schema default when key absent from config + test('active uses schema default when activationKey not in config', () => { + // schema default = true → active=true when enabled + const registryDefaultTrue = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + configSchema: { 'myfeature.enabled': { default: true } }, + }); + const resultTrue = resolveCapabilityState({ + registry: registryDefaultTrue, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: {}, // no explicit key + }); + assert.strictEqual(resultTrue.capabilities[0].active, true, 'active=true when schema default=true'); + + // schema default = false → active=false when enabled + const registryDefaultFalse = makeRegistry({ + skills: ['my-skill'], + activationKey: 'myfeature.enabled', + configSchema: { 'myfeature.enabled': { default: false } }, + }); + const resultFalse = resolveCapabilityState({ + registry: registryDefaultFalse, + installedSkills: new Set(['my-skill']), + surfacedSkills: new Set(['my-skill']), + config: {}, // no explicit key + }); + assert.strictEqual(resultFalse.capabilities[0].active, false, 'active=false when schema default=false'); + }); +}); + +// ─── isCapabilityActive convenience predicate ───────────────────────────────── + +describe('isCapabilityActive — convenience predicate (Phase 2)', () => { + // isCapabilityActive delegates to resolveCapabilityRuntimeState which does I/O. + // We test it via the CLI+tmpDir path used by the existing e2e tests above, and + // a pure-function boundary test that forces a known-missing capability id. + + test('returns false for unknown capId (not in registry)', () => { + // We can't easily inject the registry into resolveCapabilityRuntimeState + // without a cwd that has been set up. Instead, probe a capability id that + // is guaranteed to never appear in the real registry. + // We use a non-existent cwd so resolveCapabilityRuntimeState degrades + // gracefully (installedSkills/surfacedSkills → empty → all disabled) but + // still returns a capabilities array from the real registry. + // Any truly-unknown capId must return false regardless. + const nonExistentCwd = path.join(os.tmpdir(), 'cap-active-nonexistent-' + Date.now()); + const result = isCapabilityActive('__definitely_not_a_capability__', nonExistentCwd); + assert.strictEqual(result, false, 'unknown capId must return false'); + }); + + test('isCapabilityActive returns same value as the entry active field (e2e)', () => { + // Use a non-existent cwd so resolveCapabilityRuntimeState degrades gracefully. + // resolveCapabilityRuntimeState and isCapabilityActive both resolve against + // the SAME runtime environment: we compare isCapabilityActive(capId, cwd) + // to the matching entry's .active from resolveCapabilityRuntimeState(cwd, undefined) + // called on the identical cwd. This guarantees a real equality check — not typeof. + // + // We need resolveCapabilityRuntimeState for the comparison; require it here + // since it is exported from the same module. + const { resolveCapabilityRuntimeState } = require('../gsd-core/bin/lib/capability-state.cjs'); + + const nonExistentCwd = path.join(os.tmpdir(), 'cap-active-eq-' + Date.now()); + + // Pick a capability that exists in the real registry ('ui' is always present). + const capId = 'ui'; + + // resolveCapabilityRuntimeState resolves state from the real environment. + const runtimeResult = resolveCapabilityRuntimeState(nonExistentCwd, undefined); + const entry = runtimeResult.capabilities.find((c) => c.id === capId); + assert.ok(entry, `'${capId}' must be present in the real registry`); + + // isCapabilityActive must return exactly the same boolean as the resolved entry. + const actual = isCapabilityActive(capId, nonExistentCwd); + assert.strictEqual( + actual, + entry.active, + `isCapabilityActive('${capId}') must equal entry.active=${entry.active} from resolveCapabilityRuntimeState`, + ); + + // Also verify the already-covered false case for unknown capId: + assert.strictEqual(isCapabilityActive('__no_such_cap__', nonExistentCwd), false); + }); +});