refactor(#1307): gate intel on isCapabilityActive + loop-resolver honors capability active (#1315)

* 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:
Tom Boucher
2026-06-16 00:12:54 -04:00
committed by GitHub
parent e50ead7ad2
commit 14e0709eed
10 changed files with 431 additions and 65 deletions

View File

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

View File

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