* feat(#1136): consume resolved capability state * chore(#1136): add capability state changeset
This commit is contained in:
5
.changeset/1136-capability-state-consumption.md
Normal file
5
.changeset/1136-capability-state-consumption.md
Normal 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
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
100
src/capability-activation.cts
Normal file
100
src/capability-activation.cts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user