The index is reachable only over MCP, so no agent runtime can gate an edit on it — change_impact cannot be surfaced at edit time by any hook #282

Closed
opened 2026-09-16 10:30:22 +02:00 by buildagent · 1 comment
Member

The finding

code-index --help lists every subcommand the binary has:

init    Write a default `.code-index.toml` and generate AI agent instruction rules
rules   Refresh the AI agent instruction rules in this project
index   One-shot index of the project. No daemon, no watcher
watch   Run an in-foreground watcher
doctor  Diagnose the local install
link    Manage `[[links]]` entries in `.code-index.toml`
plugin  Build, inspect, install and approve `.cip` plugin packages

There is no query surface. search_symbols, find_callers, change_impact, review_diff, index_coverage — all 21 tools — exist only as MCP tools. main.rs's own module doc still says "Future milestones add daemon, stats, query"; query never arrived.

Why it matters, and why it is not a cosmetic gap

It makes a whole class of integration structurally impossible.

An agent runtime that wants to check something before an edit — Claude Code's PreToolUse hook, a git pre-commit, a CI step, an editor's on-save action — runs a shell command. None of them speak MCP. So none of them can ask "what is the blast radius of this edit" without spawning an MCP server and hand-rolling JSON-RPC over stdio, which is not something a hook script does.

This came out of a concrete attempt. In one session change_impact, find_references, get_dependencies, find_callees, review_diff and repo_map had zero uses until the agent was challenged on it; the nine call sites of a changed pub enum were found from compiler errors instead, when change_impact returns all nine at depth 1 in one call. The obvious fix is to surface the tool at the moment of the edit. That fix cannot be built — not for Claude Code, and not for anything else.

What this blocks, specifically

  • A pre-edit blast-radius check in any runtime.
  • code-index rules --check has a sibling that cannot exist: a pre-commit hook that fails when a public signature moved and nothing looked at its callers.
  • CI that asserts a property of the index (no new phantom binds in this diff) without standing up the MCP server.
  • Any non-agent scripting at all: a human who wants code-index callers foo in a terminal has no route to it.

The asymmetry worth naming

crates/cli/src/rules.rs distributes INSTRUCTIONS to eight agent conventions — AGENTS.md, CLAUDE.md, .cursorrules, .cursor/rules/code-index.mdc, .gemini/rules/code-index.md, .windsurfrules, .github/copilot-instructions.md, .clinerules. Instructions are portable; every runtime reads a file.

ENFORCEMENT is not portable, and the reason is this issue. Telling an agent to run change_impact reaches all eight. Making it impossible to skip reaches none of them, because the check would have to run in the runtime and the runtime has no way in.

So the honest current answer to "how do we make the tool-choice gate automatically available to every agent" is: instructions, yes, already, via code-index rules; enforcement, not until the index has a shell-reachable surface.

Proposal

A read-only code-index query <tool> [args] subcommand over the same daemon the MCP server talks to, emitting the same JSON the MCP tool emits — including the whole disclosure envelope, unchanged, since the value of these replies is that they carry their own reading instructions.

Constraints that follow from the rest of the system:

  1. One implementation. The CLI must route through the same IndexAccess the MCP server uses. A second query path is a second set of answers, and this project has already paid for that shape (doctor is exposed through the lib for exactly this reason — "there is exactly one doctor in the workspace and the binary runs the same code the test grades").
  2. Read-only, and it must not become a second writer. guard_live_daemon exists because code-index watch once quietly became one.
  3. The envelope ships intact. A CLI form that drops evidence_gaps because it is verbose would be a surface where an absent row silently stops meaning "not measured" — see #281 for why the envelope is priced the way it is.
  4. Exit codes are part of the contract, because the callers are scripts. code-index rules --check is the precedent: 0 clean, 1 with findings, and the findings on stderr.

Scope note: this is plumbing for a surface that already exists, not a new capability. The hard part is deciding which of the 21 tools get a CLI spelling and what their argument shape is — 21 hand-written arg structs is the wrong answer, and the #[tool] schemas are already machine-readable.

Related: #70 (tool adoption under real discovery friction) — an instruction an agent can skip and a check it cannot are different instruments, and only one of them is buildable today.

