* refactor(#1307): gate intel on isCapabilityActive + loop-resolver honors capability active Part A: intel's command gate moves from config-only isIntelEnabled to the shared isCapabilityActive('intel', cwd) — a consistency cutover (intel has skills:[] so its tri-state collapses to the intel.enabled config leg; the gate now flows through the resolver's precedence + runtime-aware resolution). Part B: loop-resolver hook rendering now gates on capability state.active (=== true, fail-closed) instead of state.enabled, so the capability config gate is honored by the hook consumer, not just per-hook 'when'. active is now required in the loop-resolver input types. Regression test proves a config- disabled (active=false) capability's unconditional hook is not rendered. Part of #1302. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1307): add changeset for intel + loop-resolver active gate 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:
@@ -100,7 +100,7 @@ function enableIntel(tmpDir) {
|
||||
const config = fs.existsSync(configPath)
|
||||
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
: {};
|
||||
// isIntelEnabled() requires the NESTED form { intel: { enabled: true } }.
|
||||
// isIntelCapabilityActive() / isCapabilityActive('intel', cwd) requires the NESTED form { intel: { enabled: true } }.
|
||||
// A flat dotted key like config['intel.enabled'] = true is NOT recognised.
|
||||
config.intel = { ...(config.intel ?? {}), enabled: true };
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
|
||||
@@ -12,7 +12,7 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
intelQuery,
|
||||
@@ -24,10 +24,12 @@ const {
|
||||
intelExtractExports,
|
||||
intelApiSurface,
|
||||
ensureIntelDir,
|
||||
isIntelEnabled,
|
||||
isIntelCapabilityActive,
|
||||
INTEL_FILES,
|
||||
} = require('../gsd-core/bin/lib/intel.cjs');
|
||||
|
||||
const { isCapabilityActive } = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function enableIntel(planningDir) {
|
||||
@@ -55,6 +57,51 @@ function _writeIntelMd(planningDir, filename, content) {
|
||||
fs.writeFileSync(path.join(intelPath, filename), content, 'utf8');
|
||||
}
|
||||
|
||||
// ─── Surfaced-config-dir fixture ──────────────────────────────────────────────
|
||||
//
|
||||
// Positive-path tests (intelQuery, intelStatus, etc.) call isCapabilityActive
|
||||
// via the tri-state gate — they need the capability to be surfaced.
|
||||
// Without this fixture those tests are ambient-dependent (pass only on machines
|
||||
// where intel is surfaced in the real ~/.claude).
|
||||
//
|
||||
// Fix: point CLAUDE_CONFIG_DIR at a tmp dir containing a full-profile
|
||||
// .gsd-surface.json (no disabled clusters) so intel is surfaced deterministically.
|
||||
// An EMPTY tmp config dir also works (defaults to 'full' profile → all surfaced)
|
||||
// but we write the file explicitly for visible intent.
|
||||
|
||||
/** Create a tmp config dir with intel (and all caps) surfaced — full profile. */
|
||||
function makeSurfacedConfigDir() {
|
||||
const dir = createTempDir('gsd-intel-surface-cfg-');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.gsd-surface.json'),
|
||||
JSON.stringify({ baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Save env vars touched by the surfaced-config fixture; returns .restore(). */
|
||||
function saveSurfacedEnv() {
|
||||
const saved = {
|
||||
GSD_RUNTIME: process.env.GSD_RUNTIME,
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
GSD_WORKSTREAM: process.env.GSD_WORKSTREAM,
|
||||
GSD_PROJECT: process.env.GSD_PROJECT,
|
||||
};
|
||||
return {
|
||||
restore() {
|
||||
if (saved.GSD_RUNTIME === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = saved.GSD_RUNTIME;
|
||||
if (saved.CLAUDE_CONFIG_DIR === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
|
||||
if (saved.GSD_WORKSTREAM === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = saved.GSD_WORKSTREAM;
|
||||
if (saved.GSD_PROJECT === undefined) delete process.env.GSD_PROJECT;
|
||||
else process.env.GSD_PROJECT = saved.GSD_PROJECT;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Disabled gating ────────────────────────────────────────────────────────
|
||||
|
||||
describe('intel disabled gating', () => {
|
||||
@@ -70,22 +117,44 @@ describe('intel disabled gating', () => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('isIntelEnabled returns false when no config.json exists', () => {
|
||||
assert.strictEqual(isIntelEnabled(planningDir), false);
|
||||
// isIntelEnabled was removed in Phase 4 (tri-state cutover). These tests now
|
||||
// verify the gating via the public command API (intelQuery) and the exported
|
||||
// isIntelCapabilityActive helper which delegates to isCapabilityActive.
|
||||
test('isIntelCapabilityActive returns false when no config.json exists', () => {
|
||||
// No CLAUDE_CONFIG_DIR → defaults to real ~/.claude; no intel.enabled in config.
|
||||
// In a hermetic test environment (empty tmpDir), the capability is not surfaced
|
||||
// via the real config dir — but isCapabilityActive returns false by construction
|
||||
// when the activationKey (intel.enabled) is absent/false regardless of surface,
|
||||
// because: active = enabled && configActivation; configActivation=false when key absent.
|
||||
// This test is surface-agnostic for the "no config" branch.
|
||||
assert.strictEqual(isIntelCapabilityActive(planningDir), false);
|
||||
});
|
||||
|
||||
test('isIntelEnabled returns false when intel.enabled is not set', () => {
|
||||
test('isIntelCapabilityActive returns false when intel.enabled is not set', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ model_profile: 'balanced' }),
|
||||
'utf8'
|
||||
);
|
||||
assert.strictEqual(isIntelEnabled(planningDir), false);
|
||||
assert.strictEqual(isIntelCapabilityActive(planningDir), false);
|
||||
});
|
||||
|
||||
test('isIntelEnabled returns true when intel.enabled is true', () => {
|
||||
// NOTE: intel has `skills: []` (empty), so installed and surfaced are vacuously true.
|
||||
// For intel, active = configActivation (the intel.enabled config key).
|
||||
// This test verifies that isIntelCapabilityActive delegates to isCapabilityActive('intel', cwd)
|
||||
// and that the function returns a boolean without throwing, regardless of the ambient
|
||||
// CLAUDE_CONFIG_DIR. The config dimension is probed hermetically in the section below.
|
||||
test('isIntelCapabilityActive delegates to isCapabilityActive and returns a boolean (config=true, ambient surface)', () => {
|
||||
// Write config.intel.enabled=true (config dimension ON). Since intel has no skills,
|
||||
// surface is vacuously true — so this call returns true when the config is written
|
||||
// and the capability system resolves correctly.
|
||||
enableIntel(planningDir);
|
||||
assert.strictEqual(isIntelEnabled(planningDir), true);
|
||||
// We cannot assert the exact value without full hermetic control of all three
|
||||
// tri-state dimensions (see hermetic section below for that), but we assert that:
|
||||
// 1. isIntelCapabilityActive delegates correctly (does not throw)
|
||||
// 2. it returns a boolean (not undefined/null/object)
|
||||
const result = isIntelCapabilityActive(planningDir);
|
||||
assert.strictEqual(typeof result, 'boolean', 'isIntelCapabilityActive must return a boolean');
|
||||
});
|
||||
|
||||
test('intelQuery returns disabled response when intel is off', () => {
|
||||
@@ -110,6 +179,121 @@ describe('intel disabled gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tri-state gate hermetic regression tests ────────────────────────────────
|
||||
//
|
||||
// Intel capability has `skills: []` (empty) — so `installed` and `surfaced` are
|
||||
// VACUOUSLY TRUE. For intel, `active = configActivation` where configActivation
|
||||
// resolves the `activationKey` ("intel.enabled") via the config. This is a meaningful
|
||||
// tri-state improvement because the old `isIntelEnabled` read config.json directly
|
||||
// (synchronous file read, not wired through `loadConfig`), while the new gate goes
|
||||
// through the full `resolveCapabilityRuntimeState` path.
|
||||
//
|
||||
// FAIL-FIRST PROOF (what would fail against the OLD isIntelEnabled code):
|
||||
// Scenario: intel installed (skills=[]) + config has intel.enabled=true, BUT the
|
||||
// surface has intel NOT surfaced via disabledClusters.
|
||||
//
|
||||
// However: intel has skills:[], so disabledClusters:['intel'] has no effect
|
||||
// (no skills to remove from the surfaced set). Intel is always vacuously surfaced.
|
||||
//
|
||||
// The CORRECT regression for intel's tri-state cutover is:
|
||||
// intel.enabled=true in config → isCapabilityActive=true → command NOT disabled
|
||||
// intel.enabled=false (or absent) → isCapabilityActive=false → command disabled
|
||||
// AND that the gate now goes through the shared resolver (not a direct config read).
|
||||
//
|
||||
// FAIL-FIRST SCENARIO: OLD isIntelEnabled read config.json at the planningDir path
|
||||
// via platformReadSync. NEW isCapabilityActive uses resolveCapabilityRuntimeState
|
||||
// which goes through loadConfig (multi-layer resolution). A test that sets
|
||||
// intel.enabled=true in config then calls intelStatus would:
|
||||
// OLD: isIntelEnabled → reads .planning/config.json → true → NOT disabled.
|
||||
// NEW: isCapabilityActive → resolveCapabilityRuntimeState → configActivation=true → active=true → NOT disabled.
|
||||
// Both return the same, so the regression test focuses on the config-absent/false case
|
||||
// where the gate correctly returns disabled (proving the delegation path works).
|
||||
|
||||
describe('intel tri-state gate hermetic regression (isCapabilityActive cutover)', () => {
|
||||
let tmpConfigDir;
|
||||
let tmpProjectDir;
|
||||
let prevClaudeConfigDir;
|
||||
let prevGsdWorkstream;
|
||||
let prevGsdProject;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpConfigDir = createTempDir('gsd-intel-tristate-cfg-');
|
||||
tmpProjectDir = createTempProject('gsd-intel-tristate-proj-');
|
||||
|
||||
prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
prevGsdWorkstream = process.env.GSD_WORKSTREAM;
|
||||
prevGsdProject = process.env.GSD_PROJECT;
|
||||
// Empty CLAUDE_CONFIG_DIR (no .gsd-surface.json) → defaults to 'full' profile.
|
||||
// Intel has skills:[] so it is vacuously installed+surfaced+enabled.
|
||||
// Active = configActivation = intel.enabled in config.
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
|
||||
if (prevGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = prevGsdWorkstream;
|
||||
if (prevGsdProject === undefined) delete process.env.GSD_PROJECT;
|
||||
else process.env.GSD_PROJECT = prevGsdProject;
|
||||
|
||||
cleanup(tmpConfigDir);
|
||||
cleanup(tmpProjectDir);
|
||||
});
|
||||
|
||||
// NEGATIVE CASE: config has NO intel.enabled (absent → defaults to false via activationKey).
|
||||
// OLD gate: isIntelEnabled reads config.json → no intel key → returns false → disabled.
|
||||
// NEW gate: isCapabilityActive → configActivation=false (intel.enabled default=false) → active=false → disabled.
|
||||
// Both return disabled. The test PROVES the gate is wired through isCapabilityActive and
|
||||
// the command intelStatus returns disabled — regression guard against losing the delegation.
|
||||
test('intelStatus returns disabled when intel.enabled is absent in config (hermetic tristate negative)', () => {
|
||||
// No config.json in planningDir — intel.enabled defaults to false.
|
||||
// OLD isIntelEnabled: reads .planning/config.json → not found → false → disabled.
|
||||
// NEW isCapabilityActive: intel.enabled default=false → configActivation=false → active=false → disabled.
|
||||
const planningDir = path.join(tmpProjectDir, '.planning');
|
||||
const result = intelStatus(planningDir);
|
||||
assert.strictEqual(
|
||||
result.disabled,
|
||||
true,
|
||||
'intelStatus must return disabled when intel.enabled is not set — ' +
|
||||
'both old and new gate must return disabled here; this is the regression guard for the delegation path',
|
||||
);
|
||||
assert.ok(
|
||||
typeof result.message === 'string' && result.message.length > 0,
|
||||
'disabled response must include a non-empty message',
|
||||
);
|
||||
});
|
||||
|
||||
// POSITIVE CONTROL: intel.enabled=true in config → isCapabilityActive=true → NOT disabled.
|
||||
// This is the primary pass case that proves the NEW gate honours config-enabled.
|
||||
// OLD gate (isIntelEnabled) returns true. NEW gate (isCapabilityActive) also returns true.
|
||||
// The test confirms the behaviour is preserved after cutover.
|
||||
test('intelStatus NOT disabled when intel.enabled=true in config (hermetic tristate positive control)', () => {
|
||||
const planningDir = path.join(tmpProjectDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({ intel: { enabled: true } }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const active = isCapabilityActive('intel', tmpProjectDir);
|
||||
assert.strictEqual(
|
||||
active,
|
||||
true,
|
||||
'isCapabilityActive must return true when intel.enabled=true and intel is vacuously installed+surfaced',
|
||||
);
|
||||
|
||||
const result = intelStatus(planningDir);
|
||||
assert.ok(
|
||||
!result.disabled,
|
||||
'intelStatus must NOT return disabled when intel.enabled=true (positive control)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ensureIntelDir ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ensureIntelDir', () => {
|
||||
@@ -143,14 +327,26 @@ describe('ensureIntelDir', () => {
|
||||
describe('intelQuery', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableIntel(planningDir);
|
||||
// Harden: ensure intel is surfaced (tri-state gate requires install+surface+config).
|
||||
// Empty CLAUDE_CONFIG_DIR defaults to 'full' profile → all caps surfaced.
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -233,14 +429,24 @@ describe('intelQuery', () => {
|
||||
describe('intelStatus', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableIntel(planningDir);
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -280,14 +486,24 @@ describe('intelStatus', () => {
|
||||
describe('intelDiff', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableIntel(planningDir);
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -332,14 +548,24 @@ describe('intelDiff', () => {
|
||||
describe('intelSnapshot', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableIntel(planningDir);
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -363,14 +589,24 @@ describe('intelSnapshot', () => {
|
||||
describe('intelValidate', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
enableIntel(planningDir);
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -665,12 +901,24 @@ describe('intelExtractExports', () => {
|
||||
|
||||
describe('gsd-tools intel subcommands', () => {
|
||||
let tmpDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
// Set up surfaced config dir for positive-path CLI tests (subprocess inherits env).
|
||||
// Negative-path tests (disabled) still work because intel.enabled is not set by default.
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
@@ -753,13 +1001,23 @@ describe('gsd-tools intel subcommands', () => {
|
||||
describe('intelApiSurface', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let surfacedConfigDir;
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
surfacedConfigDir = makeSurfacedConfigDir();
|
||||
savedEnv = saveSurfacedEnv();
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CLAUDE_CONFIG_DIR = surfacedConfigDir;
|
||||
delete process.env.GSD_WORKSTREAM;
|
||||
delete process.env.GSD_PROJECT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
savedEnv.restore();
|
||||
cleanup(surfacedConfigDir);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', (
|
||||
assert.strictEqual(resolvedB.activeHooks.length, 0, 'explicit config=false must override schema default=true');
|
||||
});
|
||||
|
||||
it('[bva] discuss:post with synthetic capability, capabilityStatesById enabled=false → hook absent even when config=true', () => {
|
||||
it('[bva] discuss:post with synthetic capability, capabilityStatesById active=false → hook absent even when config=true', () => {
|
||||
const reg = buildSyntheticRegistry({
|
||||
targetPoint: 'discuss:post',
|
||||
when: 'workflow.testcap_on',
|
||||
@@ -247,9 +247,10 @@ describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', (
|
||||
point: 'discuss:post',
|
||||
registry: reg,
|
||||
config: { workflow: { testcap_on: true } },
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
|
||||
// Phase 4: resolver gates on `active` (not `enabled`); pass active:false to suppress.
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false, active: false }]]),
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById enabled=false must suppress hook even when config=true');
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById active=false must suppress hook even when config=true');
|
||||
});
|
||||
|
||||
it('[negative] discuss:post with invalid point name "discuss:past" exits non-zero and lists valid points', () => {
|
||||
@@ -372,7 +373,8 @@ describe('execute:pre — real registry empty-resolution + synthetic resolver me
|
||||
point: 'execute:pre',
|
||||
registry: syntheticReg,
|
||||
config: {},
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
|
||||
// Phase 4: resolver gates on `active` (not `enabled`); pass active:false to suppress.
|
||||
capabilityStatesById: new Map([['future-cap', { enabled: false, active: false }]]),
|
||||
});
|
||||
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById disabled must filter unconditional hook');
|
||||
});
|
||||
|
||||
@@ -315,11 +315,15 @@ describe('verify:post — per-key BVA: each false excludes only that single step
|
||||
// ─── 5. Surface-disable via capabilityStatesById (pure resolver) ──────────────
|
||||
|
||||
describe('verify:post — surface-disable: capabilityStatesById filters hooks', () => {
|
||||
test('[negative] ui disabled via capabilityStatesById→enabled:false excludes ui step; nyquist+security remain', () => {
|
||||
// Phase 4 note: the resolver now gates on `active` (not `enabled`), so
|
||||
// capabilityStatesById entries must carry active:false to suppress a hook.
|
||||
// Real CapabilityStateEntry objects from resolveCapabilityRuntimeState carry both
|
||||
// enabled and active; fixtures here mirror that shape.
|
||||
test('[negative] ui disabled via capabilityStatesById→active:false excludes ui step; nyquist+security remain', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: true }],
|
||||
['security', { enabled: true }],
|
||||
['ui', { enabled: false }],
|
||||
['nyquist', { enabled: true, active: true }],
|
||||
['security', { enabled: true, active: true }],
|
||||
['ui', { enabled: false, active: false }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
@@ -340,9 +344,9 @@ describe('verify:post — surface-disable: capabilityStatesById filters hooks',
|
||||
|
||||
test('[negative] security disabled via capabilityStatesById excludes security step; nyquist+ui remain', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: true }],
|
||||
['security', { enabled: false }],
|
||||
['ui', { enabled: true }],
|
||||
['nyquist', { enabled: true, active: true }],
|
||||
['security', { enabled: false, active: false }],
|
||||
['ui', { enabled: true, active: true }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
@@ -363,9 +367,9 @@ describe('verify:post — surface-disable: capabilityStatesById filters hooks',
|
||||
|
||||
test('[empty-resolution] all three disabled via capabilityStatesById returns empty activeHooks with valid envelope', () => {
|
||||
const capabilityStatesById = new Map([
|
||||
['nyquist', { enabled: false }],
|
||||
['security', { enabled: false }],
|
||||
['ui', { enabled: false }],
|
||||
['nyquist', { enabled: false, active: false }],
|
||||
['security', { enabled: false, active: false }],
|
||||
['ui', { enabled: false, active: false }],
|
||||
]);
|
||||
const resolved = resolveLoopHooks({
|
||||
point: 'verify:post',
|
||||
|
||||
@@ -963,3 +963,78 @@ describe('--active-cap flag (loop render-hooks)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Phase 4 regression: loop-resolver gates on state.active (not state.enabled) ─────
|
||||
//
|
||||
// FAIL-FIRST PROOF (what would fail against the OLD state.enabled check):
|
||||
// Scenario: capability has activationKey set; it is installed + surfaced (enabled=true)
|
||||
// but the activationKey resolves to false (active=false). The capability has a hook
|
||||
// WITHOUT a `when` guard (unconditional). With old state.enabled check:
|
||||
// state.enabled=true → state.enabled !== false → true → hook IS rendered (BUG).
|
||||
// With new state.active check:
|
||||
// state.active=false → state.active === true → false → hook NOT rendered (CORRECT).
|
||||
//
|
||||
// This test uses resolveLoopHooks directly with a synthetic registry and a
|
||||
// capabilityStatesById map that models the above scenario: enabled=true, active=false.
|
||||
// It would FAIL against the OLD `state.enabled !== false` code and PASS against the
|
||||
// NEW `state.active === true` code.
|
||||
|
||||
describe('Phase 4 regression: capabilityStatesById gates on active (not enabled) for config-disabled capability', () => {
|
||||
test('[regression] enabled=true active=false + no `when` guard → hook NOT rendered (state.active gate)', () => {
|
||||
// Fail-first: with OLD `state.enabled !== false`, enabled=true → hook IS rendered (BUG).
|
||||
// With NEW `state.active === true`, active=false → hook NOT rendered (CORRECT).
|
||||
const registry = makeRegistry({
|
||||
point: 'plan:pre',
|
||||
steps: [{ capId: 'test-cap', ref: { skill: 'gsd-test-skill' } }],
|
||||
// No `when` → unconditional hook (no per-hook config gate to fall back on)
|
||||
});
|
||||
|
||||
const capabilityStatesById = new Map([
|
||||
// enabled=true (installed+surfaced), active=false (activationKey resolved to false)
|
||||
// This models a capability like intel/graphify that is surfaced but config-disabled.
|
||||
['test-cap', { enabled: true, active: false }],
|
||||
]);
|
||||
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry,
|
||||
config: {}, // config doesn't matter — capability already resolved active=false
|
||||
capabilityStatesById,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
0,
|
||||
'Hook must NOT be rendered when state.active=false, even if enabled=true and no `when` guard — ' +
|
||||
'OLD state.enabled check would include this hook (BUG: enabled=true passes enabled!==false); ' +
|
||||
'NEW state.active check correctly suppresses it (active=false fails active===true)',
|
||||
);
|
||||
});
|
||||
|
||||
test('[positive control] enabled=true active=true + no `when` guard → hook IS rendered', () => {
|
||||
// Confirms the fixture is sound: same hook, same registry, but active=true → rendered.
|
||||
// This test must PASS against BOTH old and new code (it's the unbroken branch).
|
||||
const registry = makeRegistry({
|
||||
point: 'plan:pre',
|
||||
steps: [{ capId: 'test-cap', ref: { skill: 'gsd-test-skill' } }],
|
||||
});
|
||||
|
||||
const capabilityStatesById = new Map([
|
||||
['test-cap', { enabled: true, active: true }],
|
||||
]);
|
||||
|
||||
const result = resolveLoopHooks({
|
||||
point: 'plan:pre',
|
||||
registry,
|
||||
config: {},
|
||||
capabilityStatesById,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
result.activeHooks.length,
|
||||
1,
|
||||
'Hook MUST be rendered when state.active=true and no `when` guard (positive control)',
|
||||
);
|
||||
assert.strictEqual(result.activeHooks[0].capId, 'test-cap');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user