Skip to content

hooks/cli: never render bare tool verbs; teach and recover the gortex call shape#260

Merged
zzet merged 7 commits into
mainfrom
fix/hook-tool-invocation-shape
Jul 6, 2026
Merged

hooks/cli: never render bare tool verbs; teach and recover the gortex call shape#260
zzet merged 7 commits into
mainfrom
fix/hook-tool-invocation-shape

Conversation

@zzet

@zzet zzet commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Fixes #259.

What was happening

Hook and adapter guidance named MCP tools (`read_file`, `get_symbol_source`, ...) without ever showing a callable shell shape, and the generated CLAUDE.md no longer carried the `gortex call ` fallback rule. An agent operating from a shell (MCP unmounted or degraded) would invent `gortex read_file ` — a nonexistent top-level verb — and hit `unknown command`.

Reproduced under a controlled harness (hooks active, MCP not mounted, 15 sessions): invalid invocations in 13% of sessions (`gortex index status`, `gortex reindex ...`), 10/15 sessions dodging the deny via `git show`/`sed`/`grep`, and `gortex call` discovered only by `--help` trial-and-error — never from the hook text.

The fix (three layers + two transcript-driven refinements)

  1. One shared renderer for every guidance emitter (`internal/toolref`): any shell-fallback suggestion renders the real shape — `gortex call --arg k=v` with a per-tool example — and states there is no bare `gortex ` verb. Swept ALL emitters: Read/Grep/Glob/Bash/Edit/Write denies, nudge mode, session-start preamble (flows into all adapter briefings), prompt-symbol injection, read-advisory, subagent briefings, and the bespoke adapter policy docs. A registry-driven regression test iterates every MCP tool name against every template output, so a bare-verb shape can never ship again.
  2. The generated CLAUDE.md policy core regains the one-line CLI-fallback rule (197 B; ceiling tests green).
  3. CLI recovery: `gortex ` exits 1 with a did-you-mean showing the exact `gortex call` invocation; a conservative fuzzy match also catches truncated inventions (`gortex reindex` → `gortex call reindex_repository --arg path=`; `gortex index` → `index_health` / `index_repository`), while nonsense verbs still fall through to cobra's normal error.
  4. Symbol examples everywhere now show the method form (`::<Name|Recv.Name>`) — every observed post-fix friction was a bare-name-vs-receiver miss.

Validated end to end

Same 30-session harness against this branch, gates enforced: invalid invocations 0/30 sessions; degraded-condition sessions answering via gortex 15/15 (from 5/15; zero deny-dodges); `gortex call` shape used correctly first-try in 15/15 (deny fired only once — the policy-core line pre-empts the path); 15/15 recovery on the remaining symbol-naming misses via the tool-level did-you-mean.

`go test -race` on all touched packages, golangci-lint, skill-drift, and the ambient byte-ceiling tests all green (global section 4,505 B of 6,656; skills 2,147 B of 2,560).

zzet added 7 commits July 6, 2026 11:04
Hook and adapter guidance named MCP tools by bare name with no invocation
shape, so an agent in a shell context (MCP unmounted or degraded, or one that
chose Bash) invented the invalid `gortex read_file <path>` verb. There is no
such top-level verb; the correct fallback is `gortex call <tool> --arg k=v`.

Add internal/toolref, one shared renderer for tool references: MCPRef for
MCP-directed guidance and CLIFallback/FallbackLine for the shell fallback, with
realistic per-tool example args. Wire it into every runtime guidance emitter —
the Read/Grep/Bash/Glob/Edit/Write deny and soft-guidance messages, the
adaptive-nudge reminder, the subagent briefing (Claude + Kimi), the whole-file
read advisory, the session-start rule preamble, and the prompt-relevant symbol
injection.

Add internal/mcp.RegisteredToolNames, a daemon-free enumeration of the real
tool registry, and a regression test that iterates it against every emitted
guidance template and fails on any bare `gortex <tool>` shape.
The slimmed policy core no longer told agents how to reach an MCP tool from a
shell, so the only shape they had was the bare tool name — which they turned
into the invalid `gortex read_file <path>`. Add one line to both the global and
per-project instruction bodies: every tool is reachable as
`gortex call <tool> --arg k=v`, and there is no bare `gortex <tool>` verb. The
global section stays well within its byte ceiling.
Running a bare MCP tool name as a top-level verb (`gortex read_file foo.go`)
only got cobra's opaque "unknown command". Before cobra errors, detect when the
first positional argument names a registered MCP tool that is not a real
subcommand and print a did-you-mean pointing at `gortex call <tool> --arg …`
with a realistic example arg, then exit nonzero. The check is daemon-free — it
matches against the in-process tool registry — and only runs after the cheap
flag/known-verb rejects, so a normal invocation pays nothing.
The Hermes and Kiro adapters render their own tool-swap tables instead of the
shared instructions body, so they missed the restored CLI-fallback rule: an
agent reading those docs from a shell context still had only bare tool names
and would invent the invalid `gortex <tool>` verb. Add the same one-line rule
to both docs — every listed tool is reachable as `gortex call <tool> --arg
k=v`, with a worked read_file example.
Agents don't only run exact tool names as verbs — they invent truncations like
`gortex index` and `gortex reindex`, which the exact-match recovery missed and
cobra answered with a bare "unknown command". Extend the interceptor with a
conservative fuzzy pass over the tool registry: an unknown verb that is an
underscore-boundary prefix of a tool name (reindex → reindex_repository) or an
exact underscore-delimited token of one (usages → find_usages) draws up to two
`gortex call <tool> --arg …` suggestions. Verbs shorter than four characters,
real CLI verbs, and flags never match, so cobra's own error remains the
fallback for genuinely unknown commands.
Every symbol-ID example rendered `<file>::<Name>`, so agents targeting a
method issued the bare-name form and got "symbol not found" — a method ID
carries the receiver (`pkg/s.go::Server.Handle`). Render both forms
(`<file>::<Name|Recv.Name>`) in the shared toolref example args, which
propagates to every hook guidance line and the CLI did-you-mean, and spell the
receiver form out in the two docs that hand-render a get_symbol_source ID
example. Byte ceilings unaffected.
… docs

The Hermes and Kiro policy docs gained the shell-fallback rule and the
method-form symbol-ID example, but the all-platform render goldens were not
regenerated, so the byte-compare gate failed. Regenerated via the golden
test's -update-agent-render flag; the diff is exactly the two intended doc
lines.
@zzet zzet merged commit b867d03 into main Jul 6, 2026
11 checks passed
@zzet zzet deleted the fix/hook-tool-invocation-shape branch July 6, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: hook guidance can lead agents to run invalid CLI commands like

1 participant