## The finding `code-index --help` lists every subcommand the binary has: ``` init Write a default `.code-index.toml` and generate AI agent instruction rules rules Refresh the AI agent instruction rules in this project index One-shot index of the project. No daemon, no watcher watch Run an in-foreground watcher doctor Diagnose the local install link Manage `[[links]]` entries in `.code-index.toml` plugin Build, inspect, install and approve `.cip` plugin packages ``` There is **no query surface**. `search_symbols`, `find_callers`, `change_impact`, `review_diff`, `index_coverage` — all 21 tools — exist only as MCP tools. `main.rs`'s own module doc still says "Future milestones add `daemon`, `stats`, `query`"; `query` never arrived. ## Why it matters, and why it is not a cosmetic gap It makes a whole class of integration structurally impossible. An agent runtime that wants to check something before an edit — Claude Code's `PreToolUse` hook, a git `pre-commit`, a CI step, an editor's on-save action — runs a **shell command**. None of them speak MCP. So none of them can ask "what is the blast radius of this edit" without spawning an MCP server and hand-rolling JSON-RPC over stdio, which is not something a hook script does. This came out of a concrete attempt. In one session `change_impact`, `find_references`, `get_dependencies`, `find_callees`, `review_diff` and `repo_map` had **zero** uses until the agent was challenged on it; the nine call sites of a changed `pub enum` were found from compiler errors instead, when `change_impact` returns all nine at depth 1 in one call. The obvious fix is to surface the tool at the moment of the edit. That fix cannot be built — not for Claude Code, and not for anything else. ## What this blocks, specifically - A pre-edit blast-radius check in any runtime. - `code-index rules --check` has a sibling that cannot exist: a **pre-commit hook** that fails when a public signature moved and nothing looked at its callers. - CI that asserts a property of the index (no new phantom binds in this diff) without standing up the MCP server. - Any non-agent scripting at all: a human who wants `code-index callers foo` in a terminal has no route to it. ## The asymmetry worth naming `crates/cli/src/rules.rs` distributes INSTRUCTIONS to eight agent conventions — `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.cursor/rules/code-index.mdc`, `.gemini/rules/code-index.md`, `.windsurfrules`, `.github/copilot-instructions.md`, `.clinerules`. Instructions are portable; every runtime reads a file. ENFORCEMENT is not portable, and the reason is this issue. Telling an agent to run `change_impact` reaches all eight. Making it impossible to skip reaches none of them, because the check would have to run in the runtime and the runtime has no way in. So the honest current answer to "how do we make the tool-choice gate automatically available to every agent" is: **instructions, yes, already, via `code-index rules`; enforcement, not until the index has a shell-reachable surface.** ## Proposal A read-only `code-index query <tool> [args]` subcommand over the same daemon the MCP server talks to, emitting the same JSON the MCP tool emits — including the whole disclosure envelope, unchanged, since the value of these replies is that they carry their own reading instructions. Constraints that follow from the rest of the system: 1. **One implementation.** The CLI must route through the same `IndexAccess` the MCP server uses. A second query path is a second set of answers, and this project has already paid for that shape (`doctor` is exposed through the lib for exactly this reason — "there is exactly one `doctor` in the workspace and the binary runs the same code the test grades"). 2. **Read-only, and it must not become a second writer.** `guard_live_daemon` exists because `code-index watch` once quietly became one. 3. **The envelope ships intact.** A CLI form that drops `evidence_gaps` because it is verbose would be a surface where an absent row silently stops meaning "not measured" — see #281 for why the envelope is priced the way it is. 4. **Exit codes are part of the contract**, because the callers are scripts. `code-index rules --check` is the precedent: 0 clean, 1 with findings, and the findings on stderr. Scope note: this is plumbing for a surface that already exists, not a new capability. The hard part is deciding which of the 21 tools get a CLI spelling and what their argument shape is — 21 hand-written arg structs is the wrong answer, and the `#[tool]` schemas are already machine-readable. Related: #70 (tool adoption under real discovery friction) — an instruction an agent can skip and a check it cannot are different instruments, and only one of them is buildable today.
Author
Member

Closed by beabeb8 — CI 11/11 including Windows.

$ code-index query --list            # the 23 tools this build serves
$ code-index query change_impact '{"symbol_ids":[900392]}'
affected: 24
  crates/cli/src/plugin.rs :: cmd_check
  crates/cli/src/plugin.rs :: cmd_disable
  … the nine plugin.rs sites this issue was filed about

Arguments come from an argument, or from stdin with - so a hook can pass a value it just computed without quoting it through a shell.

It speaks the protocol rather than calling the code

The three requirements above were met in the strongest form available, and the weaker forms were not available:

crates/mcp-server is a bin-only crate. No lib.rs, so its tool handlers are reachable from no other crate — which is the structural fact that produced this issue in the first place. crates/cli fixed the same shape for doctor in #78 step 11 by growing a library face, but that exposed ONE module; here it would mean publishing a 28k-line server as a library and linking all of it into code-index. Re-deriving the answers in the CLI is what requirement 1 forbids.

So the CLI spawns the server and talks to it. The reply is not merely the same CODE an agent gets — it is the same PROCESS through the same door, so it cannot differ. Requirement 3 ("the envelope ships intact") is therefore structural: annotate_evidence_gaps sits above the tool router and runs on call_tool, so there is no path by which a shell caller could get a differently-annotated reply.

Requirement 2 holds because --no-daemon is deliberately NOT passed: the server attaches to the already-running daemon rather than becoming a second writer.

The sibling binary is resolved beside this one before PATH — the release installs all four binaries into one directory, and a stale copy on PATH would answer with a different build's schema.

Requirement 4: the exit code

Three outcomes, not two:

0  the tool answered
1  the tool REFUSED — it ran, and said no
2  nothing was asked — no such tool, or arguments that are not a JSON object

