diff --git a/CONTEXT.md b/CONTEXT.md index deb277bd3..fddc9fdcf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -76,6 +76,9 @@ Module owning the `init.*` family of query handlers that compose atomic queries ### Command Routing Hub Single dispatch seam (`gsd-core/bin/lib/command-routing-hub.cjs`) that centralizes CJS routing, the no-throw pure-result contract, typed error variants, and dispatch-event emission for all command family adapters. Interface: `createHub({ cjsRegistry, manifest, logger }) → hub`; `hub.dispatch({ family, subcommand, args, cwd, raw, parentTraceId? }) → Result` where `Result = { ok: true, data } | { ok: false, kind, ...typedPayload }` and `kind ∈ { UnknownCommand, InvalidArgs, HandlerRefusal, HandlerFailure }`. The `InvalidArgs` variant carries an optional `exitReason?: string` field (amendment #1642 / #1644 Phase 1) holding the `ERROR_REASON` enum value, separate from `reason` (the explanation text); the `makeInvalidArgs(arg, reason, exitReason?)` factory omits the field when the third arg is absent, undefined, or empty — preserving the strict-keys invariant tested at `tests/command-routing-hub.test.cjs:444`. The Hub is single-runtime (no mode selection, no sdkLoader), never prints, never exits, never throws. Adapters call `createHub`, dispatch, then translate the pure Result to `output()`/`error()` calls; when an `InvalidArgs` Result carries `exitReason`, the adapter passes it as the second arg to `error(message, exitReason)` so the JSON-error envelope (`GSD_JSON_ERRORS=1`) preserves the typed reason. Source: `gsd-core/bin/lib/command-routing-hub.cjs`; ADR: `docs/adr/0174-retire-gsd-sdk-package-boundary.md` (§5 amended #1642). +### Command Dispatch Completion (ADR-2346, epic #2345) +Decision to dissolve `runCommand`'s 73-case ~2,338-line switch (the repo's #1 PageRank / #1 Tarjan-bridge / #1 complexity symbol) into a **two-layer dispatch**: families via the `commandFamilies` registry (ADR-959 mechanism, completed) and single-purpose **leaf verbs** via a dispatch table filling the prepared `_dispatchNonFamily` seam (today a dead shim returning `false`); `runCommand` collapses to a ~15-line `try registry → try leaf table → unknown`. Family/leaf rule: promote a cluster to a family iff ≥3 related subcommands + shared backing module + shared parse/return shape; lone verbs or pairs stay leaves. Nine families result (`state`/`phase`/`init`/`roadmap`/`validate`/`verify`/`capability` + promoted `config`/`research`/`resolve`/`git`); `worktree`+`workstream` stay leaves; ~40 remaining verbs rehome into ~4 themed leaf modules. A shared `parseFamilyArgs` (in `cjs-command-router-adapter.cts` beside `routeHubCommandFamily`) deletes the 4+ duplicated inline arg-parsers. The 706-line `capability` arm extracts to a thin `capability-command-router` (intel-shaped) + `capability-cli.cts` (CLI wiring), consolidating duplicated probes (`capHostVersion`→`readHostVersion()`, `capReadStrict`+drift-guard → one `readStrictKnownRegistries`). Behavior-preserving — each cutover proven equivalent by extending `tests/audit-command-cutover.test.cjs`'s 5-category template. Phased: P1 `parseFamilyArgs`+Tier-1 cutovers, P2 capability extraction, P3 new families, P4 leaf table + collapse. Source: `docs/adr/2346-command-dispatch-completion.md`; graduates ADR-959 `Proposed → Accepted`. _Avoid_: "the dispatch service" (when you mean the seam). + ### Runtime Source Layout Module Single-runtime seam layout for this repository after SDK retirement. Runtime execution paths live under `gsd-core/bin/lib/` and are grouped by seam concern (dispatch, manifest, handlers, runtime, observability, installer). ADR-0174 preserves the seam vocabulary and defines the canonical long-term shape as a seam-aligned TypeScript `src/` tree (`src/dispatch/`, `src/handlers/`, `src/errors/`, `src/manifest/`, `src/config/`, `src/state/`, `src/workstream/`, `src/runtime/`, `src/cli/`, `src/observability/`) compiled to CJS. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5578e0bfd..1b3e154ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -305,6 +305,8 @@ See [`docs/INVENTORY.md`](INVENTORY.md#hooks) for the authoritative hook roster. CJS command family routers dispatch through `CommandRoutingHub`. The hub owns the no-throw pure-result contract (`hub.dispatch()` catches internal exceptions and returns `{ ok: false, kind, ...typedPayload }`) and the closed runtime error taxonomy (`UnknownCommand`, `InvalidArgs`, `HandlerRefusal`, `HandlerFailure`). Router adapters remain thin CLI translators — they build the hub, call `dispatch`, then map the Result to `output()`/`error()` calls. The runtime is single-path (no dual-runtime mode selection). See `docs/adr/0174-retire-gsd-sdk-package-boundary.md`. +> **Planned (ADR-2346 / epic #2345):** the `runCommand` 73-case switch is being dissolved into a two-layer dispatch — families via the `commandFamilies` registry (ADR-959 mechanism, completed) and single-purpose leaf verbs via a table filling the prepared `_dispatchNonFamily` seam — collapsing `runCommand` to a ~15-line dispatcher. Behavior-preserving; tracked phase-by-phase under epic #2345. The current-state description above holds until each phase lands. + ### Capability Command Dispatch (`gsd-core/bin/gsd-tools.cjs`, ADR-1244 D7) Command families declared by capabilities (`commands: [{ family, module, router }]`) are dispatched from the registry rather than a hardcoded switch. The `runCommand` default arm tries, in order: diff --git a/docs/adr/2346-command-dispatch-completion.md b/docs/adr/2346-command-dispatch-completion.md new file mode 100644 index 000000000..f80496dc9 --- /dev/null +++ b/docs/adr/2346-command-dispatch-completion.md @@ -0,0 +1,79 @@ +# ADR-2346: Command Dispatch Completion + +- **Status:** Accepted +- **Date:** 2026-07-17 +- **Issue:** [#2346](https://github.com/open-gsd/gsd-core/issues/2346) +- **Epic:** [#2345](https://github.com/open-gsd/gsd-core/issues/2345) (Command Dispatch Completion) +- **Builds on:** [ADR-959](959-capability-command-contribution.md) (Capability Command Contribution — graduated `Proposed → Accepted` by this ADR) · [ADR-0012](0012-command-routing-hub.md) / [ADR-0174](0174-retire-gsd-sdk-package-boundary.md) (CommandRoutingHub) + +## Context + +ADR-959 established that an in-tree command family is *"just a router, discovered via the registry instead of hardcoded"* into `runCommand`'s switch, and named `_dispatchNonFamily` as *"the deliberately-prepared seam for registry dispatch"* (today a dead shim that always returns `false`). Three first-party families (`graphify`/`audit`/`intel`) were cut over to `dispatchCapabilityCommand` in the `default` case. + +But ADR-959's scope is **family discovery only** — it assumes the 73-case switch and the `route*Command` routers *persist*. It does **not** decide (a) dissolving the switch *entirely*, or (b) where single-purpose "leaf" verbs belong. As a result the switch was never dissolved, and `runCommand` remains the repo's largest structural liability: + +- **#1 PageRank symbol** (most central), +- **#1 Tarjan articulation point** (removing it splits the call graph into 4 components), +- **#1 most complex function** (cognitive complexity 1927, cyclomatic 616, ~2,338 lines), +- with **4+ duplicated inline arg-parsers** (`capFlagValue`, `capRepeatedFlag`, `getFlagValue`, and bespoke per-arm consume-loops) and a 706-line `case 'capability':` arm nesting ~40 inline `cap*` helpers. + +`runCommand`'s upstream blast radius is **LOW** — only `main()` calls it — so a dissolution is internally safe to execute phase by phase. + +## Decision + +Complete the ADR-959 cutover and dissolve the switch entirely into a **two-layer dispatch**, recording four decisions ADR-959 leaves open. Each was grilled to a shared understanding before this ADR landed. + +### 1. Two-layer dispatch (end state) + +`runCommand` collapses to a ~15-line dispatcher: + +``` +try registry (dispatchCapabilityCommand) // families — ADR-959 mechanism, completed + → try leaf table (_dispatchNonFamily) // single-purpose verbs — fills the prepared seam + → unknown-command error +``` + +- **Families** (multi-subcommand, module-backed) route through the `commandFamilies` registry exactly as `graphify`/`audit`/`intel` already do. +- **Leaf verbs** (single-purpose) live in a dispatch table that fills the prepared `_dispatchNonFamily` seam — single-purpose verbs are *not* perverted into fake capability families (a leaf like `generate-slug` has no feature bundle, no config gate, no tier). + +### 2. Family/leaf classification rule + +> Promote a cluster to a **family** when it has **(a) ≥3 related subcommands**, **(b) a shared backing module**, and **(c) a shared parse/return shape**. Lone verbs or pairs stay **leaves** (two adapters over different modules ≠ one seam). + +Applied: 9 families result — `state`, `phase`, `init`, `roadmap`, `validate`/`verify`, `capability`, plus 4 promoted clusters (`config`, `research`, `resolve`, `git`). `worktree` + `workstream` stay leaves (2 verbs, different modules). ~40 remaining verbs rehome into ~4 themed leaf modules. + +### 3. Shared `parseFamilyArgs` + +A single helper (in `cjs-command-router-adapter.cts`, beside `routeHubCommandFamily`) consumes `--flag value` pairs → `{ values, positionals, repeated }` and calls `error()` on missing values. It deletes the 4+ duplicated inline arg-parsers (`capFlagValue`/`capRepeatedFlag`/`getFlagValue` and the bespoke `resolve-*` loops). Value-validation (e.g. `--effort` boolean coercion) stays per-handler; file-reading helpers (`readRequired`/`readOptional`) stay with their handlers. Introduced with its **first real consumer** (the Phase-1 cutover), not as a zero-consumer "foundation" PR (one adapter = hypothetical seam). + +### 4. Capability arm extraction shape + +The 706-line `case 'capability':` arm becomes a thin `capability-command-router` (intel-shaped, using `routeHubCommandFamily`) plus a `capability-cli.cts` owning the CLI wiring (scope resolution, output formatting, reconcile sweep). Handler bodies stay thin (resolve → `lifecycle.X` → format) — the fat logic already lives in `capability-writer`/trust/consent modules and is *wired*, not moved. Duplicated probes are consolidated: `capHostVersion` reuses `readHostVersion()`; `capReadStrict` + drift-guard's copy collapse into one shared `readStrictKnownRegistries`. + +### 5. Phasing (epic #2345) + +Each phase is one approved sub-issue + one behavior-preserving PR targeting `next`, each proven equivalent by extending the `tests/audit-command-cutover.test.cjs` 5-category template (UNIT / DISPATCH / BEHAVIOR / JSON-ERRORS / REGISTRY): + +| Phase | Content | +|---|---| +| P1 | `parseFamilyArgs` (first consumer) + Tier-1 family cutovers (`state`/`phase`/`init`/`roadmap`/`validate`/`verify`) | +| P2 | capability arm extraction + `readStrictKnownRegistries` consolidation | +| P3 | promote `config`/`research`/`resolve`/`git` clusters to families | +| P4 | leaf dispatch table (fills `_dispatchNonFamily`) + `runCommand` collapse to ~15 lines | + +## Alternatives considered + +1. **Amend ADR-959** to expand its scope to full dissolution — rejected: it would bloat a focused mechanism-ADR ("the `commands` field") into an execution-plan ADR. ADR-959 stays the mechanism; this ADR is the completion decision. +2. **Everything-is-a-registry-family** (even `generate-slug`) — rejected: the capability registry is for co-located feature *bundles*, not 3-line leaf verbs; it would manufacture ~60 tiny router files and 60 capability declarations for one-liners. +3. **One flat dispatch table, no registry** — rejected: abandons ADR-959's decided direction. +4. **Tier-1-only cutover** (pure ADR-959 completion, no dissolution) — rejected: the thin family arms aren't where the mass lives; `runCommand` would barely shrink and remain the #1 hotspot. + +## Consequences + +- **Positive:** the repo's #1 central/bridge/complexity hotspot is eliminated; locality (each family's parsing lives in its router) and leverage (one dispatch path, N families); the duplicated arg-parsers are killed once, everywhere; ADR-959 graduates `Proposed → Accepted` with working completion as its evidence. +- **Negative / cost:** a sequence of ~4 behavior-preserving cutover PRs; the two dispatch paths (registry + leaf table) coexist transiently until P4 collapses the switch; each cutover carries a cutover-equivalence test (real work, not a no-op). +- **Neutral:** every command keeps its exact name/output/exit-code/flags (behavior-preserving); unmigrated commands stay on their current path until their phase lands. + +## Out of scope + +Third-party / out-of-tree command modules (deferred per ADR-959 §5); the `runCommand` argument-resolution preamble (`--cwd`, `--json-errors`, workstream context) which stays in `main()`; any change to command *names* or *outputs*. diff --git a/docs/adr/959-capability-command-contribution.md b/docs/adr/959-capability-command-contribution.md index bb0dd419a..9916d700f 100644 --- a/docs/adr/959-capability-command-contribution.md +++ b/docs/adr/959-capability-command-contribution.md @@ -1,6 +1,6 @@ # ADR-959: Capability Command Contribution -- **Status:** Proposed +- **Status:** Accepted (graduated from Proposed by ADR-2346, 2026-07-17 — the cutover mechanism proven by full switch dissolution) - **Issue:** [#959](https://github.com/open-gsd/gsd-core/issues/959) - **Epic:** [#857](https://github.com/open-gsd/gsd-core/issues/857) (Capability system) — rollout phase 4d - **Amends:** [ADR-894](894-capability-declaration-format.md) (adds the deferred `commands` field) @@ -130,3 +130,9 @@ A synthetic fixture proves the *plumbing* but not the *model*; only a real comma ## 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. + +--- + +## Amendment — 2026-07-17 (ADR-2346): Status `Proposed → Accepted` + +This ADR's mechanism — *"a capability command family is just a router, discovered via the registry instead of hardcoded,"* consulted in `runCommand`'s `default` case — is **accepted**. The proof is ADR-2346 (epic #2345), which completes the cutover and dissolves the 73-case switch entirely into a two-layer dispatch (registry for families + a leaf-verb table filling the `_dispatchNonFamily` seam this ADR named). ADR-2346 records the two decisions this ADR deliberately left open (full dissolution; where leaf verbs belong); it does not alter this ADR's mechanism, the `commands` contribution field, or the `default`-case placement that makes collision structurally impossible. See [ADR-2346](2346-command-dispatch-completion.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4579efb43..f3fbb11e2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -70,6 +70,8 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [2164-statusline-scope-boundary.md](2164-statusline-scope-boundary.md) | Statusline draws its data boundary at local, read-only sources (no external/credentialed data) | Accepted | | [612-bracket-phase-id-convention.md](612-bracket-phase-id-convention.md) | Bracket phase-ID convention — lift the milestone into a `[PROJECT.MM]` prefix; terminal deprecation of M-NN | Proposed | | [2264-golden-parity-redesign.md](2264-golden-parity-redesign.md) | Redesign golden-install-parity: single-source manifest builder + split invariant | Proposed | +| [959-capability-command-contribution.md](959-capability-command-contribution.md) | Capability Command Contribution — `commands` field; family routers discovered via registry in `runCommand` default case | Accepted (graduated by ADR-2346) | +| [2346-command-dispatch-completion.md](2346-command-dispatch-completion.md) | Command Dispatch Completion — dissolve the 73-case `runCommand` switch into a two-layer (registry + leaf-table) dispatch | Accepted | ## Seam map