From 85cfa5dc136200667e3044ef2827747880314e03 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 9 Jun 2026 16:18:23 -0400 Subject: [PATCH] feat(#945): unified capability-state resolver (ADR-857 phase 4b) (#946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 43 +- src/capability-state.cts | 418 +++++++++++++++++++ tests/capability-state.test.cjs | 688 ++++++++++++++++++++++++++++++++ 9 files changed, 1157 insertions(+), 2 deletions(-) create mode 100644 src/capability-state.cts create mode 100644 tests/capability-state.test.cjs diff --git a/.gitignore b/.gitignore index 337856035..fc083c671 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index ff7058fef..122b3dcf6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 ` 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 ]` — 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c9b6d5c0c..553a754de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 ` | +| `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 ]` | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index e44086525..e2fa07caf 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 01bd82715..1189cbaf4 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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//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 ]` 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 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 4662670f1..4606ce3f0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 33907e867..a8f5b9ff2 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.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 ] 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 ] [--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 ] + // 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; diff --git a/src/capability-state.cts b/src/capability-state.cts new file mode 100644 index 000000000..f488a75da --- /dev/null +++ b/src/capability-state.cts @@ -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; + /** + * Set of installed skill stems, or '*' for full/unrestricted install. + */ + installedSkills: Set | '*'; + /** Set of surfaced skill stems for the current runtime config dir */ + surfacedSkills: Set; + /** loadConfig result for config-key activation resolution */ + config: Record; + /** 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 | '*' — from resolveProfile().skills. + * @param input.surfacedSkills Set — 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; + + 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; + + // 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; + 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: + * /gsd-core/bin/lib/capability-state.cjs + * /commands/gsd/*.md + * So we walk up three levels: lib/ → bin/ → gsd-core/ → /, 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 = {}, +): 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 | '*'; + 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(); + } + + // ── Resolve surfaced skills (from runtime surface) ──────────────────────────── + let surfacedSkills: Set; + try { + const commandsGsdDir = _resolveCommandsGsdDir(); + const manifest = loadSkillsManifest(commandsGsdDir); + const surfaceResult = resolveSurface(resolvedConfigDir, manifest); + // resolveSurface returns { name, skills: Set, agents: Set } + // (always a concrete Set — full profile is materialized) + surfacedSkills = surfaceResult.skills instanceof Set + ? surfaceResult.skills + : new Set(); + } 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(); + } + + // ── Load config ─────────────────────────────────────────────────────────────── + let config: Record; + 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; + + 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, +}; diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs new file mode 100644 index 000000000..dac85ce43 --- /dev/null +++ b/tests/capability-state.test.cjs @@ -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); + }); +});