1 and 2 are separated deliberately. A pre-commit hook that blocked a commit because a binary was missing, the same way it blocks one because the index found a problem, would be worse than no hook: the first is the hook broken, the second is the hook working.

The first implementation got this wrong against this issue's own text — an unknown tool came back as a protocol error and mapped onto exit 1. Fixed with a third outcome (NotServed).

The JSON body prints on stdout even for a refusal, because the reason, the argument at fault and the fix are all in it; sending that to stderr would make the useful half of an error invisible to jq.

Scope note answered

The issue worried that "21 hand-written arg structs is the wrong answer". None were written. Arguments are passed through as a JSON object and validated by the server's own argcheck against the published schema — so a new tool is reachable from the shell the moment it exists, with no CLI change at all.

Tests

Four mutations RUN; one survived and is recorded because it is the more useful verdict: removing the local non-object-argument check still exits 2, because the server rejects a non-object as a PROTOCOL error which maps to 2 as well. The exit code could never have graded that check. What it buys is the message, and not spawning a server for a call that cannot work — so the message is what is asserted now.

ensure_server_beside_cli builds the sibling once when absent and fails loudly if it still is, rather than skipping: cargo test --workspace compiles every member before any test runs, but cargo test -p code-index-cli alone does not.

What this unblocks

The pre-edit blast-radius check is now buildable in any runtime — a PreToolUse hook, a pre-commit, a CI step, an editor action. It is not built here; this was its prerequisite. The asymmetry this issue named — instructions portable via rules.rs to eight agent conventions, enforcement portable nowhere — is no longer structural.

Closed by `beabeb8` — CI 11/11 including Windows. ``` $ code-index query --list # the 23 tools this build serves $ code-index query change_impact '{"symbol_ids":[900392]}' affected: 24 crates/cli/src/plugin.rs :: cmd_check crates/cli/src/plugin.rs :: cmd_disable … the nine plugin.rs sites this issue was filed about ``` Arguments come from an argument, or from stdin with `-` so a hook can pass a value it just computed without quoting it through a shell. ## It speaks the protocol rather than calling the code The three requirements above were met in the strongest form available, and the weaker forms were not available: `crates/mcp-server` is a **bin-only crate**. No `lib.rs`, so its tool handlers are reachable from no other crate — which is the structural fact that produced this issue in the first place. `crates/cli` fixed the same shape for `doctor` in #78 step 11 by growing a library face, but that exposed ONE module; here it would mean publishing a 28k-line server as a library and linking all of it into `code-index`. Re-deriving the answers in the CLI is what requirement 1 forbids. So the CLI spawns the server and talks to it. The reply is not merely the same CODE an agent gets — it is the same PROCESS through the same door, so it cannot differ. Requirement 3 ("the envelope ships intact") is therefore **structural**: `annotate_evidence_gaps` sits above the tool router and runs on `call_tool`, so there is no path by which a shell caller could get a differently-annotated reply. Requirement 2 holds because `--no-daemon` is deliberately NOT passed: the server attaches to the already-running daemon rather than becoming a second writer. The sibling binary is resolved **beside this one before `PATH`** — the release installs all four binaries into one directory, and a stale copy on `PATH` would answer with a different build's schema. ## Requirement 4: the exit code Three outcomes, not two: ``` 0 the tool answered 1 the tool REFUSED — it ran, and said no 2 nothing was asked — no such tool, or arguments that are not a JSON object ``` 1 and 2 are separated deliberately. A pre-commit hook that blocked a commit because a binary was missing, the same way it blocks one because the index found a problem, would be worse than no hook: the first is the hook broken, the second is the hook working. **The first implementation got this wrong against this issue's own text** — an unknown tool came back as a protocol error and mapped onto exit 1. Fixed with a third outcome (`NotServed`). The JSON body prints on stdout even for a refusal, because the reason, the argument at fault and the fix are all in it; sending that to stderr would make the useful half of an error invisible to `jq`. ## Scope note answered The issue worried that "21 hand-written arg structs is the wrong answer". None were written. Arguments are passed through as a JSON object and validated by the server's own `argcheck` against the published schema — so a new tool is reachable from the shell the moment it exists, with no CLI change at all. ## Tests Four mutations RUN; one **survived** and is recorded because it is the more useful verdict: removing the local non-object-argument check still exits 2, because the server rejects a non-object as a PROTOCOL error which maps to 2 as well. The exit code could never have graded that check. What it buys is the message, and not spawning a server for a call that cannot work — so the message is what is asserted now. `ensure_server_beside_cli` builds the sibling once when absent and **fails loudly** if it still is, rather than skipping: `cargo test --workspace` compiles every member before any test runs, but `cargo test -p code-index-cli` alone does not. ## What this unblocks The pre-edit blast-radius check is now buildable in any runtime — a `PreToolUse` hook, a `pre-commit`, a CI step, an editor action. It is **not built here**; this was its prerequisite. The asymmetry this issue named — instructions portable via `rules.rs` to eight agent conventions, enforcement portable nowhere — is no longer structural.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#282
No description provided.