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

* feat(#1136): consume resolved capability state

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -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` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { 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/<id>/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 <point>` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. 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 <path>]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (no `when` → active=true; `when` resolved via `_resolveActivationValue` from loop-resolver). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. `runtimeConfigDir` auto-detection falls back to `getGlobalConfigDir` based on env-var presence (CODEX_HOME → codex, CURSOR_CONFIG_DIR → cursor, GEMINI_CONFIG_DIR → gemini, CLAUDE_CONFIG_DIR → claude, default → claude/`~/.claude`).
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 <path>]` — 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).

View File

@@ -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 <point>` |
| `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b; composes install profile, runtime surface, and config activation into one per-capability view; pure `resolveCapabilityState` + I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir <path>]` |
| `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 <point> [--config-dir <path>]` |
| `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 <path>]` |
| `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 |

View File

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

View File

@@ -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/<id>/capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) |
| `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b) — composes install profile, runtime surface, and config activation into one per-capability view; exports pure `resolveCapabilityState` + I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir <path>]` emitting `{ runtimeConfigDir, capabilities[] }` |
| `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 <path>]` emitting `{ runtimeConfigDir, capabilities[] }` |
| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` |
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
@@ -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 <point>` |
| `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 <point> [--config-dir <path>]` |
| `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 |

View File

@@ -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.<feature>` checks to a host workflow for a migrated feature.

View File

@@ -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 <point>`.
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 <point>`.
## 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:

View File

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

View File

@@ -1236,7 +1236,23 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// loop render-hooks <point>
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`,

View File

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

View File

@@ -0,0 +1,100 @@
/**
* Capability activation helpers.
*
* Shared by the Capability State Resolver and Loop Resolver so config-key
* activation uses one precedence chain and one prototype-pollution guard.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningDir, planningRoot } = planningWorkspaceMod;
function _getNestedConfigValue(
config: Record<string, unknown>,
dotKey: string,
): { found: boolean; value: unknown } {
const segments = dotKey.split('.');
let current: unknown = config;
for (const seg of segments) {
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
return { found: false, value: undefined };
}
if (typeof current !== 'object' || current === null) {
return { found: false, value: undefined };
}
const cur = current as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
return { found: false, value: undefined };
}
current = cur[seg];
}
return { found: true, value: current };
}
const _warnedRawConfigPaths = new Set<string>();
function _readRawConfigKey(
filePath: string,
dotKey: string,
): { found: boolean; value: unknown } {
try {
const raw = fs.readFileSync(filePath, 'utf8');
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
if (!_warnedRawConfigPaths.has(filePath)) {
_warnedRawConfigPaths.add(filePath);
try {
process.stderr.write(
`gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`,
);
} catch { /* stderr might be closed */ }
}
return { found: false, value: undefined };
}
return _getNestedConfigValue(parsed, dotKey);
} catch {
return { found: false, value: undefined };
}
}
function _resolveActivationValue(
dotKey: string,
config: Record<string, unknown>,
cwd: string | undefined,
registry: Record<string, unknown>,
): boolean {
const fromConfig = _getNestedConfigValue(config, dotKey);
if (fromConfig.found) return Boolean(fromConfig.value);
if (cwd) {
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
if (fromWs.found) return Boolean(fromWs.value);
if (wsConfigPath !== rootConfigPath) {
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
if (fromRoot.found) return Boolean(fromRoot.value);
}
}
const schemaEntry = (registry['configSchema'] as Record<string, unknown> | undefined)?.[dotKey];
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
const def = (schemaEntry as Record<string, unknown>)['default'];
if (def !== undefined) return Boolean(def);
}
return false;
}
export = {
_getNestedConfigValue,
_readRawConfigKey,
_resolveActivationValue,
};

View File

