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:
5
.changeset/sharp-geese-glide.md
Normal file
5
.changeset/sharp-geese-glide.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user