* feat(#1136): consume resolved capability state * chore(#1136): add capability state changeset
This commit is contained in:
100
src/capability-activation.cts
Normal file
100
src/capability-activation.cts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Capability activation helpers.
|
||||
*
|
||||
* Shared by the Capability State Resolver and Loop Resolver so config-key
|
||||
* activation uses one precedence chain and one prototype-pollution guard.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningRoot } = planningWorkspaceMod;
|
||||
|
||||
function _getNestedConfigValue(
|
||||
config: Record<string, unknown>,
|
||||
dotKey: string,
|
||||
): { found: boolean; value: unknown } {
|
||||
const segments = dotKey.split('.');
|
||||
let current: unknown = config;
|
||||
for (const seg of segments) {
|
||||
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
const cur = current as Record<string, unknown>;
|
||||
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
current = cur[seg];
|
||||
}
|
||||
return { found: true, value: current };
|
||||
}
|
||||
|
||||
const _warnedRawConfigPaths = new Set<string>();
|
||||
|
||||
function _readRawConfigKey(
|
||||
filePath: string,
|
||||
dotKey: string,
|
||||
): { found: boolean; value: unknown } {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
if (!_warnedRawConfigPaths.has(filePath)) {
|
||||
_warnedRawConfigPaths.add(filePath);
|
||||
try {
|
||||
process.stderr.write(
|
||||
`gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`,
|
||||
);
|
||||
} catch { /* stderr might be closed */ }
|
||||
}
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
return _getNestedConfigValue(parsed, dotKey);
|
||||
} catch {
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function _resolveActivationValue(
|
||||
dotKey: string,
|
||||
config: Record<string, unknown>,
|
||||
cwd: string | undefined,
|
||||
registry: Record<string, unknown>,
|
||||
): boolean {
|
||||
const fromConfig = _getNestedConfigValue(config, dotKey);
|
||||
if (fromConfig.found) return Boolean(fromConfig.value);
|
||||
|
||||
if (cwd) {
|
||||
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
|
||||
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
||||
|
||||
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
|
||||
if (fromWs.found) return Boolean(fromWs.value);
|
||||
|
||||
if (wsConfigPath !== rootConfigPath) {
|
||||
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
|
||||
if (fromRoot.found) return Boolean(fromRoot.value);
|
||||
}
|
||||
}
|
||||
|
||||
const schemaEntry = (registry['configSchema'] as Record<string, unknown> | undefined)?.[dotKey];
|
||||
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
|
||||
const def = (schemaEntry as Record<string, unknown>)['default'];
|
||||
if (def !== undefined) return Boolean(def);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export = {
|
||||
_getNestedConfigValue,
|
||||
_readRawConfigKey,
|
||||
_resolveActivationValue,
|
||||
};
|
||||
@@ -3,9 +3,8 @@
|
||||
*
|
||||
* Unified capability-state resolver that composes the three toggle systems
|
||||
* (install profile, runtime surface, config activation) into one per-capability
|
||||
* view. ADDITIVE — install/surface/workflows are untouched; this resolver is
|
||||
* exposed only as the `gsd-tools capability state` diagnostic, not yet consumed by
|
||||
* any workflow (workflow wiring is the phase-6 cutover).
|
||||
* view. The loop resolver consumes this state so workflow dispatch and the
|
||||
* `gsd-tools capability state` diagnostic share the same enablement answer.
|
||||
*
|
||||
* Exports (three things, mirroring loop-resolver):
|
||||
* resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
|
||||
@@ -19,9 +18,9 @@
|
||||
* cmdCapabilityState is the I/O handler.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs circular risk):
|
||||
* - node:path (used by _resolveActivationValue via loop-resolver)
|
||||
* - node:path
|
||||
* - ./core.cjs (output, error)
|
||||
* - ./loop-resolver.cjs (_resolveActivationValue — reuse the export)
|
||||
* - ./capability-activation.cjs (_resolveActivationValue)
|
||||
* - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
|
||||
* - ./surface.cjs (resolveSurface)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
@@ -36,8 +35,8 @@ import core = require('./core.cjs');
|
||||
const { output: coreOutput, error: coreError } = core;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import loopResolverMod = require('./loop-resolver.cjs');
|
||||
const { _resolveActivationValue } = loopResolverMod;
|
||||
import activationMod = require('./capability-activation.cjs');
|
||||
const { _resolveActivationValue } = activationMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoaderMod = require('./config-loader.cjs');
|
||||
@@ -66,6 +65,8 @@ interface HookEntry {
|
||||
*/
|
||||
when: unknown;
|
||||
/** Whether this hook is currently active based on config */
|
||||
configured: boolean;
|
||||
/** Whether this hook participates after capability enablement is applied */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -85,6 +86,8 @@ interface CapabilityStateEntry {
|
||||
* Vacuously true for capabilities with an empty skills array.
|
||||
*/
|
||||
surfaced: boolean;
|
||||
/** True when the capability is both installed and surfaced. */
|
||||
enabled: boolean;
|
||||
/** Resolved hook activation state across steps, gates, and contributions */
|
||||
hooks: HookEntry[];
|
||||
}
|
||||
@@ -108,6 +111,14 @@ interface ResolveCapabilityStateResult {
|
||||
capabilities: CapabilityStateEntry[];
|
||||
}
|
||||
|
||||
interface ResolveCapabilityRuntimeStateResult {
|
||||
runtimeConfigDir: string;
|
||||
warnings: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: CapabilityStateEntry[];
|
||||
}
|
||||
|
||||
// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
|
||||
|
||||
function _isSafePropKey(key: unknown): key is string {
|
||||
@@ -197,6 +208,8 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
|
||||
surfaced = skills.every((s) => surfacedSkills.has(s));
|
||||
}
|
||||
|
||||
const enabled = installed && surfaced;
|
||||
|
||||
// ── hooks ──────────────────────────────────────────────────────────────────
|
||||
// Collect from steps, gates, contributions. Each may have a `when` key.
|
||||
// Activation semantics (mirrors loop-resolver.isActive exactly):
|
||||
@@ -216,19 +229,19 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
|
||||
const point = typeof h['point'] === 'string' ? h['point'] : '';
|
||||
// Carry the raw `when` value through for visibility
|
||||
const whenRaw: unknown = h['when'];
|
||||
let active: boolean;
|
||||
let configured: boolean;
|
||||
if (whenRaw === undefined || whenRaw === null) {
|
||||
// No `when` field → unconditional, always active
|
||||
active = true;
|
||||
configured = true;
|
||||
} else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
|
||||
// Non-empty string `when` → resolve via _resolveActivationValue
|
||||
active = _resolveActivationValue(whenRaw, config, cwd, registry);
|
||||
configured = _resolveActivationValue(whenRaw, config, cwd, registry);
|
||||
} else {
|
||||
// Present-but-empty-string or non-string `when` → malformed, inactive
|
||||
// (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
|
||||
active = false;
|
||||
configured = false;
|
||||
}
|
||||
hooks.push({ point, kind, when: whenRaw, active });
|
||||
hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +253,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, hooks });
|
||||
results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
|
||||
}
|
||||
|
||||
// Deterministic sort by id for stable output across calls
|
||||
@@ -295,12 +308,10 @@ function _resolveCommandsGsdDir(): string {
|
||||
* @param raw Whether to emit raw JSON (core.output raw mode)
|
||||
* @param _options Reserved for future use
|
||||
*/
|
||||
function cmdCapabilityState(
|
||||
function resolveCapabilityRuntimeState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
): void {
|
||||
): ResolveCapabilityRuntimeStateResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
|
||||
@@ -358,7 +369,6 @@ function cmdCapabilityState(
|
||||
// Genuine resolution failure — surface it so the caller is not misled.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`profile-resolution failed: ${msg}`);
|
||||
coreError(`capability state: profile resolution failed: ${msg}`);
|
||||
// Degrade to empty set (not '*') so installed=false is reported accurately.
|
||||
installedSkills = new Set<string>();
|
||||
}
|
||||
@@ -378,7 +388,6 @@ function cmdCapabilityState(
|
||||
// Genuine surface resolution failure — surface it so the caller is not misled.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
warnings.push(`surface-resolution failed: ${msg}`);
|
||||
coreError(`capability state: surface resolution failed: ${msg}`);
|
||||
surfacedSkills = new Set<string>();
|
||||
}
|
||||
|
||||
@@ -400,6 +409,26 @@ function cmdCapabilityState(
|
||||
cwd,
|
||||
});
|
||||
|
||||
return {
|
||||
runtimeConfigDir: resolvedConfigDir,
|
||||
warnings,
|
||||
registry,
|
||||
config,
|
||||
capabilities: result.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
function cmdCapabilityState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
): void {
|
||||
const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
|
||||
for (const warning of result.warnings) {
|
||||
coreError(`capability state: ${warning}`);
|
||||
}
|
||||
|
||||
// Build envelope — include warnings array only when non-empty so the nominal
|
||||
// path keeps the output clean and callers can check `warnings` for degraded state.
|
||||
const envelope: {
|
||||
@@ -407,11 +436,11 @@ function cmdCapabilityState(
|
||||
warnings?: string[];
|
||||
capabilities: CapabilityStateEntry[];
|
||||
} = {
|
||||
runtimeConfigDir: resolvedConfigDir,
|
||||
runtimeConfigDir: result.runtimeConfigDir,
|
||||
capabilities: result.capabilities,
|
||||
};
|
||||
if (warnings.length > 0) {
|
||||
envelope.warnings = warnings;
|
||||
if (result.warnings.length > 0) {
|
||||
envelope.warnings = result.warnings;
|
||||
}
|
||||
|
||||
coreOutput(envelope, raw);
|
||||
@@ -419,6 +448,7 @@ function cmdCapabilityState(
|
||||
|
||||
export = {
|
||||
resolveCapabilityState,
|
||||
resolveCapabilityRuntimeState,
|
||||
cmdCapabilityState,
|
||||
// Exported for tests
|
||||
_resolveCommandsGsdDir,
|
||||
|
||||
@@ -35,7 +35,7 @@ const { detectSubRepos } = coreUtilsModule;
|
||||
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configSchema = require('./config-schema.cjs');
|
||||
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
|
||||
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
|
||||
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs';
|
||||
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -358,7 +358,7 @@ function _applyFederatedOverlay(
|
||||
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
||||
userConfig,
|
||||
});
|
||||
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
|
||||
@@ -492,7 +492,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
// Internal keys loadConfig reads but config-set doesn't expose
|
||||
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
|
||||
// Deprecated keys (still accepted for migration, not in config-set)
|
||||
'depth', 'multiRepo', 'branching_strategy',
|
||||
'depth', 'multiRepo', 'branching_strategy', 'research',
|
||||
]);
|
||||
|
||||
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
|
||||
@@ -504,7 +504,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
||||
const _earlyOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchemaEarly,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
||||
userConfig: parsed,
|
||||
});
|
||||
_preWarningFedValidKeys = _earlyOverlay.validKeys;
|
||||
@@ -631,7 +631,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
|
||||
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
||||
userConfig: parsed,
|
||||
});
|
||||
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
|
||||
@@ -655,8 +655,8 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
|
||||
return loadConfig(cwd, { workstream: null });
|
||||
}
|
||||
// FIX 2: Apply the federated overlay on the no-config path.
|
||||
// With the current registry (all keys central), _applyFederatedOverlay returns
|
||||
// `defaults` UNCHANGED (true no-op, preserves byte-identical output).
|
||||
// Migrated Capability keys are surfaced from the generated registry even
|
||||
// when the project has no config.json, so schema defaults still apply.
|
||||
try {
|
||||
return _applyFederatedOverlay(defaults, {});
|
||||
} catch {
|
||||
|
||||
@@ -21,13 +21,43 @@ import {
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} from './configuration.cjs';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const capabilityRegistry = require('./capability-registry.cjs') as {
|
||||
configSchema?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isCapabilityConfigKey(keyPath: string): boolean {
|
||||
if (typeof keyPath !== 'string') return false;
|
||||
const schema = capabilityRegistry.configSchema;
|
||||
if (!schema || typeof schema !== 'object') return false;
|
||||
return Object.prototype.hasOwnProperty.call(schema, keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
|
||||
* Returns true for keys owned by the central schema adapter rather than a
|
||||
* federated Capability config slice.
|
||||
*/
|
||||
function isValidConfigKey(keyPath: string): boolean {
|
||||
function isCentralConfigKey(keyPath: string): boolean {
|
||||
if (typeof keyPath !== 'string') return false;
|
||||
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
|
||||
if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
|
||||
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
|
||||
}
|
||||
|
||||
export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
|
||||
/**
|
||||
* Returns true if keyPath is a valid central, runtime-state, dynamic, or
|
||||
* federated Capability config key.
|
||||
*/
|
||||
function isValidConfigKey(keyPath: string): boolean {
|
||||
if (isCentralConfigKey(keyPath)) return true;
|
||||
return isCapabilityConfigKey(keyPath);
|
||||
}
|
||||
|
||||
export = {
|
||||
VALID_CONFIG_KEYS,
|
||||
RUNTIME_STATE_KEYS,
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
isCapabilityConfigKey,
|
||||
isCentralConfigKey,
|
||||
isValidConfigKey,
|
||||
};
|
||||
|
||||
@@ -39,6 +39,10 @@ const { output: coreOutput, error: coreError } = core;
|
||||
import configLoaderModule = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderModule;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import capabilityStateModule = require('./capability-state.cjs');
|
||||
const { resolveCapabilityRuntimeState } = capabilityStateModule;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspaceMod = require('./planning-workspace.cjs');
|
||||
const { planningDir, planningRoot } = planningWorkspaceMod;
|
||||
@@ -263,6 +267,8 @@ 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 }>;
|
||||
}
|
||||
|
||||
interface ResolveLoopHooksResult {
|
||||
@@ -286,7 +292,7 @@ interface ResolveLoopHooksResult {
|
||||
* resolved against `config`; active iff truthy. Inactive hooks are filtered out.
|
||||
*/
|
||||
function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult {
|
||||
const { point, registry, config, cwd } = input;
|
||||
const { point, registry, config, cwd, capabilityStatesById } = input;
|
||||
|
||||
// Validate point
|
||||
const canonicalPoints = _getCanonicalPoints(registry);
|
||||
@@ -322,6 +328,15 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
return _resolveActivationValue(when, config, cwd, registry);
|
||||
}
|
||||
|
||||
function isCapabilityEnabled(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;
|
||||
}
|
||||
|
||||
// Helper: safe string array
|
||||
function toStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
@@ -342,8 +357,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : [];
|
||||
for (const hook of steps) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
|
||||
? (hook['ref'] as HookRef)
|
||||
: undefined;
|
||||
@@ -367,8 +383,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
const contributions: RawHook[] = Array.isArray(contributionsRaw) ? (contributionsRaw as RawHook[]) : [];
|
||||
for (const hook of contributions) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
|
||||
const fragment = toFragment(hook['fragment']);
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
@@ -390,8 +407,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
|
||||
const gates: RawHook[] = Array.isArray(gatesRaw) ? (gatesRaw as RawHook[]) : [];
|
||||
for (const hook of gates) {
|
||||
if (!hook || typeof hook !== 'object') continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
|
||||
if (!isCapabilityEnabled(capId)) continue;
|
||||
if (!isActive(hook)) continue;
|
||||
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
|
||||
const check = hook['check'] !== undefined ? hook['check'] : undefined;
|
||||
const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
|
||||
@@ -522,24 +540,32 @@ function cmdLoopRenderHooks(
|
||||
cwd: string,
|
||||
point: string,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
options: Record<string, unknown> = {},
|
||||
): void {
|
||||
if (!point) {
|
||||
coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Load registry at call time (generated file, not at module load time)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
// FIX 1: Pass loadConfig result as `config` (level 1 of precedence);
|
||||
// raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue
|
||||
// via the `cwd` argument passed to resolveLoopHooks.
|
||||
const config = loadConfig(cwd);
|
||||
const runtimeConfigDir = typeof options['configDir'] === 'string'
|
||||
? options['configDir']
|
||||
: undefined;
|
||||
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir) as {
|
||||
warnings?: string[];
|
||||
registry: Record<string, unknown>;
|
||||
config: Record<string, unknown>;
|
||||
capabilities: Array<{ id: string; enabled?: boolean }>;
|
||||
};
|
||||
const registry = state.registry;
|
||||
const config = state.config || loadConfig(cwd);
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean }>();
|
||||
for (const cap of state.capabilities || []) {
|
||||
capabilityStatesById.set(cap.id, cap);
|
||||
}
|
||||
|
||||
let resolved: ResolveLoopHooksResult;
|
||||
try {
|
||||
resolved = resolveLoopHooks({ point, registry, config, cwd });
|
||||
resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
|
||||
} catch (err: unknown) {
|
||||
const msg = (err instanceof Error) ? err.message : String(err);
|
||||
coreError(msg);
|
||||
@@ -547,11 +573,19 @@ function cmdLoopRenderHooks(
|
||||
}
|
||||
|
||||
const rendered = renderLoopHooks(resolved);
|
||||
const envelope = {
|
||||
const envelope: {
|
||||
point: string;
|
||||
activeHooks: ActiveHook[];
|
||||
rendered: string;
|
||||
warnings?: string[];
|
||||
} = {
|
||||
point: resolved.point,
|
||||
activeHooks: resolved.activeHooks,
|
||||
rendered,
|
||||
};
|
||||
if (state.warnings && state.warnings.length > 0) {
|
||||
envelope.warnings = state.warnings;
|
||||
}
|
||||
|
||||
coreOutput(envelope, raw);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user