* 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:
@@ -5,7 +5,8 @@
|
||||
* Intel files live in .planning/intel/ and store structured data about
|
||||
* the project's files, APIs, dependencies, architecture, and tech stack.
|
||||
*
|
||||
* All public functions gate on intel.enabled config (no-op when false).
|
||||
* All public functions gate on isCapabilityActive('intel', cwd) — the shared
|
||||
* tri-state resolver (installed + surfaced + intel.enabled config key).
|
||||
*
|
||||
* ADR-457 build-at-publish: the hand-written bin/lib/intel.cjs collapsed
|
||||
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
||||
@@ -17,6 +18,10 @@ import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateMod = require('./capability-state.cjs');
|
||||
const { isCapabilityActive } = capabilityStateMod;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const INTEL_DIR = '.planning/intel';
|
||||
@@ -41,29 +46,20 @@ function ensureIntelDir(planningDir: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether intel is enabled in the project config.
|
||||
* Reads config.json directly via fs. Returns false by default
|
||||
* (when no config, no intel key, or on error).
|
||||
* Check whether intel is active (installed, surfaced, and config-enabled) for the project at cwd.
|
||||
* Delegates to the shared tri-state capability resolver (isCapabilityActive) which honours the
|
||||
* install profile, runtime surface, and activationKey (intel.enabled config gate).
|
||||
*
|
||||
* NOTE: planningDir is the legacy entry-point; cwd is derived as path.dirname(planningDir).
|
||||
* Callers that have cwd directly may call isCapabilityActive('intel', cwd) themselves.
|
||||
*
|
||||
* INVARIANT: planningDir is always `<cwd>/.planning` (i.e. path.join(cwd, '.planning')).
|
||||
* The intel-command-router always constructs planningDir as path.join(cwd, '.planning'),
|
||||
* so path.dirname(planningDir) === cwd is guaranteed. If a workstream-aware planningDir
|
||||
* were ever passed here, the dirname would be wrong — but no caller does that.
|
||||
*/
|
||||
function isIntelEnabled(planningDir: string): boolean {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
const raw = platformReadSync(configPath);
|
||||
if (raw === null) return false;
|
||||
const config: unknown = JSON.parse(raw);
|
||||
if (
|
||||
config &&
|
||||
typeof config === 'object' &&
|
||||
'intel' in config &&
|
||||
config.intel &&
|
||||
typeof config.intel === 'object' &&
|
||||
'enabled' in config.intel &&
|
||||
(config.intel as Record<string, unknown>).enabled === true
|
||||
) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function isIntelCapabilityActive(planningDir: string): boolean {
|
||||
return isCapabilityActive('intel', path.dirname(planningDir));
|
||||
}
|
||||
|
||||
interface DisabledResponse {
|
||||
@@ -188,7 +184,7 @@ interface IntelQueryResult {
|
||||
* Searches across all JSON intel files in INTEL_FILES (keys and values), including arch-decisions.json (parsed as JSON, not as text).
|
||||
*/
|
||||
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
|
||||
let total = 0;
|
||||
@@ -225,7 +221,7 @@ interface IntelStatusResult {
|
||||
* A file is considered stale if its updated_at is older than 24 hours.
|
||||
*/
|
||||
function intelStatus(planningDir: string): IntelStatusResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const now = Date.now();
|
||||
@@ -273,7 +269,7 @@ interface IntelDiffResult {
|
||||
* Show changes since the last full refresh by comparing file hashes.
|
||||
*/
|
||||
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
|
||||
const snapshot = safeReadJson(snapshotPath);
|
||||
@@ -309,7 +305,7 @@ function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true }
|
||||
* The actual update is performed by the intel-updater agent (PLAN-02).
|
||||
*/
|
||||
function intelUpdate(planningDir: string): { action: string; message: string } | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
return {
|
||||
action: 'spawn_agent',
|
||||
@@ -359,7 +355,7 @@ function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
|
||||
* Writes .last-refresh.json with accurate timestamps and hashes.
|
||||
*/
|
||||
function intelSnapshot(planningDir: string): SaveRefreshResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
return saveRefreshSnapshot(planningDir);
|
||||
}
|
||||
|
||||
@@ -373,7 +369,7 @@ interface IntelValidateResult {
|
||||
* Validate all intel files for correctness and freshness.
|
||||
*/
|
||||
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -470,7 +466,7 @@ interface IntelApiSurfaceResult {
|
||||
* mistake silence for "nothing exists".
|
||||
*/
|
||||
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
|
||||
if (!isIntelEnabled(planningDir)) return disabledResponse();
|
||||
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
|
||||
|
||||
const intelPath = ensureIntelDir(planningDir);
|
||||
const apiMapPath = path.join(intelPath, INTEL_FILES.apis);
|
||||
@@ -535,7 +531,7 @@ interface IntelPatchMetaResult {
|
||||
* Patch _meta.updated_at in a JSON intel file to the current timestamp.
|
||||
* Reads the file, updates _meta.updated_at, increments version, writes back.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
|
||||
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary file paths
|
||||
* for use by agents patching individual files outside the intel store.
|
||||
*/
|
||||
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
|
||||
@@ -576,7 +572,7 @@ interface IntelExtractExportsResult {
|
||||
/**
|
||||
* Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
|
||||
*
|
||||
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
|
||||
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary source files
|
||||
* for use by agents building intel data from project files.
|
||||
*/
|
||||
function intelExtractExports(filePath: string): IntelExtractExportsResult {
|
||||
@@ -711,7 +707,7 @@ export = {
|
||||
|
||||
// Utilities
|
||||
ensureIntelDir,
|
||||
isIntelEnabled,
|
||||
isIntelCapabilityActive,
|
||||
|
||||
// Constants
|
||||
INTEL_FILES,
|
||||
|
||||
@@ -269,8 +269,16 @@ interface ResolveLoopHooksInput {
|
||||
config: Record<string, unknown>;
|
||||
/** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */
|
||||
cwd?: string;
|
||||
/** Optional capability-state map; when present, disabled capabilities do not render hooks. */
|
||||
capabilityStatesById?: Map<string, { enabled?: boolean }> | Record<string, { enabled?: boolean }>;
|
||||
/**
|
||||
* Optional capability-state map; when present, inactive capabilities do not render hooks.
|
||||
* Each entry carries both `enabled` (installed+surfaced) and `active` (enabled+configActivation).
|
||||
* The resolver gates on `active` so that the config activation key (activationKey) is
|
||||
* honoured even when no per-hook `when` guard is present (Phase 4 tri-state alignment).
|
||||
*
|
||||
* `active` is REQUIRED (not optional) so the gate is fail-closed: a missing or undefined
|
||||
* `active` field is a compile error, never silently treated as truthy.
|
||||
*/
|
||||
capabilityStatesById?: Map<string, { enabled?: boolean; active: boolean }> | Record<string, { enabled?: boolean; active: boolean }>;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksResult {
|
||||
@@ -330,13 +338,18 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
return _resolveActivationValue(when, config, cwd, registry);
|
||||
}
|
||||
|
||||
function isCapabilityEnabled(capId: string): boolean {
|
||||
function isCapabilityActive(capId: string): boolean {
|
||||
if (!capabilityStatesById) return true;
|
||||
const state = capabilityStatesById instanceof Map
|
||||
? capabilityStatesById.get(capId)
|
||||
: capabilityStatesById[capId];
|
||||
if (!state) return false;
|
||||
return state.enabled !== false;
|
||||
// Fail-closed gate: only render the hook when active is explicitly true.
|
||||
// A capability can be installed and surfaced (enabled=true) but config-disabled
|
||||
// (active=false); in that case the hook must not render.
|
||||
// Phase 4 tri-state alignment: `active` is now required (not optional), so
|
||||
// `=== true` is the correct fail-closed check (not `!== false`).
|
||||
return state.active === true;
|
||||
}
|
||||
|
||||
// Helper: safe string array
|
||||
@@ -401,7 +414,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of steps) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
|
||||
? (hook['ref'] as HookRef)
|
||||
@@ -427,7 +440,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of contributions) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
|
||||
const fragment = toFragment(hook['fragment']);
|
||||
@@ -453,7 +466,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
for (const hook of gates) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isCapabilityActive(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const check = hook['check'] !== undefined ? hook['check'] : undefined;
|
||||
@@ -613,11 +626,11 @@ function cmdLoopRenderHooks(
|
||||
warnings?: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean }>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
|
||||
};
|
||||
const registry = state.registry;
|
||||
const config = state.config || loadConfig(cwd);
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean }>();
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
|
||||
for (const cap of state.capabilities || []) {
|
||||
capabilityStatesById.set(cap.id, cap);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user