feat(#1213): Capability State Writer — write-side inverse of the resolver (#1225)

* feat(#1213): Capability State Writer — write-side inverse of the resolver

Adds src/capability-writer.cts (setCapabilityState + cmdCapabilitySet) and the
`gsd-tools capability set` subcommand: the write-side inverse of the capability
resolver (ADR-1213). One desired capability state projects onto the substrates —
`enabled` drives the runtime surface (canonical on/off), `gates` drive federated
config keys (hook granularity), install profile is a read-only floor — then
re-resolves and reports divergence (assert-and-report), so "off means off" holds
as a write-time invariant. Adds batched setConfigValues; routes gsd:settings
capability hook-gates through the writer. Docs: CLI-TOOLS reference, how-to,
ADR-1213, CONTEXT.md term.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1213): add changeset for Capability State Writer (#1225)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 12:51:17 -04:00
committed by GitHub
parent 22f56f4431
commit bf634b95c3
16 changed files with 1427 additions and 28 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 1225
---
**`gsd-tools capability set` — turn capabilities on/off and gate hooks from one command.** Adds the write side of the capability system (ADR-857/ADR-1213): `capability set <id> --on|--off` toggles a capability through the runtime surface (the canonical on/off switch) and `--gate <key>=<true|false>` toggles a hook within an enabled capability, then re-resolves and reports — so disabling a capability is consistent across surface and config ("off means off") as a write-time invariant. `/gsd:settings` capability hook-gates now route through it. (#1213)

1
.gitignore vendored
View File

@@ -144,6 +144,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-writer.cjs
/gsd-core/bin/lib/capability-activation.cjs
/gsd-core/bin/lib/federated-config.cjs
/gsd-core/bin/lib/phase-locator.cjs

View File

@@ -169,6 +169,9 @@ A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in
### Capability State Resolver
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 State Writer
The write-side mirror of the Capability State Resolver. Takes a desired capability state — per-capability `enabled` plus per-hook `gates` — and projects it onto the substrates: `enabled` drives the runtime surface (`.gsd-surface.json`) as the capability on/off switch; `gates` drive the federated config keys (`config.json` `workflow.*`) for hook-level granularity; the install profile (`.gsd-profile`) is a read-only floor it never writes. Writes the surface once and config once (atomic per substrate), then re-runs the resolver and reports divergence (assert-and-report) — so 'off means off' holds as a write-time invariant rather than by caller discipline. Source of truth: `src/capability-writer.cts`; the surface and config writers become its internal adapters.
### 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` is Capability-owned config after ADR-857 phase 6. Completes the initial 4d capability command cutover batch.

View File

@@ -168,6 +168,44 @@ node gsd-tools.cjs config-set-model-profile <profile>
---
## Capability Commands
The capability command family resolves and mutates capability state (ADR-857). One resolved state composes three substrates: the install profile (`.gsd-profile`), the runtime surface (`.gsd-surface.json`), and config gates (`.planning/config.json` `workflow.*`). `enabled = installed && surfaced`; a hook is `active` only when its capability is enabled and its config gate is on.
### `capability state`
```bash
node gsd-tools.cjs capability state [--config-dir <path>] [--raw]
```
Resolves and prints every capability's `installed`, `surfaced`, `enabled`, and per-hook `active` state. Read-only. `--config-dir` selects the runtime config directory (defaults to the resolved Claude home). `--raw` emits JSON.
### `capability set`
```bash
node gsd-tools.cjs capability set <id> [--on | --off] [--gate <key>=<true|false>]... [--config-dir <path>] [--runtime <name>] [--scope <global|project>] [--raw]
```
Mutates one capability, re-resolves, and reports the result. Two axes:
- `--on` / `--off` (aliases `--enable` / `--disable`): the capability on/off switch, applied through the runtime surface. `--off` unsurfaces the capability; the change is reversible and reclaims the surface budget. A capability that owns no skills has no surface footprint — use `--gate` for those.
- `--gate <key>=<true|false>` (repeatable): toggles one of the capability's own config keys (a hook gate) within an enabled capability.
- `--runtime` / `--scope`: materialise the surface change for that runtime's artifact layout.
After writing, the command re-resolves and prints two message classes to stderr: errors (non-zero exit) — unknown capability id, a `--gate` key the capability does not own, a non-boolean gate value, or `--on` for a capability whose skills are not in the install profile; warnings (exit 0) — `--on`/`--off` on a skill-less capability, or a capability left surfaced while every hook is gated off ("present but dead"). Exit status is non-zero only when a requested change could not be applied.
**Examples:**
```bash
# Turn the UI capability off
node gsd-tools.cjs capability set ui --off --config-dir ~/.claude
# Keep the capability on, gate one hook off
node gsd-tools.cjs capability set code-review --gate workflow.code_review=false
```
---
## Model Resolution
```bash
@@ -503,6 +541,8 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs
| Audit | `lib/audit.cjs` | Phase/milestone audit queue handlers; `audit-open` helper |
| GSD2 Import | `lib/gsd2-import.cjs` | Reverse-migration importer from GSD-2 projects (backs `/gsd-import --from-gsd2`) |
| Intel | `lib/intel.cjs` | Queryable codebase intelligence index (backs `/gsd-map-codebase --query`) |
| Capability State | `lib/capability-state.cjs` | Capability-state resolver — composes install profile, surface, and config into per-capability `enabled`/`active` view |
| Capability Writer | `lib/capability-writer.cjs` | Capability-state writer (ADR-1213) — write-side inverse; projects `--on`/`--off`/`--gate` onto surface + config substrates then re-resolves |
| Worktree Base Ref | `lib/worktree-base-ref.cjs` | Worktree fork-base detection and `worktree base-check` / `set-baseref` commands (#683) |
---

View File

@@ -279,6 +279,7 @@
"capability-activation.cjs",
"capability-registry.cjs",
"capability-state.cjs",
"capability-writer.cjs",
"check-command-router.cjs",
"cjs-command-router-adapter.cjs",
"cli-exit.cjs",

View File

@@ -391,6 +391,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `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/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[] }` |
| `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set <id> [--on\|--off] [--gate <key>=<true\|false>]` |
| `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 |

View File

@@ -32,6 +32,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
- [Spike and sketch](how-to/spike-and-sketch.md) — use `/gsd-spike` and `/gsd-sketch` for exploratory work before committing to a plan
- [Design a UI phase](how-to/design-a-ui-phase.md) — use the UI phase loop for frontend and visual work
- [Develop a Capability for GSD 1.5+](how-to/develop-a-capability.md) — add feature Capabilities, hook fragments, and registry entries
- [Turn a capability off (and keep it off)](how-to/turn-a-capability-off.md) — disable a capability via the surface, or gate individual hooks off without removing the capability
- [Drive GSD from a tracker issue](how-to/drive-gsd-from-a-tracker-issue.md) — start a phase from a GitHub, Linear, or Jira issue
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
- [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release

View File

@@ -0,0 +1,72 @@
# ADR-1213: Capability write side — the Capability State Writer [Proposed]
- **Status:** Proposed
- **Date:** 2026-06-14
- **Issue:** #1213
- **Completes:** Capability system (ADR-857) — the write half of the phase-4 "Wire" step
- **Builds on:** Capability declaration format (ADR-894), Capability command contribution (ADR-959), Skill Surface Budget Module (ADR-0011)
## Context
ADR-857 promised: *"one resolved capability state replaces three contradicting toggle systems; 'off' means off."* The **read** side delivers it. The **Capability State Resolver** (`src/capability-state.cts`) collapses three substrates into one resolved state:
- install profile — `.gsd-profile` (is the capability's skill set installed?)
- runtime surface — `.gsd-surface.json` (is it surfaced into the runtime skills dir?)
- config gates — `config.json` `workflow.*` (is each hook configured on?)
with `enabled = installed && surfaced` and `active = enabled && configured`.
There is **no write side**. Three independent writers each mutate one substrate — `writeSurface` (`src/surface.cts`), `setConfigValue` (`src/config.cts`), `writeActiveProfile` (`src/install-profiles.cts`) — and every caller (`gsd:surface`, `gsd:settings`/`gsd:config`, `install.js`, and the future ADR-959 capability command) coordinates them by hand. So *off means off* holds only as a **read-time computation the write side can violate**: the worst case is a capability left surfaced while every hook is config-gated off — "present but dead", still materialized and still costing context, but doing nothing.
The three substrates are not three ways to say one "off". They are **orthogonal axes at different lifecycles**: install is files-on-disk (uninstall removes them), surface is reversible-without-reinstall runtime state, and config gates are per-workstream and version-controlled in `.planning/`.
## Decision
Introduce the **Capability State Writer** (`src/capability-writer.cts`), the inverse of the resolver:
```
setCapabilityState(cwd, runtimeConfigDir, desired: DesiredCapability[])
-> { capabilities: CapabilityStateEntry[]; warnings: string[] }
DesiredCapability = { id: string; enabled?: boolean; gates?: Record<string, boolean> }
```
It accepts a *desired* capability state in the resolver's own vocabulary and projects it onto the substrates:
1. **Two orthogonal axes, one interface.** Per-capability `enabled` drives the runtime **surface** (the canonical capability on/off switch); per-hook `gates` drive the federated **config keys** (hook-level granularity within an enabled capability). The install profile is a **read-only floor** the writer never writes.
2. **Surface is the canonical "off".** Disabling unsurfaces — reversible, restart-and-go, and it reclaims the surface budget. It does not uninstall and does not clear config gates, so re-enabling restores prior gates; because `enabled = false` forces every hook `active = false` in the resolver, stale gates are harmless while off.
3. **One write per substrate.** A batch computes the full new surface state (one `writeSurface`) and the config deltas (one `setConfigValue` batch under `withPlanningLock`) — atomic per substrate, so no cross-substrate transaction is required.
4. **Assert-and-report.** After writing, re-run `resolveCapabilityState` and diff against `desired`; divergence (an uninstallable skill, a present-but-dead capability) is returned as `warnings`, not silently swallowed. The resolver is the writer's test surface: `resolve(write(s, d)) == d`.
5. **Callers.** `gsd:settings` and the ADR-959 capability command route capability mutations through it, and `gsd-tools capability set` is the direct CLI. `gsd:surface` is a broader skill-surface tool operating on the **cluster superset** — capability clusters plus hand-authored clusters such as `utility`/`audit_review` — so it keeps its own surface mechanism rather than routing through the capability-scoped writer; the resolver honours any surface write, so *off means off* holds regardless of which path wrote. `install.js` keeps the profile-floor write (install lifecycle).
A `gsd-tools capability set` subcommand (sibling to `capability state`) exposes it.
New domain term recorded in `CONTEXT.md`: **Capability State Writer**.
## Alternatives considered
| Decision | Rejected alternative | Why rejected |
|---|---|---|
| Substrate model | Collapse the three substrates into one capability-intent store | They encode genuinely different lifecycles (install = files on disk; surface = reversible runtime; config = per-workstream, version-controlled). Orthogonal axes, not redundant toggles; one store cannot hold the per-workstream config dimension without reinventing it. The resolver + writer win the invariant **without** merging the lifecycles. |
| Canonical "off" | Uninstall (drop from profile + delete files) | Heavy; needs a reinstall to undo; frees disk, not the context budget surface already reclaims. Keep uninstall as a separate explicit install-lifecycle operation. |
| Canonical "off" | Config-gate every hook | Leaves the skill surfaced-but-dead ("present but dead") and is per-workstream — contradicts off-means-off at capability granularity. |
| Interface scope | Capability-level enable only; hook gates stay in `config-set` | Splits the off-means-off invariant across two interfaces; the surfaced-but-all-gated check then has no single home. |
| Verification | Hard rollback (snapshot + restore) | Earns its keep only with cross-substrate transactions, which the one-write-per-substrate split avoids; assert-and-report suffices. |
## Consequences
**Positive**
- *Off means off* becomes a **write-time invariant**, not caller discipline.
- **Locality:** the projection and the invariants (including the present-but-dead check) live in one module.
- **Leverage:** one capability-mutation interface used by `gsd:settings`, the ADR-959 capability command, and the `capability set` CLI.
- Symmetric with the resolver seam; the surface and config writers become its internal adapters.
**Negative / costs**
- A new always-on module to build and keep correct (and a generated `.cjs` to ship via `build:lib`).
- The desired-state vocabulary becomes a depended-on interface (Hyrum's Law) — name it and keep it compatible.
- The present-but-dead signal is advisory: the writer warns rather than auto-mutating, respecting explicit intent.
## Open questions
- Whether `enabled: true` for a capability below the install floor should auto-add it to surface `explicitAdds` (transitive closure) or warn-and-refuse.
- Whether the round-trip `resolve ∘ write == identity` warrants a deterministic CI conformance test alongside ADR-894's registry gates.

View File

@@ -0,0 +1,81 @@
# Turn a capability off (and keep it off)
This guide shows you how to switch a GSD capability off so it stops taking part in the loop — and stays off — and how to switch off a single feature of a capability without disabling the whole thing.
GSD resolves one capability state from three places: whether the capability is installed, whether it is surfaced, and whether each of its hooks is gated in config. "Off" means off across all three. For why the model works this way, see [Develop a Capability for GSD 1.5+](develop-a-capability.md).
---
## Turn a whole capability off
Use the runtime surface — the on/off switch. It is reversible and needs no reinstall:
```
/gsd:surface disable <capability>
```
For example, to stop the UI capability:
```
/gsd:surface disable ui
```
The capability's skills leave the surface and all of its hooks go inactive. Check the result with:
```bash
node gsd-tools.cjs capability state --raw
```
The capability now reports `enabled: false` and every hook `active: false`. To turn it back on, `/gsd:surface enable ui` — your earlier hook gates are preserved.
---
## Turn off one feature of a capability
To keep a capability on but switch off a single hook, gate that hook instead of disabling the capability. Use `/gsd:settings`, or set the key directly:
```bash
node gsd-tools.cjs capability set code-review --gate workflow.code_review=false
```
The capability stays enabled; only that hook stops firing.
---
## Capabilities that own no skills
Some capabilities (for example, research) contribute only hooks and agents — they have no skills to unsurface, so `/gsd:surface disable` does not affect them. Switch these off by gating their hooks:
```bash
node gsd-tools.cjs capability set research --gate workflow.research=false
```
If you gate every hook of a capability off while it is still surfaced, `gsd-tools capability state` flags it as surfaced-but-inactive — a sign you probably meant to disable the capability itself.
---
## Scripting it
`/gsd:surface` and `/gsd:settings` are the interactive paths. To mutate capability state directly (in scripts or CI), call the underlying command:
```bash
# Disable via surface
node gsd-tools.cjs capability set <id> --off
# Re-enable
node gsd-tools.cjs capability set <id> --on
# Toggle one hook gate
node gsd-tools.cjs capability set <id> --gate <key>=<true|false>
```
See [CLI tools — Capability Commands](../CLI-TOOLS.md#capability-commands) for the full reference.
---
## Related
- [Develop a Capability for GSD 1.5+](develop-a-capability.md)
- [Install a minimal GSD and add skills later](install-minimal-and-add-skills.md)
- [CLI tools reference — Capability Commands](../CLI-TOOLS.md#capability-commands)
- [docs index](../README.md)

View File

@@ -154,6 +154,8 @@ export default tseslint.config(
'gsd-core/bin/lib/package-legitimacy.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/git-base-branch.cts source.
'gsd-core/bin/lib/git-base-branch.cjs',
// ADR-1213: tsc-generated runtime artifact — lint the src/capability-writer.cts source.
'gsd-core/bin/lib/capability-writer.cjs',
],
},

View File

@@ -217,6 +217,7 @@ const verification = require('./lib/verification.cjs');
const { routeInitCommand } = require('./lib/init-command-router.cjs');
const loopResolver = require('./lib/loop-resolver.cjs');
const capabilityState = require('./lib/capability-state.cjs');
const capabilityWriter = require('./lib/capability-writer.cjs');
const { routePhaseCommand } = require('./lib/phase-command-router.cjs');
const { routePhasesCommand } = require('./lib/phases-command-router.cjs');
const { routeValidateCommand } = require('./lib/validate-command-router.cjs');
@@ -1304,9 +1305,85 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
const resolvedConfigDir = configDir ? path.resolve(configDir) : null;
capabilityState.cmdCapabilityState(cwd, resolvedConfigDir, raw, {});
} else if (capSubcommand === 'set') {
// capability set <id> [--on|--off|--enable|--disable] [--gate <key>=<bool>]... [--config-dir <dir>] [--runtime <r>] [--scope <s>]
const capId = args[2];
if (!capId || capId.startsWith('--')) {
error('Missing capability id for: capability set <id>', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
// Parse --config-dir
const setConfigDirIdx = args.indexOf('--config-dir');
let setConfigDir = null;
if (setConfigDirIdx !== -1) {
const setConfigDirVal = args[setConfigDirIdx + 1];
if (!setConfigDirVal || setConfigDirVal.startsWith('--')) {
error('Missing value for --config-dir', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
setConfigDir = setConfigDirVal;
}
const resolvedSetConfigDir = setConfigDir ? path.resolve(setConfigDir) : null;
// Parse --on/--enable and --off/--disable (mutually exclusive)
const hasOn = args.includes('--on') || args.includes('--enable');
const hasOff = args.includes('--off') || args.includes('--disable');
if (hasOn && hasOff) {
error('Conflicting flags: --on/--enable and --off/--disable cannot both be present', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
let setEnabled;
if (hasOn) {
setEnabled = true;
} else if (hasOff) {
setEnabled = false;
}
// Parse --gate <key>=<bool> (repeatable)
const setGates = {};
for (let gi = 0; gi < args.length; gi++) {
if (args[gi] === '--gate') {
const gateVal = args[gi + 1];
if (!gateVal || gateVal.startsWith('--')) {
error('Missing value for --gate (expected <key>=<true|false>)', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
const eqIdx = gateVal.indexOf('=');
if (eqIdx === -1) {
error(`Malformed --gate value "${gateVal}": expected <key>=<true|false>`, core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
const gateKey = gateVal.slice(0, eqIdx);
const gateBoolStr = gateVal.slice(eqIdx + 1);
if (gateBoolStr !== 'true' && gateBoolStr !== 'false') {
error(`Malformed --gate value "${gateVal}": bool must be true or false`, core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
setGates[gateKey] = gateBoolStr === 'true';
gi++; // skip consumed value
}
}
// Parse --runtime and --scope (validate that values are present and not flags)
const runtimeIdx = args.indexOf('--runtime');
let setRuntime;
if (runtimeIdx !== -1) {
const runtimeVal = args[runtimeIdx + 1];
if (!runtimeVal || runtimeVal.startsWith('--')) {
error('Missing value for --runtime', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
setRuntime = runtimeVal;
}
const scopeIdx = args.indexOf('--scope');
let setScope;
if (scopeIdx !== -1) {
const scopeVal = args[scopeIdx + 1];
if (!scopeVal || scopeVal.startsWith('--')) {
error('Missing value for --scope', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
setScope = scopeVal;
}
capabilityWriter.cmdCapabilitySet(
cwd,
resolvedSetConfigDir,
capId,
{ enabled: setEnabled, gates: Object.keys(setGates).length > 0 ? setGates : undefined, runtime: setRuntime, scope: setScope },
raw,
);
} else {
error(
`Unknown capability subcommand: ${capSubcommand}. Available: state`,
`Unknown capability subcommand: ${capSubcommand}. Available: state, set`,
core.ERROR_REASON ? core.ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
);
}

View File

@@ -441,7 +441,33 @@ Merge new settings into existing config.json:
}
```
**Safe merge:** Apply each chosen value via `gsd-tools.cjs query config-set <key.path> <value>` so unrelated keys are never clobbered. `code_review_depth` is written only if the code_review question was answered `on`; otherwise leave the existing value in place. `model_profile` is written on Q1 "Adaptive (Recommended)" (→ adaptive) or Q1 "Inherit" (→ inherit) immediately; for Q1 "Standard tier…", `model_profile` is written from Q2's answer. If Q1 = "Standard tier…" but Q2 is cancelled, leave the existing `model_profile` value unchanged — do not write any new value.
**Safe merge:** Apply each chosen value so unrelated keys are never clobbered. Use the appropriate write path per key:
- **Capability hook-gate keys** (owned by a capability in the registry — see `registry.configSchema`): write via the capability writer:
```bash
gsd_run capability set <owner> --gate <key>=<value> [--config-dir "$RUNTIME_CONFIG_DIR"]
```
The capability-owned keys written by this workflow and their owners are:
| Key | Owner capability |
|---|---|
| `workflow.research` | `research` |
| `workflow.nyquist_validation` | `nyquist` |
| `workflow.pattern_mapper` | `pattern-mapper` |
| `workflow.ui_phase` | `ui` |
| `workflow.ui_safety_gate` | `ui` |
| `workflow.ai_integration_phase` | `ai-integration` |
| `workflow.tdd_mode` | `tdd` |
| `workflow.code_review` | `code-review` |
| `workflow.code_review_depth` | `code-review` |
| `workflow.ui_review` | `ui` |
| `intel.enabled` | `intel` |
| `graphify.enabled` | `graphify` |
`code_review_depth` is written only if the `code_review` question was answered `on`; otherwise leave the existing value in place.
- **Non-capability keys** (`model_profile`, `commit_docs`, `workflow.plan_check`, `workflow.verifier`, `workflow.auto_advance`, `workflow.text_mode`, `workflow.research_before_questions`, `workflow.discuss_mode`, `workflow.skip_discuss`, `workflow.use_worktrees`, `plan_review.source_grounding`, `graphify.auto_update`, `git.*`, `hooks.*`, `model_policy.*`): write via `gsd_run query config-set <key.path> <value>` as before.
`model_profile` is written on Q1 "Adaptive (Recommended)" (→ adaptive) or Q1 "Inherit" (→ inherit) immediately; for Q1 "Standard tier…", `model_profile` is written from Q2's answer. If Q1 = "Standard tier…" but Q2 is cancelled, leave the existing `model_profile` value unchanged — do not write any new value.
Write updated config to `$GSD_CONFIG_PATH` (the workstream-aware path resolved in `ensure_and_load_config`). Never hardcode `.planning/config.json` — workstream installs route to `.planning/workstreams/<slug>/config.json`.
</step>

466
src/capability-writer.cts Normal file
View File

@@ -0,0 +1,466 @@
/**
* Capability Writer — ADR-1213 write-side inverse of capability-state resolver.
*
* Exports:
* setCapabilityState(cwd, runtimeConfigDir, desired, opts?)
* → { capabilities: CapabilityStateEntry[], warnings: string[] }
* cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw)
*
* Projection rules (three axes: install, surface, config):
* - enabled axis: mutates .gsd-surface.json via readSurface/writeSurface
* - gates axis: mutates .planning/config.json via setConfigValues (batched)
* - materialize: optionally calls applySurface to write skill files
* - re-resolve: always calls resolveCapabilityRuntimeState for the return value
*
* Dependencies (leaf modules only — no circular risk):
* - ./core.cjs (output, error)
* - ./capability-state.cjs (resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir)
* - ./surface.cjs (readSurface, writeSurface, applySurface)
* - ./install-profiles.cjs (readActiveProfile)
* - ./config.cjs (setConfigValues)
* - ./runtime-artifact-layout.cjs (resolveRuntimeArtifactLayout)
* - capability-registry.cjs (loaded at call time)
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import core = require('./core.cjs');
const { output: coreOutput, error: coreError } = core;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateMod = require('./capability-state.cjs');
const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import surfaceMod = require('./surface.cjs');
const { readSurface, writeSurface, applySurface } = surfaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installProfilesMod = require('./install-profiles.cjs');
const { readActiveProfile } = installProfilesMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configMod = require('./config.cjs');
const { setConfigValues } = configMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import nodefs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import nodepath = require('path');
// ─── Types ────────────────────────────────────────────────────────────────────
interface HookEntry {
point: string;
kind: 'step' | 'gate' | 'contribution';
when: unknown;
configured: boolean;
active: boolean;
}
interface CapabilityStateEntry {
id: string;
tier: string;
skills: string[];
installed: boolean;
surfaced: boolean;
enabled: boolean;
hooks: HookEntry[];
}
interface SurfaceState {
baseProfile: string;
disabledClusters: string[];
explicitAdds: string[];
explicitRemoves: string[];
}
interface DesiredCapability {
id: string;
enabled?: boolean;
gates?: Record<string, boolean>;
}
interface SetCapabilityStateOptions {
materialize?: { runtime: string; scope: string };
}
interface SetCapabilityStateResult {
capabilities: CapabilityStateEntry[];
warnings: string[];
errors: string[];
}
// ─── Implementation ───────────────────────────────────────────────────────────
/**
* Write-side capability state mutator.
*
* Applies desired capability state changes (enabled axis via surface, gates
* axis via config) then re-resolves and returns the full capability state.
*
* Control flow:
* 1. RESOLVE BEFORE STATE: call resolveCapabilityRuntimeState once to get the
* canonical runtimeConfigDir and current capability state.
* 2. VALIDATION PASS (no writes): validate each desired entry against the
* registry and `before` state; collect errors and warnings.
* 3. If errors → return early with before.capabilities (no writes performed).
* 4. APPLY PASS: compute new surface state, writeSurface once if changed,
* setConfigValues once for gate writes; materialize if opts provided.
* 5. RE-RESOLVE: call resolveCapabilityRuntimeState again to get final state.
* 6. POST CHECKS: enabled=true but not-surfaced (not-in-profile) error;
* present-but-dead warning; append resolver warnings.
* 7. Return { capabilities: after.capabilities, warnings, errors }.
*/
function setCapabilityState(
cwd: string,
runtimeConfigDir: string | undefined | null,
desired: DesiredCapability[],
opts?: SetCapabilityStateOptions,
): SetCapabilityStateResult {
const warnings: string[] = [];
const errors: string[] = [];
// ── Step 1: Resolve BEFORE state once ────────────────────────────────────
const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
const resolvedConfigDir = before.runtimeConfigDir;
// ── Load registry ─────────────────────────────────────────────────────────
const registry = before.registry;
const capabilitiesMap = (
registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities'])
? registry['capabilities']
: {}
) as Record<string, unknown>;
// ── Step 2: VALIDATION PASS (no writes) ──────────────────────────────────
// Accumulate all valid gate writes and surface deltas.
// If ANY error is found, we will return early without writing anything.
const pendingGateWrites: Array<{ keyPath: string; value: unknown }> = [];
// surface-delta accumulators: ids to add to / remove from disabledClusters
const idsToDisable: string[] = [];
const idsToEnable: string[] = [];
// Track which ids need surface loading (have skills + explicit enabled flag)
let needsSurface = false;
for (const entry of desired) {
const { id, enabled, gates } = entry;
// Validate capability id
if (!Object.prototype.hasOwnProperty.call(capabilitiesMap, id)) {
errors.push(`unknown capability: "${id}"`);
continue;
}
const capObj = capabilitiesMap[id] as Record<string, unknown>;
const skillsRaw = capObj['skills'];
const skills: string[] = Array.isArray(skillsRaw)
? skillsRaw.filter((s): s is string => typeof s === 'string')
: [];
const configDef = (
capObj['config'] && typeof capObj['config'] === 'object' && !Array.isArray(capObj['config'])
? capObj['config']
: {}
) as Record<string, unknown>;
// ── Validate gate keys / values ──────────────────────────────────────────
if (gates !== undefined) {
for (const [key, val] of Object.entries(gates)) {
if (!Object.prototype.hasOwnProperty.call(configDef, key)) {
errors.push(`unknown gate key "${key}" for capability "${id}"`);
continue;
}
if (typeof val !== 'boolean') {
errors.push(`gate value for "${key}" must be boolean, got ${typeof val}`);
continue;
}
pendingGateWrites.push({ keyPath: key, value: val });
}
}
// ── Validate enabled axis ────────────────────────────────────────────────
if (enabled !== undefined) {
if (skills.length === 0) {
// Advisory only — no surface effect possible
warnings.push(
`capability "${id}" owns no skills; 'enabled' has no surface effect — use gates to toggle its hooks`,
);
continue;
}
// Install-floor check: cannot enable a capability whose skills are not installed
if (enabled === true) {
const beforeEntry = before.capabilities.find((c: CapabilityStateEntry) => c.id === id);
if (beforeEntry && beforeEntry.installed === false) {
errors.push(`cannot enable "${id}": its skills are not in the install profile`);
continue;
}
}
needsSurface = true;
if (enabled === false) {
idsToDisable.push(id);
} else {
idsToEnable.push(id);
}
}
}
// ── Fix D: Pre-validate config.json parseability before any write ────────
// If there are pending gate writes, attempt to read and parse the target
// config.json BEFORE writing anything. A malformed file would cause
// setConfigValues to error() mid-operation leaving a partial write.
if (pendingGateWrites.length > 0) {
try {
const configJsonPath = nodepath.join(planningDir(cwd), 'config.json');
if (nodefs.existsSync(configJsonPath)) {
const raw = nodefs.readFileSync(configJsonPath, 'utf-8');
try {
JSON.parse(raw);
} catch (parseErr: unknown) {
const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
errors.push(`config.json is malformed: ${msg}`);
}
}
} catch {
// Cannot read the file path — not an error (e.g. planningDir env-var issue); let setConfigValues handle it
}
}
// ── Step 3: Early return on validation errors ────────────────────────────
if (errors.length > 0) {
return {
capabilities: before.capabilities,
warnings,
errors,
};
}
// ── Step 4: APPLY PASS ────────────────────────────────────────────────────
// ── Surface writes ────────────────────────────────────────────────────────
if (needsSurface && (idsToDisable.length > 0 || idsToEnable.length > 0)) {
const existing = readSurface(resolvedConfigDir);
let pendingSurface: SurfaceState = existing ?? {
baseProfile: readActiveProfile(resolvedConfigDir) ?? 'full',
disabledClusters: [],
explicitAdds: [],
explicitRemoves: [],
};
let surfaceChanged = false;
for (const id of idsToDisable) {
// Add id to disabledClusters (dedupe)
if (!pendingSurface.disabledClusters.includes(id)) {
pendingSurface = {
...pendingSurface,
disabledClusters: [...pendingSurface.disabledClusters, id],
};
surfaceChanged = true;
}
// Fix A: explicitAdds contains SKILL STEMS, not capability ids.
// Remove the capability's skill stems from explicitAdds so that
// resolveSurface does not re-add those skills after the cluster disable.
const capObjForDisable = capabilitiesMap[id] as Record<string, unknown>;
const skillsRawForDisable = capObjForDisable?.['skills'];
const skillStemsForDisable: string[] = Array.isArray(skillsRawForDisable)
? skillsRawForDisable.filter((s): s is string => typeof s === 'string')
: [];
const newExplicitAdds = pendingSurface.explicitAdds.filter(
(x) => !skillStemsForDisable.includes(x),
);
if (newExplicitAdds.length !== pendingSurface.explicitAdds.length) {
pendingSurface = { ...pendingSurface, explicitAdds: newExplicitAdds };
surfaceChanged = true;
}
}
for (const id of idsToEnable) {
// Remove id from disabledClusters
if (pendingSurface.disabledClusters.includes(id)) {
pendingSurface = {
...pendingSurface,
disabledClusters: pendingSurface.disabledClusters.filter((x) => x !== id),
};
surfaceChanged = true;
}
// Fix A (enable branch): also remove the capability's skill stems from
// explicitRemoves so that resolveSurface does not subtract those skills.
// Do NOT add anything to explicitAdds — a cap that was only in explicitAdds
// and was disabled is caught by the post-check below.
const capObjForEnable = capabilitiesMap[id] as Record<string, unknown>;
const skillsRawForEnable = capObjForEnable?.['skills'];
const skillStemsForEnable: string[] = Array.isArray(skillsRawForEnable)
? skillsRawForEnable.filter((s): s is string => typeof s === 'string')
: [];
const newExplicitRemoves = pendingSurface.explicitRemoves.filter(
(x) => !skillStemsForEnable.includes(x),
);
if (newExplicitRemoves.length !== pendingSurface.explicitRemoves.length) {
pendingSurface = { ...pendingSurface, explicitRemoves: newExplicitRemoves };
surfaceChanged = true;
}
}
if (surfaceChanged) {
writeSurface(resolvedConfigDir, pendingSurface);
}
}
// ── Config writes (once, batched) ─────────────────────────────────────────
if (pendingGateWrites.length > 0) {
setConfigValues(cwd, pendingGateWrites);
}
// ── Materialize (optional) ────────────────────────────────────────────────
if (opts?.materialize) {
const { runtime, scope } = opts.materialize;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const runtimeArtifactLayout = require('./runtime-artifact-layout.cjs') as {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolveRuntimeArtifactLayout: (runtime: string, configDir: string, scope: string) => any;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
applySurface(resolvedConfigDir, layout, manifest, undefined, registry);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Fix C: materialise was explicitly requested — a failure is an error (non-zero exit),
// not merely advisory.
errors.push(`materialize failed: ${msg}`);
}
}
// ── Step 5: RE-RESOLVE ────────────────────────────────────────────────────
const after = resolveCapabilityRuntimeState(cwd, resolvedConfigDir);
// ── Step 6: POST CHECKS ───────────────────────────────────────────────────
// Check: desired enabled=true but not actually enabled after write
// (catches the not-in-profile / not-surfaced silent no-op case).
// The install-floor case (installed===false) was already caught in validation.
for (const entry of desired) {
if (entry.enabled === true) {
const afterCap = after.capabilities.find((c: CapabilityStateEntry) => c.id === entry.id);
if (afterCap && afterCap.enabled !== true) {
errors.push(
`cannot enable "${entry.id}": not in the active surface/profile (widen the profile or use /gsd:surface enable)`,
);
}
}
// Fix B: desired enabled=false — assert it is actually disabled after write.
// Prevents "off means off" silent failures (e.g. explicitAdds containing the
// cap's skill stems re-adds them after the cluster disable).
if (entry.enabled === false) {
const afterCap = after.capabilities.find((c: CapabilityStateEntry) => c.id === entry.id);
if (afterCap && afterCap.enabled !== false) {
errors.push(`failed to disable "${entry.id}": still surfaced after write`);
}
}
}
// Check: present-but-dead — SCOPED to touched (desired) capabilities only.
const desiredIds = new Set(desired.map((d) => d.id));
for (const cap of after.capabilities as CapabilityStateEntry[]) {
if (!desiredIds.has(cap.id)) continue;
if (
cap.enabled === true &&
cap.hooks.length > 0 &&
cap.hooks.every((h: HookEntry) => !h.configured)
) {
warnings.push(
`capability "${cap.id}" is surfaced but every hook is gated off — did you mean enabled:false?`,
);
}
}
// Append resolver's own warnings
for (const w of after.warnings) {
warnings.push(w);
}
return {
capabilities: after.capabilities,
warnings,
errors,
};
}
/**
* CLI command entry point for `gsd-tools capability set`.
*
* Builds one DesiredCapability from the provided options, calls setCapabilityState,
* then prints the result. When raw=true emits JSON; else emits a human summary.
* Warnings are always printed to stderr.
*/
function cmdCapabilitySet(
cwd: string,
runtimeConfigDir: string | undefined | null,
capId: string,
options: { enabled?: boolean; gates?: Record<string, boolean>; runtime?: string; scope?: string },
raw: boolean,
): void {
const desired: DesiredCapability[] = [
{
id: capId,
...(options.enabled !== undefined ? { enabled: options.enabled } : {}),
...(options.gates ? { gates: options.gates } : {}),
},
];
const opts: SetCapabilityStateOptions | undefined =
options.runtime
? { materialize: { runtime: options.runtime, scope: options.scope ?? 'global' } }
: undefined;
const result = setCapabilityState(cwd, runtimeConfigDir, desired, opts);
if (raw) {
// Raw mode: emit JSON to stdout including errors; exit non-zero if errors present.
// Do NOT print human stderr lines — raw consumers parse the JSON.
coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true);
if (result.errors.length > 0) {
process.exit(1);
}
return;
}
// Human mode: print warnings and errors to stderr (non-fatally for warnings).
for (const w of result.warnings) {
process.stderr.write(`capability set: warning: ${w}\n`);
}
for (const e of result.errors) {
process.stderr.write(`capability set: error: ${e}\n`);
}
// Exit non-zero if any errors (hard failures — requested action was not realized).
if (result.errors.length > 0) {
coreError(`capability set: ${String(result.errors.length)} error(s) — see above`);
return; // unreachable — coreError calls process.exit(1)
}
// Human-readable summary: focus on the target capability
const cap = result.capabilities.find((c: CapabilityStateEntry) => c.id === capId);
if (!cap) {
const msg = `capability "${capId}" not found in registry`;
coreOutput(msg, false, msg);
return;
}
const activeHooks = cap.hooks.filter((h: HookEntry) => h.active).length;
const summary = `capability ${capId}: enabled=${String(cap.enabled)}, surfaced=${String(cap.surfaced)}, installed=${String(cap.installed)}, activeHooks=${String(activeHooks)}/${String(cap.hooks.length)}`;
coreOutput({ id: cap.id, enabled: cap.enabled, surfaced: cap.surfaced, installed: cap.installed, warnings: result.warnings.length > 0 ? result.warnings : undefined }, false, summary);
}
export = {
setCapabilityState,
cmdCapabilitySet,
};

View File

@@ -407,6 +407,47 @@ function cmdConfigEnsureSection(cwd: string, raw: boolean): void {
}
}
/**
* Shared helper: write a single key-path into an in-memory config object.
*
* Prototype-pollution guard: reject dangerous segments via inline literal
* comparisons on the exact key used to index `current`, immediately before
* each write. The inline comparison is the barrier CodeQL's
* js/prototype-pollution-utility query recognises — the previous Set-based
* pre-loop check was functionally correct but not traced through, so
* code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
*
* Returns the previous value at the leaf key (undefined if absent).
* Never writes to disk — callers handle persistence.
* Calls error() (process.exit(1)) on prototype-pollution attempts.
*/
function _setNestedValue(
config: Record<string, unknown>,
keyPath: string,
parsedValue: unknown,
): unknown {
const keys = keyPath.split('.');
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const existingChild = current[key];
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const previousValue = current[lastKey];
current[lastKey] = parsedValue;
return previousValue;
}
/**
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
* "workflow.research").
@@ -428,31 +469,7 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
// Set nested value using dot notation (e.g., "workflow.research").
// Prototype-pollution guard: reject dangerous segments via inline literal
// comparisons on the exact key used to index `current`, immediately before
// each write. The inline comparison is the barrier CodeQL's
// js/prototype-pollution-utility query recognises — the previous Set-based
// pre-loop check was functionally correct but not traced through, so
// code-scanning alert #26 kept firing. Behaviour is unchanged from #663.
const keys = keyPath.split('.');
let current: Record<string, unknown> = config;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
if (current[key] === undefined || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const previousValue = current[lastKey]; // Capture previous value before overwriting
current[lastKey] = parsedValue;
const previousValue = _setNestedValue(config, keyPath, parsedValue);
// Write back
try {
@@ -464,6 +481,53 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set
}) as SetConfigValueResult;
}
/**
* Batched sibling of setConfigValue: apply multiple key-path writes in a
* single load → set-all → write cycle inside ONE withPlanningLock call.
*
* Returns { updated: true, results: SetConfigValueResult[] } on success.
* An empty entries array is a no-op and returns { updated: false, results: [] }.
*
* Prototype-pollution guards are enforced per entry (identical inline-literal
* guards as setConfigValue — CodeQL barrier requirement).
*/
function setConfigValues(
cwd: string,
entries: Array<{ keyPath: string; value: unknown }>,
): { updated: boolean; results: SetConfigValueResult[] } {
if (entries.length === 0) {
return { updated: false, results: [] };
}
const configPath = path.join(planningDir(cwd), 'config.json');
return withPlanningLock(cwd, () => {
// Load existing config or start with empty object
let config: Record<string, unknown> = {};
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch (err) {
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
}
const results: SetConfigValueResult[] = [];
for (const entry of entries) {
const previousValue = _setNestedValue(config, entry.keyPath, entry.value);
results.push({ updated: true, key: entry.keyPath, value: entry.value, previousValue });
}
// Write back once for all entries
try {
platformWriteSync(configPath, JSON.stringify(config, null, 2));
return { updated: true, results };
} catch (err) {
error('Failed to write config.json: ' + (err as Error).message);
}
}) as { updated: boolean; results: SetConfigValueResult[] };
}
/**
* Command to set a value in the config file, allowing nested values via dot notation (e.g.,
* "workflow.research").
@@ -800,4 +864,7 @@ export = {
cmdConfigNewProject,
cmdConfigPath,
cmdMigrateConfig,
// Exported for programmatic use by capability-writer and tests
setConfigValue,
setConfigValues,
};

View File

@@ -0,0 +1,556 @@
'use strict';
/**
* capability-writer.test.cjs — TDD tests for capability-writer.cjs.
*
* ADR-1213: write-side inverse of the capability resolver.
* Uses node:test + node:assert/strict.
* Tests run against the REAL registry and compiled .cjs in gsd-core/bin/lib/.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { setCapabilityState } = require('../gsd-core/bin/lib/capability-writer.cjs');
const { readSurface } = require('../gsd-core/bin/lib/surface.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Create a temp runtimeConfigDir and cwd pair for a test.
* - rcd has no .gsd-profile file → default 'full' profile → installedSkills='*'
* - cwd has .planning/config.json (empty)
*/
function makeTempDirs() {
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-writer-rcd-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-writer-cwd-'));
// Ensure .planning dir + empty config
const planningDir = path.join(cwd, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
return { rcd, cwd };
}
function readConfig(cwd) {
const configPath = path.join(cwd, '.planning', 'config.json');
if (!fs.existsSync(configPath)) return {};
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('capability-writer: setCapabilityState', () => {
// ── Test 1: disable a skill-owning capability ─────────────────────────────
test('disable ui: surface gets disabledClusters=["ui"], ui.enabled===false, all ui hooks active===false', () => {
const { rcd, cwd } = makeTempDirs();
try {
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
assert.ok(Array.isArray(result.capabilities), 'capabilities is array');
assert.ok(Array.isArray(result.warnings), 'warnings is array');
const uiCap = result.capabilities.find((c) => c.id === 'ui');
assert.ok(uiCap, 'ui capability present in result');
assert.equal(uiCap.enabled, false, 'ui.enabled should be false');
// All hooks for ui should be active===false
assert.ok(uiCap.hooks.every((h) => h.active === false), 'all ui hooks should be inactive');
// Surface file should have ui in disabledClusters
const surface = readSurface(rcd);
assert.ok(surface !== null, 'surface file written');
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 2: re-enable ─────────────────────────────────────────────────────
test('re-enable ui: off then on → ui.enabled===true; disabledClusters excludes ui', () => {
const { rcd, cwd } = makeTempDirs();
try {
// First disable
setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
// Then re-enable
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: true }]);
const uiCap = result.capabilities.find((c) => c.id === 'ui');
assert.ok(uiCap, 'ui capability present');
assert.equal(uiCap.enabled, true, 'ui.enabled should be true after re-enable');
const surface = readSurface(rcd);
assert.ok(surface !== null, 'surface file exists');
assert.ok(!surface.disabledClusters.includes('ui'), 'ui NOT in disabledClusters after re-enable');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 3: gate within enabled capability ────────────────────────────────
test('gate code-review off: hook configured===false; config.json has workflow.code_review===false', () => {
const { rcd, cwd } = makeTempDirs();
try {
const result = setCapabilityState(cwd, rcd, [
{ id: 'code-review', gates: { 'workflow.code_review': false } },
]);
const crCap = result.capabilities.find((c) => c.id === 'code-review');
assert.ok(crCap, 'code-review capability present');
// code-review has skills so enabled should be true (full install + surfaced)
assert.equal(crCap.enabled, true, 'code-review should be enabled (full install)');
// The hook gated by workflow.code_review should be configured===false
const gatedHook = crCap.hooks.find((h) => h.when === 'workflow.code_review');
assert.ok(gatedHook !== undefined, 'found hook with when=workflow.code_review');
assert.equal(gatedHook.configured, false, 'hook configured===false after gate set to false');
// Config should have the value persisted
const config = readConfig(cwd);
assert.ok(config.workflow, 'workflow section in config');
assert.equal(config.workflow.code_review, false, 'workflow.code_review===false in config');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 4: re-enable preserves prior gates ───────────────────────────────
test('re-enable preserves prior gates: gate false → disable → re-enable → gate still false', () => {
const { rcd, cwd } = makeTempDirs();
try {
// Set gate to false
setCapabilityState(cwd, rcd, [
{ id: 'code-review', gates: { 'workflow.code_review': false } },
]);
// Disable code-review
setCapabilityState(cwd, rcd, [{ id: 'code-review', enabled: false }]);
// Re-enable code-review (no gate change)
setCapabilityState(cwd, rcd, [{ id: 'code-review', enabled: true }]);
// Config should still have workflow.code_review===false
const config = readConfig(cwd);
assert.equal(config?.workflow?.code_review, false, 'workflow.code_review still false after re-enable');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 5: present-but-dead warning ─────────────────────────────────────
test('present-but-dead warning: gate all hooks off for research → warning emitted', () => {
const { rcd, cwd } = makeTempDirs();
try {
// research has skills:[] (vacuously installed/surfaced) and a hook gated by workflow.research
// Set workflow.research=false to gate off its only hook
const result = setCapabilityState(cwd, rcd, [
{ id: 'research', gates: { 'workflow.research': false } },
]);
// research is enabled (vacuously surfaced since no skills) but hook is gated off
const researchCap = result.capabilities.find((c) => c.id === 'research');
assert.ok(researchCap, 'research capability present');
assert.equal(researchCap.enabled, true, 'research enabled (vacuously)');
assert.ok(researchCap.hooks.length > 0, 'research has hooks');
assert.ok(researchCap.hooks.every((h) => !h.configured), 'all research hooks configured===false');
// Should have a present-but-dead warning
const deadWarning = result.warnings.find(
(w) => w.includes('research') && w.includes('gated off'),
);
assert.ok(deadWarning !== undefined, `expected present-but-dead warning for research, got: ${JSON.stringify(result.warnings)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 6: skill-less enabled:false warns ────────────────────────────────
test('skill-less enabled:false: research warns "owns no skills", disabledClusters unchanged', () => {
const { rcd, cwd } = makeTempDirs();
try {
const result = setCapabilityState(cwd, rcd, [{ id: 'research', enabled: false }]);
const noSkillsWarning = result.warnings.find(
(w) => w.includes('research') && w.includes('no skills'),
);
assert.ok(noSkillsWarning !== undefined, `expected no-skills warning, got: ${JSON.stringify(result.warnings)}`);
// Surface should NOT have research in disabledClusters
const surface = readSurface(rcd);
// No surface file written (or if it exists, research not in disabledClusters)
if (surface !== null) {
assert.ok(!surface.disabledClusters.includes('research'), 'research NOT in disabledClusters');
}
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 7: unknown id ────────────────────────────────────────────────────
test('unknown id: error emitted (not warning), no throw', () => {
const { rcd, cwd } = makeTempDirs();
try {
let result;
assert.doesNotThrow(() => {
result = setCapabilityState(cwd, rcd, [{ id: 'does-not-exist', enabled: false }]);
});
assert.ok(result, 'result returned');
assert.ok(Array.isArray(result.errors), 'errors is array');
const unknownError = result.errors.find((e) => e.includes('does-not-exist'));
assert.ok(unknownError !== undefined, `expected unknown error, got errors: ${JSON.stringify(result.errors)}`);
// Must NOT be in warnings
const unknownWarning = result.warnings.find((w) => w.includes('does-not-exist'));
assert.equal(unknownWarning, undefined, `unknown id must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 8: invalid gate key ──────────────────────────────────────────────
test('invalid gate key: error emitted (not warning), no throw, key NOT written to config', () => {
const { rcd, cwd } = makeTempDirs();
try {
let result;
assert.doesNotThrow(() => {
result = setCapabilityState(cwd, rcd, [
{ id: 'ui', gates: { 'workflow.not_a_key': false } },
]);
});
assert.ok(result, 'result returned');
assert.ok(Array.isArray(result.errors), 'errors is array');
const invalidKeyError = result.errors.find((e) => e.includes('workflow.not_a_key'));
assert.ok(invalidKeyError !== undefined, `expected invalid-gate error, got errors: ${JSON.stringify(result.errors)}`);
// Must NOT be in warnings
const invalidKeyWarning = result.warnings.find((w) => w.includes('workflow.not_a_key'));
assert.equal(invalidKeyWarning, undefined, `invalid gate key must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
// Config should NOT have the key
const config = readConfig(cwd);
assert.equal(config?.workflow?.not_a_key, undefined, 'invalid key not written to config');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 9: batch disable ─────────────────────────────────────────────────
test('batch: disable ui and code-review → both in disabledClusters, both enabled===false', () => {
const { rcd, cwd } = makeTempDirs();
try {
const result = setCapabilityState(cwd, rcd, [
{ id: 'ui', enabled: false },
{ id: 'code-review', enabled: false },
]);
const uiCap = result.capabilities.find((c) => c.id === 'ui');
const crCap = result.capabilities.find((c) => c.id === 'code-review');
assert.ok(uiCap, 'ui capability present');
assert.ok(crCap, 'code-review capability present');
assert.equal(uiCap.enabled, false, 'ui.enabled===false');
assert.equal(crCap.enabled, false, 'code-review.enabled===false');
const surface = readSurface(rcd);
assert.ok(surface !== null, 'surface file written');
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
assert.ok(surface.disabledClusters.includes('code-review'), 'code-review in disabledClusters');
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 10: scoping regression — touching ui must not mention intel ──────
test('scoping: setCapabilityState([{id:"ui", enabled:false}]) must not mention intel in warnings or errors', () => {
const { rcd, cwd } = makeTempDirs();
try {
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
assert.ok(Array.isArray(result.warnings), 'warnings is array');
assert.ok(Array.isArray(result.errors), 'errors is array');
const intelInWarnings = result.warnings.some((w) => w.includes('intel'));
assert.equal(intelInWarnings, false,
`warnings must not mention intel, got: ${JSON.stringify(result.warnings)}`);
const intelInErrors = result.errors.some((e) => e.includes('intel'));
assert.equal(intelInErrors, false,
`errors must not mention intel, got: ${JSON.stringify(result.errors)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 11: CLI exit-code tests ──────────────────────────────────────────
test('CLI: capability set ui --off exits 0; capability set ui --on exits 0; unknown id exits non-zero', () => {
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-rcd-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-cwd-'));
try {
// Create .planning/config.json so the resolver has a valid project
const planningDir = path.join(cwd, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
const nodeExe = process.execPath;
// Test: capability set ui --off (--config-dir rcd) exits 0
const offResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'ui', '--off', '--config-dir', rcd], {
encoding: 'utf8',
cwd,
});
assert.equal(offResult.status, 0,
`capability set ui --off should exit 0, got ${String(offResult.status)}. stderr: ${offResult.stderr}`);
// Test: capability set ui --on (--config-dir rcd) exits 0
const onResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'ui', '--on', '--config-dir', rcd], {
encoding: 'utf8',
cwd,
});
assert.equal(onResult.status, 0,
`capability set ui --on should exit 0, got ${String(onResult.status)}. stderr: ${onResult.stderr}`);
// Test: unknown id exits non-zero
const unknownResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'does-not-exist', '--off', '--config-dir', rcd], {
encoding: 'utf8',
cwd,
});
assert.notEqual(unknownResult.status, 0,
`capability set does-not-exist --off should exit non-zero, got ${String(unknownResult.status)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 12: validate-before-write atomicity ──────────────────────────────
test('validate-before-write: mixed-error batch leaves substrates untouched', () => {
// If a batch has ANY error (e.g. invalid gate key), NEITHER the surface
// NOR the config must be written — even for the valid entries in the batch.
const { rcd, cwd } = makeTempDirs();
try {
// Batch: ui has an invalid gate key (error), plus code-review enabled:false (valid)
// Because there is an error, NEITHER should be written.
let result;
assert.doesNotThrow(() => {
result = setCapabilityState(cwd, rcd, [
{ id: 'ui', gates: { 'workflow.not_a_key': false } }, // invalid gate key → error
{ id: 'code-review', enabled: false }, // valid, but should NOT write
]);
});
assert.ok(result.errors.length > 0, `expected errors, got: ${JSON.stringify(result.errors)}`);
const invalidKeyError = result.errors.find((e) => e.includes('workflow.not_a_key'));
assert.ok(invalidKeyError !== undefined, `expected invalid-gate error, got: ${JSON.stringify(result.errors)}`);
// Surface must NOT be written (code-review must NOT be in disabledClusters)
const surface = readSurface(rcd);
if (surface !== null) {
assert.ok(
!surface.disabledClusters.includes('code-review'),
`code-review must not be in disabledClusters when batch has errors; surface: ${JSON.stringify(surface)}`,
);
}
// Config must NOT be written
const config = readConfig(cwd);
assert.equal(
config?.workflow?.not_a_key,
undefined,
'invalid gate key must not appear in config.json',
);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 13: disable→enable round-trip confirms enabled:true ─────────────
test('enable after disable: round-trip still yields ui.enabled===true', () => {
const { rcd, cwd } = makeTempDirs();
try {
// Disable first
setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
// Re-enable
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: true }]);
const uiCap = result.capabilities.find((c) => c.id === 'ui');
assert.ok(uiCap, 'ui capability present in result');
assert.equal(uiCap.enabled, true, 'ui.enabled should be true after re-enable');
assert.equal(result.errors.length, 0,
`no errors expected on re-enable, got: ${JSON.stringify(result.errors)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 13b (Fix A + Fix B): explicitAdds disable interaction ──────────
test('Fix A: disable when explicitAdds holds cap skill stems → skill stems removed, ui.enabled===false, no errors', () => {
const { rcd, cwd } = makeTempDirs();
try {
// Seed surface with ui skill stems already in explicitAdds
const surfacePath = path.join(rcd, '.gsd-surface.json');
fs.writeFileSync(surfacePath, JSON.stringify({
baseProfile: 'full',
disabledClusters: [],
explicitAdds: ['ui-phase', 'ui-review'],
explicitRemoves: [],
}));
const result = setCapabilityState(cwd, rcd, [{ id: 'ui', enabled: false }]);
// Fix B covers this: if disable didn't work, the post-check would emit an error
assert.equal(result.errors.length, 0,
`no errors expected, got: ${JSON.stringify(result.errors)}`);
const uiCap = result.capabilities.find((c) => c.id === 'ui');
assert.ok(uiCap, 'ui capability present in result');
assert.equal(uiCap.enabled, false, 'ui.enabled should be false after disable');
const surface = readSurface(rcd);
assert.ok(surface !== null, 'surface file exists');
assert.ok(surface.disabledClusters.includes('ui'), 'ui in disabledClusters');
// Skill stems must have been removed from explicitAdds
assert.ok(!surface.explicitAdds.includes('ui-phase'),
`ui-phase must be removed from explicitAdds, got: ${JSON.stringify(surface.explicitAdds)}`);
assert.ok(!surface.explicitAdds.includes('ui-review'),
`ui-review must be removed from explicitAdds, got: ${JSON.stringify(surface.explicitAdds)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 15 (Fix C): materialise failure → error, not warning ─────────────
test('Fix C: materialise failure is an error (not a warning)', () => {
const { rcd, cwd } = makeTempDirs();
try {
// Use an invalid runtime that throws in resolveRuntimeArtifactLayout
const result = setCapabilityState(
cwd, rcd,
[{ id: 'ui', enabled: false }],
{ materialize: { runtime: 'definitely-not-a-runtime', scope: 'global' } },
);
assert.ok(result.errors.length > 0,
`expected errors for materialise failure, got: ${JSON.stringify(result.errors)}`);
const materialiseError = result.errors.find((e) => e.includes('materialize failed'));
assert.ok(materialiseError !== undefined,
`expected "materialize failed" error, got errors: ${JSON.stringify(result.errors)}`);
// Must NOT appear in warnings
const materialiseWarning = result.warnings.find((w) => w.includes('materialize failed'));
assert.equal(materialiseWarning, undefined,
`materialise failure must not appear in warnings, got: ${JSON.stringify(result.warnings)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 16 (Fix D): malformed config.json → error, surface NOT written ───
test('Fix D: malformed config.json pre-check blocks write and surface not created', () => {
const { rcd, cwd } = makeTempDirs();
try {
// Write malformed config.json
const planningDirPath = path.join(cwd, '.planning');
fs.mkdirSync(planningDirPath, { recursive: true });
fs.writeFileSync(path.join(planningDirPath, 'config.json'), '{ not json');
const result = setCapabilityState(cwd, rcd, [
{ id: 'code-review', gates: { 'workflow.code_review': false } },
]);
assert.ok(result.errors.length > 0,
`expected errors for malformed config, got: ${JSON.stringify(result.errors)}`);
const malformedError = result.errors.find(
(e) => e.includes('malformed') || e.includes('config.json'),
);
assert.ok(malformedError !== undefined,
`expected malformed config error, got errors: ${JSON.stringify(result.errors)}`);
// Surface must NOT have been written (no partial write)
const surface = readSurface(rcd);
assert.equal(surface, null,
`surface must not have been written when config is malformed, got: ${JSON.stringify(surface)}`);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
// ── Test 14: CLI conflicting --on --off flags → non-zero exit ─────────────
test('CLI: capability set ui --on --off exits non-zero (conflicting flags)', () => {
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-conflict-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-cli-conflict-cwd-'));
try {
const planningDir = path.join(cwd, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs');
const nodeExe = process.execPath;
const conflictResult = spawnSync(
nodeExe,
[gsdToolsBin, 'capability', 'set', 'ui', '--on', '--off', '--config-dir', rcd],
{ encoding: 'utf8', cwd },
);
assert.notEqual(
conflictResult.status, 0,
`capability set ui --on --off should exit non-zero, got ${String(conflictResult.status)}. stderr: ${conflictResult.stderr}`,
);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
});
// ─── Null-intermediate config tests ──────────────────────────────────────────
describe('capability-writer: null-intermediate config guard', () => {
test('null intermediate: gate write into {workflow:null} produces {workflow:{key:false}} without throw', () => {
// Regression for Fix 2: typeof null === 'object' caused _setNestedValue to
// traverse null and throw TypeError at null[key].
const rcd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-null-rcd-'));
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-null-cwd-'));
try {
const planningDir = path.join(cwd, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Pre-write a config where the intermediate 'workflow' key is null
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ workflow: null }));
// code-review owns workflow.code_review; writing it should replace null with {}
let result;
assert.doesNotThrow(() => {
result = setCapabilityState(cwd, rcd, [
{ id: 'code-review', gates: { 'workflow.code_review': false } },
]);
}, 'setCapabilityState must not throw when an intermediate config node is null');
assert.ok(result.errors.length === 0,
`expected no errors, got: ${JSON.stringify(result.errors)}`);
// Config should have workflow.code_review=false (null replaced with object)
const config = readConfig(cwd);
assert.equal(
config?.workflow?.code_review,
false,
'workflow.code_review must be false after gate write into null-intermediate config',
);
} finally {
cleanup(rcd);
cleanup(cwd);
}
});
});

View File

@@ -68,7 +68,7 @@
"session-report.md": 4044,
"settings-advanced.md": 39621,
"settings-integrations.md": 15801,
"settings.md": 32133,
"settings.md": 33413,
"ship.md": 24388,
"sketch-wrap-up.md": 14223,
"sketch.md": 19960,