Skip to content

Commit 5d42bb8

Browse files
MCamnerclaude
andauthored
docs(registry): decide subcommand model before consumer generation (#72)
The registry delivered in #71 covers top-level commands only. Extending it to subcommands is blocked by dispatch itself: subcommand handling is four shapes, not one, and a schema that models all four turns the divergence into contract. Measured against main by case/esac depth and by executing the commands: - two dispatch variables. `sub` is lowercased at line 333; srm and repos shift and re-read the raw "${1:-}", so `repos LIST` fails where `system TIME` works - four answers to an unknown subcommand: help+exit 2, a hand-written usage string+exit 1, silent pass-through, and a `*` branch identical to the explicit one it shadows - two namespace-help mechanisms with different exit codes, split by a seven-name allowlist at line 344 - `mqlaunch git help` opens the interactive menu and loops on EOF: 12580 bytes and exit 124 under timeout Decisions: normalised $sub is the norm, a nested case "$sub" is the norm rather than a system-specific pattern, one unknown-subcommand contract, one namespace help mechanism, and the schema extends only after those land. Design only. No dispatch, registry, or validator change. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent dc00cfb commit 5d42bb8

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

ROADMAP.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ Priority: P1
186186
Risk if delayed: High
187187
Owner: `macos-scripts`
188188

189+
Subcommands are not modelled yet, and the consumer work is blocked until they
190+
are. The reasons and the decisions are in
191+
[docs/plans/P1-command-registry-subcommands.md](docs/plans/P1-command-registry-subcommands.md).
192+
189193
### Problem
190194

191195
Command names, aliases, help output, palette entries, menus, and dispatch logic can drift.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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

Comments
 (0)