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