Skip to content

Commit 702c7cd

Browse files
committed
docs(spec): ctx system unknown-subcommand verbatim relay
Spec for Phase CLI-FIX #5: make `ctx system <unknown>` emit a verbatim-relay box to stdout and exit non-zero instead of dumping ~51 lines of help at exit 0 (which a UserPromptSubmit hook reads as success and injects every prompt). Settled with the user: fire the event-log + webhook relay leg (nudge.Relay), gated on a real session ID read TTY-safely from stdin; scoped to ctx system only, parent.Cmd untouched. Runtime complement to the build-time wiring guard (specs/hooks-wiring-guard.md). Spec: specs/system-unknown-subcommand-relay.md Signed-off-by: Jose Alekhinne <jose@ctx.ist>
1 parent 175e43c commit 702c7cd

2 files changed

Lines changed: 293 additions & 0 deletions

File tree

‎.context/TASKS.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,21 @@ These have priority because other knowledge ingestion projects depend on them.
201201
its own spec before implementation.
202202
#priority:medium #session:96765858 #branch:feat/pad-undo-snapshot #commit:
203203
b9ce72e8 #added:2026-05-27-130130
204+
- #in-progress 2026-05-28 (branch feat/system-unknown-relay, session 0066d49b):
205+
SPEC WRITTEN — specs/system-unknown-subcommand-relay.md. Spec precondition
206+
("needs its own spec") is now met; implementation NOT started.
207+
Approach settled: add a RunE on system.Cmd() only (legacyArgs lets the
208+
leftover args reach the group's RunE for non-root); on unknown verb emit a
209+
message.NudgeBox to stdout, set SilenceUsage (else cobra re-dumps the help
210+
we're killing), exit non-zero. system is Hidden so RootCmd PersistentPreRunE
211+
early-returns — no context/git preconditions.
212+
Decisions settled with user: (1) DO fire the event-log + webhook relay leg
213+
(nudge.Relay), gated on a real session ID read best-effort from stdin via
214+
session.ReadID (TTY-safe, timeout-guarded → IDUnknown means skip the leg);
215+
(2) scoped to ctx system only, parent.Cmd untouched.
216+
Follow-up surfaced: ctx hook (and any parent.Cmd group) has the same latent
217+
exit-0-on-unknown behavior — not wired into hooks.json so out of scope here;
218+
capture as its own task if it ever gets hook-wired.
204219

205220
## Important
206221

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
# `ctx system` Unknown-Subcommand Verbatim Relay
2+
3+
## Problem
4+
5+
`ctx system <unknown>` prints the `system` group's ~51-line Long
6+
help and exits **0**. Cobra's default `legacyArgs` raises an
7+
"unknown command" error only for the *root* command
8+
(`!cmd.HasParent()`); for a non-root grouping command like
9+
`ctx system`, leftover args fall through and — because the group
10+
has no `Run`/`RunE` — cobra calls `Help()` and returns `nil`.
11+
12+
In a Claude Code `UserPromptSubmit` hook, exit 0 is read by the
13+
harness as "hook success", so the entire help blob is injected
14+
into the agent's context **on every prompt**. This is exactly the
15+
failure mode that the version-skew bug rode in on: a stale
16+
`hooks.json` wired `ctx system check-anchor-drift` after the
17+
binary deleted that command, and instead of a visible error the
18+
agent silently ate 51 lines of help each turn (see
19+
`specs/experiments/acdl-session-start.md` §Root Cause, Bug #2).
20+
21+
A non-zero exit code **alone** does not fix this: in a
22+
`UserPromptSubmit` hook a non-zero exit with help suppressed is
23+
swallowed by the harness — the user/agent sees nothing. The signal
24+
has to travel on the channel the harness actually surfaces: hook
25+
**stdout**. So the fix must *emit a message* (not just fail) when
26+
`ctx system` is handed a verb it does not recognise.
27+
28+
The companion build-time guard already shipped
29+
(`specs/hooks-wiring-guard.md`,
30+
`TestShippedHooksResolveToRegisteredCommands`): it stops *our own*
31+
package from shipping a hooks.json that names a missing verb. This
32+
spec covers the **runtime** half — making a skew that reaches a
33+
user's machine (old plugin, new binary, or vice versa) fail
34+
*loud and legible* instead of silent.
35+
36+
## Approach
37+
38+
Give the `ctx system` command — and only that command — a `RunE`
39+
that intercepts the unknown-subcommand case:
40+
41+
- **Bare `ctx system`** (no args): print help, exit 0. Unchanged
42+
behavior.
43+
- **`ctx system <verb>` where `<verb>` is a real subcommand**:
44+
cobra descends into it and runs *its* `RunE`; `system`'s own
45+
`RunE` is never reached. Unchanged behavior.
46+
- **`ctx system <unknown>`**: cobra finds no matching subcommand,
47+
so the leftover args reach `system`'s `RunE`. The handler emits a
48+
verbatim-relay box to stdout naming the unknown verb and hinting
49+
at the likely cause (plugin/binary version skew), sets
50+
`SilenceUsage`, and returns a non-nil error → exit non-zero. When
51+
a real session ID is present on stdin (i.e. the command was run
52+
by a hook), it *also* records a relay event — event-log append
53+
then webhook — via the same `nudge.Relay` path the `check-*`
54+
hooks use, so the skew is captured for out-of-band/autonomous
55+
alerting, not just the live session.
56+
57+
The behavior attaches to `system.Cmd()` after it is built by the
58+
shared `parent.Cmd(...)`. The shared `parent.Cmd` is **not**
59+
changed: every other group (`ctx hook`, …) keeps cobra's current
60+
default. Scope is `ctx system` only, because that is the only group
61+
wired into `hooks.json` and therefore the only one whose skew
62+
pollutes a session.
63+
64+
Why `RunE` and not `Args: cobra.NoArgs`: `NoArgs` would surface
65+
cobra's *generic* "unknown command" error on stderr and still emit
66+
nothing to stdout — the harness would swallow it just like a bare
67+
non-zero exit. We need a controlled message on stdout, which a
68+
`RunE` gives us.
69+
70+
`ctx system` is registered `Hidden` (group.go `hiddenCmds`), so
71+
`RootCmd`'s `PersistentPreRunE` early-returns for it
72+
(`if cmd.Hidden { return nil }`) — adding a `RunE` does **not**
73+
subject `ctx system` to the context-dir / init / git
74+
preconditions. Verify this holds during implementation.
75+
76+
## Behavior
77+
78+
### Happy Path
79+
80+
1. A stale `hooks.json` (or a typo) runs
81+
`ctx system check-anchor-drift`.
82+
2. Cobra finds no `check-anchor-drift` subcommand; the arg falls
83+
through to `system`'s `RunE` as `args = ["check-anchor-drift"]`.
84+
3. The handler renders a verbatim-relay box to **stdout**:
85+
- names the unknown subcommand,
86+
- states the likely cause (a hook referencing a command this
87+
binary no longer ships — version skew between the installed
88+
plugin's `hooks.json` and the on-PATH `ctx` binary),
89+
- gives the recovery (align plugin and binary to the same
90+
release).
91+
4. Best-effort: the handler reads the session ID from stdin
92+
(`session.ReadID`, which is TTY-safe and timeout-guarded). If a
93+
real session ID is present, it calls `nudge.Relay` to append a
94+
relay event to the local event log and fire the relay webhook
95+
(log-first: webhook only on a successful append).
96+
5. The handler sets `cmd.SilenceUsage = true` and returns a
97+
non-nil error.
98+
6. The harness surfaces the stdout box to the user/agent; the
99+
non-zero exit makes the failure debuggable instead of disguised
100+
as success; the event-log/webhook record gives out-of-band
101+
visibility. No 51-line help dump.
102+
103+
### Edge Cases
104+
105+
| Case | Expected behavior |
106+
|------|-------------------|
107+
| Bare `ctx system` (no args) | Print help, exit 0. Unchanged. |
108+
| Valid hidden subcommand (`ctx system bootstrap`) | Routes to the subcommand; `system`'s `RunE` not invoked. Unchanged. |
109+
| Valid subcommand + bad flag (`ctx system heartbeat --nope`) | Cobra's flag parsing on the *subcommand* errors as today; `system`'s `RunE` not involved. |
110+
| Multiple leftover args (`ctx system foo bar`) | Treated as unknown; relay names the first token (`foo`). Exit non-zero. |
111+
| Unknown verb that is a prefix of a real one (`ctx system check`) | No exact match → unknown; relay names `check`. (Cobra prefix-matching is not enabled.) |
112+
| `ctx system --help` | Cobra's help flag short-circuits before `RunE`; prints help, exit 0. Unchanged. |
113+
| Manual `ctx system typo` at a terminal (stdin is a TTY) | `session.ReadID` detects the char device and returns `IDUnknown` immediately — **no hang**. Stdout box + exit non-zero; relay leg (event log + webhook) is skipped (no session context to attribute it to). |
114+
| Hook stdin present but blank/garbage session JSON | `ReadID` returns `IDUnknown` within the 2s timeout; relay leg skipped; stdout box + exit non-zero still happen. |
115+
| Hook runs the dead command under a non-`UserPromptSubmit` event | Stdout may not be injected for every event, but the non-zero exit + stderr error + event-log/webhook relay still beat a silent exit-0 help dump; no regression. |
116+
117+
### Validation Rules
118+
119+
The "unknown" determination is cobra's, not ours: if `system`'s
120+
`RunE` runs with `len(args) > 0`, cobra has already failed to match
121+
a subcommand. The handler treats any non-empty `args` as an unknown
122+
subcommand and `args[0]` as its name. No additional parsing.
123+
124+
### Error Handling
125+
126+
| Error condition | User-facing message (stdout verbatim box) | Recovery |
127+
|-----------------|-------------------------------------------|----------|
128+
| `ctx system <unknown>` | Verbatim-relay box: `ctx system: unknown subcommand "<verb>". A Claude Code hook is likely calling a ctx command this binary no longer ships (version skew between the installed plugin and the on-PATH ctx binary). Align the plugin and binary to the same release.` | Update plugin/binary to matching versions; or fix the hook command. |
129+
130+
The returned error (stderr, via `main.go`'s `writeErr`; cobra's
131+
own printing stays silenced by `RootCmd`'s `SilenceErrors`) is a
132+
terse companion to the stdout box — it carries the unknown verb so
133+
a human reading logs sees the cause without the help dump.
134+
135+
## Interface
136+
137+
### CLI
138+
139+
No new command or flag. Changes the behavior of the existing
140+
(hidden) `ctx system` grouping command when invoked with an
141+
unrecognised subcommand.
142+
143+
```
144+
ctx system <unknown> # was: help + exit 0 ; now: verbatim relay + exit 1
145+
ctx system # unchanged: help, exit 0
146+
ctx system bootstrap # unchanged: runs bootstrap
147+
```
148+
149+
## Implementation
150+
151+
### Files to Create/Modify
152+
153+
| File | Change |
154+
|------|--------|
155+
| `internal/cli/system/system.go` | After `parent.Cmd(...)`, set `c.RunE` to the unknown-subcommand handler. Keep grouping construction via `parent.Cmd`. |
156+
| `internal/cli/system/core/.../unknown.go` (new, e.g. `core/unknown/`) | Handler: render the verbatim box, set `SilenceUsage`, return the error. Keeps `system.go` thin. |
157+
| `internal/err/<system>` | New `UnknownSubcommand(verb string) error` constructor. Locate the existing system/CLI error file rather than creating a package. |
158+
| `internal/assets/commands/text/*.yaml` + `internal/config/embed/text` | New `DescKey` for the relay box title and body text (no hardcoded user-facing strings, per convention). |
159+
| `internal/cli/system/*_test.go` | Tests per Testing section. |
160+
161+
### Key Functions
162+
163+
```go
164+
// in internal/cli/system/system.go (sketch)
165+
c := parent.Cmd(cmd.DescKeySystem, cmd.UseSystem, /* subs... */)
166+
c.RunE = unknown.Handler // name TBD
167+
return c
168+
169+
// in the new handler (sketch)
170+
func Handler(cmd *cobra.Command, args []string) error {
171+
if len(args) == 0 {
172+
return cmd.Help() // bare `ctx system`: unchanged
173+
}
174+
verb := args[0]
175+
box := message.NudgeBox(relayPrefix, title, bodyFor(verb))
176+
fmt.Fprintln(cmd.OutOrStdout(), box)
177+
178+
// Best-effort relay leg: only when a hook supplied a session.
179+
// ReadID is TTY-safe and timeout-guarded, so a manual typo at a
180+
// terminal returns IDUnknown without blocking.
181+
if sid := session.ReadID(os.Stdin); sid != cfgSession.IDUnknown {
182+
ref := entity.NewTemplateRef(hook.System, variantUnknownSub, nil)
183+
if relayErr := nudge.Relay(relayMsgFor(verb), sid, ref); relayErr != nil {
184+
logWarn.Warn(warn.RelayUnknownSubcommand, relayErr) // log, don't mask
185+
}
186+
}
187+
188+
cmd.SilenceUsage = true // do NOT re-dump help on error
189+
return errSystem.UnknownSubcommand(verb)
190+
}
191+
```
192+
193+
Note: a relay-leg failure is logged via the existing warn path and
194+
does **not** change the returned error — the user's actual problem
195+
is the unknown subcommand, and that is what the exit reflects. The
196+
hook name/variant for the `TemplateRef` (`hook.System` /
197+
`variantUnknownSub`) may need new constants; reuse existing ones if
198+
a suitable pair already exists.
199+
200+
### Helpers to Reuse
201+
202+
- `message.NudgeBox(relayPrefix, title, content)` — the same
203+
verbatim-box framing the `check-*` hooks use
204+
(`internal/cli/system/core/message`).
205+
- `session.ReadID(os.Stdin)` — TTY-safe, timeout-guarded session-ID
206+
read from hook stdin; returns `cfgSession.IDUnknown` when absent
207+
(`internal/cli/system/core/session`).
208+
- `nudge.Relay(msg, sessionID, ref)` — log-first event-log append +
209+
relay webhook (`internal/cli/system/core/nudge`).
210+
- `entity.NewTemplateRef(hook, variant, vars)` — relay event ref for
211+
filtering/aggregation.
212+
- `desc.Text(...)` — load box title/body from assets, not literals.
213+
- `parent.Cmd` — still builds the grouping command; only the `RunE`
214+
is added on top.
215+
216+
## Configuration
217+
218+
None. No `.ctxrc` keys, env vars, or settings.
219+
220+
## Testing
221+
222+
- **Unit (handler):** call the handler with `args=["bogus"]`;
223+
assert (a) stdout contains the verbatim box, the token `bogus`,
224+
and the skew hint; (b) it returns a non-nil error;
225+
(c) `cmd.SilenceUsage` is true after the call.
226+
- **Unit (bare):** handler with `args=[]` prints help and returns
227+
nil.
228+
- **Cobra-level:** build `system.Cmd()`, execute with
229+
`["definitely-not-a-cmd"]`, capture output; assert non-nil error
230+
and the box on stdout, and assert **no** Long-help body is
231+
printed. Execute with `[]` (bare) and assert help + nil error.
232+
- **Routing:** assert a known subcommand name resolves to its own
233+
command (e.g. `system.Cmd().Find([]string{"bootstrap"})` returns
234+
`bootstrap`), proving the handler is bypassed for valid verbs.
235+
- **Relay leg — fired:** with a stdin carrying a real session ID,
236+
assert `nudge.Relay` records an event (event-log append observed
237+
in a temp state dir) for the unknown verb. Assert a relay failure
238+
is logged but the handler still returns the `UnknownSubcommand`
239+
error (relay failure does not mask it).
240+
- **Relay leg — skipped:** with a TTY/blank stdin (`ReadID` →
241+
`IDUnknown`), assert no relay event is recorded and the call does
242+
not block; stdout box + error still happen.
243+
- **Scope:** assert `parent.Cmd` is unchanged — another group built
244+
from `parent.Cmd` (e.g. `ctx hook`) still exits 0 on an unknown
245+
subcommand. This documents the intentional scoping and will flag
246+
if someone later moves the fix into the shared helper.
247+
248+
## Non-Goals
249+
250+
- **Not** changing the shared `parent.Cmd`. Other groups keep
251+
cobra's default unknown-subcommand behavior.
252+
- **Not** fixing `ctx hook` or any other group. They are not wired
253+
into `hooks.json`, so their skew does not pollute a session. If
254+
that changes, revisit.
255+
- **Not** the build-time wiring guard — already shipped
256+
(`specs/hooks-wiring-guard.md`). This is its runtime complement.
257+
- **Not** removing the leftover `check-anchor-drift` prose mention
258+
in `commands.yaml` (acdl follow-up #3 — a separate explicit
259+
decision).
260+
- **Not** generalizing the unknown-subcommand handler into the
261+
shared `parent.Cmd`. `ctx hook` (and any future group) has the
262+
same latent exit-0-on-unknown behavior, but it is not wired into
263+
`hooks.json`, so its skew does not pollute a session. Captured as
264+
a follow-up task, not built here.
265+
266+
## Resolved Decisions
267+
268+
Both decisions below were settled with the user at spec time
269+
(session 0066d49b):
270+
271+
1. **Relay leg is in scope.** The handler fires the event-log +
272+
webhook relay (`nudge.Relay`), gated on a real session ID read
273+
best-effort from stdin. When no session is present (manual TTY
274+
typo, blank JSON), it emits the stdout box and exits non-zero
275+
without a relay record.
276+
2. **Scoped to `ctx system` only.** `parent.Cmd` is untouched;
277+
other groups keep cobra's default. Generalization is recorded as
278+
a follow-up task (see Non-Goals).

0 commit comments

Comments
 (0)