feat(#1136): consume resolved capability state (#1153)

* feat(#1136): consume resolved capability state

* chore(#1136): add capability state changeset
This commit is contained in:
Tom Boucher
2026-06-12 23:43:07 -04:00
committed by GitHub
parent b431b1fab4
commit 607813f5d0
22 changed files with 513 additions and 141 deletions

View File

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