diff --git a/.changeset/1136-capability-state-consumption.md b/.changeset/1136-capability-state-consumption.md new file mode 100644 index 000000000..86758ae38 --- /dev/null +++ b/.changeset/1136-capability-state-consumption.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 1153 +--- +**Capability hook rendering now consumes resolved Capability State** — `gsd-tools loop render-hooks` uses the same installed/surfaced/configured state reported by `gsd-tools capability state`, so disabling a migrated capability at the runtime surface removes its workflow hooks even when config defaults are enabled. Migrated capability config keys remain accepted through the generated capability registry/federated config path instead of duplicated central `VALID_CONFIG_KEYS` entries. (#1136) diff --git a/.gitignore b/.gitignore index 63f31d212..169c2f39a 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,7 @@ build/ /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/capability-activation.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 4df33120a..2d90bf216 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -152,16 +152,16 @@ Generated description of what the five-step loop (Discuss → Plan → Execute Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ : [] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ : { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities//capability.json`. ### Federated Config -ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. Wired into `loadConfig` as a true no-op today: every Capability config key is still in the central config-schema, so `isCentralKey()` returns true for all of them and `values` is always empty. The channel becomes live when a key is atomically removed from the central schema at cutover (the ADR-857 migration step). `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests. +ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. ADR-857 phase 6 made the channel live for migrated Capability keys: `config-schema.cjs` exposes `isCentralConfigKey()` for central ownership and `isValidConfigKey()` accepts central + runtime + dynamic + Capability-owned registry keys. `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests. ### 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. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing. ### 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; exposed as the `gsd-tools capability state` diagnostic, not yet consumed by a workflow (workflow wiring is the phase-6 cutover). 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`). +ADR-857 phase 4b/6 unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view consumed by workflow hook rendering. 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); `resolveCapabilityRuntimeState(cwd, runtimeConfigDir)` (I/O resolver shared by workflow dispatch and diagnostics); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O output entry point). CLI surface: `gsd-tools capability state [--config-dir ]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, enabled, 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), `enabled = installed && surfaced`, and `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, configured, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (`configured` resolves `when`; `active = enabled && configured`). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. ### Capability Command Family [Planned — mechanism built, unconsumed] -ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). **Intel cutover (4d-impl-4, last first-party cutover):** `intel` cut over — `capabilities/intel/capability.json` declares the command (`family: intel`, `module: intel-command-router.cjs`, `router: routeIntelCommand`) and the existing config gate (`intel.enabled`, default false); the `case 'intel':` arm removed from `gsd-tools.cjs`; `commandFamilies` now holds `intel`, `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.intel → intel-command-router.cjs → routeIntelCommand`; all 9 subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and both usage-error paths preserved; non-raw `timeAgo` transform on `status.files[*].updated_at` preserved exactly. `intel.enabled` declared in capability config (`pending-migration` warning expected during staged 3a-impl cutover). Completes the initial 4d capability command cutover batch. +ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). **Intel cutover (4d-impl-4, last first-party cutover):** `intel` cut over — `capabilities/intel/capability.json` declares the command (`family: intel`, `module: intel-command-router.cjs`, `router: routeIntelCommand`) and the existing config gate (`intel.enabled`, default false); the `case 'intel':` arm removed from `gsd-tools.cjs`; `commandFamilies` now holds `intel`, `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.intel → intel-command-router.cjs → routeIntelCommand`; all 9 subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and both usage-error paths preserved; non-raw `timeAgo` transform on `status.files[*].updated_at` preserved exactly. `intel.enabled` is Capability-owned config after ADR-857 phase 6. Completes the initial 4d capability command cutover batch. ### 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. Note: "third-party" here is the authorship/distribution axis (who wrote/ships it), distinct from the integration-shape axis (in-host vs Connected Capability). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 84c8ac658..0b6bfc8ff 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -344,7 +344,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | Module | Responsibility | | ---------------------- | --------------------------------------------------------------------------------------------------- | | `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation, and federated config overlay (ADR-857 phase 3b) (extracted from `core.cjs`, ADR-857) | -| `federated-config.cjs` | Defensive merge of capability-declared config slices (ADR-857 phase 3b); exports `mergeFederatedConfig`; no-op until capability keys are removed from the central config-schema at cutover | +| `federated-config.cjs` | Defensive merge of capability-declared config slices (ADR-857 phase 3b); exports `mergeFederatedConfig`; live for migrated Capability keys that are absent from the central config schema | | `core-utils.cjs` | Shared low-level utility primitives — POSIX path normalization, sub-repo/subdirectory scanning, phase file stats, slug/one-liner/plan-id helpers, time-ago (extracted from `core.cjs`, ADR-857) | | `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers | | `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover | @@ -373,8 +373,8 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `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 ]` | +| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; consumes resolved Capability State, filters `byLoopPoint` by capability enablement plus config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks [--config-dir ]` | +| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir ]` | | `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry | | `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry | | `intel-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-4, last first-party cutover); extracted from the `case 'intel':` arm in `gsd-tools.cjs`; `routeIntelCommand` → all 9 intel subcommands via lazy `require('./intel.cjs')`; preserves non-raw `timeAgo` transform on `status.files[*].updated_at`; discovered via `commandFamilies` in the capability registry | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 8091c1fb9..fba13bb14 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-11", + "generated": "2026-06-13", "families": { "agents": [ "gsd-advisor-researcher", @@ -273,6 +273,7 @@ "artifacts.cjs", "audit-command-router.cjs", "audit.cjs", + "capability-activation.cjs", "capability-registry.cjs", "capability-state.cjs", "check-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 38ed3a3cd..4ba444ca7 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -372,7 +372,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (109 shipped) +## CLI Modules (110 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -384,8 +384,9 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | +| `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings | | `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[] }` | +| `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and 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 | @@ -409,7 +410,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `drift.cjs` | Post-execute codebase structural drift detector (#2003): classifies file changes into new-dir/barrel/migration/route categories and round-trips `last_mapped_commit` frontmatter | | `edge-probe.cjs` | Spec-completeness edge probe (compiled from `src/edge-probe.cts`, gitignored) — the first adapter of the `probe-core` resolution model (ADR-550 Decision 7): shape classification, applicable-category relevance filter, edge proposal, and the `{explicit, backstop}` verification validators; delegates merge/rollup/CLI to `probe-core`; exports `classifyShape`, `applicableCategories`, `proposeEdges`, `analyzeCoverage`, `validateResolution`, `TAXONOMY` (#550) | | `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization | -| `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; no-op until a key is atomically removed from the central config-schema (the cutover step) | +| `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema | | `frontmatter.cjs` | YAML frontmatter CRUD operations | | `gap-checker.cjs` | Post-planning gap analysis (#2493): unified REQUIREMENTS.md + CONTEXT.md decisions vs PLAN.md coverage report (`gsd-tools gap-analysis`) | | `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` | @@ -427,7 +428,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | -| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; given a canonical loop point, filters `byLoopPoint` by config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks ` | +| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks [--config-dir ]` | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | diff --git a/docs/how-to/develop-a-capability.md b/docs/how-to/develop-a-capability.md index a9bc88f81..580ef224e 100644 --- a/docs/how-to/develop-a-capability.md +++ b/docs/how-to/develop-a-capability.md @@ -130,6 +130,31 @@ In installed workflow prose, the same resolver surface appears as `gsd-tools loo The rendered JSON should include the active hook, the declared `ref`, and the materialised `fragment.inline`. +To verify a runtime-specific surface, pass the same config directory that the runtime installation uses: + +```bash +node gsd-core/bin/gsd-tools.cjs loop render-hooks plan:pre --config-dir ~/.claude --raw +node gsd-core/bin/gsd-tools.cjs capability state --config-dir ~/.claude --raw +``` + +`capability state` is the diagnostic view for the same state that workflow dispatch consumes. For each Capability, `enabled` is true only when the Capability is both installed by the active profile and surfaced by the runtime surface. A hook's `configured` field reflects the `when` config key; `active` is true only when the Capability is enabled and the hook is configured on. + +## Own config in the Capability + +Declare feature config keys in the Capability manifest: + +```json +"config": { + "workflow.example_enabled": { + "type": "boolean", + "default": true, + "description": "Enable the example planning step." + } +} +``` + +Do not add migrated Capability keys to `gsd-core/bin/shared/config-schema.manifest.json`. The central schema remains for host/core keys; Capability-owned keys validate through the generated registry and are merged into `loadConfig` by the federated config overlay. Existing nested `.planning/config.json` values such as `{ "workflow": { "example_enabled": false } }` continue to override the Capability default. + ## Wire the workflow through the registry Host workflows should ask the resolver for active hooks and then dispatch from the resolved data. Do not add new direct `config-get workflow.` checks to a host workflow for a migrated feature. diff --git a/docs/reference/review-verification-capabilities.md b/docs/reference/review-verification-capabilities.md index a9b2d5530..341f7ab73 100644 --- a/docs/reference/review-verification-capabilities.md +++ b/docs/reference/review-verification-capabilities.md @@ -24,7 +24,7 @@ For the system design, see [ADR-857: Capability system](../adr/857-capability-sy | `security` | `secure-phase` | `gsd-security-auditor` | `workflow.security_enforcement`, `workflow.security_asvs_level`, `workflow.security_block_on` | | `nyquist` | `validate-phase` | `gsd-nyquist-auditor` | `workflow.nyquist_validation` | -The central config schema still accepts these keys during migration, but workflows must not branch directly on the boolean activation keys. Workflows resolve activation by calling `gsd-tools loop render-hooks `. +These keys are Capability-owned config keys. They remain valid for `.planning/config.json`, but they are not central schema keys; validation and defaults come from the generated Capability Registry. Workflows must not branch directly on the boolean activation keys. Workflows resolve activation by calling `gsd-tools loop render-hooks `. ## Hook Map @@ -45,7 +45,20 @@ EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw) VERIFY_POST_HOOKS_JSON=$(gsd_run loop render-hooks verify:post --raw) ``` -The resolver evaluates each hook's `when` key against the project config and the capability config default. If the key is absent and the capability declaration default is `true`, the hook is active. +The resolver evaluates each hook's `when` key against the project config and the capability config default. If the key is absent and the capability declaration default is `true`, the hook is configured on. + +A hook is active only when both conditions are true: + +- The Capability is enabled by the resolved Capability State: installed by `.gsd-profile` and surfaced by `.gsd-surface.json`. +- The hook is configured on by its `when` key. + +Use the diagnostic state view to inspect the same answer the workflows consume: + +```bash +gsd-tools capability state --config-dir ~/.claude --raw +``` + +In that output, `configured` reflects config/default resolution, while `active` reflects final participation after install and surface state. Disabling a migrated Capability at the runtime surface removes its active hooks even when the project config default is `true`. Direct command workflows self-gate the same way: diff --git a/eslint.config.mjs b/eslint.config.mjs index 579dfa5f1..caba11fa5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -78,6 +78,7 @@ export default tseslint.config( '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/capability-activation.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 d39deed7d..21e8b61dd 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1236,7 +1236,23 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // loop render-hooks const loopSubcommand = args[1]; if (loopSubcommand === 'render-hooks') { - loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {}); + let loopConfigDir = null; + const configDirEqArg = args.find(arg => arg.startsWith('--config-dir=')); + const configDirIdx = args.indexOf('--config-dir'); + if (configDirEqArg) { + const value = configDirEqArg.slice('--config-dir='.length).trim(); + if (!value) error('Missing value for --config-dir', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined); + loopConfigDir = value; + } else if (configDirIdx !== -1) { + const value = args[configDirIdx + 1]; + if (!value || value.startsWith('--')) { + error('Missing value for --config-dir', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined); + } + loopConfigDir = value; + } + loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, { + configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined, + }); } else { error( `Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`, diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 44192e79d..3bbf03bfa 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -10,13 +10,8 @@ "brave_search", "firecrawl", "exa_search", - "workflow.research", "workflow.plan_check", "workflow.verifier", - "workflow.nyquist_validation", - "workflow.ai_integration_phase", - "workflow.ui_phase", - "workflow.ui_safety_gate", "workflow.auto_advance", "workflow.node_repair", "workflow.node_repair_budget", @@ -29,19 +24,13 @@ "workflow.auto_prune_state", "workflow.use_worktrees", "workflow.worktree_skip_hooks", - "workflow.code_review", - "workflow.code_review_depth", "workflow.code_review_command", - "workflow.pattern_mapper", "workflow.plan_bounce", "workflow.plan_bounce_script", "workflow.plan_bounce_passes", "workflow.plan_chunked", "workflow.plan_review_convergence", "workflow.post_planning_gaps", - "workflow.security_enforcement", - "workflow.security_asvs_level", - "workflow.security_block_on", "workflow.drift_threshold", "workflow.drift_action", "code_quality.fallow.enabled", @@ -76,7 +65,6 @@ "workflow.context_coverage_gate", "statusline.show_last_command", "statusline.context_position", - "workflow.ui_review", "workflow.max_discuss_passes", "features.thinking_partner", "context", @@ -90,8 +78,6 @@ "manager.flags.execute", "response_language", "context_window", - "intel.enabled", - "graphify.enabled", "graphify.build_timeout", "graphify.auto_update", "claude_md_path", diff --git a/src/capability-activation.cts b/src/capability-activation.cts new file mode 100644 index 000000000..1440dc69f --- /dev/null +++ b/src/capability-activation.cts @@ -0,0 +1,100 @@ +/** + * Capability activation helpers. + * + * Shared by the Capability State Resolver and Loop Resolver so config-key + * activation uses one precedence chain and one prototype-pollution guard. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningWorkspaceMod = require('./planning-workspace.cjs'); +const { planningDir, planningRoot } = planningWorkspaceMod; + +function _getNestedConfigValue( + config: Record, + dotKey: string, +): { found: boolean; value: unknown } { + const segments = dotKey.split('.'); + let current: unknown = config; + for (const seg of segments) { + if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { + return { found: false, value: undefined }; + } + if (typeof current !== 'object' || current === null) { + return { found: false, value: undefined }; + } + const cur = current as Record; + if (!Object.prototype.hasOwnProperty.call(cur, seg)) { + return { found: false, value: undefined }; + } + current = cur[seg]; + } + return { found: true, value: current }; +} + +const _warnedRawConfigPaths = new Set(); + +function _readRawConfigKey( + filePath: string, + dotKey: string, +): { found: boolean; value: unknown } { + try { + const raw = fs.readFileSync(filePath, 'utf8'); + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + if (!_warnedRawConfigPaths.has(filePath)) { + _warnedRawConfigPaths.add(filePath); + try { + process.stderr.write( + `gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`, + ); + } catch { /* stderr might be closed */ } + } + return { found: false, value: undefined }; + } + return _getNestedConfigValue(parsed, dotKey); + } catch { + return { found: false, value: undefined }; + } +} + +function _resolveActivationValue( + dotKey: string, + config: Record, + cwd: string | undefined, + registry: Record, +): boolean { + const fromConfig = _getNestedConfigValue(config, dotKey); + if (fromConfig.found) return Boolean(fromConfig.value); + + if (cwd) { + const wsConfigPath = path.join(planningDir(cwd), 'config.json'); + const rootConfigPath = path.join(planningRoot(cwd), 'config.json'); + + const fromWs = _readRawConfigKey(wsConfigPath, dotKey); + if (fromWs.found) return Boolean(fromWs.value); + + if (wsConfigPath !== rootConfigPath) { + const fromRoot = _readRawConfigKey(rootConfigPath, dotKey); + if (fromRoot.found) return Boolean(fromRoot.value); + } + } + + const schemaEntry = (registry['configSchema'] as Record | undefined)?.[dotKey]; + if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) { + const def = (schemaEntry as Record)['default']; + if (def !== undefined) return Boolean(def); + } + + return false; +} + +export = { + _getNestedConfigValue, + _readRawConfigKey, + _resolveActivationValue, +}; diff --git a/src/capability-state.cts b/src/capability-state.cts index 5d996274d..9c3397200 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -3,9 +3,8 @@ * * Unified capability-state resolver that composes the three toggle systems * (install profile, runtime surface, config activation) into one per-capability - * view. ADDITIVE — install/surface/workflows are untouched; this resolver is - * exposed only as the `gsd-tools capability state` diagnostic, not yet consumed by - * any workflow (workflow wiring is the phase-6 cutover). + * view. The loop resolver consumes this state so workflow dispatch and the + * `gsd-tools capability state` diagnostic share the same enablement answer. * * Exports (three things, mirroring loop-resolver): * resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd }) @@ -19,9 +18,9 @@ * cmdCapabilityState is the I/O handler. * * Dependencies (leaf modules only — no core.cjs circular risk): - * - node:path (used by _resolveActivationValue via loop-resolver) + * - node:path * - ./core.cjs (output, error) - * - ./loop-resolver.cjs (_resolveActivationValue — reuse the export) + * - ./capability-activation.cjs (_resolveActivationValue) * - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile) * - ./surface.cjs (resolveSurface) * - ./config-loader.cjs (loadConfig) @@ -36,8 +35,8 @@ import core = require('./core.cjs'); const { output: coreOutput, error: coreError } = core; // eslint-disable-next-line @typescript-eslint/no-require-imports -import loopResolverMod = require('./loop-resolver.cjs'); -const { _resolveActivationValue } = loopResolverMod; +import activationMod = require('./capability-activation.cjs'); +const { _resolveActivationValue } = activationMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import configLoaderMod = require('./config-loader.cjs'); @@ -66,6 +65,8 @@ interface HookEntry { */ when: unknown; /** Whether this hook is currently active based on config */ + configured: boolean; + /** Whether this hook participates after capability enablement is applied */ active: boolean; } @@ -85,6 +86,8 @@ interface CapabilityStateEntry { * Vacuously true for capabilities with an empty skills array. */ surfaced: boolean; + /** True when the capability is both installed and surfaced. */ + enabled: boolean; /** Resolved hook activation state across steps, gates, and contributions */ hooks: HookEntry[]; } @@ -108,6 +111,14 @@ interface ResolveCapabilityStateResult { capabilities: CapabilityStateEntry[]; } +interface ResolveCapabilityRuntimeStateResult { + runtimeConfigDir: string; + warnings: string[]; + registry: Record; + config: Record; + capabilities: CapabilityStateEntry[]; +} + // ─── Prototype-pollution guard (inline literal, CodeQL barrier) ─────────────── function _isSafePropKey(key: unknown): key is string { @@ -197,6 +208,8 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa surfaced = skills.every((s) => surfacedSkills.has(s)); } + const enabled = installed && surfaced; + // ── hooks ────────────────────────────────────────────────────────────────── // Collect from steps, gates, contributions. Each may have a `when` key. // Activation semantics (mirrors loop-resolver.isActive exactly): @@ -216,19 +229,19 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa const point = typeof h['point'] === 'string' ? h['point'] : ''; // Carry the raw `when` value through for visibility const whenRaw: unknown = h['when']; - let active: boolean; + let configured: boolean; if (whenRaw === undefined || whenRaw === null) { // No `when` field → unconditional, always active - active = true; + configured = true; } else if (typeof whenRaw === 'string' && whenRaw.length > 0) { // Non-empty string `when` → resolve via _resolveActivationValue - active = _resolveActivationValue(whenRaw, config, cwd, registry); + configured = _resolveActivationValue(whenRaw, config, cwd, registry); } else { // Present-but-empty-string or non-string `when` → malformed, inactive // (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false) - active = false; + configured = false; } - hooks.push({ point, kind, when: whenRaw, active }); + hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured }); } } @@ -240,7 +253,7 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate'); processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution'); - results.push({ id: capId, tier, skills, installed, surfaced, hooks }); + results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks }); } // Deterministic sort by id for stable output across calls @@ -295,12 +308,10 @@ function _resolveCommandsGsdDir(): string { * @param raw Whether to emit raw JSON (core.output raw mode) * @param _options Reserved for future use */ -function cmdCapabilityState( +function resolveCapabilityRuntimeState( cwd: string, runtimeConfigDir: string | undefined | null, - raw: boolean, - _options: Record = {}, -): void { +): ResolveCapabilityRuntimeStateResult { const warnings: string[] = []; // Resolve runtimeConfigDir using the canonical runtime-homes resolver. @@ -358,7 +369,6 @@ function cmdCapabilityState( // Genuine resolution failure — surface it so the caller is not misled. const msg = err instanceof Error ? err.message : String(err); warnings.push(`profile-resolution failed: ${msg}`); - coreError(`capability state: profile resolution failed: ${msg}`); // Degrade to empty set (not '*') so installed=false is reported accurately. installedSkills = new Set(); } @@ -378,7 +388,6 @@ function cmdCapabilityState( // Genuine surface resolution failure — surface it so the caller is not misled. const msg = err instanceof Error ? err.message : String(err); warnings.push(`surface-resolution failed: ${msg}`); - coreError(`capability state: surface resolution failed: ${msg}`); surfacedSkills = new Set(); } @@ -400,6 +409,26 @@ function cmdCapabilityState( cwd, }); + return { + runtimeConfigDir: resolvedConfigDir, + warnings, + registry, + config, + capabilities: result.capabilities, + }; +} + +function cmdCapabilityState( + cwd: string, + runtimeConfigDir: string | undefined | null, + raw: boolean, + _options: Record = {}, +): void { + const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir); + for (const warning of result.warnings) { + coreError(`capability state: ${warning}`); + } + // Build envelope — include warnings array only when non-empty so the nominal // path keeps the output clean and callers can check `warnings` for degraded state. const envelope: { @@ -407,11 +436,11 @@ function cmdCapabilityState( warnings?: string[]; capabilities: CapabilityStateEntry[]; } = { - runtimeConfigDir: resolvedConfigDir, + runtimeConfigDir: result.runtimeConfigDir, capabilities: result.capabilities, }; - if (warnings.length > 0) { - envelope.warnings = warnings; + if (result.warnings.length > 0) { + envelope.warnings = result.warnings; } coreOutput(envelope, raw); @@ -419,6 +448,7 @@ function cmdCapabilityState( export = { resolveCapabilityState, + resolveCapabilityRuntimeState, cmdCapabilityState, // Exported for tests _resolveCommandsGsdDir, diff --git a/src/config-loader.cts b/src/config-loader.cts index 942c9fd3c..f18cf18ae 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -35,7 +35,7 @@ const { detectSubRepos } = coreUtilsModule; import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import configSchema = require('./config-schema.cjs'); -const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema; +const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema; import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs'; // ─── Federated Config (ADR-857 phase 3b) ───────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -358,7 +358,7 @@ function _applyFederatedOverlay( if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig; const _fedOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchema, - isCentralKey: (key: string) => _isValidConfigKeyFn(key), + isCentralKey: (key: string) => _isCentralConfigKeyFn(key), userConfig, }); // True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone) @@ -492,7 +492,7 @@ function loadConfig(cwd: string, options: Record = {}): Record< // Internal keys loadConfig reads but config-set doesn't expose 'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode', // Deprecated keys (still accepted for migration, not in config-set) - 'depth', 'multiRepo', 'branching_strategy', + 'depth', 'multiRepo', 'branching_strategy', 'research', ]); // FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that @@ -504,7 +504,7 @@ function loadConfig(cwd: string, options: Record = {}): Record< if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') { const _earlyOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchemaEarly, - isCentralKey: (key: string) => _isValidConfigKeyFn(key), + isCentralKey: (key: string) => _isCentralConfigKeyFn(key), userConfig: parsed, }); _preWarningFedValidKeys = _earlyOverlay.validKeys; @@ -631,7 +631,7 @@ function loadConfig(cwd: string, options: Record = {}): Record< if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') { const _fedOverlay = mergeFederatedConfig({ configSchema: _fedRegistrySchema, - isCentralKey: (key: string) => _isValidConfigKeyFn(key), + isCentralKey: (key: string) => _isCentralConfigKeyFn(key), userConfig: parsed, }); // Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase) @@ -655,8 +655,8 @@ function loadConfig(cwd: string, options: Record = {}): Record< return loadConfig(cwd, { workstream: null }); } // FIX 2: Apply the federated overlay on the no-config path. - // With the current registry (all keys central), _applyFederatedOverlay returns - // `defaults` UNCHANGED (true no-op, preserves byte-identical output). + // Migrated Capability keys are surfaced from the generated registry even + // when the project has no config.json, so schema defaults still apply. try { return _applyFederatedOverlay(defaults, {}); } catch { diff --git a/src/config-schema.cts b/src/config-schema.cts index f2c0bbc8d..5ce5e1f5a 100644 --- a/src/config-schema.cts +++ b/src/config-schema.cts @@ -21,13 +21,43 @@ import { DYNAMIC_KEY_PATTERNS, } from './configuration.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const capabilityRegistry = require('./capability-registry.cjs') as { + configSchema?: Record; +}; + +function isCapabilityConfigKey(keyPath: string): boolean { + if (typeof keyPath !== 'string') return false; + const schema = capabilityRegistry.configSchema; + if (!schema || typeof schema !== 'object') return false; + return Object.prototype.hasOwnProperty.call(schema, keyPath); +} + /** - * Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state). + * Returns true for keys owned by the central schema adapter rather than a + * federated Capability config slice. */ -function isValidConfigKey(keyPath: string): boolean { +function isCentralConfigKey(keyPath: string): boolean { + if (typeof keyPath !== 'string') return false; if (VALID_CONFIG_KEYS.has(keyPath)) return true; if (RUNTIME_STATE_KEYS.has(keyPath)) return true; return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath)); } -export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey }; +/** + * Returns true if keyPath is a valid central, runtime-state, dynamic, or + * federated Capability config key. + */ +function isValidConfigKey(keyPath: string): boolean { + if (isCentralConfigKey(keyPath)) return true; + return isCapabilityConfigKey(keyPath); +} + +export = { + VALID_CONFIG_KEYS, + RUNTIME_STATE_KEYS, + DYNAMIC_KEY_PATTERNS, + isCapabilityConfigKey, + isCentralConfigKey, + isValidConfigKey, +}; diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts index 0f292f572..c43ee4c2b 100644 --- a/src/loop-resolver.cts +++ b/src/loop-resolver.cts @@ -39,6 +39,10 @@ const { output: coreOutput, error: coreError } = core; import configLoaderModule = require('./config-loader.cjs'); const { loadConfig } = configLoaderModule; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import capabilityStateModule = require('./capability-state.cjs'); +const { resolveCapabilityRuntimeState } = capabilityStateModule; + // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspaceMod = require('./planning-workspace.cjs'); const { planningDir, planningRoot } = planningWorkspaceMod; @@ -263,6 +267,8 @@ interface ResolveLoopHooksInput { config: Record; /** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */ cwd?: string; + /** Optional capability-state map; when present, disabled capabilities do not render hooks. */ + capabilityStatesById?: Map | Record; } interface ResolveLoopHooksResult { @@ -286,7 +292,7 @@ interface ResolveLoopHooksResult { * resolved against `config`; active iff truthy. Inactive hooks are filtered out. */ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult { - const { point, registry, config, cwd } = input; + const { point, registry, config, cwd, capabilityStatesById } = input; // Validate point const canonicalPoints = _getCanonicalPoints(registry); @@ -322,6 +328,15 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult return _resolveActivationValue(when, config, cwd, registry); } + function isCapabilityEnabled(capId: string): boolean { + if (!capabilityStatesById) return true; + const state = capabilityStatesById instanceof Map + ? capabilityStatesById.get(capId) + : capabilityStatesById[capId]; + if (!state) return false; + return state.enabled !== false; + } + // Helper: safe string array function toStringArray(v: unknown): string[] { if (!Array.isArray(v)) return []; @@ -342,8 +357,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : []; for (const hook of steps) { if (!hook || typeof hook !== 'object') continue; - if (!isActive(hook)) continue; const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + if (!isCapabilityEnabled(capId)) continue; + if (!isActive(hook)) continue; const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null) ? (hook['ref'] as HookRef) : undefined; @@ -367,8 +383,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult const contributions: RawHook[] = Array.isArray(contributionsRaw) ? (contributionsRaw as RawHook[]) : []; for (const hook of contributions) { if (!hook || typeof hook !== 'object') continue; - if (!isActive(hook)) continue; const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + if (!isCapabilityEnabled(capId)) continue; + if (!isActive(hook)) continue; const into = typeof hook['into'] === 'string' ? hook['into'] : undefined; const fragment = toFragment(hook['fragment']); const when = typeof hook['when'] === 'string' ? hook['when'] : undefined; @@ -390,8 +407,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult const gates: RawHook[] = Array.isArray(gatesRaw) ? (gatesRaw as RawHook[]) : []; for (const hook of gates) { if (!hook || typeof hook !== 'object') continue; - if (!isActive(hook)) continue; const capId = typeof hook['capId'] === 'string' ? hook['capId'] : ''; + if (!isCapabilityEnabled(capId)) continue; + if (!isActive(hook)) continue; const when = typeof hook['when'] === 'string' ? hook['when'] : undefined; const check = hook['check'] !== undefined ? hook['check'] : undefined; const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined; @@ -522,24 +540,32 @@ function cmdLoopRenderHooks( cwd: string, point: string, raw: boolean, - _options: Record = {}, + options: Record = {}, ): void { if (!point) { coreError('loop render-hooks requires a argument. Valid points: ' + CANONICAL_POINTS.join(', ')); return; } - // Load registry at call time (generated file, not at module load time) - // eslint-disable-next-line @typescript-eslint/no-require-imports - const registry = require('./capability-registry.cjs') as Record; - // FIX 1: Pass loadConfig result as `config` (level 1 of precedence); - // raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue - // via the `cwd` argument passed to resolveLoopHooks. - const config = loadConfig(cwd); + const runtimeConfigDir = typeof options['configDir'] === 'string' + ? options['configDir'] + : undefined; + const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir) as { + warnings?: string[]; + registry: Record; + config: Record; + capabilities: Array<{ id: string; enabled?: boolean }>; + }; + const registry = state.registry; + const config = state.config || loadConfig(cwd); + const capabilityStatesById = new Map(); + for (const cap of state.capabilities || []) { + capabilityStatesById.set(cap.id, cap); + } let resolved: ResolveLoopHooksResult; try { - resolved = resolveLoopHooks({ point, registry, config, cwd }); + resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById }); } catch (err: unknown) { const msg = (err instanceof Error) ? err.message : String(err); coreError(msg); @@ -547,11 +573,19 @@ function cmdLoopRenderHooks( } const rendered = renderLoopHooks(resolved); - const envelope = { + const envelope: { + point: string; + activeHooks: ActiveHook[]; + rendered: string; + warnings?: string[]; + } = { point: resolved.point, activeHooks: resolved.activeHooks, rendered, }; + if (state.warnings && state.warnings.length > 0) { + envelope.warnings = state.warnings; + } coreOutput(envelope, raw); } diff --git a/tests/bug-2530-valid-config-keys.test.cjs b/tests/bug-2530-valid-config-keys.test.cjs index 53e30ef53..0518fa243 100644 --- a/tests/bug-2530-valid-config-keys.test.cjs +++ b/tests/bug-2530-valid-config-keys.test.cjs @@ -4,7 +4,7 @@ * Regression tests for config key bugs: * #2530 — workflow._auto_chain_active is internal state, must not be in VALID_CONFIG_KEYS * #2531 — hooks.workflow_guard is used by hook and documented but missing from VALID_CONFIG_KEYS - * #2532 — workflow.ui_review is used in autonomous.md but missing from VALID_CONFIG_KEYS + * #2532 — workflow.ui_review is used in autonomous.md but missing from config validation * #2533 — workflow.max_discuss_passes is used in discuss-phase.md but missing from VALID_CONFIG_KEYS * #2535 — sub_repos and plan_checker legacy keys need CONFIG_KEY_SUGGESTIONS migration hints * #3162 — resolve_model_ids missing from VALID_CONFIG_KEYS; workflow._auto_chain_active must be @@ -15,7 +15,13 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); -const { VALID_CONFIG_KEYS, isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); +const { + VALID_CONFIG_KEYS, + isCentralConfigKey, + isValidConfigKey, +} = require('../gsd-core/bin/lib/config-schema.cjs'); + +const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); describe('VALID_CONFIG_KEYS correctness', () => { test('#2530: workflow._auto_chain_active must not be in VALID_CONFIG_KEYS (internal state)', () => { @@ -32,10 +38,16 @@ describe('VALID_CONFIG_KEYS correctness', () => { ); }); - test('#2532: workflow.ui_review must be in VALID_CONFIG_KEYS (used in autonomous.md)', () => { - assert.ok( - VALID_CONFIG_KEYS.has('workflow.ui_review'), - 'workflow.ui_review is read in autonomous.md via gsd-sdk query config-get' + test('#2532: workflow.ui_review must remain valid but is no longer centrally owned', () => { + assert.strictEqual( + isValidConfigKey('workflow.ui_review'), + true, + 'workflow.ui_review is still user-facing config and must validate' + ); + assert.strictEqual( + isCentralConfigKey('workflow.ui_review'), + false, + 'workflow.ui_review is owned by the UI capability after ADR-857 Phase 6 cutover' ); }); @@ -62,6 +74,19 @@ describe('VALID_CONFIG_KEYS correctness', () => { }); }); +describe('ADR-857 Phase 6 capability config ownership', () => { + test('migrated capability config keys are valid through the registry, not central schema residue', () => { + const capabilityKeys = Object.keys(capabilityRegistry.configSchema || {}).sort(); + assert.ok(capabilityKeys.length > 0, 'expected generated registry config schema keys'); + + for (const key of capabilityKeys) { + assert.strictEqual(isValidConfigKey(key), true, `${key} must remain accepted by config validation`); + assert.strictEqual(isCentralConfigKey(key), false, `${key} must be capability-owned, not central`); + assert.strictEqual(VALID_CONFIG_KEYS.has(key), false, `${key} must not remain in central VALID_CONFIG_KEYS`); + } + }); +}); + describe('CONFIG_KEY_SUGGESTIONS migration hints (#2535)', () => { let tmpDir; diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index dac85ce43..062cd58ac 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -288,6 +288,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { assert.ok(uiCap, 'ui capability should be present'); assert.strictEqual(uiCap.installed, true); assert.strictEqual(uiCap.surfaced, true); + assert.strictEqual(uiCap.enabled, true); }); test('UI cap: installed=false when ui-review missing from installedSkills', () => { @@ -301,6 +302,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { const uiCap = result.capabilities.find((c) => c.id === 'ui'); assert.ok(uiCap); assert.strictEqual(uiCap.installed, false); + assert.strictEqual(uiCap.enabled, false); }); test('UI cap: surfaced=false when ui-review missing from surfacedSkills', () => { @@ -314,13 +316,14 @@ describe('resolveCapabilityState — UI capability with real registry', () => { const uiCap = result.capabilities.find((c) => c.id === 'ui'); assert.ok(uiCap); assert.strictEqual(uiCap.surfaced, false); + assert.strictEqual(uiCap.enabled, false); }); test('UI cap step hook: workflow.ui_phase true → active=true', () => { const result = resolveCapabilityState({ registry: realRegistry, installedSkills: '*', - surfacedSkills: new Set(), + surfacedSkills: new Set(['ui-phase', 'ui-review']), config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }, cwd: tmpProjectDir, }); @@ -331,9 +334,29 @@ describe('resolveCapabilityState — UI capability with real registry', () => { (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.configured, true); assert.strictEqual(planPreStep.active, true); }); + test('UI cap step hook: surfaced=false and workflow.ui_phase true → configured=true but active=false', () => { + 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); + assert.strictEqual(uiCap.enabled, false); + 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.configured, true); + assert.strictEqual(planPreStep.active, false); + }); + test('UI cap step hook: workflow.ui_phase false → active=false', () => { const result = resolveCapabilityState({ registry: realRegistry, @@ -348,6 +371,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { (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.configured, false); assert.strictEqual(planPreStep.active, false); }); @@ -355,7 +379,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { const result = resolveCapabilityState({ registry: realRegistry, installedSkills: '*', - surfacedSkills: new Set(), + surfacedSkills: new Set(['ui-phase', 'ui-review']), config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } }, cwd: tmpProjectDir, }); @@ -365,6 +389,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { (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.configured, true); assert.strictEqual(safetyGate.active, true); }); @@ -382,6 +407,7 @@ describe('resolveCapabilityState — UI capability with real registry', () => { (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.configured, false); assert.strictEqual(safetyGate.active, false); }); }); @@ -643,6 +669,7 @@ function runCapabilityState(cwd, configDir) { describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => { let tmpConfigDir; let tmpConfigDirCore; + let tmpConfigDirUiDisabled; before(() => { // Tmp runtime config dir without .gsd-profile (defaults to 'full') @@ -651,11 +678,24 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => { // 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'); + + tmpConfigDirUiDisabled = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-cfg-ui-disabled-')); + fs.writeFileSync( + path.join(tmpConfigDirUiDisabled, '.gsd-surface.json'), + JSON.stringify({ + baseProfile: 'full', + disabledClusters: ['ui'], + explicitAdds: [], + explicitRemoves: [], + }, null, 2), + 'utf8', + ); }); after(() => { cleanup(tmpConfigDir); cleanup(tmpConfigDirCore); + cleanup(tmpConfigDirUiDisabled); }); test('emits envelope with runtimeConfigDir and capabilities array', () => { @@ -685,4 +725,21 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => { const envelope = JSON.parse(result.stdout); assert.strictEqual(envelope.runtimeConfigDir, tmpConfigDir); }); + + test('surface-disabled UI capability reports enabled=false and inactive hooks', () => { + const result = runCapabilityState(tmpProjectDir, tmpConfigDirUiDisabled); + assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}: ${result.stderr}`); + const envelope = JSON.parse(result.stdout); + const uiCap = envelope.capabilities.find((c) => c.id === 'ui'); + assert.ok(uiCap, 'ui capability should be present in output'); + assert.strictEqual(uiCap.installed, true, 'full base profile still installs UI'); + assert.strictEqual(uiCap.surfaced, false, 'surface disables UI capability skills'); + assert.strictEqual(uiCap.enabled, false, 'disabled surface must disable the capability'); + const planPreStep = uiCap.hooks.find( + (h) => h.kind === 'step' && h.when === 'workflow.ui_phase', + ); + assert.ok(planPreStep, 'ui plan step should be present in diagnostic state'); + assert.strictEqual(planPreStep.configured, true, 'project config/schema still configures UI on'); + assert.strictEqual(planPreStep.active, false, 'effective hook activity must match workflow dispatch'); + }); }); diff --git a/tests/feat-2527-settings-layers.test.cjs b/tests/feat-2527-settings-layers.test.cjs index 662b0260d..1f6abf451 100644 --- a/tests/feat-2527-settings-layers.test.cjs +++ b/tests/feat-2527-settings-layers.test.cjs @@ -5,8 +5,8 @@ * six visual sections. Adds 8 new fields (pattern_mapper, tdd_mode, code_review, * code_review_depth, ui_review, commit_docs, intel.enabled, graphify.enabled) * and verifies each is present in the AskUserQuestion block, the update_config - * step, the confirmation table, the ~/.gsd/defaults.json save step, and - * VALID_CONFIG_KEYS. + * step, the confirmation table, the ~/.gsd/defaults.json save step, and the + * effective config-key validator. * * Closes: #2527 */ @@ -18,7 +18,11 @@ const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const SETTINGS_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'settings.md'); -const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); +const { + VALID_CONFIG_KEYS, + isCentralConfigKey, + isValidConfigKey, +} = require('../gsd-core/bin/lib/config-schema.cjs'); const NEW_FIELDS = [ 'workflow.pattern_mapper', @@ -31,6 +35,13 @@ const NEW_FIELDS = [ 'graphify.enabled', ]; +const CENTRAL_NEW_FIELDS = [ + 'workflow.tdd_mode', + 'commit_docs', +]; + +const CAPABILITY_OWNED_NEW_FIELDS = NEW_FIELDS.filter((field) => !CENTRAL_NEW_FIELDS.includes(field)); + const SECTION_HEADERS = ['Planning', 'Execution', 'Docs & Output', 'Features', 'Model & Pipeline', 'Misc']; /** @@ -134,12 +145,40 @@ describe('#2527: settings.md adds grouped settings layers', () => { }); }); - describe('Acceptance: all 8 new fields registered in VALID_CONFIG_KEYS', () => { + describe('Acceptance: all 8 new fields accepted by the config validator', () => { for (const field of NEW_FIELDS) { - test(`VALID_CONFIG_KEYS contains ${field}`, () => { + test(`config validator accepts ${field}`, () => { + assert.ok( + isValidConfigKey(field), + `${field} must be accepted so config-set can write it` + ); + }); + } + }); + + describe('Acceptance: migrated capability fields are no longer central config keys', () => { + for (const field of CAPABILITY_OWNED_NEW_FIELDS) { + test(`${field} is capability-owned, not central-schema residue`, () => { + assert.equal( + isCentralConfigKey(field), + false, + `${field} must be owned by the capability registry instead of the central schema` + ); + assert.equal( + VALID_CONFIG_KEYS.has(field), + false, + `${field} must not be duplicated in VALID_CONFIG_KEYS after Phase 6 migration` + ); + }); + } + }); + + describe('Acceptance: still-central settings remain in VALID_CONFIG_KEYS', () => { + for (const field of CENTRAL_NEW_FIELDS) { + test(`VALID_CONFIG_KEYS contains central setting ${field}`, () => { assert.ok( VALID_CONFIG_KEYS.has(field), - `${field} must be in VALID_CONFIG_KEYS so config-set accepts it` + `${field} is not a migrated capability key and must remain in VALID_CONFIG_KEYS` ); }); } diff --git a/tests/federated-config-loadconfig.test.cjs b/tests/federated-config-loadconfig.test.cjs index 36e7f8134..d3d5489f7 100644 --- a/tests/federated-config-loadconfig.test.cjs +++ b/tests/federated-config-loadconfig.test.cjs @@ -69,10 +69,10 @@ function mkTemp() { return d; } -// ─── 1. Equivalence / no-op with real registry ─────────────────────────────── +// ─── 1. Real registry overlay after Phase 6 cutover ────────────────────────── -describe('EQUIVALENCE: real registry is a no-op overlay', () => { - test('loadConfig with an empty config.json returns base config without extra federated keys', () => { +describe('REAL REGISTRY: capability config keys are surfaced by federated overlay', () => { + test('loadConfig with an empty config.json returns capability-owned defaults', () => { const tmpDir = mkTemp(); // Write an empty config to trigger the try-branch (federated overlay path) writeConfig(tmpDir, {}); @@ -99,30 +99,14 @@ describe('EQUIVALENCE: real registry is a no-op overlay', () => { ); } - // UI-capability keys must NOT appear as new top-level keys (they're still central - // and the overlay is empty — so these keys should not be added) - // Note: 'workflow' IS an existing top-level key concept via VALID_CONFIG_KEYS, - // but the nested keys like 'ui_phase' must not be present. const workflowSection = result['workflow']; - if (workflowSection && typeof workflowSection === 'object') { - // workflow section may already exist from user config but should not have ui_phase - // in the default no-config case - assert.ok( - !Object.prototype.hasOwnProperty.call(workflowSection, 'ui_phase'), - 'workflow.ui_phase should not be injected by the federated overlay (key is still central)', - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(workflowSection, 'ui_review'), - 'workflow.ui_review should not be injected by the federated overlay (key is still central)', - ); - assert.ok( - !Object.prototype.hasOwnProperty.call(workflowSection, 'ui_safety_gate'), - 'workflow.ui_safety_gate should not be injected by the federated overlay (key is still central)', - ); - } + assert.ok(typeof workflowSection === 'object' && workflowSection !== null, 'workflow section must be created by federated overlay'); + assert.strictEqual(workflowSection.ui_phase, true, 'workflow.ui_phase comes from the UI capability default'); + assert.strictEqual(workflowSection.ui_review, true, 'workflow.ui_review comes from the UI capability default'); + assert.strictEqual(workflowSection.ui_safety_gate, true, 'workflow.ui_safety_gate comes from the UI capability default'); }); - test('loadConfig with a real config.json returns expected values + no unexpected keys from overlay', () => { + test('loadConfig with a real config.json returns expected values plus capability defaults', () => { const tmpDir = mkTemp(); writeConfig(tmpDir, { model_profile: 'balanced', research: true }); const result = loadConfig(tmpDir); @@ -130,10 +114,8 @@ describe('EQUIVALENCE: real registry is a no-op overlay', () => { assert.strictEqual(result['model_profile'], 'balanced', 'model_profile from config'); assert.strictEqual(result['research'], true, 'research from config'); - // The overlay must not have added any unexpected keys from the registry - // (all UI keys are central → skipped → no additions) - // Verify a spot-check: 'ui_phase' should not exist anywhere assert.strictEqual(result['ui_phase'], undefined, 'ui_phase should not appear as top-level key'); + assert.strictEqual(result.workflow.ui_phase, true, 'workflow.ui_phase must be nested under workflow'); }); }); @@ -309,8 +291,7 @@ describe('FIX 2: overlay applied on the no-config path', () => { ); }); - test('no-config path with REAL registry (all keys central) → output is byte-identical to defaults (no-op)', () => { - // No config.json — use real registry which has all keys central + test('no-config path with REAL registry → capability defaults are surfaced', () => { _resetFederatedRegistryForTests(); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fed-noconfig-real-')); tmpDirs.push(tmpDir); @@ -320,16 +301,9 @@ describe('FIX 2: overlay applied on the no-config path', () => { const result = loadConfig(tmpDir); assert.ok(typeof result === 'object' && result !== null, 'result must be an object'); - // With real registry (all keys central → overlay is empty → no-op), the result - // should be the defaults object WITHOUT any extra injected keys. - // Spot-check: ui_phase / ui_review / ui_safety_gate must NOT be injected const workflowSection = result['workflow']; - if (workflowSection && typeof workflowSection === 'object') { - assert.ok( - !Object.prototype.hasOwnProperty.call(workflowSection, 'ui_phase'), - 'workflow.ui_phase must not be injected on no-config path (no-op)', - ); - } + assert.ok(typeof workflowSection === 'object' && workflowSection !== null, 'workflow section must be created by real registry overlay'); + assert.strictEqual(workflowSection.ui_phase, true, 'workflow.ui_phase must be injected on no-config path'); // model_profile must be present (it comes from defaults) assert.ok(Object.prototype.hasOwnProperty.call(result, 'model_profile'), 'model_profile must be present'); }); diff --git a/tests/federated-config.test.cjs b/tests/federated-config.test.cjs index f6275badb..cdb69949f 100644 --- a/tests/federated-config.test.cjs +++ b/tests/federated-config.test.cjs @@ -641,16 +641,15 @@ describe('FIX 6c: N-level nested write and prototype-pollution via dotted keys', }); }); -// ─── 8. Real registry — all UI keys are central (no-op guarantee) ──────────── +// ─── 8. Real registry — capability-owned keys are live ─────────────────────── -describe('real registry: all UI keys are central → no-op channel', () => { - test('with real capability-registry, all configSchema keys are skipped (pending-migration)', () => { +describe('real registry: capability config keys are federated', () => { + test('with real capability-registry, configSchema keys are accepted through the federated channel', () => { const capRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); const configSchemaFromRegistry = capRegistry.configSchema; - // Import the real isValidConfigKey const configSchemaModule = require('../gsd-core/bin/lib/config-schema.cjs'); - const { isValidConfigKey } = configSchemaModule; + const { isCentralConfigKey } = configSchemaModule; if (!configSchemaFromRegistry || Object.keys(configSchemaFromRegistry).length === 0) { // Registry has no configSchema keys — no-op by definition @@ -659,22 +658,17 @@ describe('real registry: all UI keys are central → no-op channel', () => { const result = mergeFederatedConfig({ configSchema: configSchemaFromRegistry, - isCentralKey: isValidConfigKey, + isCentralKey: isCentralConfigKey, userConfig: {}, }); - // Every key should be skipped (pending-migration) because UI keys are still in central schema - assert.strictEqual(Object.keys(result.values).length, 0, 'values must be empty — all keys are central (pending-migration)'); - assert.deepEqual(result.validKeys, [], 'validKeys must be empty'); - assert.ok(result.warnings.length > 0, 'Should have pending-migration warnings'); + assert.ok(Object.keys(result.values).length > 0, 'values must include capability-owned defaults'); + assert.ok(result.validKeys.includes('workflow.ui_phase'), 'workflow.ui_phase must flow through federated config'); + assert.strictEqual(result.values['workflow.ui_phase'], true); - // Confirm each UI key specifically const uiKeys = ['workflow.ui_phase', 'workflow.ui_review', 'workflow.ui_safety_gate']; for (const key of uiKeys) { - assert.ok( - result.warnings.some((w) => w.includes(key)), - 'Should have a warning for ' + key + ', got: ' + JSON.stringify(result.warnings), - ); + assert.ok(result.validKeys.includes(key), 'Expected ' + key + ' in validKeys'); } }); }); diff --git a/tests/loop-render-hooks.test.cjs b/tests/loop-render-hooks.test.cjs index f3bffad52..0e916daf8 100644 --- a/tests/loop-render-hooks.test.cjs +++ b/tests/loop-render-hooks.test.cjs @@ -54,6 +54,8 @@ let tmpProjectDir; let tmpEmptyProjectDir; // A project where ui_phase is explicitly false in root config let tmpFalseConfigProjectDir; +// Runtime config dir whose surface disables the UI capability +let tmpUiDisabledConfigDir; before(() => { tmpProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-test-')); @@ -79,12 +81,25 @@ before(() => { JSON.stringify({ workflow: { ui_phase: false, ui_review: false, ui_safety_gate: false } }), 'utf8', ); + + tmpUiDisabledConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-resolver-ui-disabled-')); + fs.writeFileSync( + path.join(tmpUiDisabledConfigDir, '.gsd-surface.json'), + JSON.stringify({ + baseProfile: 'full', + disabledClusters: ['ui'], + explicitAdds: [], + explicitRemoves: [], + }, null, 2), + 'utf8', + ); }); after(() => { if (tmpProjectDir) cleanup(tmpProjectDir); if (tmpEmptyProjectDir) cleanup(tmpEmptyProjectDir); if (tmpFalseConfigProjectDir) cleanup(tmpFalseConfigProjectDir); + if (tmpUiDisabledConfigDir) cleanup(tmpUiDisabledConfigDir); }); // ─── 1. Canonical-point validation ─────────────────────────────────────────── @@ -799,6 +814,31 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { assert.match(envelope.rendered, /ui-phase/); }); + test('loop render-hooks plan:pre with ui capability disabled in surface → ui hooks absent', () => { + const result = spawnSync( + process.execPath, + [ + GSD_TOOLS, + 'loop', + 'render-hooks', + 'plan:pre', + '--cwd', + tmpEmptyProjectDir, + '--config-dir', + tmpUiDisabledConfigDir, + ], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + const envelope = JSON.parse(result.stdout.trim()); + const uiHooks = envelope.activeHooks.filter(h => h.capId === 'ui'); + assert.deepStrictEqual( + uiHooks, + [], + 'UI hooks must be absent when the UI capability is disabled at the runtime surface', + ); + }); + // FIX 4: explicit false in config.json overrides schema default test('loop render-hooks plan:pre with ui_phase=false in config.json → ui-phase step absent', () => { const result = spawnSync(