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

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