Add a read-side query composing the three toggle systems into one
per-capability view. resolveCapabilityState({registry, installedSkills,
surfacedSkills, config, cwd}) reports installed (skills ⊆ resolved install
profile), surfaced (skills ⊆ resolved surface), and per-hook active (no when →
active; non-empty-string when → resolved via _resolveActivationValue; empty/
non-string → inactive), with no forced composite verdict. cmdCapabilityState
does the I/O (resolveProfile + resolveSurface + loadConfig), resolves the
runtime config dir via the canonical getGlobalConfigDir (--config-dir override),
and surfaces resolution failures as warnings rather than a false installed='*'.
Routed as `gsd-tools capability state`.
Additive: install/surface/workflows untouched; consumed by nothing.
Closes #945
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -134,6 +134,7 @@ build/
|
||||
/gsd-core/bin/lib/config-loader.cjs
|
||||
/gsd-core/bin/lib/model-resolver.cjs
|
||||
/gsd-core/bin/lib/loop-resolver.cjs
|
||||
/gsd-core/bin/lib/capability-state.cjs
|
||||
/gsd-core/bin/lib/federated-config.cjs
|
||||
/gsd-core/bin/lib/phase-locator.cjs
|
||||
/gsd-core/bin/lib/roadmap-parser.cjs
|
||||
|
||||
@@ -157,6 +157,9 @@ ADR-857 phase 3b seam that merges capability-declared config slices into the `lo
|
||||
### Loop Extension Point
|
||||
A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks <point>` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. Wiring a workflow to call this query is the ADR-857 phase-6 cutover (out of scope here).
|
||||
|
||||
### Capability State Resolver
|
||||
ADR-857 phase 4b unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view. ADDITIVE — install/surface/workflows untouched; currently consumed by nothing (phase-6 wiring out of scope). Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O entry point). CLI surface: `gsd-tools capability state [--config-dir <path>]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (no `when` → active=true; `when` resolved via `_resolveActivationValue` from loop-resolver). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. `runtimeConfigDir` auto-detection falls back to `getGlobalConfigDir` based on env-var presence (CODEX_HOME → codex, CURSOR_CONFIG_DIR → cursor, GEMINI_CONFIG_DIR → gemini, CLAUDE_CONFIG_DIR → claude, default → claude/`~/.claude`).
|
||||
|
||||
### Runtime Capability [Planned]
|
||||
A `role: runtime` variant of a Capability (a Capability carries `role: feature | runtime`) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate.
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
|
||||
| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` |
|
||||
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) |
|
||||
| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; filters `byLoopPoint` by config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks <point>` |
|
||||
| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b; composes install profile, runtime surface, and config activation into one per-capability view; pure `resolveCapabilityState` + I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir <path>]` |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -271,6 +271,7 @@
|
||||
"artifacts.cjs",
|
||||
"audit.cjs",
|
||||
"capability-registry.cjs",
|
||||
"capability-state.cjs",
|
||||
"check-command-router.cjs",
|
||||
"cjs-command-router-adapter.cjs",
|
||||
"cli-exit.cjs",
|
||||
|
||||
@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (101 shipped)
|
||||
## CLI Modules (102 shipped)
|
||||
|
||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
|
||||
@@ -382,6 +382,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
|
||||
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
|
||||
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities/<id>/capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) |
|
||||
| `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b) — composes install profile, runtime surface, and config activation into one per-capability view; exports pure `resolveCapabilityState` + I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir <path>]` emitting `{ runtimeConfigDir, capabilities[] }` |
|
||||
| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` |
|
||||
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |
|
||||
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
|
||||
|
||||
@@ -75,6 +75,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/model-profiles.cjs',
|
||||
'gsd-core/bin/lib/model-resolver.cjs',
|
||||
'gsd-core/bin/lib/loop-resolver.cjs',
|
||||
'gsd-core/bin/lib/capability-state.cjs',
|
||||
'gsd-core/bin/lib/federated-config.cjs',
|
||||
'gsd-core/bin/lib/installer-migrations/002-codex-legacy-hooks-json.cjs',
|
||||
'gsd-core/bin/lib/installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs',
|
||||
|
||||
@@ -169,6 +169,11 @@
|
||||
* Valid points: discuss:pre/post, plan:pre/post,
|
||||
* execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post
|
||||
*
|
||||
* Capability State (ADR-857 phase 4b):
|
||||
* capability state [--config-dir <path>] Resolve per-capability install/surface/hook-activation state
|
||||
* Returns JSON envelope { runtimeConfigDir, capabilities[] }
|
||||
* --config-dir: runtime config dir (default: auto-detect current runtime)
|
||||
*
|
||||
* GSD-2 Migration:
|
||||
* from-gsd2 [--path <dir>] [--force] [--dry-run]
|
||||
* Import a GSD-2 (.gsd/) project back to GSD v1 (.planning/) format
|
||||
@@ -208,6 +213,7 @@ const { routeVerificationCommand } = require('./lib/verification-command-router.
|
||||
const verification = require('./lib/verification.cjs');
|
||||
const { routeInitCommand } = require('./lib/init-command-router.cjs');
|
||||
const loopResolver = require('./lib/loop-resolver.cjs');
|
||||
const capabilityState = require('./lib/capability-state.cjs');
|
||||
const { routePhaseCommand } = require('./lib/phase-command-router.cjs');
|
||||
const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
|
||||
const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
|
||||
@@ -386,7 +392,7 @@ async function main() {
|
||||
'current-timestamp, detect-custom-files, docs-init, effort, extract-messages, find-phase, ' +
|
||||
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
|
||||
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
|
||||
'classify-confidence, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
|
||||
'capability, classify-confidence, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
|
||||
'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
|
||||
'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
|
||||
'Global flags:\n' +
|
||||
@@ -427,6 +433,11 @@ async function main() {
|
||||
// Multi-repo guard: resolve project root for commands that read/write .planning/.
|
||||
// Skip for pure-utility commands that don't touch .planning/ to avoid unnecessary
|
||||
// filesystem traversal on every invocation.
|
||||
// 'loop' and 'capability' are intentionally NOT in SKIP_ROOT_RESOLUTION.
|
||||
// Both are registry/config queries that resolve activation via
|
||||
// .planning/config.json; they need the project root (cwd) for correct
|
||||
// `when` key resolution. If one is ever moved to SKIP_ROOT_RESOLUTION,
|
||||
// move the other at the same time (keep them consistent).
|
||||
const SKIP_ROOT_RESOLUTION = new Set([
|
||||
'generate-slug', 'current-timestamp', 'verify-path-exists',
|
||||
'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
|
||||
@@ -1139,6 +1150,36 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
case 'capability': {
|
||||
// capability state [--config-dir <path>]
|
||||
// Root resolution: 'capability' is NOT in SKIP_ROOT_RESOLUTION for the
|
||||
// same reason 'loop' is not: both are registry/config queries that need
|
||||
// the project root (cwd) for .planning/config.json activation resolution.
|
||||
// If 'loop' were ever added to SKIP_ROOT_RESOLUTION, 'capability' should
|
||||
// be added at the same time to keep them consistent.
|
||||
const capSubcommand = args[1];
|
||||
if (capSubcommand === 'state') {
|
||||
const configDirIdx = args.indexOf('--config-dir');
|
||||
let configDir = null;
|
||||
if (configDirIdx !== -1) {
|
||||
const configDirVal = args[configDirIdx + 1];
|
||||
// Validate that --config-dir has a following non-flag value.
|
||||
if (!configDirVal || configDirVal.startsWith('--')) {
|
||||
error('Missing value for --config-dir', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
|
||||
}
|
||||
configDir = configDirVal;
|
||||
}
|
||||
const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
|
||||
capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, {});
|
||||
} else {
|
||||
error(
|
||||
`Unknown capability subcommand: ${capSubcommand}. Available: state`,
|
||||
core.ERROR_REASON ? core.ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase-plan-index': {
|
||||
phase.cmdPhasePlanIndex(cwd, args[1], raw);
|
||||
break;
|
||||
|
||||
418
src/capability-state.cts
Normal file
418
src/capability-state.cts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Capability State Resolver — ADR-857 phase 4b
|
||||
*
|
||||
* 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
|
||||
* consumed by nothing yet (phase-6 wiring is out of scope).
|
||||
*
|
||||
* Exports (three things, mirroring loop-resolver):
|
||||
* resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
|
||||
* → { capabilities: CapabilityStateEntry[] }
|
||||
* cmdCapabilityState(cwd, runtimeConfigDir, raw, options) — I/O entry point
|
||||
*
|
||||
* resolveCapabilityState is DETERMINISTIC given (registry, installedSkills,
|
||||
* surfacedSkills, config) and — when `cwd` is provided — the project config
|
||||
* files at `cwd` (.planning/config.json etc). Pass `cwd: undefined` for a
|
||||
* pure, config-only resolution with no filesystem I/O.
|
||||
* cmdCapabilityState is the I/O handler.
|
||||
*
|
||||
* Dependencies (leaf modules only — no core.cjs circular risk):
|
||||
* - node:path (used by _resolveActivationValue via loop-resolver)
|
||||
* - ./core.cjs (output, error)
|
||||
* - ./loop-resolver.cjs (_resolveActivationValue — reuse the export)
|
||||
* - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
|
||||
* - ./surface.cjs (resolveSurface)
|
||||
* - ./config-loader.cjs (loadConfig)
|
||||
* - ./runtime-homes.cjs (getGlobalConfigDir — for runtimeConfigDir auto-detection)
|
||||
* - capability-registry.cjs (loaded at call time)
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
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;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import configLoaderMod = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfilesMod = require('./install-profiles.cjs');
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile } = installProfilesMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import surfaceMod = require('./surface.cjs');
|
||||
const { resolveSurface } = surfaceMod;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface HookEntry {
|
||||
/** Loop point this hook fires at */
|
||||
point: string;
|
||||
/** Which hook kind */
|
||||
kind: 'step' | 'gate' | 'contribution';
|
||||
/**
|
||||
* The raw `when` value from the registry entry. Carried through for
|
||||
* visibility (diagnostic aid). undefined = no `when` field present
|
||||
* (unconditional hook). empty-string or non-string = present but
|
||||
* malformed → inactive (mirrors loop-resolver semantics).
|
||||
*/
|
||||
when: unknown;
|
||||
/** Whether this hook is currently active based on config */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface CapabilityStateEntry {
|
||||
id: string;
|
||||
tier: string;
|
||||
/** Skill stems this capability owns */
|
||||
skills: string[];
|
||||
/**
|
||||
* True if every skill owned by this capability is in the installed set.
|
||||
* Vacuously true for capabilities with an empty skills array.
|
||||
* True when installedSkills is the '*' sentinel (full install).
|
||||
*/
|
||||
installed: boolean;
|
||||
/**
|
||||
* True if every skill owned by this capability is in the surfaced set.
|
||||
* Vacuously true for capabilities with an empty skills array.
|
||||
*/
|
||||
surfaced: boolean;
|
||||
/** Resolved hook activation state across steps, gates, and contributions */
|
||||
hooks: HookEntry[];
|
||||
}
|
||||
|
||||
interface ResolveCapabilityStateInput {
|
||||
/** The registry object (typically from capability-registry.cjs) */
|
||||
registry: Record<string, unknown>;
|
||||
/**
|
||||
* Set of installed skill stems, or '*' for full/unrestricted install.
|
||||
*/
|
||||
installedSkills: Set<string> | '*';
|
||||
/** Set of surfaced skill stems for the current runtime config dir */
|
||||
surfacedSkills: Set<string>;
|
||||
/** loadConfig result for config-key activation resolution */
|
||||
config: Record<string, unknown>;
|
||||
/** Optional cwd — enables raw config.json fallback read (mirrors loop-resolver) */
|
||||
cwd?: string | undefined;
|
||||
}
|
||||
|
||||
interface ResolveCapabilityStateResult {
|
||||
capabilities: CapabilityStateEntry[];
|
||||
}
|
||||
|
||||
// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
|
||||
|
||||
function _isSafePropKey(key: unknown): key is string {
|
||||
// Inline literal guards — CodeQL barrier pattern
|
||||
if (typeof key !== 'string') return false;
|
||||
if (key === '__proto__') return false;
|
||||
if (key === 'constructor') return false;
|
||||
if (key === 'prototype') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Pure resolver ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deterministic resolver: for each capability in the registry, produce the
|
||||
* three-dimension state view:
|
||||
* 1. installed — does the install profile cover this capability?
|
||||
* 2. surfaced — does the runtime surface enable this capability?
|
||||
* 3. hooks — per-hook activation derived from config `when` keys.
|
||||
*
|
||||
* Determinism contract: given the same (registry, installedSkills,
|
||||
* surfacedSkills, config) and — when `cwd` is set — the same project config
|
||||
* files at `cwd`, the output is identical across calls. Pass `cwd: undefined`
|
||||
* for a pure, config-only resolution with no filesystem I/O.
|
||||
*
|
||||
* Never throws for malformed registry/hook entries — skips/defaults defensively.
|
||||
* An empty or missing capabilities object → { capabilities: [] }.
|
||||
*
|
||||
* @param input.registry The capability-registry.cjs module export.
|
||||
* @param input.installedSkills Set<string> | '*' — from resolveProfile().skills.
|
||||
* @param input.surfacedSkills Set<string> — from resolveSurface().skills.
|
||||
* @param input.config Record from loadConfig(cwd).
|
||||
* @param input.cwd Optional; when provided, enables raw .planning/config.json
|
||||
* fallback reads (levels 2+3 of _resolveActivationValue
|
||||
* precedence). Omit for a pure in-memory resolution.
|
||||
*/
|
||||
function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapabilityStateResult {
|
||||
const { registry, installedSkills, surfacedSkills, config, cwd } = input;
|
||||
|
||||
// Guard: registry missing capabilities
|
||||
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
|
||||
return { capabilities: [] };
|
||||
}
|
||||
const capabilitiesRaw = registry['capabilities'];
|
||||
if (!capabilitiesRaw || typeof capabilitiesRaw !== 'object' || Array.isArray(capabilitiesRaw)) {
|
||||
return { capabilities: [] };
|
||||
}
|
||||
const capabilitiesMap = capabilitiesRaw as Record<string, unknown>;
|
||||
|
||||
const results: CapabilityStateEntry[] = [];
|
||||
|
||||
for (const capId of Object.keys(capabilitiesMap)) {
|
||||
// Prototype-pollution guard on capability id
|
||||
if (!_isSafePropKey(capId)) continue;
|
||||
|
||||
const cap = capabilitiesMap[capId];
|
||||
if (!cap || typeof cap !== 'object' || Array.isArray(cap)) continue;
|
||||
const capObj = cap as Record<string, unknown>;
|
||||
|
||||
// Extract tier
|
||||
const tier = typeof capObj['tier'] === 'string' ? capObj['tier'] : 'unknown';
|
||||
|
||||
// Extract skills array
|
||||
const skillsRaw = capObj['skills'];
|
||||
const skills: string[] = Array.isArray(skillsRaw)
|
||||
? skillsRaw.filter((s): s is string => typeof s === 'string')
|
||||
: [];
|
||||
|
||||
// ── installed ──────────────────────────────────────────────────────────────
|
||||
// Empty-skills cap → vacuously installed (no skills to be absent).
|
||||
// installedSkills === '*' → installed = true for every cap.
|
||||
let installed: boolean;
|
||||
if (installedSkills === '*') {
|
||||
installed = true;
|
||||
} else if (skills.length === 0) {
|
||||
installed = true; // vacuous: no skills required
|
||||
} else {
|
||||
installed = skills.every((s) => installedSkills.has(s));
|
||||
}
|
||||
|
||||
// ── surfaced ───────────────────────────────────────────────────────────────
|
||||
// Empty-skills cap → vacuously surfaced.
|
||||
let surfaced: boolean;
|
||||
if (skills.length === 0) {
|
||||
surfaced = true; // vacuous
|
||||
} else {
|
||||
surfaced = skills.every((s) => surfacedSkills.has(s));
|
||||
}
|
||||
|
||||
// ── hooks ──────────────────────────────────────────────────────────────────
|
||||
// Collect from steps, gates, contributions. Each may have a `when` key.
|
||||
// Activation semantics (mirrors loop-resolver.isActive exactly):
|
||||
// - No `when` field present (undefined/null) → unconditional, active=true
|
||||
// - Non-empty string `when` → resolve via _resolveActivationValue
|
||||
// - Present-but-empty-string or non-string `when` → malformed, active=false
|
||||
// The original `when` value is carried through to the output for visibility.
|
||||
const hooks: HookEntry[] = [];
|
||||
|
||||
function processHooks(
|
||||
arr: unknown[],
|
||||
kind: 'step' | 'gate' | 'contribution',
|
||||
): void {
|
||||
for (const hookRaw of arr) {
|
||||
if (!hookRaw || typeof hookRaw !== 'object' || Array.isArray(hookRaw)) continue;
|
||||
const h = hookRaw as Record<string, unknown>;
|
||||
const point = typeof h['point'] === 'string' ? h['point'] : '';
|
||||
// Carry the raw `when` value through for visibility
|
||||
const whenRaw: unknown = h['when'];
|
||||
let active: boolean;
|
||||
if (whenRaw === undefined || whenRaw === null) {
|
||||
// No `when` field → unconditional, always active
|
||||
active = true;
|
||||
} else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
|
||||
// Non-empty string `when` → resolve via _resolveActivationValue
|
||||
active = _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;
|
||||
}
|
||||
hooks.push({ point, kind, when: whenRaw, active });
|
||||
}
|
||||
}
|
||||
|
||||
const stepsRaw = capObj['steps'];
|
||||
const gatesRaw = capObj['gates'];
|
||||
const contributionsRaw = capObj['contributions'];
|
||||
|
||||
processHooks(Array.isArray(stepsRaw) ? stepsRaw : [], 'step');
|
||||
processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
|
||||
processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
|
||||
|
||||
results.push({ id: capId, tier, skills, installed, surfaced, hooks });
|
||||
}
|
||||
|
||||
// Deterministic sort by id for stable output across calls
|
||||
results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
||||
|
||||
return { capabilities: results };
|
||||
}
|
||||
|
||||
// ─── I/O command handler ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive the commands/gsd path from __dirname (which resolves to
|
||||
* gsd-core/bin/lib/ at runtime). The source tree is:
|
||||
* <repo>/gsd-core/bin/lib/capability-state.cjs
|
||||
* <repo>/commands/gsd/*.md
|
||||
* So we walk up three levels: lib/ → bin/ → gsd-core/ → <repo>/, then
|
||||
* into commands/gsd/.
|
||||
*/
|
||||
function _resolveCommandsGsdDir(): string {
|
||||
// __dirname = gsd-core/bin/lib/
|
||||
const repoRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
return path.join(repoRoot, 'commands', 'gsd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Command entry point: resolve install profile, surface, and config; compute
|
||||
* capability state; emit the envelope via core.output.
|
||||
*
|
||||
* Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
|
||||
*
|
||||
* runtimeConfigDir resolution (when not provided or empty):
|
||||
* Uses the canonical getGlobalConfigDir from runtime-homes.cjs to detect the
|
||||
* active runtime's config dir — the same resolver used by install.js. This
|
||||
* correctly handles all supported runtimes (claude, codex, cursor, gemini,
|
||||
* opencode, grok, etc.) and their env-var overrides. Defaults to claude
|
||||
* (falls back to ~/.claude) if the resolver throws.
|
||||
*
|
||||
* Failure surfacing: genuine resolution failures (manifest/profile/surface
|
||||
* errors) are reported in the `warnings` array in the envelope. The output
|
||||
* remains useful — degraded to the best available state — but the caller can
|
||||
* detect that the state is not fully resolved.
|
||||
*
|
||||
* Legitimate "no marker → default full profile" is NOT a warning.
|
||||
* A thrown error during profile/surface resolution IS a warning.
|
||||
*
|
||||
* @param cwd Project root directory
|
||||
* @param runtimeConfigDir Runtime config directory (e.g. ~/.claude). May be
|
||||
* empty/undefined — falls back to auto-detection.
|
||||
* Providing a value without a next token (e.g. the flag
|
||||
* is last in argv with no following value) should be
|
||||
* caught by the caller before invoking this function.
|
||||
* @param raw Whether to emit raw JSON (core.output raw mode)
|
||||
* @param _options Reserved for future use
|
||||
*/
|
||||
function cmdCapabilityState(
|
||||
cwd: string,
|
||||
runtimeConfigDir: string | undefined | null,
|
||||
raw: boolean,
|
||||
_options: Record<string, unknown> = {},
|
||||
): void {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
|
||||
// When not provided, getGlobalConfigDir(runtime) is called with 'claude'
|
||||
// as the default runtime — the same fallback as install.js. The canonical
|
||||
// resolver handles all env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME,
|
||||
// CURSOR_CONFIG_DIR, GROK_AGENTS_HOME, etc.) correctly and without
|
||||
// fabricating env vars that don't exist upstream.
|
||||
let resolvedConfigDir: string = runtimeConfigDir || '';
|
||||
if (!resolvedConfigDir) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const runtimeHomes = require('./runtime-homes.cjs') as {
|
||||
getGlobalConfigDir: (runtime: string) => string;
|
||||
};
|
||||
// Delegate runtime detection entirely to getGlobalConfigDir: calling it
|
||||
// with 'claude' causes it to check CLAUDE_CONFIG_DIR first, falling back
|
||||
// to ~/.claude. The canonical resolver already encodes the correct env-var
|
||||
// precedence for each runtime — we do not re-implement that logic here.
|
||||
// For non-claude runtimes, the caller should pass --config-dir explicitly
|
||||
// (or set the runtime-specific env var, which getGlobalConfigDir honors).
|
||||
resolvedConfigDir = runtimeHomes.getGlobalConfigDir('claude');
|
||||
} catch {
|
||||
// Defensive fallback: use ~/.claude if the canonical resolver throws.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const os = require('node:os') as typeof import('node:os');
|
||||
resolvedConfigDir = path.join(os.homedir(), '.claude');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resolve installed skills (from install profile) ──────────────────────────
|
||||
// Distinguish "no profile marker → default full" (legitimate) from a thrown
|
||||
// error (surface as a warning and degrade gracefully — do NOT silently report
|
||||
// installedSkills='*' as if the install profile were truly unlimited).
|
||||
let installedSkills: Set<string> | '*';
|
||||
try {
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = loadSkillsManifest(commandsGsdDir);
|
||||
const profileName = readActiveProfile(resolvedConfigDir) ?? 'full';
|
||||
const resolvedInstall = resolveProfile({
|
||||
modes: profileName.split(',').map((s: string) => s.trim()),
|
||||
manifest,
|
||||
});
|
||||
installedSkills = resolvedInstall.skills;
|
||||
} catch (err: unknown) {
|
||||
// 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>();
|
||||
}
|
||||
|
||||
// ── Resolve surfaced skills (from runtime surface) ────────────────────────────
|
||||
let surfacedSkills: Set<string>;
|
||||
try {
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = loadSkillsManifest(commandsGsdDir);
|
||||
const surfaceResult = resolveSurface(resolvedConfigDir, manifest);
|
||||
// resolveSurface returns { name, skills: Set<string>, agents: Set<string> }
|
||||
// (always a concrete Set — full profile is materialized)
|
||||
surfacedSkills = surfaceResult.skills instanceof Set
|
||||
? surfaceResult.skills
|
||||
: new Set<string>();
|
||||
} catch (err: unknown) {
|
||||
// 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>();
|
||||
}
|
||||
|
||||
// ── Load config ───────────────────────────────────────────────────────────────
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
|
||||
// ── Load registry and resolve state ─────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills,
|
||||
surfacedSkills,
|
||||
config,
|
||||
cwd,
|
||||
});
|
||||
|
||||
// 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: {
|
||||
runtimeConfigDir: string;
|
||||
warnings?: string[];
|
||||
capabilities: CapabilityStateEntry[];
|
||||
} = {
|
||||
runtimeConfigDir: resolvedConfigDir,
|
||||
capabilities: result.capabilities,
|
||||
};
|
||||
if (warnings.length > 0) {
|
||||
envelope.warnings = warnings;
|
||||
}
|
||||
|
||||
coreOutput(envelope, raw);
|
||||
}
|
||||
|
||||
export = {
|
||||
resolveCapabilityState,
|
||||
cmdCapabilityState,
|
||||
// Exported for tests
|
||||
_resolveCommandsGsdDir,
|
||||
_isSafePropKey,
|
||||
};
|
||||
688
tests/capability-state.test.cjs
Normal file
688
tests/capability-state.test.cjs
Normal file
@@ -0,0 +1,688 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* capability-state.test.cjs — behavioral tests for capability-state.cjs.
|
||||
*
|
||||
* ADR-857 phase 4b.
|
||||
* Uses node:test + node:assert/strict.
|
||||
* Pure-function tests (resolveCapabilityState) pass registry+Sets+config
|
||||
* directly — no I/O. End-to-end tests use cmdCapabilityState + temp dirs.
|
||||
*/
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
resolveCapabilityState,
|
||||
_isSafePropKey,
|
||||
} = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
// The real capability registry
|
||||
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
|
||||
// ─── Synthetic registry fixture ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a minimal synthetic registry for a single capability with the given
|
||||
* skills, steps, gates, contributions, and configSchema entries.
|
||||
*/
|
||||
function makeRegistry({
|
||||
id = 'test-cap',
|
||||
tier = 'standard',
|
||||
skills = [],
|
||||
steps = [],
|
||||
gates = [],
|
||||
contributions = [],
|
||||
configSchema = {},
|
||||
} = {}) {
|
||||
return {
|
||||
capabilities: {
|
||||
[id]: {
|
||||
id,
|
||||
tier,
|
||||
skills,
|
||||
steps,
|
||||
gates,
|
||||
contributions,
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
configSchema,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Temp project helpers ─────────────────────────────────────────────────────
|
||||
|
||||
let tmpProjectDir;
|
||||
let tmpProjectDirFalse;
|
||||
|
||||
before(() => {
|
||||
// Project with UI flags enabled
|
||||
tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-test-'));
|
||||
const planningDir = path.join(tmpProjectDir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planningDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
workflow: {
|
||||
ui_phase: true,
|
||||
ui_review: true,
|
||||
ui_safety_gate: true,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Project with all UI flags disabled
|
||||
tmpProjectDirFalse = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-false-'));
|
||||
fs.mkdirSync(path.join(tmpProjectDirFalse, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(path.join(tmpProjectDirFalse, '.planning'), 'config.json'),
|
||||
JSON.stringify({
|
||||
workflow: {
|
||||
ui_phase: false,
|
||||
ui_review: false,
|
||||
ui_safety_gate: false,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpProjectDir);
|
||||
cleanup(tmpProjectDirFalse);
|
||||
});
|
||||
|
||||
// ─── _isSafePropKey helper ────────────────────────────────────────────────────
|
||||
|
||||
describe('_isSafePropKey', () => {
|
||||
test('allows normal keys', () => {
|
||||
assert.strictEqual(_isSafePropKey('ui'), true);
|
||||
assert.strictEqual(_isSafePropKey('my-cap'), true);
|
||||
assert.strictEqual(_isSafePropKey('cap123'), true);
|
||||
});
|
||||
|
||||
test('blocks __proto__', () => {
|
||||
assert.strictEqual(_isSafePropKey('__proto__'), false);
|
||||
});
|
||||
|
||||
test('blocks constructor', () => {
|
||||
assert.strictEqual(_isSafePropKey('constructor'), false);
|
||||
});
|
||||
|
||||
test('blocks prototype', () => {
|
||||
assert.strictEqual(_isSafePropKey('prototype'), false);
|
||||
});
|
||||
|
||||
test('blocks non-string', () => {
|
||||
assert.strictEqual(_isSafePropKey(null), false);
|
||||
assert.strictEqual(_isSafePropKey(42), false);
|
||||
assert.strictEqual(_isSafePropKey(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — basic shapes ────────────────────────────────────
|
||||
|
||||
describe('resolveCapabilityState — basic shapes', () => {
|
||||
test('empty registry → {capabilities:[]}', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: { capabilities: {} },
|
||||
installedSkills: new Set(),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.deepStrictEqual(result, { capabilities: [] });
|
||||
});
|
||||
|
||||
test('missing capabilities key → {capabilities:[]}', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: {},
|
||||
installedSkills: new Set(),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.deepStrictEqual(result, { capabilities: [] });
|
||||
});
|
||||
|
||||
test('null registry → {capabilities:[]}', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: null,
|
||||
installedSkills: new Set(),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.deepStrictEqual(result, { capabilities: [] });
|
||||
});
|
||||
|
||||
test('array registry → {capabilities:[]}', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: [],
|
||||
installedSkills: new Set(),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.deepStrictEqual(result, { capabilities: [] });
|
||||
});
|
||||
|
||||
test('malformed capabilities entry is skipped gracefully', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: { capabilities: { 'bad-cap': 'not-an-object' } },
|
||||
installedSkills: new Set(),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.deepStrictEqual(result, { capabilities: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — installed dimension ────────────────────────────
|
||||
|
||||
describe('resolveCapabilityState — installed dimension', () => {
|
||||
test('installedSkills="*" → installed=true for all caps', () => {
|
||||
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities.length, 1);
|
||||
assert.strictEqual(result.capabilities[0].installed, true);
|
||||
});
|
||||
|
||||
test('all skills in installedSkills → installed=true', () => {
|
||||
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].installed, true);
|
||||
});
|
||||
|
||||
test('one skill missing from installedSkills → installed=false', () => {
|
||||
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: new Set(['ui-phase']), // missing ui-review
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].installed, false);
|
||||
});
|
||||
|
||||
test('empty skills array → installed=true vacuously', () => {
|
||||
// A capability with zero skills has no skills to be absent, so it is
|
||||
// vacuously installed and surfaced regardless of the installed/surfaced sets.
|
||||
// This is intentional: capabilities that gate purely on config (no skills
|
||||
// required) should report installed=true/surfaced=true when no skills are
|
||||
// needed. Activation state is still governed by hook `when` keys.
|
||||
const registry = makeRegistry({ skills: [] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: new Set(), // nothing installed — vacuous true still applies
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].installed, true);
|
||||
assert.strictEqual(result.capabilities[0].surfaced, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — surfaced dimension ──────────────────────────────
|
||||
|
||||
describe('resolveCapabilityState — surfaced dimension', () => {
|
||||
test('all skills in surfacedSkills → surfaced=true', () => {
|
||||
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].surfaced, true);
|
||||
});
|
||||
|
||||
test('one skill missing from surfacedSkills → surfaced=false', () => {
|
||||
const registry = makeRegistry({ skills: ['ui-phase', 'ui-review'] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(['ui-phase']), // missing ui-review
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].surfaced, false);
|
||||
});
|
||||
|
||||
test('empty skills array → surfaced=true vacuously', () => {
|
||||
const registry = makeRegistry({ skills: [] });
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(), // nothing surfaced
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities[0].surfaced, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — UI capability (real registry) ──────────────────
|
||||
|
||||
describe('resolveCapabilityState — UI capability with real registry', () => {
|
||||
test('UI cap: installed=true when ui-phase + ui-review in installedSkills', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap, 'ui capability should be present');
|
||||
assert.strictEqual(uiCap.installed, true);
|
||||
assert.strictEqual(uiCap.surfaced, true);
|
||||
});
|
||||
|
||||
test('UI cap: installed=false when ui-review missing from installedSkills', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase']), // missing ui-review
|
||||
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
assert.strictEqual(uiCap.installed, false);
|
||||
});
|
||||
|
||||
test('UI cap: surfaced=false when ui-review missing from surfacedSkills', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase']), // missing ui-review
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
assert.strictEqual(uiCap.surfaced, false);
|
||||
});
|
||||
|
||||
test('UI cap step hook: workflow.ui_phase true → active=true', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
// Find the plan:pre step (ui-phase step)
|
||||
const planPreStep = uiCap.hooks.find(
|
||||
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
||||
);
|
||||
assert.ok(planPreStep, 'should have plan:pre step with when=workflow.ui_phase');
|
||||
assert.strictEqual(planPreStep.active, true);
|
||||
});
|
||||
|
||||
test('UI cap step hook: workflow.ui_phase false → active=false', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } },
|
||||
cwd: tmpProjectDirFalse,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
const planPreStep = uiCap.hooks.find(
|
||||
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
||||
);
|
||||
assert.ok(planPreStep, 'should have plan:pre step with when=workflow.ui_phase');
|
||||
assert.strictEqual(planPreStep.active, false);
|
||||
});
|
||||
|
||||
test('UI cap gate hook: workflow.ui_safety_gate true → active=true', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
const safetyGate = uiCap.hooks.find(
|
||||
(h) => h.kind === 'gate' && h.when === 'workflow.ui_safety_gate',
|
||||
);
|
||||
assert.ok(safetyGate, 'should have gate with when=workflow.ui_safety_gate');
|
||||
assert.strictEqual(safetyGate.active, true);
|
||||
});
|
||||
|
||||
test('UI cap gate hook: workflow.ui_safety_gate false → active=false', () => {
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } },
|
||||
cwd: tmpProjectDirFalse,
|
||||
});
|
||||
const uiCap = result.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap);
|
||||
const safetyGate = uiCap.hooks.find(
|
||||
(h) => h.kind === 'gate' && h.when === 'workflow.ui_safety_gate',
|
||||
);
|
||||
assert.ok(safetyGate, 'should have gate with when=workflow.ui_safety_gate');
|
||||
assert.strictEqual(safetyGate.active, false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — hook activation ─────────────────────────────────
|
||||
|
||||
describe('resolveCapabilityState — hook activation details', () => {
|
||||
test('hook with no `when` → active=true (unconditional)', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ point: 'plan:pre', ref: { skill: 'test-skill' } }], // no `when`
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
assert.strictEqual(result.capabilities.length, 1);
|
||||
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
||||
assert.ok(hook, 'step hook should be present');
|
||||
assert.strictEqual(hook.when, undefined);
|
||||
assert.strictEqual(hook.active, true);
|
||||
});
|
||||
|
||||
test('hook with `when` resolving truthy → active=true', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ point: 'plan:pre', when: 'workflow.my_feature' }],
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { my_feature: true } },
|
||||
});
|
||||
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
||||
assert.ok(hook);
|
||||
assert.strictEqual(hook.active, true);
|
||||
});
|
||||
|
||||
test('hook with `when` resolving falsy → active=false', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [{ point: 'plan:pre', when: 'workflow.my_feature' }],
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { my_feature: false } },
|
||||
});
|
||||
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
||||
assert.ok(hook);
|
||||
assert.strictEqual(hook.active, false);
|
||||
});
|
||||
|
||||
test('mixed hooks: some active, some not', () => {
|
||||
const registry = makeRegistry({
|
||||
steps: [
|
||||
{ point: 'plan:pre', when: 'workflow.feat_a' },
|
||||
{ point: 'plan:post' }, // no when → unconditional
|
||||
],
|
||||
gates: [{ point: 'execute:wave:post', when: 'workflow.feat_b' }],
|
||||
// contributions must be a real array (not an object) so hook enumeration works
|
||||
contributions: [
|
||||
{ point: 'plan:pre', into: 'context', when: 'workflow.feat_c' },
|
||||
],
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: { workflow: { feat_a: false, feat_b: true, feat_c: true } },
|
||||
});
|
||||
const cap = result.capabilities[0];
|
||||
// feat_a step: inactive
|
||||
const featAStep = cap.hooks.find((h) => h.when === 'workflow.feat_a');
|
||||
assert.ok(featAStep);
|
||||
assert.strictEqual(featAStep.active, false);
|
||||
// unconditional step: active
|
||||
const unconditional = cap.hooks.find((h) => h.kind === 'step' && !h.when);
|
||||
assert.ok(unconditional);
|
||||
assert.strictEqual(unconditional.active, true);
|
||||
// feat_b gate: active
|
||||
const featBGate = cap.hooks.find((h) => h.when === 'workflow.feat_b');
|
||||
assert.ok(featBGate);
|
||||
assert.strictEqual(featBGate.active, true);
|
||||
// feat_c contribution: active, enumerated correctly
|
||||
const featCContrib = cap.hooks.find((h) => h.kind === 'contribution' && h.when === 'workflow.feat_c');
|
||||
assert.ok(featCContrib, 'contribution hook should be enumerated from array');
|
||||
assert.strictEqual(featCContrib.active, true);
|
||||
});
|
||||
|
||||
test('empty-string `when` → active=false (aligned with loop-resolver)', () => {
|
||||
// loop-resolver.isActive: `when.length === 0` → false
|
||||
// capability-state must behave identically
|
||||
const registry = makeRegistry({
|
||||
steps: [{ point: 'plan:pre', when: '' }],
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
||||
assert.ok(hook, 'step hook should be present');
|
||||
assert.strictEqual(hook.when, '', 'original when value must be preserved');
|
||||
assert.strictEqual(hook.active, false, 'empty-string when → inactive');
|
||||
});
|
||||
|
||||
test('non-string `when` → active=false (aligned with loop-resolver)', () => {
|
||||
// loop-resolver.isActive: `typeof when !== 'string'` → false
|
||||
const registry = makeRegistry({
|
||||
steps: [{ point: 'plan:pre', when: 42 }],
|
||||
});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
const hook = result.capabilities[0].hooks.find((h) => h.kind === 'step');
|
||||
assert.ok(hook, 'step hook should be present');
|
||||
assert.strictEqual(hook.when, 42, 'original non-string when value must be preserved');
|
||||
assert.strictEqual(hook.active, false, 'non-string when → inactive');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — determinism ─────────────────────────────────────
|
||||
|
||||
describe('resolveCapabilityState — determinism', () => {
|
||||
test('sorted by id — two caps returned in lexicographic order', () => {
|
||||
// contributions must be an array (not an object) for the hook enumeration to work
|
||||
const registry = {
|
||||
capabilities: {
|
||||
'zzz-cap': { id: 'zzz-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
||||
'aaa-cap': { id: 'aaa-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
||||
'mmm-cap': { id: 'mmm-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
||||
},
|
||||
};
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
const ids = result.capabilities.map((c) => c.id);
|
||||
assert.deepStrictEqual(ids, ['aaa-cap', 'mmm-cap', 'zzz-cap']);
|
||||
});
|
||||
|
||||
test('two calls with same inputs produce identical output', () => {
|
||||
const result1 = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase']),
|
||||
config: { workflow: { ui_phase: true, ui_review: false, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
const result2 = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase']),
|
||||
config: { workflow: { ui_phase: true, ui_review: false, ui_safety_gate: true } },
|
||||
cwd: tmpProjectDir,
|
||||
});
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
});
|
||||
|
||||
test('pure config-only resolution (cwd: undefined) — no I/O, deterministic', () => {
|
||||
// When cwd is omitted, resolveCapabilityState does no filesystem I/O.
|
||||
// Two calls with identical args must produce identical output regardless
|
||||
// of any .planning/config.json files that may exist on disk.
|
||||
const result1 = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } },
|
||||
// no cwd
|
||||
});
|
||||
const result2 = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
surfacedSkills: new Set(['ui-phase', 'ui-review']),
|
||||
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: false } },
|
||||
// no cwd
|
||||
});
|
||||
assert.deepStrictEqual(result1, result2);
|
||||
// Activation should come from the `config` arg only, not from disk
|
||||
const uiCap = result1.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap, 'ui capability should be present');
|
||||
const uiPhaseStep = uiCap.hooks.find(
|
||||
(h) => h.kind === 'step' && h.when === 'workflow.ui_phase',
|
||||
);
|
||||
if (uiPhaseStep) {
|
||||
assert.strictEqual(uiPhaseStep.active, true, 'should use config arg, not disk');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveCapabilityState — prototype pollution guard ──────────────────────
|
||||
|
||||
describe('resolveCapabilityState — prototype pollution guard', () => {
|
||||
test('prototype-pollution capId is skipped; Object.prototype unpolluted', () => {
|
||||
// Use Object.create(null) + Object.defineProperty to create a capabilities
|
||||
// map with a real OWN '__proto__' key (not the prototype chain).
|
||||
// The `{ __proto__: ... }` object literal syntax sets the prototype, not
|
||||
// an own property — so it cannot exercise the guard. Using defineProperty
|
||||
// ensures the key is an enumerable own property that Object.keys() returns.
|
||||
const capabilitiesMap = Object.create(null);
|
||||
Object.defineProperty(capabilitiesMap, '__proto__', {
|
||||
value: { id: '__proto__', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(capabilitiesMap, 'safe-cap', {
|
||||
value: { id: 'safe-cap', tier: 'standard', skills: [], steps: [], gates: [], contributions: [] },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
const registry = { capabilities: capabilitiesMap };
|
||||
const before = Object.prototype.toString.call({});
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
installedSkills: '*',
|
||||
surfacedSkills: new Set(),
|
||||
config: {},
|
||||
});
|
||||
const after = Object.prototype.toString.call({});
|
||||
// Object.prototype must be unpolluted
|
||||
assert.strictEqual(before, after);
|
||||
// Verify no pollution occurred — a new plain object must not have a `polluted` property
|
||||
assert.strictEqual(({}).polluted, undefined);
|
||||
// Only the safe cap should appear
|
||||
assert.strictEqual(result.capabilities.length, 1);
|
||||
assert.strictEqual(result.capabilities[0].id, 'safe-cap');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── cmdCapabilityState — end-to-end via gsd-tools CLI ──────────────────────
|
||||
//
|
||||
// Because cmdCapabilityState destructures `output` at module load time, patching
|
||||
// core.cjs after the fact is ineffective. We instead invoke gsd-tools via
|
||||
// spawnSync so each test gets a fresh process with stdout captured.
|
||||
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const gsdToolsPath = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
function runCapabilityState(cwd, configDir) {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[gsdToolsPath, 'capability', 'state', '--config-dir', configDir, '--raw', '--cwd', cwd],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => {
|
||||
let tmpConfigDir;
|
||||
let tmpConfigDirCore;
|
||||
|
||||
before(() => {
|
||||
// Tmp runtime config dir without .gsd-profile (defaults to 'full')
|
||||
tmpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-'));
|
||||
|
||||
// Tmp runtime config dir with core profile marker
|
||||
tmpConfigDirCore = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-core-'));
|
||||
fs.writeFileSync(path.join(tmpConfigDirCore, '.gsd-profile'), 'core\n', 'utf8');
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpConfigDir);
|
||||
cleanup(tmpConfigDirCore);
|
||||
});
|
||||
|
||||
test('emits envelope with runtimeConfigDir and capabilities array', () => {
|
||||
const result = runCapabilityState(tmpProjectDir, tmpConfigDir);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.ok(typeof envelope === 'object' && envelope !== null, 'envelope must be an object');
|
||||
assert.ok('runtimeConfigDir' in envelope, 'envelope must have runtimeConfigDir');
|
||||
assert.ok(Array.isArray(envelope.capabilities), 'envelope.capabilities must be an array');
|
||||
assert.ok(envelope.capabilities.length > 0, 'should have at least one capability');
|
||||
});
|
||||
|
||||
test('with core profile marker: capabilities present (profile resolution does not throw)', () => {
|
||||
const result = runCapabilityState(tmpProjectDir, tmpConfigDirCore);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(envelope.capabilities));
|
||||
// ui capability should appear; installed=false because core profile doesn't include ui-phase/ui-review
|
||||
const uiCap = envelope.capabilities.find((c) => c.id === 'ui');
|
||||
assert.ok(uiCap, 'ui capability should be present in output');
|
||||
assert.strictEqual(uiCap.installed, false, 'ui-phase/ui-review not in core profile');
|
||||
});
|
||||
|
||||
test('runtimeConfigDir is echoed in the envelope', () => {
|
||||
const result = runCapabilityState(tmpProjectDir, tmpConfigDir);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
assert.strictEqual(envelope.runtimeConfigDir, tmpConfigDir);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user