Design how a Capability contributes a gsd-tools CLI command family and how the hardcoded 73-case runCommand switch opens to registry-driven dispatch. Realizes ADR-857 decision 7's reserved commands/module field (deferred by ADR-894). Grilled to its leanest form: the registry DISCOVERS a standard route*Command (no rebuilt handler table, no new arg convention); dispatch sits in the default case (collision structurally impossible, no shadowing gate needed); graphify is the first real cutover (lowest blast radius, has skill+cluster+config gate, full-only so 4c stays no-op), proven equivalent and serving as the phase-6 template. Design-only; CONTEXT.md gains a "Capability Command Family [Planned]" entry. Closes #959 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
ADR-959: Capability Command Contribution
- Status: Proposed
- Issue: #959
- Epic: #857 (Capability system) — rollout phase 4d
- Amends: ADR-894 (adds the deferred
commandsfield) - Realizes: ADR-857 decision 7 (in-tree code modules become Capabilities via an opened
runCommandentrypoint) - Builds on: ADR-0012 / ADR-0174 (CommandRoutingHub)
Context
ADR-857 decision 7 reserved a commands/module field so in-tree code modules (graphify, intel, audit) could "become Capabilities by registering their query family through an opened gsd-tools.cjs entrypoint (registry) over the current hardcoded switch." ADR-894 fixed the capability.json format but deferred the commands field. This ADR designs it.
The current dispatch reality:
runCommandis a 73-case hardcodedswitchplus 12route*Commandfamily routers (init,config,phase, …). There is no registry table — every command-name → handler binding is hand-written._dispatchNonFamilyis a dead shim. It always returnsfalse; its 10 call sites already pass{ registryCommand, registryArgs, legacyCommand, legacyArgs }and fall through to the legacy CJS handler. Its own comment says it exists to "keep the helper contract so existing call sites remain unchanged during the phase sequence." It is the deliberately-prepared seam for registry dispatch.- The CommandRoutingHub (ADR-0012, simplified by ADR-0174) is stateless.
createHub({ cjsRegistry, manifest })is constructed per dispatch with an inlinefamily → subcommand → handlertable and exposes only{ dispatch }. There is noregister()API and no persistent family registry. Each of the 12 routers builds a one-shot hub viarouteHubCommandFamilyand discards it. The hub's value is its uniform Result contract (UnknownCommand/InvalidArgs/HandlerRefusal/HandlerFailure), manifest-backed subcommand validation, arg-shape coercion, and observability. - The registry and
capability.jsonhave nocommandsfield today (confirmed absent).
Two distinct "command" kinds exist and must not be conflated: gsd-tools CLI subcommands (internal, invoked by workflows via gsd_run) and slash-commands/skills (commands/gsd/*.md, agent-facing). This ADR is about the former — the CLI subcommand families that code-module features own. Skills are already a capability contribution (skills).
Decision
Core principle (grilled): the codebase already has the right abstraction — the router. The 12
route*Commandfunctions each own a family's subcommand dispatch + arg parsing, and each already routes through the hub viarouteCjsCommandFamily. A capability command family is therefore just a router, discovered via the registry instead of hardcoded into gsd-tools' requires + switch. The registry's job is discovery, not re-implementing routing. This is the minimal change and reuses the proven abstraction wholesale — there is no new handler/arg convention.
1. A commands contribution on the feature role — no new role
A capability declares the CLI command families it owns, alongside its existing skills/agents/hooks/config contributions. No new role is introduced; a "code-module capability" is simply a feature whose primary contribution is commands rather than skills.
// capability.json (feature role) — new optional field
"commands": [
{
"family": "graphify", // the top-level gsd-tools command this capability owns
"module": "graphify.cjs", // first-party in-tree module under gsd-core/bin/lib/
"router": "routeGraphifyCommand", // exported router fn (same shape as the 12 host routers)
"subcommands": ["query", "status"] // OPTIONAL — doc/introspection metadata only; NOT used for dispatch
}
]
family— the top-level command string (single ownership across all capabilities).module— a first-party path resolved relative togsd-core/bin/lib/. Third-party / out-of-tree modules are out of scope (ADR-857 decision 7); they require their own trust/load ADR.router— the exported function with the standard router signature({ args, cwd, raw, error }) => void— identical torouteInitCommandet al. It owns its own subcommand list, arg parsing, anddefaultSubcommandinternally, exactly as the 12 existing routers do.subcommands— optional, doc/introspection only; dispatch does not consult it (the router owns subcommand resolution).
2. The registry materializes a commandFamilies index
gen-capability-registry.cjs emits a discovery index — family → {capId, module, router} — no subcommand/handler enumeration (those live inside the router):
const commandFamilies = {
"graphify": { capId: "graphify", module: "graphify.cjs", router: "routeGraphifyCommand" }
};
Added to module.exports alongside byLoopPoint, configKeys, etc.
3. Dispatch = invoke the registry-discovered router (which itself routes through the hub)
The capability's router is a standard route*Command: internally it calls routeCjsCommandFamily({ subcommands, handlers, … }) → routeHubCommandFamily → the per-call hub — exactly like the 12 existing routers. So "route through the hub" is satisfied by reuse, with zero new dispatch machinery and no registry-rebuilt handler table.
The dead _dispatchNonFamily(...) shim is replaced by a real dispatchCapabilityCommand({ command, args, cwd, raw }):
dispatchCapabilityCommand(command, args, cwd, raw):
entry = registry.commandFamilies[command] # guarded literal-key lookup
if not entry: return false # → legacy fall-through (unchanged behavior)
mod = require('./lib/' + entry.module) # first-party, confined to bin/lib
mod[entry.router]({ args, cwd, raw, error }) # the standard router signature
return true
There is no new handler/arg convention: the capability author writes a router using the same routeCjsCommandFamily helper and the same per-subcommand parseNamedArgs/positional access the host routers use. The arg-parsing lives where it always has — inside the router — now owned by the capability.
4. Entrypoint placement: the default case
The registry dispatch is consulted in the default case of runCommand (unknown command), before the unknown-command error:
- An unmigrated command still hits its hardcoded
casearm — untouched, behavior-identical. - A migrated command's
casearm is removed in its cutover PR, so the command now reachesdefault→dispatchCapabilityCommand→ the capability handler. - A command owned by no capability and no
case→dispatchCapabilityCommandreturnsfalse→ the existing unknown-command error.
This makes collision structurally impossible: a command is dispatched by its hardcoded case or the registry, never both (a case shadows default). The 10 existing _dispatchNonFamily call sites remain valid no-op seams and become the per-command migration points.
5. First-party only; third-party deferred
module is confined to gsd-core/bin/lib/ and required in-process — the same trust level as the host's own handlers. Loading out-of-tree / third-party command modules carries a distinct trust/load/build/security surface and is deferred to its own ADR (mirroring the runtime third-party deferral in ADR-857 branch 8). The seam is built so that door can be opened later additively.
6. Staged rollout — graphify is the first real cutover (the pilot), not a synthetic fixture
A synthetic fixture proves the plumbing but not the model; only a real command exercises router extraction, the full capability bundle (command + skill + config gate + tier), and registry multiplicity. The maintainer also wants a user-testable artifact. So the pilot is a real cutover, chosen for minimum risk:
-
Build phase (4d-impl): add the
commandsschema + thecommandFamiliesindex + the realdispatchCapabilityCommandin thedefaultcase, and cut overgraphifyas the first capability that owns a command family — in one cohesive migration:- a
graphifycapability bundling its command (family: graphify, a new first-partygraphify-command-router.cjsextracted from the inlinecasesub-switch), its existing skill (commands/gsd/graphify.md), its config gate (isGraphifyEnabled), andtier: full; - remove the
case 'graphify':arm so dispatch flowsdefault → registry → routeGraphifyCommand; - equivalence tests proving every
graphify <sub>invocation (incl. the--budgetflag and the hiddenbuild snapshot) behaves identically old-path vs new-path. graphifyis the safest first target: zero workflow hot-path blast radius (no core workflow invokes it via bash — the skill drives it), smallest handler (496 LOC / 4 subcommands), already has a skill + cluster + config gate, and isfull-only so the 4c install/surface consumption stays a no-op.
This pilot is the phase-6 cutover template, executed once on a low-risk feature to surface integration surprises early.
- a
-
Per-feature cutover (phase 6): repeat the graphify template for the remaining code-module features.
intelgoes last — a rollout finding: it has no skill and no cluster placement (invoked only from workflow bash), so it is not a clean capability and would need its skill/cluster created from scratch.
Validation invariants (enforced by the generator)
- Single family ownership — a
familyis owned by exactly one capability. - First-party module —
moduleresolves undergsd-core/bin/lib/; no traversal. - Router presence —
routeris a non-empty string; its existence as an export is verified at load (and may be lint-checked). - Prototype-pollution guards at every dynamic-key site (the repo's CodeQL barrier), as with the other registry indexes.
Shadowing is not a generator concern (grilled). Because dispatch sits in the default case, a capability that names a still-live hardcoded command is harmless at runtime — the hardcoded case always wins and the capability command simply never dispatches. So no upfront HOST_RESERVED_COMMANDS list is maintained (it would be a real new maintenance surface for a problem the placement already neutralizes). The one moment it bites is a phase-6 cutover that adds the capability command but forgets to remove the old case (silent no-op). That is caught by a one-line cutover test ("command X now dispatches to capability Y"), not a build-time gate.
Alternatives considered
- Registry rebuilds the hub handler table (
subcommands:[{name,export}]→ per-call hub). Rejected after grilling: this re-implements, in the registry + dispatcher, what aroute*Commandalready does (subcommand list, handler wiring, arg parsing,defaultSubcommand). Discovering a standard router instead keeps the existing abstraction intact, removes the need for the registry to know subcommands/exports, and dissolves any "new handler/arg convention." The chosen design routes through the hub because the discovered router does (viarouteCjsCommandFamily) — satisfying the hub decision with zero new dispatch machinery. - Direct
byCommand → module.exportdispatch (bypass the hub entirely). Rejected (maintainer choice): the discovered-router path already routes through the hub (ADR-0174's uniform Result/manifest/observability) and stays consistent with the 12 family routers. - A new persistent hub registry (
hub.register(family, handlers)). Rejected: the hub is intentionally stateless (ADR-0012/0174); introducing a singleton mutable registry contradicts that design and is unnecessary — a registry-discovered router constructs its own per-call hub exactly as today. - A generator
HOST_RESERVED_COMMANDSshadowing gate. Rejected after grilling: thedefault-case placement makes shadowing harmless at runtime, so an upfront host-reserved list is a maintenance surface for a neutralized problem. A one-line cutover test covers the only failure mode (forgetting to remove a migratedcase). - A new
role: code-module/tool. Rejected: commands are just another contribution kind on afeature; a separate role adds taxonomy without behavior. - Migrate the 73 hardcoded cases now. Rejected: that is the per-feature cutover (phase 6). Build the mechanism + a synthetic pilot first (registry-only), proven behavior-preserving — the rollout's consistent pattern.
- Open the entrypoint at each
_dispatchNonFamilycall site (notdefault). Rejected as the primary placement: thedefault-case approach makes collision structurally impossible and keeps unmigrated commands on their exact current path. The per-case shims remain as migration markers.
Consequences
- Positive: code-module features can own CLI command families declaratively; the hardcoded switch shrinks one command at a time at cutover; dispatch reuses the audited hub contract; the third-party door is left openable additively; the
graphifypilot is a tangible, testable plug-in and the proven phase-6 template. - Negative / cost: a second dispatch path (registry
default-case) coexists with the 73-case switch until migrations complete; a capability command family is a first-party router the author must write to the existingroute*Commandshape (no new convention, but it is real code the registry only discovers); the pilot is a real migration, sographify's dispatch path changes (equivalence-proven) rather than being a pure no-op. - Neutral: every non-migrated command stays on its exact current
casepath; the registry-defaultseam is dormant for them.
Out of scope
The build (4d-impl); migrating commands other than the graphify pilot; third-party / out-of-tree command modules; phase 5 (runtime descriptors); the remaining phase-6 per-feature cutovers.