Skip to content

Commit 47b5a94

Browse files
skill: name the MCP tool before the shell verb on the first-call surfaces (#1425)
The skill description, the orient and directive hooks, and the block that `axiomcode install` writes spelled every call as a shell command, with the MCP tool in parentheses or not named at all. install pre-approves only the MCP tools, so the first call an agent made was the one left behind a permission prompt, and in a headless session it was denied. Each of those surfaces now names axiomcode_<verb> first and keeps the shell form after it, for a host where the MCP server is absent. The Gemini copy of the skill is regenerated. tests/mcp_first.py checks the order and the shell fallback on every surface; on 0.1.3 it fails 18 of 34 checks. Headless runs, 6 tasks on 4 Java projects and a fixture: the first axiomcode call is the MCP tool in 16 of 18 runs where MCP is listed (0 of 18 before), and still Bash in 6 of 6 runs with no MCP server.
1 parent 0df257c commit 47b5a94

7 files changed

Lines changed: 132 additions & 17 deletions

File tree

‎plugins/axiomcode/hooks/direct.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@
3131
# the shell commands that are the search or read this directive competes with
3232
SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b')
3333

34+
# The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools,
35+
# so an agent that takes the first spelling it reads should be taking the one it is already allowed to call.
3436
FULL = (
3537
"graph: this repository has a resolved call graph, and it answers the question this search is asking.\n"
36-
" `axiomcode impact <name>` — everything a change to that declaration reaches, each entry labelled\n"
37-
" with how certain it is; finds callers that never spell the name (an interface, an override, a\n"
38+
" the axiomcode_impact tool (targets=[<name>]) — everything a change to that declaration reaches, each entry\n"
39+
" labelled with how certain it is; finds callers that never spell the name (an interface, an override, a\n"
3840
" callback, DI, a config key), which is what a grep for the name cannot do.\n"
39-
" `axiomcode path <A> <B>` — how one thing reaches another. `axiomcode context \"<task>\"` — where a\n"
40-
" task's words land. `axiomcode changed` / `test-impact` — what an edit moved and which tests reach it.\n"
41+
" axiomcode_path — how one thing reaches another. axiomcode_context — where a task's words land.\n"
42+
" axiomcode_changed / axiomcode_test_impact — what an edit moved and which tests reach it.\n"
43+
" Without those tools, the same verbs from the shell: `axiomcode impact <name>`, `axiomcode path <A> <B>`,\n"
44+
" `axiomcode context \"<task>\"`, `axiomcode changed`, `axiomcode test-impact`.\n"
4145
" Search raw files after that, to read or to change specific lines. If you hand this to a subagent,\n"
4246
" carry this line into its prompt — exploration done in a child is exploration done without the graph."
4347
)

‎plugins/axiomcode/hooks/orient.py‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,19 +174,23 @@ def names_code(prompt, db):
174174
# rather than restating that there was a match, which told the reader nothing about WHICH match
175175
_, _, hits = rest.partition('<- ')
176176
print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else ''))
177-
print(' `axiomcode context "<the task>" --in <one of these>` (the axiomcode_context tool) ranks the files and '
178-
'declarations inside it; call it directly, no skill needs loading first.')
177+
print(' the axiomcode_context tool with in_path=<one of these> ranks the files and declarations inside it; call it '
178+
'directly, no skill needs loading first (without that tool: `axiomcode context "<the task>" --in <one of these>`).')
179179
else:
180180
print("graph: where this task's own words land in the index —")
181181
for l in lines[:MAX_LINES]:
182182
print(" " + l[:150])
183183
# the first call, named: an agent that only has the plugin otherwise spends two turns loading the skill and then
184-
# the tool schemas before it asks anything (#1202)
184+
# the tool schemas before it asks anything (#1202). The MCP tool comes first and the shell form second (#1425):
185+
# `axiomcode install` pre-approves only the tools, so a first call spelled as a shell command is the one call the
186+
# install left behind a permission prompt, and headless it is simply denied.
185187
if 'how it runs —' in out:
186188
# a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the
187189
# one to make — named here so no turn goes to loading the skill or the tool schemas first
188-
print(' next: `axiomcode context "<the question>" --source --from <where it starts>` returns the call flow with '
189-
"each step's code; call it directly, no skill needs loading first.")
190+
print(" next: the axiomcode_context tool with source=True, from_=<where it starts> returns the call flow with each "
191+
"step's code; call it directly, no skill needs loading first (without that tool: "
192+
'`axiomcode context "<the question>" --source --from <where it starts>`).')
190193
else:
191-
print(' a starting point, not a conclusion: next, `axiomcode impact <name> --in <path>` (the axiomcode_impact tool) '
192-
'for what a change reaches; call it directly, no skill needs loading first.')
194+
print(' a starting point, not a conclusion: next, the axiomcode_impact tool with targets=[<name>], in_path=<path> '
195+
'for what a change reaches; call it directly, no skill needs loading first (without that tool: '
196+
'`axiomcode impact <name> --in <path>`).')