@@ -3,9 +3,8 @@
*
* Unified capability-state resolver that composes the three toggle systems
* (install profile, runtime surface, config activation) into one per-capability
* view. ADDITIVE — install/surface/workflows are untouched; this resolver is
* exposed only as the `gsd-tools capability state` diagnostic, not yet consumed by
* any workflow (workflow wiring is the phase-6 cutover).
* view. The loop resolver consumes this state so workflow dispatch and the
* `gsd-tools capability state` diagnostic share the same enablement answer.
*
* Exports (three things, mirroring loop-resolver):
* resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
@@ -19,9 +18,9 @@
* cmdCapabilityState is the I/O handler.
*
* Dependencies (leaf modules only — no core.cjs circular risk):
* - node:path (used by _resolveActivationValue via loop-resolver)
* - node:path
* - ./core.cjs (output, error)
* - ./loop-resolver.cjs (_resolveActivationValue — reuse the export)
* - ./capability-activation.cjs (_resolveActivationValue)
* - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
* - ./surface.cjs (resolveSurface)
* - ./config-loader.cjs (loadConfig)
@@ -36,8 +35,8 @@ import core = require('./core.cjs');
const { output: coreOutput, error: coreError } = core;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import loopResolverMod = require('./loop-resolver.cjs');
const { _resolveActivationValue } = loopResolverMod;
import activationMod = require('./capability-activation.cjs');
const { _resolveActivationValue } = activationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
@@ -66,6 +65,8 @@ interface HookEntry {
*/
when: unknown;
/** Whether this hook is currently active based on config */
configured: boolean;
/** Whether this hook participates after capability enablement is applied */
active: boolean;
}
@@ -85,6 +86,8 @@ interface CapabilityStateEntry {
* Vacuously true for capabilities with an empty skills array.
*/
surfaced: boolean;
/** True when the capability is both installed and surfaced. */
enabled: boolean;
/** Resolved hook activation state across steps, gates, and contributions */
hooks: HookEntry[];
}
@@ -108,6 +111,14 @@ interface ResolveCapabilityStateResult {
capabilities: CapabilityStateEntry[];
}
interface ResolveCapabilityRuntimeStateResult {
runtimeConfigDir: string;
warnings: string[];
registry: Record<string, unknown>;
config: Record<string, unknown>;
capabilities: CapabilityStateEntry[];
}
// ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
function _isSafePropKey(key: unknown): key is string {
@@ -197,6 +208,8 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
surfaced = skills.every((s) => surfacedSkills.has(s));
}
const enabled = installed && surfaced;
// ── hooks ──────────────────────────────────────────────────────────────────
// Collect from steps, gates, contributions. Each may have a `when` key.
// Activation semantics (mirrors loop-resolver.isActive exactly):
@@ -216,19 +229,19 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
const point = typeof h['point'] === 'string' ? h['point'] : '';
// Carry the raw `when` value through for visibility
const whenRaw: unknown = h['when'];
let active: boolean;
let configured: boolean;
if (whenRaw === undefined || whenRaw === null) {
// No `when` field → unconditional, always active
active = true;
configured = true;
} else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
// Non-empty string `when` → resolve via _resolveActivationValue
active = _resolveActivationValue(whenRaw, config, cwd, registry);
configured = _resolveActivationValue(whenRaw, config, cwd, registry);
} else {
// Present-but-empty-string or non-string `when` → malformed, inactive
// (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
active = false;
configured = false;
}
hooks.push({ point, kind, when: whenRaw, active });
hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
}
}
@@ -240,7 +253,7 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
results.push({ id: capId, tier, skills, installed, surfaced, hooks });
results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
}
// Deterministic sort by id for stable output across calls
@@ -295,12 +308,10 @@ function _resolveCommandsGsdDir(): string {
* @param raw Whether to emit raw JSON (core.output raw mode)
* @param _options Reserved for future use
*/
function cmdCapabilityState(
function resolveCapabilityRuntimeState(
cwd: string,
runtimeConfigDir: string | undefined | null,
raw: boolean,
_options: Record<string, unknown> = {},
): void {
): ResolveCapabilityRuntimeStateResult {
const warnings: string[] = [];
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
@@ -358,7 +369,6 @@ function cmdCapabilityState(
// Genuine resolution failure — surface it so the caller is not misled.
const msg = err instanceof Error ? err.message : String(err);
warnings.push(`profile-resolution failed: ${msg}`);
coreError(`capability state: profile resolution failed: ${msg}`);
// Degrade to empty set (not '*') so installed=false is reported accurately.
installedSkills = new Set<string>();
}
@@ -378,7 +388,6 @@ function cmdCapabilityState(
// Genuine surface resolution failure — surface it so the caller is not misled.
const msg = err instanceof Error ? err.message : String(err);
warnings.push(`surface-resolution failed: ${msg}`);
coreError(`capability state: surface resolution failed: ${msg}`);
surfacedSkills = new Set<string>();
}
@@ -400,6 +409,26 @@ function cmdCapabilityState(
cwd,
});
return {
runtimeConfigDir: resolvedConfigDir,
warnings,
registry,
config,
capabilities: result.capabilities,
};
}
function cmdCapabilityState(
cwd: string,
runtimeConfigDir: string | undefined | null,
raw: boolean,
_options: Record<string, unknown> = {},
): void {
const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
for (const warning of result.warnings) {
coreError(`capability state: ${warning}`);
}
// Build envelope — include warnings array only when non-empty so the nominal
// path keeps the output clean and callers can check `warnings` for degraded state.
const envelope: {
@@ -407,11 +436,11 @@ function cmdCapabilityState(
warnings?: string[];
capabilities: CapabilityStateEntry[];
} = {
runtimeConfigDir: resolvedConfigDir,
runtimeConfigDir: result.runtimeConfigDir,
capabilities: result.capabilities,
};
if (warnings.length > 0) {
envelope.warnings = warnings;
if (result.warnings.length > 0) {
envelope.warnings = result.warnings;
}
coreOutput(envelope, raw);
@@ -419,6 +448,7 @@ function cmdCapabilityState(
export = {
resolveCapabilityState,
resolveCapabilityRuntimeState,
cmdCapabilityState,
// Exported for tests
_resolveCommandsGsdDir,

View File

@@ -35,7 +35,7 @@ const { detectSubRepos } = coreUtilsModule;
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs';
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -358,7 +358,7 @@ function _applyFederatedOverlay(
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
userConfig,
});
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
@@ -492,7 +492,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
// Internal keys loadConfig reads but config-set doesn't expose
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
// Deprecated keys (still accepted for migration, not in config-set)
'depth', 'multiRepo', 'branching_strategy',
'depth', 'multiRepo', 'branching_strategy', 'research',
]);
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
@@ -504,7 +504,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
const _earlyOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchemaEarly,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
userConfig: parsed,
});
_preWarningFedValidKeys = _earlyOverlay.validKeys;
@@ -631,7 +631,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
userConfig: parsed,
});
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
@@ -655,8 +655,8 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
return loadConfig(cwd, { workstream: null });
}
// FIX 2: Apply the federated overlay on the no-config path.
// With the current registry (all keys central), _applyFederatedOverlay returns
// `defaults` UNCHANGED (true no-op, preserves byte-identical output).
// Migrated Capability keys are surfaced from the generated registry even
// when the project has no config.json, so schema defaults still apply.
try {
return _applyFederatedOverlay(defaults, {});
} catch {

View File

@@ -21,13 +21,43 @@ import {
DYNAMIC_KEY_PATTERNS,
} from './configuration.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const capabilityRegistry = require('./capability-registry.cjs') as {
configSchema?: Record<string, unknown>;
};
function isCapabilityConfigKey(keyPath: string): boolean {
if (typeof keyPath !== 'string') return false;
const schema = capabilityRegistry.configSchema;
if (!schema || typeof schema !== 'object') return false;
return Object.prototype.hasOwnProperty.call(schema, keyPath);
}
/**
* Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
* Returns true for keys owned by the central schema adapter rather than a
* federated Capability config slice.
*/
function isValidConfigKey(keyPath: string): boolean {
function isCentralConfigKey(keyPath: string): boolean {
if (typeof keyPath !== 'string') return false;
if (VALID_CONFIG_KEYS.has(keyPath)) return true;
if (RUNTIME_STATE_KEYS.has(keyPath)) return true;
return DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
}
export = { VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey };
/**
* Returns true if keyPath is a valid central, runtime-state, dynamic, or
* federated Capability config key.
*/
function isValidConfigKey(keyPath: string): boolean {
if (isCentralConfigKey(keyPath)) return true;
return isCapabilityConfigKey(keyPath);
}
export = {
VALID_CONFIG_KEYS,
RUNTIME_STATE_KEYS,
DYNAMIC_KEY_PATTERNS,
isCapabilityConfigKey,
isCentralConfigKey,
isValidConfigKey,
};

View File

@@ -39,6 +39,10 @@ const { output: coreOutput, error: coreError } = core;
import configLoaderModule = require('./config-loader.cjs');
const { loadConfig } = configLoaderModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateModule = require('./capability-state.cjs');
const { resolveCapabilityRuntimeState } = capabilityStateModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningDir, planningRoot } = planningWorkspaceMod;
@@ -263,6 +267,8 @@ interface ResolveLoopHooksInput {
config: Record<string, unknown>;
/** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */
cwd?: string;
/** Optional capability-state map; when present, disabled capabilities do not render hooks. */
capabilityStatesById?: Map<string, { enabled?: boolean }> | Record<string, { enabled?: boolean }>;
}
interface ResolveLoopHooksResult {
@@ -286,7 +292,7 @@ interface ResolveLoopHooksResult {
* resolved against `config`; active iff truthy. Inactive hooks are filtered out.
*/
function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult {
const { point, registry, config, cwd } = input;
const { point, registry, config, cwd, capabilityStatesById } = input;
// Validate point
const canonicalPoints = _getCanonicalPoints(registry);
@@ -322,6 +328,15 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
return _resolveActivationValue(when, config, cwd, registry);
}
function isCapabilityEnabled(capId: string): boolean {
if (!capabilityStatesById) return true;
const state = capabilityStatesById instanceof Map
? capabilityStatesById.get(capId)
: capabilityStatesById[capId];
if (!state) return false;
return state.enabled !== false;
}
// Helper: safe string array
function toStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
@@ -342,8 +357,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : [];
for (const hook of steps) {
if (!hook || typeof hook !== 'object') continue;
if (!isActive(hook)) continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isActive(hook)) continue;
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
? (hook['ref'] as HookRef)
: undefined;
@@ -367,8 +383,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
const contributions: RawHook[] = Array.isArray(contributionsRaw) ? (contributionsRaw as RawHook[]) : [];
for (const hook of contributions) {
if (!hook || typeof hook !== 'object') continue;
if (!isActive(hook)) continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isActive(hook)) continue;
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
const fragment = toFragment(hook['fragment']);
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
@@ -390,8 +407,9 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
const gates: RawHook[] = Array.isArray(gatesRaw) ? (gatesRaw as RawHook[]) : [];
for (const hook of gates) {
if (!hook || typeof hook !== 'object') continue;
if (!isActive(hook)) continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isActive(hook)) continue;
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
const check = hook['check'] !== undefined ? hook['check'] : undefined;
const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
@@ -522,24 +540,32 @@ function cmdLoopRenderHooks(
cwd: string,
point: string,
raw: boolean,
_options: Record<string, unknown> = {},
options: Record<string, unknown> = {},
): void {
if (!point) {
coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
return;
}
// Load registry at call time (generated file, not at module load time)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
// FIX 1: Pass loadConfig result as `config` (level 1 of precedence);
// raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue
// via the `cwd` argument passed to resolveLoopHooks.
const config = loadConfig(cwd);
const runtimeConfigDir = typeof options['configDir'] === 'string'
? options['configDir']
: undefined;
const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir) as {
warnings?: string[];
registry: Record<string, unknown>;
config: Record<string, unknown>;
capabilities: Array<{ id: string; enabled?: boolean }>;
};
const registry = state.registry;
const config = state.config || loadConfig(cwd);
const capabilityStatesById = new Map<string, { enabled?: boolean }>();
for (const cap of state.capabilities || []) {
capabilityStatesById.set(cap.id, cap);
}
let resolved: ResolveLoopHooksResult;
try {
resolved = resolveLoopHooks({ point, registry, config, cwd });
resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
} catch (err: unknown) {
const msg = (err instanceof Error) ? err.message : String(err);
coreError(msg);
@@ -547,11 +573,19 @@ function cmdLoopRenderHooks(
}
const rendered = renderLoopHooks(resolved);
const envelope = {
const envelope: {
point: string;
activeHooks: ActiveHook[];
rendered: string;
warnings?: string[];
} = {
point: resolved.point,
activeHooks: resolved.activeHooks,
rendered,
};
if (state.warnings && state.warnings.length > 0) {
envelope.warnings = state.warnings;
}
coreOutput(envelope, raw);
}

View File

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

View File

@@ -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');
});
});

View File

@@ -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`
);
});
}

View File

@@ -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');
});

View File

@@ -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');
}
});
});

View File

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