* 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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user