|
| 1 | +# P1 — Subcommand Model for the Command Registry |
| 2 | + |
| 3 | +**Status:** Design — no code change proposed in this document |
| 4 | +**Priority:** P1 |
| 5 | +**Type:** Architecture / Command surface governance |
| 6 | +**Owner:** mqlaunch |
| 7 | +**Goal:** Decide how subcommands are modelled before any consumer generates |
| 8 | +help, palette entries, or docs from the registry. |
| 9 | + |
| 10 | +Linked from [ROADMAP.md](../../ROADMAP.md). Follows |
| 11 | +[P1-runtime-authority.md](P1-runtime-authority.md) and the registry foundation |
| 12 | +delivered in #71. |
| 13 | + |
| 14 | +## Why this document exists |
| 15 | + |
| 16 | +The registry in `mqlaunch/lib/command-registry.json` covers **top-level** |
| 17 | +commands only. The natural next step is to let help, palette, and docs read it |
| 18 | +instead of hardcoding their own lists. That step is blocked, and not for a |
| 19 | +reason visible from the registry: subcommand dispatch is not one shape. It is |
| 20 | +four. A registry that models four shapes describes the current state instead of |
| 21 | +constraining it, and every consumer built on top of it inherits the divergence. |
| 22 | + |
| 23 | +So the order is: converge dispatch, then extend the schema, then generate. |
| 24 | + |
| 25 | +## Verified facts (measured against `main` at 6b11b1e, 2026-07-25) |
| 26 | + |
| 27 | +All numbers below come from parsing `case`/`esac` depth in |
| 28 | +`terminal/launchers/mqlaunch-command-mode.sh` and from executing the commands. |
| 29 | +Indentation in that file is not reliable, so depth tracking — not layout — is |
| 30 | +the basis. |
| 31 | + |
| 32 | +* 69 branches in the top-level `case "$area" in` (line 359); 67 real commands |
| 33 | + after excluding `""|menu` and `*`. |
| 34 | +* 9 of those branches contain a nested subcommand `case`. The other 60 either |
| 35 | + take no subcommand or pass their arguments straight to a delegate. |
| 36 | + |
| 37 | +| Branch | Line | Dispatches on | Subcommands | |
| 38 | +| --- | --- | --- | --- | |
| 39 | +| `workspace\|snapshots` | 373 | `$sub` | 5 + `*` | |
| 40 | +| `srm\|memory\|repo-memory` | 512 | `"${1:-}"` | 4, no `*` | |
| 41 | +| `repos` | 547 | `"${1:-}"` | 2 + `*` | |
| 42 | +| `system` | 642 | `$sub` | 10 + `*` | |
| 43 | +| `git` | 682 | `$sub` | 1 + `*` | |
| 44 | +| `release` | 694 | `$sub` | 4 + `*` | |
| 45 | +| `dev` | 716 | `$sub` | 4 + `*` | |
| 46 | +| `help\|-h\|--help` | 738 | `$sub` | 8 + `*` | |
| 47 | +| `obsidian\|…` | 922 | `$sub` | 5 + `*` | |
| 48 | + |
| 49 | +### Divergence 1 — two dispatch variables, one of them case-sensitive |
| 50 | + |
| 51 | +`sub` is normalised to lowercase at line 333 via `normalize_cli_word`. The |
| 52 | +`srm` and `repos` branches `shift` and re-read the **raw** `"${1:-}"`, so they |
| 53 | +never see that normalisation. This is user-visible: |
| 54 | + |
| 55 | +```text |
| 56 | +mqlaunch system TIME → works, exit 0 (same 2765 bytes as `system time`) |
| 57 | +mqlaunch repos LIST → mq-repos.py: error: invalid choice: 'LIST' |
| 58 | +``` |
| 59 | + |
| 60 | +### Divergence 2 — four different answers to an unknown subcommand |
| 61 | + |
| 62 | +* `system`, `release`, `dev`, `help` → `print_command_help "<ns>"`, exit 2. |
| 63 | +* `obsidian` → hand-written `echo` with a hardcoded usage string, exit 1. The |
| 64 | + string lists four subcommands; the branch implements five. |
| 65 | +* `workspace`, `git` → no error at all. The unknown word is forwarded to the |
| 66 | + handler as if it were valid. |
| 67 | +* `repos` → its `*` branch is byte-identical to its explicit |
| 68 | + `list|roadmaps|skills|status|wiki-status|diff-summary` branch, so the |
| 69 | + explicit list has no effect whatsoever. |
| 70 | + |
| 71 | +### Divergence 3 — two namespace-help mechanisms with different exit codes |
| 72 | + |
| 73 | +Line 344 grants `agent hal obsidian repos skills srm stack` a shared |
| 74 | +`print_namespace_help` path that exits 0. Namespaces outside that list fall |
| 75 | +through to their own `*` fallback instead: |
| 76 | + |
| 77 | +```text |
| 78 | +hal|repos|srm|stack|agent|obsidian|skills help → exit 0, namespace help |
| 79 | +system|release|dev help → exit 2, command help |
| 80 | +workspace help → exit 0, workspace.sh's own help |
| 81 | +git help → exit 124 (timeout, see below) |
| 82 | +``` |
| 83 | + |
| 84 | +Same gesture, four outcomes. `tests/namespace-help-smoke.sh` covers the first |
| 85 | +group only, which is why the split has survived. |
| 86 | + |
| 87 | +### Divergence 4 — `mqlaunch git help` does not terminate |
| 88 | + |
| 89 | +`git`'s `*` fallback calls `open_git_menu "help"`. That prints |
| 90 | +`Path not found: help`, then opens the interactive GitHub Launchpad and loops |
| 91 | +on EOF rather than exiting. With `</dev/null` and a 6-second timeout it emits |
| 92 | +12580 bytes and exits 124. This is a hang, not slow output. |
| 93 | + |
| 94 | +### Adjacent finding — delegated exit codes are discarded in 21 branches |
| 95 | + |
| 96 | +29 top-level branches invoke a script under `$BASE_DIR`. 21 of them end in an |
| 97 | +unconditional `return 0`, discarding the delegate's status. Confirmed by |
| 98 | +execution on two independent branches: |
| 99 | + |
| 100 | +```text |
| 101 | +mqlaunch repos LIST → argparse error on stderr, exit 0 |
| 102 | +mqlaunch skills no-such-subcommand → argparse error on stderr, exit 0 |
| 103 | +``` |
| 104 | + |
| 105 | +`tests/delegated-exit-code-smoke.sh` covers `review`, `stack status`, and |
| 106 | +`hal brief` — the agent and HAL families, which propagate correctly. It does not |
| 107 | +reach the 21 branches that do not. This contradicts the v2.0.0 output-contract |
| 108 | +exit gate "CI can trust exit codes without parsing terminal text", and is |
| 109 | +tracked separately so it does not ride along with a schema change. |
| 110 | + |
| 111 | +## Decisions |
| 112 | + |
| 113 | +### D1 — the normalised `$sub` is the norm; raw `"${1:-}"` is a defect |
| 114 | + |
| 115 | +`srm` and `repos` convert to `$sub`. Case-insensitive command words are already |
| 116 | +the contract at the top level; a namespace that quietly opts out of it is a bug, |
| 117 | +not a variant worth modelling. |
| 118 | + |
| 119 | +### D2 — a nested `case "$sub"` is the norm, not a special case |
| 120 | + |
| 121 | +`system` is not architecturally special. It is the same shape as `release`, |
| 122 | +`dev`, and `obsidian` with more branches — 10 instead of 4. Size is not a reason |
| 123 | +for a second pattern. Any command with subcommands uses one nested |
| 124 | +`case "$sub"` in the dispatcher; commands without subcommands forward their |
| 125 | +arguments and declare no subcommand list. |
| 126 | + |
| 127 | +### D3 — one unknown-subcommand contract |
| 128 | + |
| 129 | +Unknown subcommand → `print_command_help "<namespace>"` on stderr → exit 2. |
| 130 | +No hand-written usage strings, because those drift from the branch that |
| 131 | +implements them (`obsidian` already has). No silent pass-through, because that |
| 132 | +turns a typo into an argument. `repos`'s duplicated `*` branch collapses into |
| 133 | +one. |
| 134 | + |
| 135 | +### D4 — one namespace-help mechanism |
| 136 | + |
| 137 | +Every namespace with subcommands gets `print_namespace_help`, exit 0, and the |
| 138 | +line-344 allowlist disappears in favour of "has subcommands". Namespace help is |
| 139 | +a successful operation; it exits 0 whether the namespace is `hal` or `system`. |
| 140 | + |
| 141 | +### D5 — the schema is extended only after D1–D4 land |
| 142 | + |
| 143 | +Adding a `subcommands` array now would require per-command fields describing |
| 144 | +which dispatch variable, which fallback, and which help mechanism each namespace |
| 145 | +uses. That encodes the divergence in the contract and makes it permanent. The |
| 146 | +registry states what the surface must be; it is not a survey of what it is. |
| 147 | + |
| 148 | +## Minimum contract for consumers |
| 149 | + |
| 150 | +Once D1–D5 are in place, each subcommand entry carries the same fields as a |
| 151 | +top-level entry: `name`, `aliases`, `summary`, `safety`, `output_modes`, |
| 152 | +`json`, `interactive`, and `delegates_to` when it leaves the repo. |
| 153 | + |
| 154 | +Consumers then hold to three rules: |
| 155 | + |
| 156 | +* Read the registry. Never parse `mqlaunch-command-mode.sh`, and never keep a |
| 157 | + private list. `tests/command-docs-smoke.sh` greps a hardcoded set of eleven |
| 158 | + names today and is the first thing to convert. |
| 159 | +* Fail the build on drift rather than rendering partial output. The gate already |
| 160 | + works in both directions for top-level commands (#71) and extends unchanged. |
| 161 | +* Take rendering decisions from `interactive` and `output_modes`, not from |
| 162 | + guesswork about the command name. |
| 163 | + |
| 164 | +Three consumers are in scope later, in this order: namespace and command help, |
| 165 | +the palette, and generated docs. `.mq/context/command-surface.json` is a fourth, |
| 166 | +hand-maintained inventory that nothing currently reads or generates; it is |
| 167 | +either generated from the registry or deleted. |
| 168 | + |
| 169 | +## Sequencing |
| 170 | + |
| 171 | +1. Fix the raw-`"${1:-}"` dispatch in `srm` and `repos` (D1). |
| 172 | +2. Standardise the unknown-subcommand contract, including `git`'s hang (D3, D4). |
| 173 | +3. Extend the registry schema with `subcommands` and widen the validator (D5). |
| 174 | +4. Convert consumers one at a time, help first. |
| 175 | + |
| 176 | +Steps 1 and 2 are behavioural and need tests written first. Step 3 is |
| 177 | +mechanical once they land. No consumer work starts before step 3 is green. |
| 178 | + |
| 179 | +## Out of scope |
| 180 | + |
| 181 | +* Renaming or removing any command. This document changes dispatch shape, not |
| 182 | + the surface. |
| 183 | +* The plain-output contract (#67) and `repo_state` semantics (#66). |
| 184 | +* Subcommands of delegated tools. `mq-repos.py` owns its own argument parsing; |
| 185 | + the registry records that `repos` delegates, not what the Python argparse |
| 186 | + accepts. |
0 commit comments