feat(#1305): per-capability active tri-state + isCapabilityActive in Capability State Resolver (#1311)

* 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) <noreply@anthropic.com>

* chore(#1305): add changeset for capability active tri-state

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:
Tom Boucher
2026-06-15 22:13:20 -04:00
committed by GitHub
parent 1f41a0ce9a
commit e9ee7e9ba6
5 changed files with 352 additions and 14 deletions

View File

@@ -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)

View File

@@ -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 <point>` 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 <path>]` — 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 <path>]` — 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.

View File

@@ -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 <point> [--config-dir <path>]` |
| `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 <path>]` |
| `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 <path>]` 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 |

View File

@@ -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,

View File

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