‎plugins/axiomcode/skills/axiomcode/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: axiomcode
33
description: >-
4-
Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: `axiomcode context "<the question>" --source` for how something works (the call flow with each step's code; `--from <start>` when you know where it begins), `axiomcode impact <name>` for what a change reaches, `axiomcode path <A> <B>` for how A reaches B. Java, TypeScript, Python, JavaScript, C#.
4+
Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_=<start> when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "<the question>" --source`, `axiomcode impact <name>`, `axiomcode path <A> <B>`. Java, TypeScript, Python, JavaScript, C#.
55
---
66

77
# axiomcode

‎plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ END = '<!-- END axiomcode -->'
3535
BLOCK = """<!-- BEGIN axiomcode -->
3636
## Finding code in this repository
3737
38-
This repository has a resolved call graph. Ask it FIRST — from the shell, or through the
38+
This repository has a resolved call graph. Ask it FIRST, through the
3939
`mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading:
4040
41-
axiomcode context "<the task, in your words>" # where the work is, when you have no name yet
42-
axiomcode impact <Owner.method> # what a change reaches: contract, users, tests
43-
axiomcode path <A> <B> # how A reaches B
41+
axiomcode_context task="<the task, in your words>" # where the work is, when you have no name yet
42+
axiomcode_impact targets=["<Owner.method>"] # what a change reaches: contract, users, tests
43+
axiomcode_path from_="<A>" to="<B>" # how A reaches B
44+
45+
Only when those tools are not in your list, the same from the shell: `axiomcode context "<task>"`,
46+
`axiomcode impact <Owner.method>`, `axiomcode path <A> <B>`.
4447
4548
**Trust the answer.** A `[resolved]` / `[sound]` row has already been looked up again in the graph
4649
(the `verified:` line) — do not re-derive it by grepping or opening the other files it names. Every

‎skills/axiomcode/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: axiomcode
33
description: >-
4-
Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: `axiomcode context "<the question>" --source` for how something works (the call flow with each step's code; `--from <start>` when you know where it begins), `axiomcode impact <name>` for what a change reaches, `axiomcode path <A> <B>` for how A reaches B. Java, TypeScript, Python, JavaScript, C#.
4+
Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Is this safe to delete?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_=<start> when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "<the question>" --source`, `axiomcode impact <name>`, `axiomcode path <A> <B>`. Java, TypeScript, Python, JavaScript, C#.
55
---
66

77
# axiomcode

‎tests/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ One check needs no graph and is its own script:
2727
python3 tests/manifests.py every agent's manifest (Claude, Codex, Cursor, Gemini) names the same
2828
plugin and points at files that exist, the way that agent resolves
2929
them, and Gemini's skill and Cursor's rule are current copies
30+
python3 tests/mcp_first.py the description, the install block and the orient and directive hooks name the
31+
MCP tool before the shell verb, and still carry the shell verb for a host
32+
without the server (#1425; indexes one case, so it needs the engine)
3033
python3 tests/hosts.py each hook tells Cursor and Gemini CLI what it tells the original host,
3134
in their own event names and output shape (indexes one case, so it needs the engine)
3235
python3 tests/enrich_budget.py what a Read or a Grep adds to a session is capped: a declaration annotated once,

‎tests/mcp_first.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""tests/mcp_first.py — every surface an agent reads before its first call names the MCP tool first (#1425).
3+
4+
`axiomcode install` pre-approves the plugin's MCP tools and nothing else. The skill's description, the
5+
prompt-time orientation and the pre-search directive used to spell the call as a shell command, with the tool
6+
in parentheses or not at all, so an agent took the shell spelling; under the install's permissions that first
7+
call is the one that stops for a prompt, and in a headless session it is denied outright.
8+
9+
Each check is one surface: the tool's name comes before the shell form of the same verb. The control on every
10+
surface is the other half: the shell form is still there, after it. A host without the MCP server, or a session
11+
where the server did not start, has nothing else to call, and the skill has to keep working there.
12+
13+
python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does
14+
"""
15+
import json, os, re, shutil, subprocess, sys, tempfile
16+
17+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18+
PLUG = os.path.join(ROOT, 'plugins', 'axiomcode')
19+
HOOKS = os.path.join(PLUG, 'hooks')
20+
SCRIPTS = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts')
21+
AX = os.path.join(SCRIPTS, 'axiomcode')
22+
CASE = os.path.join(ROOT, 'tests', 'cases', 'java', 'injected-into-a-field')
23+
24+
fails, checked = [], []
25+
def check(why, cond, detail=''):
26+
checked.append(why)
27+
print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else ''))
28+
if not cond:
29+
fails.append(why)
30+
31+
32+
def tool_first(surface, text, verbs):
33+
"""for each verb: the MCP tool is named, the shell form is named, and the tool comes first"""
34+
for v in verbs:
35+
tool, shell = f"axiomcode_{v.replace('-', '_')}", f"`axiomcode {v}"
36+
t, s = text.find(tool), text.find(shell)
37+
check(f'{surface}: {v} is named as the {tool} tool', t >= 0, text[:200])
38+
check(f'{surface}: {v} keeps its shell form, for a host without the MCP server', s >= 0, text[:200])
39+
if t >= 0 and s >= 0:
40+
check(f'{surface}: {tool} comes before `axiomcode {v}`', t < s, f'tool at {t}, shell at {s}')
41+
42+
43+
def fire(hook, ev):
44+
r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev),
45+
capture_output=True, text=True, timeout=120)
46+
out = r.stdout.strip()
47+
try:
48+
return r.returncode, json.loads(out)['hookSpecificOutput']['additionalContext']
49+
except (ValueError, KeyError, TypeError):
50+
return r.returncode, out
51+
52+
53+
# 1. the description, which is the one part of the skill every session sees
54+
skill = open(os.path.join(PLUG, 'skills', 'axiomcode', 'SKILL.md'), encoding='utf-8').read()
55+
m = re.search(r'^description: >-\n(.*?)\n---', skill, re.S | re.M)
56+
check('SKILL.md has a description block', bool(m))
57+
if m:
58+
tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('context', 'impact', 'path'))
59+
60+
# 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants
61+
r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'],
62+
capture_output=True, text=True, timeout=30)
63+
check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:])
64+
tool_first('install block', r.stdout, ('context', 'impact', 'path'))
65+
66+
# 3. the directive before the first search; it needs only a graph file to exist
67+
with tempfile.TemporaryDirectory() as repo:
68+
os.makedirs(os.path.join(repo, '.axiomcode', 'out'))
69+
open(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'), 'w').close()
70+
rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep',
71+
'tool_input': {'pattern': 'f'}, 'cwd': repo, 'session_id': 'm1'})
72+
check('direct: the first search hears the directive', rc == 0 and bool(said), f'rc={rc}')
73+
tool_first('direct', said, ('impact', 'path', 'context', 'changed', 'test-impact'))
74+
75+
# 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question
76+
with tempfile.TemporaryDirectory() as work:
77+
repo = os.path.join(work, 'case')
78+
shutil.copytree(CASE, repo)
79+
b = subprocess.run(['bash', AX, 'index', repo], capture_output=True, text=True, timeout=900)
80+
built = os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite'))
81+
check('the case indexes', built, (b.stdout + b.stderr)[-300:])
82+
if built:
83+
rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o1',
84+
'prompt': 'What breaks if I change Repo.find and its callers?'})
85+
check('orient: a change question is oriented', rc == 0 and 'next,' in said, said[:300])
86+
tool_first('orient (change)', said, ('impact',))
87+
rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2',
88+
'prompt': 'How does Consumer.go work, step by step?'})
89+
check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300])
90+
tool_first('orient (how)', said, ('context',))
91+
92+
# 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be
93+
# fired; the order is checked in the source line that prints it.
94+
src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read()
95+
i = src.find("the axiomcode_context tool with in_path=<one of these>")
96+
check('orient (scope refused): its hint is still in the source', i >= 0)
97+
if i >= 0:
98+
tool_first('orient (scope refused)', src[i:src.find('\n', src.find("')", i))], ('context',))
99+
100+
print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held')
101+
sys.exit(1 if fails else 0)

0 commit comments

Comments
 (0)