# Kivgraph — complete documentation > A local cross-repository code intelligence MCP server for AI coding agents. Kivgraph builds a canonical semantic code graph across registered Go, TypeScript, Rust, Python and Dart repositories. It answers questions about symbols, repository relationships, callers, dependencies and change impact locally through MCP. Results carry analyzer evidence or remain CANDIDATE/UNRESOLVED; they are never invented from matching names. Generated 2026-08-27. 36 pages, in the order the site navigation presents them. Includes 9 releases with notes. Licensed Apache-2.0 (https://spdx.org/licenses/Apache-2.0.html). Source: https://github.com/Luqueee/kivgraph The server registers 12 tools over stdio: find_by_intent, find_symbol, get_symbol, get_source, get_file_outline, find_references, find_cross_repo_consumers, trace_dependencies, get_blast_radius, list_repositories, graph_status, index_project. Index of the same pages, one link each: https://kivgraph.dev/llms.txt --- # Install URL: https://kivgraph.dev/install/ Markdown: https://kivgraph.dev/raw/install.md Kivgraph is distributed as a self-contained bundle. The installer detects the platform, downloads the latest published release for it, verifies both the release archive and the bundle checksums, and installs it without requiring Go, Node.js or pnpm on the build side. The release contains the Go server, the pinned LadybugDB library, the TypeScript worker, the bundled Python AST worker, the pinned `rust-analyzer`, the grammar manifest and the web viewer served by `kivgraph ui`. ## Published platforms Linux `amd64` and macOS `arm64`. Those are the only two. On macOS only Apple Silicon is published; `darwin/amd64` is out of scope by decision, and the installer says so when it refuses. ## Runtime requirements - Bash - Node.js `22` or later — the TypeScript worker is a Node process - Python `3.10` or later when indexing Python — the bundled worker is a Python process - The Dart or Flutter SDK when indexing Dart — the loader drives the Dart Analysis Server it supplies - `curl`, `tar` - `sha256sum` or `shasum` The bundle carries its own `rust-analyzer`. Indexing Rust repositories additionally needs `cargo` on the `PATH`: the analyzer cannot load a Cargo workspace without it. ## One command ```bash curl -fsSL https://github.com/Luqueee/kivgraph/releases/latest/download/install.sh | bash ``` From a checkout, the same installer runs directly: ```bash ./scripts/install.sh ``` To install a specific release instead of the latest one: ```bash KIVGRAPH_VERSION=v0.8.1 ./scripts/install.sh ``` ## Where it lands The script installs the bundle in `~/.local/opt/kivgraph` and puts launchers in `~/.local/bin`. Override both with `KIVGRAPH_INSTALL_ROOT` and `KIVGRAPH_BIN_DIR`. It never modifies a registered repository, creates an index or replaces configuration files. Installing Kivgraph and initialising it are two separate acts. Add the launcher directory to the current shell and verify both runtimes: ```bash export PATH="$HOME/.local/bin:$PATH" kivgraph version kivgraph-ts-worker <<'EOF' hello EOF ``` ## macOS and quarantine The binaries are not notarized and the project uses no Developer ID. The executable carries an ad-hoc signature, which is what Apple Silicon requires in order to run at all. Gatekeeper only blocks a file carrying the `com.apple.quarantine` attribute, and neither `curl` nor `tar` writes it: a release downloaded with the installer runs. A copy downloaded with a browser needs: ```bash xattr -dr com.apple.quarantine ~/.local/opt/kivgraph ``` ## Updating ```bash kivgraph update --check kivgraph update ``` The update is atomic, preserves the configuration and graph state, verifies the release and bundle checksums, and replaces only the installed bundle. Restart the MCP client afterwards so it launches the new binary. When `kivgraph` is invoked without a command from an interactive terminal, it checks for a newer release with an 800 ms timeout and a 24-hour cache in the platform cache directory (`$XDG_CACHE_HOME` on Linux, `$HOME/Library/Caches` on macOS), under `kivgraph/update-check.json`. The check never blocks the command when the network is unavailable. Interactive command output uses semantic ANSI colours when the destination is a terminal. Set `NO_COLOR`, or redirect the output, to keep it plain. --- # Quickstart URL: https://kivgraph.dev/quickstart/ Markdown: https://kivgraph.dev/raw/quickstart.md Kivgraph serves queries from a published generation. Nothing answers until one exists, so the order below is the whole setup: register, check, index, connect. ## 1. Register a repository ```bash kivgraph init \ --repository project=/absolute/path/to/project \ --languages go,typescript,rust ``` `--repository NAME=PATH` may be repeated. The name is an identifier, compared exactly — two repositories differing only in case are two repositories — and it travels inside the stable keys of everything the repository declares. `--languages` accepts ten tokens: `go`, `typescript`, `javascript`, `ts`, `js`, `rust`, `rs`, `python`, `py` and `dart`. The five languages are not resolved to the same standard. Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. ## 2. Check the machine ```bash kivgraph doctor ``` `doctor` reports the configuration, the toolchains it found and the state of the published graph. It names the language version ceiling this binary type-checks Go with, which is not the `go` on your `PATH` and is the number that decides whether a module can be indexed at all. ## 3. Index and publish ```bash kivgraph index --full ``` The pass analyses every registered repository, validates the canonical graph and publishes it as a new generation. Publication is atomic: a candidate that fails integrity or validation never becomes `CURRENT`, and the previous generation keeps serving. ## 4. Point a client at it Configure any MCP client to start the server over stdio: ```json { "mcpServers": { "kivgraph": { "command": "/home/user/.local/bin/kivgraph", "args": [ "serve", "--config", "/home/user/.config/kivgraph/config.yaml" ] } } } ``` Most clients can be wired automatically. `kivgraph mcp install` has five targets — `claude-code`, `claude-desktop`, `codex`, `opencode` and `oh-my-pi` — and takes `--scope user|project` (default `user`), `--dry-run` and `--force`. Claude Desktop is user-scope only and is the one target that installs no local skill. See [Clients](/mcp/clients/). ## What `serve` guarantees With no published generation there is no query surface. `serve` completes the handshake, publishes only `index_project` — which is how a client with no graph builds its first one — and puts the rebuild command in its `instructions`. It does not exit: a client launches the process itself, so exiting reads as a crash. The process writes MCP framing exclusively to `stdout` and logs to `stderr`. It follows the published generation: it loads the HotSnapshot at start and republishes when the `CURRENT` pointer advances, so an `index --full` in another terminal cannot leave a server answering from a graph that no longer exists on disk. --- # Register with a client URL: https://kivgraph.dev/mcp/clients/ Markdown: https://kivgraph.dev/raw/mcp/clients.md The release installer does not edit client configuration automatically. After installing Kivgraph, run the integration commands without `--target` to detect the coding agents present on this machine and select one or more of them: ```bash kivgraph mcp install --scope user kivgraph skill install --scope user ``` Neither command initialises Kivgraph nor indexes any repository. The skill command is documented separately in [Agent Skill](/mcp/skills/). Client integrations run on Linux and macOS only. On any other operating system the command fails instead of guessing a path. ## The selector Kivgraph checks each client's known local configuration or installation roots and marks the agents it detected. | Key | Action | | --- | --- | | `↑` / `↓`, or `j` / `k` | Move | | `space` | Toggle an agent | | `a` | Select all | | `n` | Select none | | `Enter` | Confirm | | `q` or `Esc` | Cancel | Detected agents start selected. If none is detected, the selector starts with no agents selected, and confirming with an empty selection is refused. It respects `NO_COLOR` and emits no ANSI when the output is redirected. When neither the output nor standard input is a terminal, the selector refuses to run and tells you to pass `--target`. Use `--target` for scripted, non-interactive installation. ## Supported clients Five targets are supported. `~` is the user's home directory; the project scope resolves against the current working directory. | Client | `--target` value | Config file | Format | Where the entry goes | | --- | --- | --- | --- | --- | | Claude Code | `claude-code` | `~/.claude.json` (user scope) | JSON | `mcpServers.kivgraph` | | Claude Code | `claude-code` | `.mcp.json` in the project directory (project scope) | JSON | `mcpServers.kivgraph` | | Claude Desktop | `claude-desktop` | `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `~/.config/Claude/claude_desktop_config.json` on Linux; user scope only | JSON | `mcpServers.kivgraph` | | Codex | `codex` | `~/.codex/config.toml` (user scope) | TOML | `[mcp_servers.kivgraph]` | | Codex | `codex` | `.codex/config.toml` in the project directory (project scope) | TOML | `[mcp_servers.kivgraph]` | | OpenCode | `opencode` | `~/.config/opencode/opencode.json` (user scope) | JSON | `mcp.kivgraph` | | OpenCode | `opencode` | `opencode.json` in the project directory (project scope) | JSON | `mcp.kivgraph` | | Oh My Pi | `oh-my-pi` | `~/.omp/agent/mcp.json` (user scope) | JSON | `mcpServers.kivgraph` | | Oh My Pi | `oh-my-pi` | `.omp/mcp.json` in the project directory (project scope) | JSON | `mcpServers.kivgraph` | Claude Desktop supports the `user` scope only; asking for `--scope project` fails. It is also the one target with no local skill target, so it is absent from the skill selector. ## What gets written In every case the server is named `kivgraph`, and `command` is the absolute path of the Kivgraph executable that ran the install command. The examples below use `/usr/local/bin/kivgraph`; your path is whatever the running binary resolves to. Kivgraph adds only its own entry. Existing servers, keys and top-level settings in the file are preserved. JSON files are re-encoded with two-space indentation. ### Claude Code Written to `~/.claude.json` in the user scope and to `.mcp.json` in the project directory in the project scope. ```json { "mcpServers": { "kivgraph": { "command": "/usr/local/bin/kivgraph", "args": ["serve"] } } } ``` ### Claude Desktop Same shape as Claude Code. The file lives at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and `~/.config/Claude/claude_desktop_config.json` on Linux. ```json { "mcpServers": { "kivgraph": { "command": "/usr/local/bin/kivgraph", "args": ["serve"] } } } ``` ### Codex Appended to `~/.codex/config.toml` in the user scope, or to `.codex/config.toml` in the project directory in the project scope. The table is appended at the end of the file; the rest of the TOML is untouched. ```toml [mcp_servers.kivgraph] command = "/usr/local/bin/kivgraph" args = ["serve"] ``` ### OpenCode Written to `~/.config/opencode/opencode.json` in the user scope and to `opencode.json` in the project directory in the project scope. OpenCode's document shape differs: the section key is `mcp`, `command` is an array holding the executable and its arguments, and the entry carries `type` and `enabled`. ```json { "mcp": { "kivgraph": { "type": "local", "command": ["/usr/local/bin/kivgraph", "serve"], "enabled": true } } } ``` ### Oh My Pi Written to `~/.omp/agent/mcp.json` in the user scope and to `.omp/mcp.json` in the project directory in the project scope. ```json { "mcpServers": { "kivgraph": { "command": "/usr/local/bin/kivgraph", "args": ["serve"] } } } ``` ## One process for many clients `kivgraph mcp install` registers a client against **one daemon** by default: it writes a `url` entry, not a `command`. Every client shares that process instead of spawning a `serve` of its own. ```bash kivgraph mcp install --target claude-code ``` That installs the daemon's supervisor if it is missing, waits for the endpoint, and reports both. What it saves, measured on a workspace of `108.737` symbols in `benchmarks/daemon-cost`: at eight clients, `61 MB` against `328` when they are asking questions, and `11` against `80` when they are not. The crossover is around one client, so with a single editor open it is a coin flip; from the second it is not close. `--stdio` is the way out, and it writes exactly what the previous default wrote: ```bash kivgraph mcp install --target claude-code --stdio ``` Three conditions write the stdio entry on their own, and each says so: `--scope project`, because a url entry carries a token and that file gets committed; a platform with no supported supervisor, because the daemon would have no owner there; and a machine with no configuration yet, because there is no state directory to point at. Passing `--daemon` explicitly refuses instead of falling back, which is the point of passing it. Reinstalling replaces the entry it finds, so switching transports leaves one registration and not two. An entry naming a **different** kivgraph install still needs `--force`: that one belongs to another installation. `daemon install` gives the daemon an owner: a launchd agent on macOS, a systemd user unit on Linux. Both start it with your session and bring it back if it dies, which is what makes a `url` entry safe to depend on -- a `command` entry is owned by the client that spawns it, but a url pointing at a daemon nobody restarts takes every client down at once. `kivgraph daemon status` says whether one is installed and where its unit lives, and `kivgraph daemon remove` takes it out. The unit is per state directory, so two configurations can hold two supervised daemons without either replacing the other. Nothing supervises a daemon you start by hand with `kivgraph daemon &`; it dies with the shell that launched it. The url and its token come from `~/.local/state/kivgraph/daemon.json`, mode `0600`. HTTP is the door that matters: no MCP client configuration dials a unix socket -- it takes an executable or a url. The daemon binds loopback. `--allow-remote` is required to bind anywhere else, because the answers carry repository paths, file paths and symbol names. ## Scope `--scope user` is the default. It writes into the client's own configuration under your home directory, so the registration applies to every project you open with that agent. `--scope project` writes into a file inside the current working directory, so the registration travels with the repository and applies only there. Claude Desktop has no project-scoped configuration file and rejects this scope. ## Safety - The file is parsed as JSON or TOML before it is modified. A file that does not parse, a top-level value that is not an object, or a section (`mcpServers`, `mcp`, `mcp_servers`) that is not an object or table, is an error and the file is left alone. - A destination that is a symlink is refused, as is a destination that exists but is not a regular file. - Writes are atomic: the new content goes to a temporary file in the same directory with mode `0600`, is synced, and is then renamed over the destination. Missing parent directories are created with mode `0700`. - Before a replacement or a removal, the previous content is copied to a backup next to the file, with the suffix `.kivgraph.bak` appended to the full filename, for example `~/.claude.json.kivgraph.bak`. An existing backup is kept as it is, so the backup always holds the state from before the first Kivgraph write. A backup path that is a symlink or not a regular file is refused. - An entry named `kivgraph` that does not match what Kivgraph writes is reported as `incompatible` and stops the command with an error. `--force` is required to replace or remove it. - An entry that already matches is reported as `managed` and the file is not rewritten. - `--dry-run` reports the plan as `would-install` or `would-remove` and writes nothing. ## Inspect and remove Both `status` and `remove` require `--target`; there is no selector for them. ```bash kivgraph mcp status --target claude-code --scope user kivgraph mcp remove --target claude-code --scope user ``` `status` reads the file and reports `absent`, `managed` or `incompatible` together with the path it inspected, and changes nothing. `remove` withdraws only Kivgraph's own entry; every other server and setting in the client configuration is left as it was. The file itself is never deleted. If the entry is not there, the command reports `absent` and does nothing. ## After registering The client launches `kivgraph serve` itself and speaks MCP over stdio. `serve` opens no HTTP port; the graph viewer is a separate opt-in command. With no published generation, `serve` still completes the handshake. It exposes only `index_project`, and its `instructions` tell the agent to run `kivgraph index --full` and restart the server. Nothing is broken: there is simply no graph to answer from yet. `serve` also writes the default configuration when none exists, and continues, because a client spawns the server itself and exiting because nobody ran `init` reads as a crash. That default registers no repository and indexes nothing. An existing configuration that cannot be read is a failure and is never overwritten. Next: [Quickstart](/quickstart/) to build the first graph, or [Troubleshooting](/mcp/troubleshooting/) if the client reports no tools. --- # Kivgraph MCP Server for Claude Code URL: https://kivgraph.dev/mcp/claude-code/ Markdown: https://kivgraph.dev/raw/mcp/claude-code.md Kivgraph gives **Claude Code** a local semantic code graph for repository navigation. Claude can ask for symbols, references, callers, dependencies and change impact through MCP instead of scanning the repository from scratch. ## Install the integration After installing the Kivgraph binary, register it in the user configuration: ```bash kivgraph mcp install --scope user --target claude-code kivgraph skill install --scope user --target claude-code ``` The user-scope MCP entry is written to `~/.claude.json`. For a project-only setup: ```bash kivgraph mcp install --scope project --target claude-code kivgraph skill install --scope project --target claude-code ``` The project-scope entry is written to `.mcp.json` in the current repository. ## Index before asking questions Register and publish the graph before starting a structural query: ```bash kivgraph init \ --repository project=/absolute/path/to/project \ --languages go,typescript,rust kivgraph index --full ``` Then ask Claude Code questions such as: ```text Who calls this symbol, including consumers in another repository? What breaks if I change this interface? ``` See [code intelligence](/code-intelligence/) and [repository relationships](/repository-relationships/) for the query model. --- # Kivgraph MCP Server for Codex URL: https://kivgraph.dev/mcp/codex/ Markdown: https://kivgraph.dev/raw/mcp/codex.md Kivgraph can serve as a local **code intelligence MCP server for Codex**. It exposes repository structure, symbols, references and bounded impact through the MCP configuration used by Codex. ## Register Kivgraph with Codex After installing Kivgraph, run: ```bash kivgraph mcp install --scope user --target codex ``` This writes the `kivgraph` server entry to `~/.codex/config.toml`. For a project-only configuration: ```bash kivgraph mcp install --scope project --target codex ``` The project entry is written to `.codex/config.toml` in the current repository. ## Build the repository graph Register the repository and publish a full graph: ```bash kivgraph init \ --repository project=/absolute/path/to/project \ --languages go,typescript,rust kivgraph index --full ``` Codex can then use Kivgraph for semantic code navigation and questions about repository relationships, callers, dependencies and change impact. Start with [`find_references`](/docs/tools/find-references/) for direct references or [`get_blast_radius`](/docs/tools/get-blast-radius/) before a risky change. --- # Kivgraph MCP Server for Oh My Pi URL: https://kivgraph.dev/mcp/oh-my-pi/ Markdown: https://kivgraph.dev/raw/mcp/oh-my-pi.md Kivgraph integrates with **Oh My Pi** as a local code intelligence MCP server. Pi can query symbols, callers, dependencies and change impact through the graph instead of exploring every repository file first. ## Register Kivgraph with Oh My Pi After installing the Kivgraph binary, register the user-scope MCP server: ```bash kivgraph mcp install --scope user --target oh-my-pi ``` This writes the `kivgraph` entry to: ```text ~/.omp/agent/mcp.json ``` For one project only, run the command from the project directory: ```bash kivgraph mcp install --scope project --target oh-my-pi ``` That writes `.omp/mcp.json` in the current repository. ## Build the graph first Register and publish the repository before asking Pi structural questions: ```bash kivgraph init \ --repository project=/absolute/path/to/project \ --languages go,typescript,rust kivgraph index --full ``` Then use Oh My Pi to ask questions such as: ```text Who calls this function across the workspace? What repository relationships and dependencies are affected by this change? ``` Kivgraph returns repository, file, symbol and line-range evidence where the analyzer can prove the relationship. Read [code intelligence](/code-intelligence/) and [token-efficient code understanding](/token-efficient-code-understanding/) for the query model. --- # Agent Skill URL: https://kivgraph.dev/mcp/skills/ Markdown: https://kivgraph.dev/raw/mcp/skills.md ## What a skill is here An Agent Skill is a Markdown instruction file a coding agent loads alongside its tools. Kivgraph ships one, and it exists for a single reason: to route a question to the right tool before the agent reaches for grep or starts opening files. It is not required in order to use the MCP server. Install it to change which tool the agent picks; skip it and the twelve tools still work. ## Install ```bash kivgraph skill install ``` With no `--target`, the command detects the supported agents present in the requested scope, opens a selector with the detected ones pre-checked, and installs into every entry you confirm. Arrows or `j`/`k` move, space toggles, `a` selects all, `n` selects none, Enter confirms, `q` or Esc cancels. Confirming with nothing selected is refused. For scripted use, name the client: ```bash kivgraph skill install --target claude-code --scope user ``` | Flag | Default | Meaning | | --- | --- | --- | | `--target` | empty | Client to write: `claude-code`, `claude-desktop`, `codex`, `opencode`, `oh-my-pi`. Omit it on `install` to open the selector. Required for `status` and `remove`. | | `--scope` | `user` | `user` or `project`. `project` resolves against the current working directory. Any other value is rejected. | | `--dry-run` | off | Report the plan as `would-install` and write nothing. Not accepted by `skill status`. | | `--force` | off | Replace a file at the skill path that is not the canonical skill. Without it, that case is an error. | Without a terminal and without `--target`, the selector cannot run and the command fails with `interactive selection requires a terminal; pass --target`. Client integrations are supported on Linux and macOS only. `skill install` copies one file. It does not initialise Kivgraph, does not register the MCP server, and does not index anything. Register the server separately: see [MCP clients](/mcp/clients/). Build a graph with [indexing](/guides/indexing/). ## Where it lands The installed file is always named `SKILL.md` and always sits in a `skills/kivgraph/` directory. `` is the current working directory. | Client | `--target` value | Scope | Path | | --- | --- | --- | --- | | Claude Code | `claude-code` | `user` | `~/.claude/skills/kivgraph/SKILL.md` | | Claude Code | `claude-code` | `project` | `/.claude/skills/kivgraph/SKILL.md` | | Codex | `codex` | `user` | `~/.agents/skills/kivgraph/SKILL.md` | | Codex | `codex` | `project` | `/.agents/skills/kivgraph/SKILL.md` | | OpenCode | `opencode` | `user` | `~/.config/opencode/skills/kivgraph/SKILL.md` | | OpenCode | `opencode` | `project` | `/.opencode/skills/kivgraph/SKILL.md` | | Oh My Pi | `oh-my-pi` | `user` | `~/.omp/agent/skills/kivgraph/SKILL.md` | | Oh My Pi | `oh-my-pi` | `project` | `/.omp/skills/kivgraph/SKILL.md` | Claude Desktop has no local skill target. It is a supported MCP client, but it never appears in the skill selector, and `--target claude-desktop` fails with `target "claude-desktop" does not support local skill installation`. Register the MCP server for it and it uses the tools without the skill. ## What the skill says The skill teaches one contract: reach for the graph when the question is about callers, references, impact or cross-repository consumers, and reach for the files only after the graph has named them. ### Routing | Question | Tool | | --- | --- | | Is a graph published, how old is it, what does it cover | [`graph_status`](/docs/tools/graph-status/) | | Which repositories are indexed, and in which language | [`list_repositories`](/docs/tools/list-repositories/) | | Where is this name or qualified name declared | [`find_symbol`](/docs/tools/find-symbol/) | | Everything about one symbol already identified | [`get_symbol`](/docs/tools/get-symbol/) | | The code behind rows a tool returned | [`get_source`](/docs/tools/get-source/) | | What a package or directory declares | [`get_file_outline`](/docs/tools/get-file-outline/) | | Who references this, or what it reaches, one hop out | [`find_references`](/docs/tools/find-references/) | | Bounded dependency paths out of a symbol | [`trace_dependencies`](/docs/tools/trace-dependencies/) | | Consumers in another repository | [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) | | Bounded incoming impact, grouped | [`get_blast_radius`](/docs/tools/get-blast-radius/) | | Register projects and rebuild the graph | [`index_project`](/docs/tools/index-project/) | The skill orders the first moves: `graph_status` to confirm a published snapshot exists and how old it is, then `list_repositories` to pick the repository and language before narrowing. Repository names are case sensitive; two names differing only in case are two repositories. ### Why an empty answer is an answer Edges are resolved by `go/types`, the TypeScript checker and `rust-analyzer`, not by matching names. A reference list is therefore complete for those languages, and an empty one means nobody calls the symbol. Grep cannot tell you that. ### Rows are addressable Every row carries a repository, a repository-relative path, a qualified name and a line range, and every tool accepts that triple in place of a stable key. The next call is built from the answer just received, so stable keys need never enter the conversation. ### Evidence rules the skill imposes - Keep `EXACT`, `CANDIDATE` and `UNRESOLVED` apart. Never upgrade a candidate or an unresolved result because a name, path, alias or text matches. - Read the `coverage` of `get_blast_radius`: `exact` and `candidate` count consumers of the symbol asked for, while `package_level` counts dependencies on the provider package and proves nothing about that symbol. - Respect result limits and cursors. Narrow the query instead of treating a truncated response as complete. - Treat the snapshot as a projection of the canonical graph, not as licence to invent facts. If it is missing or stale for the question, say so and ask for a re-index. - In a `serve` process, `graph_status` answers `not_applicable` for `storage` and `worker`, with the reason: the server responds from the published snapshot and never opens the database or runs the TypeScript worker. That is not a misconfiguration. - Unresolved references are facts about the workspace, not defects. Report the reason instead of concluding that coverage is broken. See [resolution](/docs/resolution/). ### Indexing `index_project` is the only mutating tool. The skill requires explicit user approval before calling it, all projects passed in one call through `projects`, and no claim of success until a new generation and snapshot are published. A rebuild resolves cross-repository edges over the complete fact set, so it costs the whole corpus whatever was added: eleven separate calls build eleven graphs and keep the last one. A full rebuild can outlive the client's per-call timeout; the work still completes, and `graph_status` showing an advanced `snapshot_id` is the check, not a retry. ### Where it loses Stated in the skill's own terms, and not softened: a rare name in a single small repository is cheaper to grep, and one small file is cheaper to read than to outline. Kivgraph wins on common names, on transitive impact, on cross-repository consumers and on proving an absence. It is the wrong tool for a one-off literal string search. ## Inspect and remove ```bash kivgraph skill status --target claude-code --scope user kivgraph skill remove --target claude-code --scope user ``` Both require `--target`; neither opens the selector. `skill status` reads the path and reports one of three states: | Status | Meaning | | --- | --- | | `absent` | Nothing at the path. | | `managed` | The file is byte-identical to the canonical Kivgraph skill. | | `incompatible` | A file exists at the path and is not the canonical skill. | `skill remove` deletes only a file that is byte-identical to the canonical skill. Anything else at that path is left alone and reported as an error unless you pass `--force`. Nothing else in the client's skills directory is touched: removal withdraws Kivgraph's own skill and nothing more. `--dry-run` reports `would-remove` and deletes nothing. ## Safety - The destination is inspected with a symlink-aware stat. A symlink at the skill path is refused (`refusing symlink integration path`), and so is anything that is not a regular file. - Missing parent directories are created with mode `0700`. - Writes are atomic: the content goes to a temporary file in the destination directory, is set to mode `0600`, is synced, is renamed over the destination, and the directory itself is synced afterwards. Removal uses the same rename-then-delete path. - Before an existing file is overwritten or removed, its previous content is copied to `.kivgraph.bak`. An existing backup is kept as it is and never overwritten, so the first backup survives later runs. A backup path that is a symlink or not a regular file aborts the operation. - Replacing a file that is not the canonical skill requires `--force`. Without it the command fails with `integration path "" contains an incompatible Kivgraph entry; use --force to replace or remove it`. - An install whose destination already matches the canonical skill reports `managed` and writes nothing. No backup is created and no timestamp changes. ## In a release bundle The canonical skill ships inside the release bundle at `skills/kivgraph/SKILL.md` and is listed in the bundle's `SHA256SUMS`, so it is verified with the same checksum pass as the rest of the payload. See [install](/install/). --- # Using the MCP server URL: https://kivgraph.dev/mcp/usage/ Markdown: https://kivgraph.dev/raw/mcp/usage.md `kivgraph serve` speaks MCP over stdio and, once a generation is published, registers twelve tools; before that it registers `index_project` alone. This page is about using them: which tool answers which question, how to name a symbol, and how to read what comes back. Per-tool arguments live under [`/docs/mcp-tools/`](/docs/mcp-tools/) and on each tool's own page. ## What the server tells your agent The `initialize` result carries an `instructions` string. This is it, literally: ```text Kivgraph answers "what breaks if I change this" from a published code graph over Go, TypeScript, Rust, Python and Dart. Before grepping or reading files to find callers, references or impact, call find_references or get_blast_radius; to read the code they name, call get_source. Check confidence and completeness: Python fallback facts can be CANDIDATE and external Dart packages can be UNRESOLVED. Its edges are resolved by go/types, the TypeScript checker and rust-analyzer, not by matching names, so a reference list is complete for those languages and an empty one means nobody calls it. Grep cannot tell you that. Rows are addressable: every one carries a repository, a repository-relative path, a qualified name and a line range, and every tool accepts that triple instead of a stable key. Where it loses: a rare name in a single small repository is cheaper to grep, and one small file is cheaper to read than to outline. It wins on common names, on transitive impact, on cross-repository consumers and on proving an absence. ``` A client keeps the tool names and their one-line descriptions resident in the model's context, and fetches the full input schemas only when a tool is about to be called. That is why the routing advice lives in `instructions` and in the descriptions rather than in the schemas: the schemas are not in front of the model at the moment it decides whether to call anything. ## Pick the tool by the question | The question | The tool | | --- | --- | | Who calls this, what references this | [`find_references`](/docs/tools/find-references/) | | What breaks if I change this | [`get_blast_radius`](/docs/tools/get-blast-radius/) | | What does this reach outward | [`trace_dependencies`](/docs/tools/trace-dependencies/) | | Who uses this from another repository | [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) | | Where is this declared | [`find_symbol`](/docs/tools/find-symbol/) | | I do not know its name; which files do I open | [`find_by_intent`](/docs/tools/find-by-intent/), with `keywords` | | What are this symbol's package, signature, visibility and line range | [`get_symbol`](/docs/tools/get-symbol/) | | What is declared under this path | [`get_file_outline`](/docs/tools/get-file-outline/) | | Give me the code of these symbols | [`get_source`](/docs/tools/get-source/) | | Which repositories does the graph cover, at which commit | [`list_repositories`](/docs/tools/list-repositories/) | | Is the published graph current | [`graph_status`](/docs/tools/graph-status/) | | Register projects and rebuild the graph | [`index_project`](/docs/tools/index-project/) | `index_project` is the only tool that changes anything. Every other tool is annotated `readOnlyHint`. ## Address a symbol without a stable key Every tool that takes a symbol accepts one of two selectors: - `stable_key`, or - the triple `repository` plus repository-relative `path` plus `qualified_name`. Exactly one of the two. Passing both is rejected with `INVALID_ARGUMENT` rather than resolved quietly, because the two can disagree and answering one of them answers a question nobody asked. `repository` and `path` narrow a `qualified_name`; a `stable_key` already names one symbol, so combining them is also rejected. `path` alone is rejected too: it is repository-relative, so it requires `repository`. A stable key is exact and durable, and it is also about thirty-five tokens of opaque base32 that nothing outside the server can read. You never need one, because every row a tool returns already carries the triple -- which is why the default view of every query tool leaves the key out. Find the symbol, then feed the answer straight into the next call. Step one, locate it: ```json { "name": "MergeAll", "repo": "kivgraph", "limit": 3 } ``` `find_symbol` answers with the row you need. This is the default `compact` view: the header states what the whole page shares, the row states where it is: ```json { "name": "MergeAll", "kind": "func", "exported": true, "repository": "kivgraph", "symbols": [ { "at": "internal/facts/facts.go:516", "end": 542, "sig": "func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set" } ] } ``` `at` is `path:line` under a header that names the repository, and the full `repository:path:line` triple when the page's rows come from more than one. Under `"view": "full"` the same row is spelled field by field, key included: ```json { "stable_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "name": "MergeAll", "qualified_name": "MergeAll", "kind": "func", "exported": true, "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 516, "end_line": 542 } ``` Step two, ask the real question. The header's `repository`, the row's path and its qualified name become the call's `repository`, `path` and `qualified_name`: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "incoming", "limit": 3 } ``` Note the one rename: a compact row reports the file inside `at`, a full row as `file_path`, and arguments call it `path`. All of them are repository-relative. For a reference question the two steps collapse into one: [`find_references`](/docs/tools/find-references/) takes `name` on its own, answers directly when a single declaration carries that name, and returns the candidates as `repository:path:line` under `AMBIGUOUS_SYMBOL` when several do. [`get_file_outline`](/docs/tools/get-file-outline/) is the other way in when you know the file or the package but not the name. It takes `repository` and `path` and returns the declarations grouped by file, each as a `name@start-end` entry with its kind, and those rows address the next call the same way. The signature comes with `response_format: "detailed"`. If a qualified name matches more than one symbol, the call fails with `AMBIGUOUS_SYMBOL` instead of a silent pick. What the error offers depends on what is left to narrow. With no `repository` and no `path` it names the candidates by where they are, repository, file and line range, because that is a narrowing you can express in the next call. Once both were given and the name still matches twice, only the key separates them, and then, and only then, the error lists the stable keys. A name that is absent says so distinctly. This is the captured answer to `get_symbol` with `repository` `kivgraph` and `qualified_name` `NoSuchThing`: ```text SYMBOL_NOT_FOUND: qualified name "NoSuchThing" was not found under kivgraph; call it without repository and path to search the whole graph ``` ## Choose a view Every query tool takes a `view`, and it changes the granularity of an answer, never the answer. The same edges, with the same confidence and the same provenance, spelled with or without the parts a row shares with every other row. | `view` | What you get | Where | | --- | --- | --- | | `compact` | The default. Whatever every row of the page shares is stated once in a header, and a row carries only what the header could not state for it. | Every query tool | | `full` | The field-per-row shape: every field on every row, including the stable keys and the `language` the compact view drops. Ask for it when a client was written against that shape. | Every query tool | | `files` | Only which files hold the facts, with a count each. The answer to "which files do I open". | [`find_references`](/docs/tools/find-references/) and [`get_file_outline`](/docs/tools/get-file-outline/) | An unsupported value is `INVALID_ARGUMENT`, and so is `files` on a tool whose answer is not a set of files -- it fails rather than quietly returning something else. Pick `compact` unless you have a reason, which is why it is the default: over the four reference questions of `benchmarks/codebase-memory-comparison`, measured on the private benchmark corpus, they cost `13.594` tokens in the previous shape, `2.883` in the compact view and `963` in the files view, with precision and recall unchanged. `view` is never part of a cursor's identity, so a page taken in one view can be continued in another. Four tools group their rows by file and spell each row as a label: [`find_references`](/docs/tools/find-references/), [`trace_dependencies`](/docs/tools/trace-dependencies/), [`get_blast_radius`](/docs/tools/get-blast-radius/) and [`get_file_outline`](/docs/tools/get-file-outline/). Three habits make their answers readable: - **Read the header first.** A field there applies to every row. A field missing from it means the rows disagreed and each carries its own. - **A row is a label.** `qualified_name@start`, or `qualified_name@start-end` when the declaration spans lines -- the qualified name when the row has one and the bare name when it does not. It becomes an array when the row had to carry a column the header could not hoist, and the elements after the label are that tool's columns in a fixed order, skipping whatever the header states -- so the header tells you which columns a tail can hold and the order tells you which is which. Numbers in a tail are written as strings. - **The triple is assembled, not printed.** The repository comes from the header (or a group's own `repo`), the path from the group's `file`, the qualified name and line from the label. That is the selector every tool accepts, so no row needs a stable key. The other two spell a row as an object, for the same reason: their answer is not a set of files. [`find_symbol`](/docs/tools/find-symbol/) addresses a declaration with `at`, which is `path:line` under a header that names the repository and the whole `repository:path:line` triple when the rows come from more than one. [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) keeps a field per consumer -- `repo`, `pkg`, `at` -- because a package dependency has a repository and no file at all. ## When a page groups A header only states a column when **every** row of the page agrees on it. One dissenting row is enough to knock a column back down to the rows -- a `65`-row page sharing `kind` and `edge_kind` pays for both on every row if row `66` is a re-export the other `65` are not. `groups` is the second tier that catches this: the six query tools that carry a `compact` view -- `find_symbol`, `find_references`, `trace_dependencies`, `get_blast_radius`, `get_file_outline` and `find_cross_repo_consumers` -- also try grouping the page by whatever exact tuple of the remaining columns each row still shares, instead of stating that tuple over and over on every row. `results.groups` replaces the flat field -- `symbols`, `files` or `consumers`, depending on the tool -- it never sits beside it. Each entry is a small header of its own, holding the columns that tuple fixed, followed by the tool's normal rows in their normal shape: a label list under `files` for the four that group by file, a `symbols` array for `find_symbol`, a `consumers` array for `find_cross_repo_consumers`. A column already stated on the page header is never repeated on a group; a column that still disagrees within a group stays on the row, exactly as it would on a flat page. Grouping is a bet, and the response never assumes it pays off: building both the flat page and the grouped one and keeping whichever serializes smaller is cheap next to a snapshot lookup, and it is the only way to guarantee grouping never costs more than not grouping. A page where every row disagrees with every other -- three hops down three different edges is the ordinary shape of `trace_dependencies` -- stays flat, because a group is an object with its own keys and a row with nothing to share only had to pay for its own values. Neither shape changes `total`, `returned` or `coverage`; it only changes how the same rows are spelled. A `find_symbol` search for `handle` across two repositories, three kinds and no column the whole page agrees on: ```json { "exported": false, "groups": [ { "kind": "method", "symbols": [ /* 6 rows */ ] }, { "kind": "variable", "symbols": [ /* 1 row */ ] }, { "kind": "func", "symbols": [ /* 1 row */ ] } ] } ``` `exported` is `false` for the whole page -- nothing here is exported -- so it stays in the header; `kind` disagrees, so each group states its own and never repeats it on a row. Every tool's own reference page shows the full, unabridged capture: [`find_symbol`](/docs/tools/find-symbol/#example-grouped), [`find_references`](/docs/tools/find-references/#reading-a-grouped-page), [`trace_dependencies`](/docs/tools/trace-dependencies/#reading-a-grouped-page), [`get_blast_radius`](/docs/tools/get-blast-radius/#reading-a-grouped-page), [`get_file_outline`](/docs/tools/get-file-outline/#example-grouped) and [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/#reading-a-grouped-page). ## Read the answer Every query tool returns the same envelope around its `results`. No tool publishes an `outputSchema`, so the answer arrives once, in the text block, and is not repeated as structured content. | Field | What it says | In a compact envelope | | --- | --- | --- | | `snapshot_id` | The published generation that answered. `null` means none is published. | Always | | `total` | How many rows the query matched. | Always | | `returned` | How many are in this page. | Always | | `results` | The answer itself. | Always | | `truncated` | Whether `returned` is less than `total`. | Only when it is `true` | | `next_cursor` | The opaque token for the next page. | Only when there is one | | `coverage` | Four disjoint counters over the **whole answer**, not this page: `exact`, `candidate`, `unresolved_related`, `package_level`. | Only the counters above zero, and absent entirely when all four are zero | | `snapshot_age_ms` | How long ago that generation was built. | Never; ask [`graph_status`](/docs/tools/graph-status/) for the age | | `guidance` | Present only when the count alone would mislead. | Unchanged | | `completeness` | Present only when the tool checked how far its answer reaches. | Unchanged | What the compact envelope leaves out is what carried no information: an age nobody asked for, a `truncated` that is `false`, a cursor that does not exist and a category that counted nothing. Read an absence accordingly -- a missing `truncated` is `false`, a missing `next_cursor` is `null`, a missing `unresolved_related` is `0` -- and note that `snapshot_id`, `total`, `returned` and `results` are always there, so the four numbers that say how much of the answer you are holding never move. The `full` view writes every field, `false`, `null` and zeros included. `coverage.package_level` is counted apart from `exact` on purpose: a package dependency proves the consumer depends on the provider package, never that it uses the symbol you asked about, so folding it into `exact` would report a use nobody observed. **The counters describe resolved relations, so a tool that returns no relations carries none of them.** `exact` and `candidate` are edge confidences, and the scope is the answer rather than the page: the `trace_dependencies` envelope below reports `exact` at `37` while `returned` is `3`. That is what makes the pair worth reading -- a counter equal to `returned` could not tell you anything `returned` had not already said. So `get_file_outline` publishes no counter at all, and `find_symbol` publishes only `unresolved_related`: both answer with declarations of one repository, and a declaration has no confidence to report. `get_source` keeps a count of its own and it is not one of these four: it answers in prose, and its header line says how many bodies it could actually serve, which is genuinely less than `returned` when a file moved under the index. Three rules matter more than the rest. **A zero-result answer means "nobody", not "not found" -- but only when the surface says so.** That is what `guidance` is for. This is the captured answer to `find_cross_repo_consumers` on `MergeAll`, `total` 0: ```text no repository in the published graph consumes this symbol. Check graph_status if a consumer is registered but was not indexed, and find_references for uses inside its own repository ``` An empty `find_references` page carries the same kind of sentence, and which one depends on its verdict: with `COMPLETE` it says the edges are type-checked and this is an absence rather than a miss, and with `LOWER_BOUND` it says the index recorded places it could not read that ask for this name, and sends you to `completeness.blind_spots` and the fallback pattern instead. The two are never interchangeable: the first is a claim about the code, the second about the index. Without either sentence, treat a zero only as far as the tool's own description takes you. **`guidance` stays silent when there are rows and no truncation.** It costs about fifteen tokens, and fifteen tokens of advice on every call is how a saving becomes a cost. Its absence is not a warning that something is wrong; it means the numbers speak for themselves. **A `completeness` verdict of `LOWER_BOUND` means the answer is a floor.** `COMPLETE` means nothing the index recorded could add to it. `LOWER_BOUND` means the index recorded places it could not read that this query reaches, and it names them: `invisible_scopes` for whole packages or modules that could not be read, `blind_spots` for individual references the resolver could not follow, and `fallback` for the recovery action, a regular expression and the paths to run it over. Absence of the whole block means the tool did not check, which is not the same as checking and finding nothing. Six tools check, and **what bounds an answer is not the same question for all of them**, so each one looks at a different set of recorded failures: | Tool | What can bound its answer | | --- | --- | | `find_references` | Failures that asked for this name, plus unreadable scopes of the subject's repository. `direction` does not change the verdict: it is charged with the naming question either way. | | `get_blast_radius` | The same pair, taken from the repository the walk starts in. | | `trace_dependencies` | Failures **this symbol itself** made, plus unreadable scopes of its repository. "What does this reach" is never bounded by who asked for its name -- somebody else's unreadable call hides a caller, not a dependency. | | `find_symbol` | Unreadable scopes. Narrowed with `repo`, only that repository's; unnarrowed, every one in the graph. | | `get_file_outline` | Unreadable scopes of the repository asked for. There is no symbol name here, so nothing else can bound it. | | `find_cross_repo_consumers` | Unreadable scopes **anywhere in the graph**, deliberately. A package nobody could read in any repository is exactly where an outside consumer hides. | The scope follows the question for a reason: a verdict charged for every blind spot in the graph would read `LOWER_BOUND` on every answer of a corpus with one bad package, and a verdict that never says `COMPLETE` carries no information. The other five tools do not check, and none of them claims an absence. `get_symbol` and `get_source` refuse a symbol they cannot find instead of answering an empty list; `graph_status`, `list_repositories` and `index_project` answer about the index itself. This is the `completeness` block captured from `get_blast_radius` on `MergeAll` at depth 2: ```json { "verdict": "LOWER_BOUND", "invisible_scopes": [ { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-delta-profile", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile" }, { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-recovery", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@astrojs/node" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@astrojs/starlight" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@tailwindcss/vite" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "astro" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "vitest" } ], "fallback": { "pattern": "\\bMergeAll\\b", "paths": [ "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile", "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" ] } } ``` Read that as: the five rows are real, two Go directories were excluded by build constraints and five TypeScript package names had no provider in the graph, and if the answer has to be airtight, grep `\bMergeAll\b` over the two paths named. One response enumerates at most twenty entries per list; when the list is cut, `more_invisible_scopes` and `more_blind_spots` carry the exact remainder. ## Paginate A page is not the whole answer. When `truncated` is `true`, `total` is the real count and `next_cursor` is how you get the rest. This is `trace_dependencies` on `MergeAll` at depth 1 with `limit` 3, envelope only: ```json { "snapshot_id": 30, "total": 37, "returned": 3, "truncated": true, "next_cursor": "Ah4DYbLgOYhUrECElsmz0oFlyLUGpgE", "coverage": { "exact": 37 }, "guidance": "showing 3 of 37; narrow with depth, max_nodes, edge_kinds or confidence, or pass the cursor for the next page" } ``` Three of thirty-seven. Concluding anything about the other thirty-four from this page is a mistake the envelope told you not to make. The token is base64url without padding, and it is opaque: do not parse it, do not build one. It is about 31 characters over a binary body -- a format version, the snapshot id it was taken against, the row offset, a digest of the query identity, a digest of the sorting contract and a checksum over all of it -- where the previous version spent 314 characters of base64-wrapped JSON, `221` tokens on every truncated page. The query identity covers the tool name and every argument that can affect which rows match or in what order, so the next call must repeat the original arguments unchanged and add `cursor`. `view`, `limit` and `response_format` are not among them. | What changed | What happens | | --- | --- | | A newer generation was published | `CURSOR_SNAPSHOT_EXPIRED`; start the query again from page one. | | Any argument affecting membership or ordering | `CURSOR_INVALID`, the cursor does not match the active query. | | The tool's sorting contract | `CURSOR_INVALID`. | | The cursor was edited, truncated or re-encoded, or decodes with trailing bytes after the checksum | `CURSOR_INVALID`; the checksum covers the body. | | The cursor was minted by a server of the previous cursor version | `CURSOR_INVALID`; the body declares version 2, and a version-1 token fails closed rather than being read under the new layout. | The guidance names narrowing before paging, and in that order for a reason: a second page of rows you did not want is a second payload. Cut with `depth`, `max_nodes`, `edge_kinds`, `confidence`, `repo` or `language` first, and page only when the narrowed answer is still too large. ## Confidence and provenance Every edge carries a `confidence` and a `provenance`, and neither view ever drops them: in the compact one they sit in the header when the whole page agrees and on the row when it does not, and in the full one they are on every row. Traversal rows name the edge they arrived by as `via_confidence` and `via_provenance`. This is a captured `find_references` row on `MergeAll` in the `full` view; the compact page that carries the same edge states the last three fields once above it, and spells the row as `["mergeSets@681-712", "func"]`: ```json { "name": "mergeSets", "qualified_name": "mergeSets", "kind": "func", "repository": "kivgraph", "file_path": "internal/indexer/full.go", "start_line": 681, "end_line": 712, "language": "go", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL" } ``` `confidence` is one of `EXACT_TYPECHECKED`, `EXACT_DECLARATION_MAPPED`, `EXACT_PACKAGE_MAPPED`, `STRUCTURAL_CERTAIN`, `CANDIDATE` or `UNRESOLVED`. `provenance` says which analysis observed it: `GO_AST_CALL` and `GO_TYPES_USE` come from the Go analysis, `TYPESCRIPT_CHECKER` from the TypeScript checker, `TYPESCRIPT_PROJECT_REFERENCE` from a provider's own build configuration. An `EXACT` edge requires sufficient evidence and the correct provenance. It is never created by a coincidence of name, text, path or alias, and never because one candidate happened to be the only one left. That is the property that makes an empty `find_references` answer worth acting on. `CANDIDATE` and `UNRESOLVED` are different results from `EXACT`, not weaker versions of it. `CANDIDATE` is plausible and unproven. `UNRESOLVED` carries no target identity at all: it records that a reference was seen and could not be followed, with its reason, repository and language, and its file and position when there is a concrete occurrence. Neither is an edge you can treat as a use. `coverage.unresolved_related` counts the unresolved records that touch your query, which is the number that tells you whether the exact ones are the whole story. ## The Rust standard library The standard library of the toolchain that indexed the graph enters as a synthetic derived repository named `rust:`. That namespace is reserved, so no registered repository can take the name, and the name is the only authority needed to tell a derived row from yours. It has no commit, no branch and no dirty flag: nothing clones it and nothing can move it. It is withheld by default from the four served tools that could return one of its rows: - [`find_symbol`](/docs/tools/find-symbol/) - [`find_references`](/docs/tools/find-references/) - [`trace_dependencies`](/docs/tools/trace-dependencies/) - [`get_blast_radius`](/docs/tools/get-blast-radius/) The default is the difference between a usable answer and an unusable one: with the standard library in the graph, `find_references` on `Clone` or `Debug` reaches most of the corpus, and a page of `core` is not what someone asking about their own code wants. Two things override it. `include_derived: true`, on any of the four. And naming the derived provider in `repo`, on the three that take a `repo` filter, because an explicit filter is a request and not an accident. `get_blast_radius` has no `repo` argument, so there `include_derived` is the only way. Withholding a row is a page decision and never a claim about what was observed. The edge stays published with its exact confidence; it was simply not put on this page. The counts stay honest too: `graph_status` breaks the derived provider out under `derived`, with its own packages, files, symbols, inbound and internal edges and unresolved references, and `list_repositories` marks its row `derived`. ## When not to use it A rare name in one small repository is cheaper to grep. Indexing a small file costs more than reading it. Kivgraph wins on common names, on transitive impact, on consumers in another repository and on proving an absence. Spending a call where grep would have done means paying for the graph twice: once for the call, once for the read you still make. The repository carries a token-cost harness under `benchmarks/mcp-token-cost`, and its numbers say the same thing. It compares two arms on the question "who calls this symbol, and what do those callers look like": the host's own captured answer, grep plus the file reads that follow it, against the MCP calls a session needs against the published generation plus the same reads. Tokens are counted with `cl100k_base`, over the `kivgraph` corpus at generation `000026`, 14424 symbols across 363 files. Measured on the answer alone, the part a graph server owns. These factors were taken against the previous output shape, before the compact view became the default, so they are a ceiling on what a session pays today: | Symbol | Class | Factor against the native arm | | --- | --- | ---: | | `MergeAll` | rare name | 1.20x | | `CanonicalColumns` | rare name | 2.24x | | `DiscoverGo` | rare name | 1.74x | | `BuildPlan` | shared name | 4.35x | | `NewServer` | common name | 6.00x | | `Publish` | common name | 5.52x | | Total | | 3.46x | Counting the whole session, the answer plus the bodies the agent then opens, which both arms pay identically, the total factor is 1.42x, and 15102 of the 25768 native tokens are source bodies. The report publishes both figures on purpose: the answer factor flatters the graph and the session factor flatters grep. The cross-repository question was measured separately, on `Compute` in a three-file `shared-library` corpus, and there the MCP arm cost more: 0.53x on the answer and 0.70x on the session. The harness records why that column is a floor rather than a ceiling for the native side: a grep finds the name but cannot tell whether the hit is the same symbol, and says nothing about a consumer that depends on the provider package without using the symbol. Neither latency nor money is measured anywhere in the harness. ## Keep the graph fresh Answers come from the published generation, not from your working tree. A file you edited a minute ago is not in the graph until a rebuild publishes it. [`graph_status`](/docs/tools/graph-status/) is how you find out. Its `repository_freshness` block lists each repository with the commit it was indexed at and the commit its working tree is on now, and `repositories_moved` counts the ones that left the indexed commit. A repository whose HEAD could not be read is not counted as moved and not silently counted as fresh either. Rebuilding is `kivgraph index --full` from the CLI, or [`index_project`](/docs/tools/index-project/) from the client. The tool requires explicit user approval before it runs: called without `confirmed`, it returns `PERMISSION_REQUIRED`. A rebuild costs the whole corpus, so pass every project in one call. See [`/guides/indexing/`](/guides/indexing/) for the full procedure. --- # MCP troubleshooting URL: https://kivgraph.dev/mcp/troubleshooting/ Markdown: https://kivgraph.dev/raw/mcp/troubleshooting.md Every failure the tool surface returns carries a stable code and a human-readable message. Branch on the code; the message text is free to change. | Code | Meaning | | --- | --- | | `INVALID_ARGUMENT` | The arguments cannot name a symbol, a page or a limit. | | `SYMBOL_NOT_FOUND` | Nothing in the published graph answers to that selector. | | `AMBIGUOUS_SYMBOL` | The qualified name matches more than one symbol. | | `REPOSITORY_NOT_FOUND` | The `repository` given is not in the published graph. | | `CURSOR_INVALID` | The `cursor` is malformed, or does not match the active query and sorting. | | `CURSOR_SNAPSHOT_EXPIRED` | The cursor belongs to an older snapshot. | | `TRAVERSAL_LIMIT_REACHED` | The traversal exceeded its deadline. | | `SNAPSHOT_UNAVAILABLE` | The published snapshot is inconsistent or could not be read. | | `INDEX_NOT_READY` | No graph is published. | | `PERMISSION_REQUIRED` | `index_project` was called without approval. | | `PERMISSION_DENIED` | The user refused the `index_project` elicitation. | | `INDEXING_FAILED` | `index_project` ran and the pass failed. | ## The client sees no tools **Symptom** The client lists one tool, `index_project`, and nothing else. The session instructions read: ```text Kivgraph has no published graph to answer from, so it exposes no query tools. Run "kivgraph index --full" to build one, then restart this server. Until then, use the host's own search and file tools. ``` **Cause** `serve` checks for a published generation before it registers anything. With none, it registers only `index_project` and returns. `index_project` is the exception because it is how a client without a graph builds one, and it needs no graph to run. The handshake still completes. The client spawns this process itself, so exiting reads as a crash and says nothing; and publishing ten tools that would answer `INDEX_NOT_READY` to everything teaches the agent that the tools do not work. **Fix** Build a generation, then restart the server so it loads it: ```bash kivgraph index --full ``` While the store is empty, `kivgraph doctor` says so without failing: ```text graph.store: PASS (no published generation) snapshot: PASS (no published generation) unresolved: PASS (no published generation) ``` ## A query says the index is not ready **Symptom** ```text INDEX_NOT_READY: no graph is published yet: index a project with index_project, or run "kivgraph index --full" ``` **Cause** No graph is published, so nothing can answer. `serve` registers the query tools only when a generation exists, so this is what a query tool returns whenever the snapshot store it was given holds nothing — the state a freshly installed client is in. The code is stable; only the message carries the guidance. **Fix** Either of the two the message names. From a shell: ```bash kivgraph index --full ``` Or, from the client, call `index_project` with the project and `confirmed` set. A configuration written outside the default location is self-contained: its state, its cache and its registry hang from its own directory, so a `serve` started with `--config` pointing elsewhere does not see the graph built against `~/.config/kivgraph/config.yaml`. ## A tool refuses the arguments Every tool that takes a symbol accepts either a `stable_key` or the triple `repository` + `path` + `qualified_name`, and exactly one of the two. `path` is repository-relative. Two selectors can disagree, so passing both is refused instead of resolved quietly. ### The selector is malformed **Symptom** One of these, with code `INVALID_ARGUMENT`: ```text one of stable_key or qualified_name is required pass either stable_key or qualified_name, not both repository and path narrow a qualified_name; a stable_key already names one symbol path is repository-relative, so it requires repository stable_key must not carry surrounding whitespace ``` **Cause** The arguments do not name exactly one symbol. **Fix** Build the call out of the answer you already have. Every row the surface returns carries `repository`, `file_path`, `qualified_name` and a line range, so the next call is the triple copied from the previous response. Stable keys never have to enter the conversation. ### The name matches more than one symbol **Symptom** With code `AMBIGUOUS_SYMBOL`. With no `repository` or `path` yet, the message names each candidate by where it is, because that is a narrowing you can express in the next call: ```text qualified name "" names symbols; narrow with repository and path: :-, ... ``` Once `repository` and `path` were both given and the name still matches more than once, only the key separates them, so the keys are listed instead: ```text qualified name "" names symbols in , so only a stable_key separates them: , ``` **Cause** Kivgraph does not pick one for you. Choosing by name is the coincidence its edges exist to avoid. **Fix** Add `repository`, then `path`. When the message offers keys, pass one as `stable_key` and drop the triple. ### The symbol is not found **Symptom** A name the narrowing excluded says where it looked and how to widen: ```text SYMBOL_NOT_FOUND: qualified name "NoSuchThing" was not found under kivgraph; call it without repository and path to search the whole graph ``` A name nobody declares says only that: ```text SYMBOL_NOT_FOUND: qualified name "" was not found ``` An unknown repository is its own code: ```text REPOSITORY_NOT_FOUND: repository "" is not in the published graph ``` **Cause** The two not-found cases need different fixes, so they read differently. The first is a narrowing problem; the second means the graph has no such qualified name. **Fix** Drop `repository` and `path` and call again. If it is still absent, search by name instead: `find_symbol` returns an empty result set rather than an error, so `{"name": "ThisSymbolDoesNotExistAnywhere"}` answers `"total": 0` and proves the absence. Use `list_repositories` for the registered names. ## index_project refuses to run **Symptom** ```text PERMISSION_REQUIRED: user approval is required; confirm the operation before setting confirmed=true ``` Or, when the client supports elicitation and the user declines: ```text PERMISSION_DENIED: project indexing was not approved ``` **Cause** `index_project` registers projects and rebuilds the whole corpus. It is gated on explicit user approval, and it never infers approval from the call. **Fix** A client that declares the `elicitation` capability is prompted by the server, naming the projects the approval covers, and needs no argument. A client without it must ask the user itself and then send `confirmed: true`. Pass every project in one call: a rebuild costs the whole corpus. If the pass itself fails, the code is `INDEXING_FAILED` and the message carries the reason the indexer gave. ## Two rebuilds at once **Symptom** ```text another process is publishing into this generation store ``` Reaching a client through `index_project`, this arrives as `INDEXING_FAILED` with that text inside the message. **Cause** Publishing a generation takes a `flock` on the store. One state directory is shared by an `index --full`, a client's `index_project` and a server's resynchroniser, so without the lock two passes overwrite each other. The lock does not wait. A rebuild takes minutes and blocking would look like a hang, so the loser is told instead. **Fix** Let the winner finish, then retry. For the follower inside a running `serve` or `ui` this is not a failure and is not reported as one: it is exactly what the lock exists to produce, and the generation already published keeps answering. ## The graph is behind the working tree **Symptom** Paths and line ranges name code that has moved. `graph_status` reports it in `repository_freshness`, and `repositories_moved` counts the entries whose working tree left the indexed commit. One entry, with `path` omitted: ```json { "name": "mole", "languages": ["go", "typescript"], "indexed_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "indexed_branch": "main", "indexed_dirty": true, "current_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "current_branch": "main", "moved": false } ``` **Cause** A snapshot is immutable. It describes the commit it was built from, whatever the tree holds now. **Fix** Call `graph_status` first and read the entry for each repository: | Field | What it says | | --- | --- | | `indexed_commit`, `indexed_branch` | The tree the graph was built from. | | `current_commit`, `current_branch` | The tree on disk now. | | `indexed_dirty` | The indexed tree carried uncommitted changes, so matching commits do not prove matching files. | | `moved` | The two disagree. `moved_detail` names both positions. | A repository whose `HEAD` could not be read is not counted as moved and is not counted as fresh either; its entry says why. A path that is not a checkout, and a graph built before the commit was recorded, are also not `moved` — and only a tree still holding the indexed commit means the results can be trusted. A `derived` entry is a provider Kivgraph built from the machine rather than from the registry; nothing checks it out and nothing can move it. `serve` and `ui` follow the published generation and republish when the `CURRENT` pointer advances, so a rebuild in another terminal reaches a running server. To force the corpus back onto the current code: ```bash kivgraph index --full ``` One thing the fact cache cannot see: a lockfile is searched from the registered repository root upwards, and `node_modules` is never walked because that is the lockfile's job. A lockfile that is not found leaves that dependency with no control at all — in a pnpm monorepo it lives above the registered repositories. This is a declared limitation. Where it applies, rebuild with the cache off or set `indexing.fact_cache: verify`, which analyses everything and aborts the pass when an entry disagrees with the analysis. ## After a full clean the server serves nothing new **Symptom** `clean` prints the warning: ```text clean: restart any running serve or ui before the next index --full ``` Ignored, a running server keeps answering from the graph that was removed, and its log says so once: ```text generation store was rewound to while serving : restart to follow it again ``` **Cause** After a full `clean` the numbering returns to `000001`, and `SnapshotStore.Publish` only accepts a strictly newer generation. A live server keeps the graph that no longer exists and installs no further one. The follower declares it once, and `clean` warns about it in advance. **Fix** Stop the long-running processes, rebuild, start them again: ```bash kivgraph stop kivgraph index --full ``` `kivgraph stop --dry-run` lists what it would stop and stops nothing. It matches `serve` and `ui` only: an index in flight is left alone, because killing one throws away minutes of analysis. A client launches `serve` itself, so restarting the server means restarting that client, or reloading its MCP connection. `clean` never removes registered repositories, only published generations, so rebuilding what is registered is `kivgraph index --full` and nothing has to be registered again. Without `--yes` it lists and changes nothing; with `--keep-active` it keeps exactly the generation currently published, and then `rollback` has nothing to restore. ## Rust is not indexed Rust is the one language Kivgraph does not analyse itself. `rust-analyzer scip` is the authority, invoked as an external process once per Cargo workspace, and it is a prerequisite like the Node runtime of the TypeScript worker. ### The analyzer is not there, or is the wrong one **Symptom** ```text toolchain.rust: FAIL (command "rust-analyzer" is unavailable) ``` Or the binary resolves and fails to run: ```text toolchain.rust: FAIL ( --version failed: ) ``` Run by hand, that binary prints: ```text Unknown binary 'rust-analyzer' in official toolchain ``` **Cause** Being on the `PATH` is not being installed. rustup leaves a proxy named `rust-analyzer` for every toolchain whether or not the component exists, and the proxy fails with the line above. Resolution is fixed. A `rust.analyzer_command` that contains a path separator is honoured exactly as written. A bare command name resolves first to the binary sitting beside the Kivgraph executable, and only then through the `PATH`: an installation that ships its own engine must use it, or two machines with the same bundle would index the same repository with different analyzers. **Fix** ```bash rustup component add rust-analyzer ``` `kivgraph doctor` reports which binary answered and its source — `bundled`, `explicit` or `path` — and `kivgraph version --json` publishes its release. ### cargo is missing **Symptom** ```text toolchain.cargo: FAIL (cargo is unavailable, so no workspace can be loaded) ``` **Cause** The bundle carries `bin/rust-analyzer` but no Rust toolchain. Without `cargo` the analyzer cannot load a Cargo workspace, so the analyzer travelling inside the installation is not enough. `doctor` checks it separately and fails naming it. **Fix** Install a Rust toolchain on the machine that indexes. ### derive macros, operators and `?` produce no edges **Symptom** `find_references` on a trait reached through `#[derive(...)]`, an overloaded operator or the `?` operator returns nothing, and calls into the standard library are absent from the graph. **Cause** `core`, `std` and `alloc` enter the graph with `rust.index_sysroot`, which is off by default. Four silences follow, all measured: `#[derive(...)]` produces no relation, operator overloading does not reach its trait, `?` does not reach `Try::branch`, and every call into the standard library disappears. **Fix** Turn the standard library on: ```yaml rust: index_sysroot: true ``` With it, all four become exact edges. It costs an order of magnitude in graph size — one toolchain is around 350.000 monikers and half a minute of indexing — and one cold pass per toolchain, since the fact-cache fingerprint includes `rustc --version` and a toolchain change invalidates every fact taken from it. This is a declared limitation, never a failure: a machine with no toolchain, or without `rust-src`, indexes its own repositories and says why it did not index the library. `rust.sysroot` is `discover`, `none`, or a path; it says where the standard library is, not whether it enters the graph. ## A Go package or module is missing ### The package is not buildable **Symptom** The package contributes nothing, `graph_status` counts it under `unresolved_by_reason`, and a walk that would have crossed it downgrades its verdict. From a real `get_blast_radius` response, with the absolute path abridged: ```json {"completeness":{"verdict":"LOWER_BOUND","invisible_scopes":[{"reason":"PACKAGE_NOT_BUILDABLE","repository":"kivgraph","requested_package":"github.com/Luqueee/kivgraph/benchmarks/ladybug-recovery","detail":"LIST: build constraints exclude all Go files in /path/to/benchmarks/ladybug-recovery"}]}} ``` **Cause** Go is loaded with the tags in `go.build_tags`. A directory whose files those tags exclude is not an index failure: it is declared `UNRESOLVED` with reason `PACKAGE_NOT_BUILDABLE` and the pass continues. Every other loader diagnostic still aborts it. **Fix** Add the tag the package needs to `go.build_tags` and rebuild. Indexing the Kivgraph repository itself requires the `ladybug` tag. The other reasons seen alongside it in a real graph are `DECLARATION_NOT_RESOLVED`, `MODULE_PROVIDER_NOT_FOUND` and `PACKAGE_PROVIDER_NOT_FOUND`. A package name nobody provides today is a dependency with an `absent` fingerprint, not the absence of a dependency, so it becomes the edge it should be once a provider appears. ### The module was not loaded **Symptom** The module's symbols are absent and its references are declared `MODULE_NOT_LOADED`, with the diagnostics observed. **Cause** A module the loader cannot read publishes no facts, because they would not be reliable, and it does not bring down the pass. A repository whose dependencies nobody downloaded does not decide whether the others have a graph. **Fix** Download the module's dependencies in its own checkout, then rebuild. Indexing is hermetic by default: a module the local cache does not hold is reported, not fetched. `go.allow_network` is the one declared way out, and a multi-repository workspace resolves one shared build list, so its selection can need a version no member downloaded on its own. ### The module is above the language-version ceiling **Symptom** ```text toolchain.typecheck: FAIL (registered Go module requires a newer Go language version than this build supports (this build type-checks with go ): repository "" module "" requires go ; rebuild Kivgraph with that toolchain or drop "go" from the languages of that repository) ``` A passing run reports both numbers, the ceiling and the highest registered module: ```text toolchain.typecheck: PASS (go (highest registered module: go )) ``` **Cause** `go/types` travels linked inside the binary, so Kivgraph type checks only up to the language version of the toolchain that compiled it. The `go` on your `PATH` is a different number and is not the one that decides whether a repository can be indexed. A module above the ceiling is rejected by name — repository, module and version — rather than being allowed to escalate the synthetic `go.work` and break the load of every other repository. The ceiling is `major.minor`. **Fix** The message names both ways out: rebuild Kivgraph with that toolchain, or drop `go` from the languages of that repository. `kivgraph doctor` is where the ceiling is stated. ## A provider is ambiguous or absent These three codes say something about the registry, not about your code. A name has to have exactly one owner for a stable key to mean one thing. | Code | What it means | | --- | --- | | `AMBIGUOUS_PACKAGE_PROVIDER` | A TypeScript package name is declared by several manifests. No manifest provides it, and both leave the registry. A Go module with several providers gets the same treatment. | | `AMBIGUOUS_CRATE_PROVIDER` | A crate name is declared by several registered repositories, or a registered repository declares a crate of the standard library, or two toolchains are present at once. None of them provides it. | | `CRATE_PROVIDER_NOT_FOUND` | Code the analyzer indexed that no manifest of the repository declares — a crate vendored through `[patch.crates-io]`, for example. Its uses are declared as such rather than published against declarations that were discarded. | **Fix** Make the ownership unambiguous: remove the duplicate declaration, or register only one of the repositories that claims the name. A repository name is compared exactly and is an identifier, never a path component, so two names differing only in case are two repositories. A reference that names `core` with a release is `CRATE_VERSION_MISMATCH`, and a version the analyzer does not know (`.`) identifies no code and never resolves. ## The viewer will not start **Symptom** ```text ui: this binary carries no web bundle; build one with scripts/build-bundle.sh (without --mcp-only), or run the viewer from a source checkout with the webassets build tag ``` `kivgraph --help` marks the command in the same build: ```text ui [--addr HOST:PORT] Serve the read-only graph viewer, every interface by default (unavailable: this build carries no web bundle) ``` **Cause** `kivgraph ui` refuses to start when the binary lacks the `webassets` build tag. Without it, every route would serve the page that says the bundle is missing, so the refusal costs one line instead of a browser tab. **Fix** The published release carries the viewer: the release workflow builds without `--mcp-only` and verifies both halves, that `web/index.html` is in the payload and that the help does not mark `ui` unavailable. If your binary refuses, it was built with `--mcp-only` or from a source checkout without the tag. Install the published release, or rebuild the bundle without `--mcp-only`. The viewer is opt-in and serves only the published `HotSnapshot` over read-only HTTP. Its default bind is `0.0.0.0:7777`, and it logs the address it bound, including the one a port `0` resolves to. ## Where to look next **`kivgraph doctor`** answers the machine questions in one pass, one line per check: `config`, the `state.*` directories, `repositories`, the `toolchain.*` checks, `graph.store`, `snapshot.digest`, `snapshot` and `unresolved`, then `doctor: PASS` or `doctor: FAIL`. It is where the Go type-checking ceiling is stated and where you find out which `rust-analyzer` answered. **`graph_status`, through the client**, answers the graph questions: `status`, which is `ready` or `empty`; `snapshot_id`, `snapshot_built_at` and `snapshot_age_ms`; the counts; `edges_by_kind` and `unresolved_by_reason`; and `repository_freshness` with `repositories_moved`. It reports nothing this process did not use or measure: `serve` answers from the published `HotSnapshot`, so it never opens the database and never runs the TypeScript worker, and declares them `not_applicable` saying why. Metrics nobody observed are omitted rather than reported as zero. **The logs.** A one-shot command reports plain text when its `stderr` is a terminal and a JSON log record when it is not, so a pipeline gets records and a person gets sentences. `serve` and `ui` always log JSON, because a client reads their `stderr`. In both, progress is `INFO` and only a failure is `ERROR`, and the text of the line is the record's `msg`, never a field buried inside a fixed message — a record you cannot filter by level or find by text reports nothing. On `serve`, `stdout` carries protocol framing and nothing else. --- # Indexing URL: https://kivgraph.dev/guides/indexing/ Markdown: https://kivgraph.dev/raw/guides/indexing.md ```bash kivgraph index --full ``` A pass analyses every registered repository, merges the facts, validates the canonical graph and publishes it as a new generation. Publication is atomic: a candidate that fails integrity or validation never becomes `CURRENT`, and the generation already being served is untouched. ## The unit of analysis Analysis is concurrent, and the unit differs per language: | Language | Unit | Budget | | --- | --- | --- | | Go | Module | `go.maximum_loads` | | TypeScript | Package | `typescript.maximum_workers` | | Rust | Cargo workspace | `rust.maximum_workspaces` | The budgets are separate because the costs are different. A Go load holds a complete type universe; a TypeScript worker is a process; a `rust-analyzer` invocation holds a whole Cargo workspace and its sysroot. Each queue is drained heaviest-first, with no more workers than it has units, and the first failure cancels the rest. ## Where a pass runs A pass never runs inside a process that answers queries. It holds the type universe of every Go module, every TypeScript worker and every SCIP index at once, and a Go heap that has grown to that peak keeps the arena for as long as the process lives. Measured on a 41-repository, 102,385-symbol corpus, a server that indexed in its own process parked at `1.68 GB` of resident memory against a live heap that stayed flat at 173 MB — a memory measurement on that corpus, not one of the token figures the [benchmark](/comparison/) reports. So when a server indexes — because a client called [`index_project`](/docs/tools/index-project/), or because `HEAD` moved in a registered repository — it runs `index --full --json` as a child process and reads the result. The peak dies with the child, and the server pays only for the snapshot it then loads. That flag is public. With `--json`, `stdout` carries only newline-delimited JSON events — any number of `progress`, then exactly one `result` — and the report a person reads is not written at all: ```bash kivgraph index --full --json ``` ```text {"event":"progress","progress":{"phase":"go","repository":"data-service","completed":3,"total":41}} {"event":"result","result":{"passed":true,"generation_id":"000054","counts":{"symbols":102385},"index":{"go_definitions":41230}}} ``` The corpus this was captured on is private, so the repository name in that event is substituted. The phase, the counts and the generation are the captured ones. A reader ignores an event kind it does not know, so a new one is not a breaking change. Without the flag, nothing changes: the report goes to `stdout` and progress to `stderr`. ## What a second server costs A generation carries its snapshot as a file, and a server maps it read-only instead of deriving the graph. So the largest part of it — the string arena — is one copy in physical memory however many servers read the same generation. Measured on Linux with `benchmarks/shared-snapshot`, one generation of a 51-repository corpus — `161,819` symbols in a `129 MB` file — against the same binary made to derive the graph instead, `Pss` summed over every server: | servers | mapping the file | deriving the graph | share | | --- | --- | --- | --- | | 2 | `326 MB` | `654 MB` | `50%` | | 4 | `514 MB` | `1,234 MB` | `42%` | | 8 | `888 MB` | `2,385 MB` | `37%` | The share falls as servers are added, which is the whole point: a mapped page is paid for once by the machine however many processes hold it, so each new server adds only what it decodes for itself — `614 B` per symbol, flat across all three counts. At eight servers the machine keeps `1.5 GB` it would otherwise spend. The other half is startup. A server that maps the file answers its first query in `261 ms`; one that derives the graph takes `3,394 ms`, and that ratio does not improve with more servers because each one starts alone. On Linux, `Pss` and `Shared_Clean` in `/proc//smaps_rollup` split shared from private directly. macOS reports a footprint per process and no such split, so there the numbers come from `footprint`, which separates dirty from clean and names the mapped region — a different quantity, not comparable to the table above. Measured that way with two servers on `123,531` symbols: `94 MB` of clean mapped file in one shared copy, `44.5 MB` dirty per process. If the file is absent, foreign, stale or corrupt, the server derives the graph from the canonical store exactly as it always did, says so, and answers. It is an economy, never a precondition. `kivgraph doctor` tells the two apart on the `snapshot.published` line. ## Determinism The merge follows the order of the units, never the order in which they finished. Two passes over the same corpus produce byte-identical facts, whatever the scheduler did. ## The fact cache `indexing.fact_cache` decides whether an analysis unit may be served from what a previous pass stored for it: - `off` — analyse everything. - `on` — serve an entry whose recorded inputs all still match. - `verify` — analyse everything and fail the pass when a servable entry disagrees with the analysis. An entry records the whole list of what the unit read and the fingerprint of each item; serving it revalidates that list in full. An entry is never served to a different analyzer: its identity includes the content of the executable, the answer of `go env`, the content of the TypeScript worker, the build tags, `include_tests` and `go.allow_network`. A module the loader could not read is never cached, because its failure depends on the module cache and no fingerprint of the code describes it. Comparing two cached passes proves nothing. When touching this area, run with `fact_cache: verify`. ## Hermetic by default A pass never writes inside the code it indexes, and it does not reach the network. There are exactly two declared escapes: - `go.allow_network` — lets the go command reach a module proxy while loading. A multi-repository workspace resolves one shared build list, so its selection can need a version no member downloaded on its own. - `rust.allow_network` — lets cargo reach a registry while the analyzer loads a workspace. Without them, a module or crate the local cache does not hold is *reported*, not fetched. ## Build tags The tags Go is loaded with come from `go.build_tags`. A directory whose files that configuration excludes is not an index failure: it is declared `UNRESOLVED` with reason `PACKAGE_NOT_BUILDABLE` and the pass continues. Any other loader diagnostic still aborts it. Indexing the Kivgraph repository itself requires the `ladybug` tag. ## Failures that do not stop the pass A Go module the loader cannot read publishes no facts — they would not be trustworthy — and is declared `MODULE_NOT_LOADED` with the diagnostics observed. One repository whose dependencies nobody downloaded does not decide whether the others have a graph. A package name declared by several manifests is an ambiguity, not a broken repository: nobody provides it, every manifest leaves the registry, and it is declared `AMBIGUOUS_PACKAGE_PROVIDER`. Go modules and Rust crates get the same treatment. ## Indexing from an MCP client `index_project` registers one or more projects and rebuilds once. Pass every project in a single call: a rebuild resolves cross-repository edges over the complete set of facts, so it costs the whole corpus whatever is added. Calling it once per project pays that cost once per project and keeps only the last graph. --- # Graph viewer URL: https://kivgraph.dev/guides/viewer/ Markdown: https://kivgraph.dev/raw/guides/viewer.md ```bash kivgraph ui ``` The viewer is opt-in and serves only the published HotSnapshot over read-only HTTP. It never mutates the snapshot and never opens the canonical database for writing. ## The default bind is every interface `web.address` defaults to `0.0.0.0:7777`. The graph is indexed where the repositories are and the viewer is looked at from another machine, so a loopback default would mean editing the configuration in the normal case. That makes the warning the guard, and the warning is not decoration. Every bind that is not loopback logs exactly what is being exposed: - repository and file paths - symbol names and signatures **There is no authentication.** Restricting the endpoint is `--addr` or `web.address`: ```bash kivgraph ui --addr 127.0.0.1:7777 ``` The command logs the address it actually bound, including the one a port `0` resolves to. ## Builds without the viewer `ui` refuses to start when the binary was not built with the `webassets` tag, rather than serving a "bundle not available" page on every route. The published release bundle carries the viewer. ## What it draws The viewer derives its own 3D layout from the structure of each tile — containment, dependencies, communities and hierarchical depth — computed once per tile in a Web Worker. It is deterministic: directions come from the hash of a node's identity, so the same tile always draws the same world. Nodes are coloured by rank: repository, package, file, symbol. Containment is drawn as segments; local, cross-repository and exact dependencies each have their own colour and weight. Only repositories and hubs carry permanent labels; everything else is captioned on hover. Rendering is on demand: the loop stops when the graph is still and wakes on pointer events. A published graph does not move, and redrawing it sixty times a second costs a core for nothing. ## `serve` and `stop` `kivgraph serve` stays on stdio and opens no HTTP listener. The two commands are independent. ```bash kivgraph stop --dry-run kivgraph stop ``` `stop` terminates this user's long-running `serve` and `ui` processes and nothing else. It selects by invocation, not by executable: an indexing pass is minutes of analysis and is never killed, and `stop` does not kill itself. It sends `SIGTERM`, waits for the bounded graceful shutdown, re-checks that the pid is still the same invocation, and only then escalates to `SIGKILL`. `--dry-run` enumerates without signalling. --- # Maintenance URL: https://kivgraph.dev/guides/maintenance/ Markdown: https://kivgraph.dev/raw/guides/maintenance.md ## Inspect ```bash kivgraph doctor kivgraph graph status --root PATH ``` `doctor` checks the configuration, the toolchains and the published graph. It reports the language version ceiling this binary type-checks with, which is a different number from the `go` on the `PATH` and is the one that decides whether a repository can be indexed. It also checks `cargo` separately: the bundle carries `rust-analyzer` but no Rust toolchain, and the analyzer cannot load a workspace without cargo. `graph status` prints `graph.active`, `graph.next` and `graph.backup` with the path each names on disk, plus the full list of retained generations. A store with no active generation reports `graph.active: none`; that is not an error. ## Audit the registered repositories ```bash kivgraph doctor repositories kivgraph doctor repositories --repository NAME --json ``` It answers whether each registered repository is structured so that a pass can read it, without indexing anything, and says what to change where it is not. Until this existed, a coverage hole only showed up as one warning among hundreds at the end of `index --full`. Every check asks the code a pass asks: the same package registry, the same source resolution, the same `go list` with the same package patterns, the same `cargo metadata`. A finding is `blocking` when a repository or one of its packages contributes nothing at all, and `partial` when it is indexed and part of it is invisible. The command exits `1` only for a blocking finding: a partial one is a hole its owner may have chosen. Each finding carries a remedy — a file to write with its exact content, a configuration key, or a command to run. Remedies are proposals: Kivgraph never writes inside the code it indexes. `--json` emits the whole report, with the stable `code` of every finding, for an agent to act on. ## Validate a database ```bash kivgraph doctor storage --database PATH kivgraph doctor graph --database PATH ``` `doctor storage` opens the database read-only and runs its transaction test on a temporary copy. It reports location, size, effective permissions, external locks, engine versions, storage and Go binding, schema, rollback, counts and referential integrity, and returns `0` only when every check is `PASS`. `doctor graph` checks the [six canonical invariants](/docs/resolution/) on an already-published database without rebuilding it. Neither command modifies the database it is given. ## Roll back ```bash kivgraph rollback --root PATH --generation 000123 ``` `--generation` is optional: without it, `rollback` uses the registered `graph.backup`, and fails explaining there is nowhere to go if there is neither a backup nor an explicit generation. Before switching `CURRENT`, it recomputes the destination generation's digest from its per-table counts — the same formula written to `snapshot.sha256` when the generation was published — and requires all six invariants to pass. A generation without `snapshot.sha256` is never reactivated blindly. If either check fails, `CURRENT` does not change. ## Rebuild the hot snapshot ```bash kivgraph snapshot --root PATH --generation 000123 ``` Without `--generation` it builds from the registered `graph.active`. The snapshot is derived from the canonical graph already published in LadybugDB, never from the fact set that originated it, and the database is not modified. ## Upgrade after a schema change ```bash kivgraph upgrade ``` An incompatible schema change requires detecting the version, backing up and verifying the active generation, and rebuilding from the source repositories. Only a candidate that passes integrity and validation may change `CURRENT`. ## Clean ```bash kivgraph clean kivgraph clean --keep-active --yes ``` `clean` removes published generations. Without `--yes` it enumerates and touches nothing, because there is no undo — it also removes the backup that `rollback` depends on. With no flags it leaves the store empty and releases the reserved space; `--keep-active` preserves exactly the published generation. It never touches the configuration or the repository registry. Rebuilding what is registered is `index --full`. After a full clean the numbering restarts at `000001`, and a snapshot store only accepts a strictly newer generation: **a running server keeps the graph that no longer exists and will install no further one.** The command says to restart it. ## Update the installation ```bash kivgraph update --check kivgraph update ``` The update validates the manifest, the version and the checksums before replacing the bundle, and preserves the configuration and graph state. Restart the MCP client afterwards. --- # Code Intelligence MCP Server for AI Coding Agents URL: https://kivgraph.dev/code-intelligence/ Markdown: https://kivgraph.dev/raw/code-intelligence.md Kivgraph is a local **code intelligence MCP server** for AI coding agents. It indexes Go, TypeScript, Rust, Python and Dart repositories into a canonical knowledge graph, then answers structural questions about the codebase. The five languages are not resolved to the same standard, and the graph says which is which: Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. This is **semantic code navigation**, not a text search wrapper. Kivgraph preserves declarations, symbols, callers, callees, repository relationships and unresolved facts with the evidence that produced them. ## What code intelligence answers An agent can ask: - Where is this symbol declared? - Who calls or references it? - What dependencies does it reach? - Which repositories consume it? - What breaks if I change it? - What source code belongs to the returned symbols? The MCP tools expose these questions directly through [`find_symbol`](/docs/tools/find-symbol/), [`find_references`](/docs/tools/find-references/), [`trace_dependencies`](/docs/tools/trace-dependencies/), [`get_blast_radius`](/docs/tools/get-blast-radius/) and [`get_source`](/docs/tools/get-source/). ## Code intelligence versus text search `grep` can find matching text. It cannot distinguish two homonymous methods, prove that a reference is a call to a particular declaration, or show a dependency that crosses repository boundaries. Kivgraph uses the configured language analyzers and keeps `EXACT`, `CANDIDATE` and `UNRESOLVED` results distinct. An empty result is therefore meaningful only when the response reports sufficient confidence and completeness. Unresolved facts remain visible instead of being silently discarded. Text search is still the cheaper tool for some questions. On the 29-question benchmark `grep` costs fewer tokens on five of them — a rare name in a single repository, where reading the two files that hold it beats a graph query. Kivgraph is worth the call on common names, on transitive impact, on cross-repository consumers and where an absence has to be proven. The measured split is in the [comparison](/comparison/). ## Local and repository-aware Indexing, graph queries and MCP serving run locally. A repository is identified explicitly, so the graph can answer cross-repository questions without merging unrelated projects into one anonymous namespace. Start with the [Quickstart](/quickstart/), then register the server with a [supported MCP client](/mcp/clients/). --- # Repository Relationships and Code Dependencies URL: https://kivgraph.dev/repository-relationships/ Markdown: https://kivgraph.dev/raw/repository-relationships.md A codebase is more than a directory tree. Its important structure is the set of **repository relationships** between symbols, files, packages and projects. Kivgraph indexes those relationships into a canonical code graph so an AI coding agent can navigate dependencies without reading every file first. ## Relationships Kivgraph can answer The graph represents questions such as: - Which symbols call this function? - Which declarations does this implementation reference? - What reaches this symbol within a bounded depth? - Which repository consumes a symbol from another repository? - Which packages and files are affected by a change? Use [`find_references`](/docs/tools/find-references/) for direct incoming and outgoing references, [`trace_dependencies`](/docs/tools/trace-dependencies/) for paths outward, and [`get_blast_radius`](/docs/tools/get-blast-radius/) for bounded incoming impact. ## Cross-repository dependencies Each registered repository keeps its identity in the graph. Cross-repository consumers are reported separately from local callers, so an agent can tell whether a change affects one project or a wider workspace. The [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) tool reports consumers in another repository when the index has enough evidence. A package dependency alone is not presented as a symbol use. ## Evidence is part of the relationship Kivgraph does not promote a plausible name match to an exact edge. Results carry confidence and provenance. Facts that cannot be resolved remain `UNRESOLVED` with their reason, while weaker analyzer results remain `CANDIDATE` instead of being presented as exact. How strong that evidence is depends on the language. Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. A Python relationship read from the fallback is a candidate, and the response says so. That distinction is what makes repository relationship queries useful in code review and impact analysis: the agent can see what is proven, what is uncertain and what the index could not load. It is not a reason to route every question through the graph — on five of the 29 benchmark questions plain `grep` answered correctly for fewer tokens, and the [comparison](/comparison/) names them. Read the [resolution vocabulary](/docs/resolution/) before relying on a result in an automated workflow. --- # Token-Efficient Code Understanding for AI Agents URL: https://kivgraph.dev/token-efficient-code-understanding/ Markdown: https://kivgraph.dev/raw/token-efficient-code-understanding.md AI coding agents often build context by scanning files with `grep`, `glob` and repeated reads. On a large repository that can mean many tool calls before the agent has identified the relevant symbols and relationships. Kivgraph provides **token-efficient code understanding** by indexing the repository first and returning structured graph facts: declarations, callers, callees, dependencies, impact and source code. ## Fewer exploratory calls A typical structural question can begin with one graph query instead of a sequence of text searches and file reads: ```text What breaks if I change MergeAll? ``` The agent can use [`get_blast_radius`](/docs/tools/get-blast-radius/) to receive bounded impact, grouped by repository, package, depth and relation kind. It can then call [`get_source`](/docs/tools/get-source/) only for the symbols that matter. Fewer calls do not mean less evidence. Each result keeps its repository, path, symbol, line range, confidence and provenance where applicable. ## Token saving should be measured The amount saved depends on the question, repository, language and response view. A broad exploratory task and a rare symbol lookup do not have the same baseline, so Kivgraph publishes the comparison instead of a universal percentage. The current run answers 29 questions over a corpus of 37 private repositories with Kivgraph `0.5.0`, counting with the `o200k_base` tokenizer: | Arm | Tokens | Tool calls | Precision | Recall | Exact answers | | --- | --- | --- | --- | --- | --- | | Kivgraph `0.5.0` | 35,961 | 36 | 1.00 | 0.9962 | 28 / 29 | | `grep` + reading | 267,980 | 101 | 1.00 | 0.9885 | 28 / 29 | That is 7.45x on totals and a median of 5.95x per question. The spread matters more than the total: a Rust trait-method question cost 264 tokens against `grep`'s 22,016, both answers exact, while the widest margin in the run is 86.8x. ## Where the graph is the more expensive route `grep` is cheaper on five of the 29 questions, and both arms are correct on all five. They are the questions text search is built for: a rare name, one repository, two files to open. Looking up a Go constructor that occurs twice in the whole corpus cost Kivgraph 123 tokens and `grep` 65 — the graph query is 1.9x the price of the obvious `grep`. A graph query also has a floor an agent should know about. Asked for `withBackoff` by bare name, where the corpus declares seven distinct symbols with that name, the server refuses with `AMBIGUOUS_SYMBOL` and lists all seven for 129 tokens rather than guessing. Naming the repository and path in the first call avoids paying it. The benchmark corpus is private, so that symbol name is substituted. The counts and the token figures are the measured ones, and the 129 was measured against the real identifiers. Kivgraph also exposes compact and file-oriented response views where supported, so an agent can request the amount of detail the task needs. ## Local context and privacy The index and MCP server run locally. Repository contents are not sent to a hosted search service by Kivgraph. This makes token-efficient retrieval useful for private codebases as well as public projects. See the [comparison](/comparison/) for measured tradeoffs and the [Quickstart](/quickstart/) to build the first published graph. --- # Cross-Repository Code Graph MCP Server URL: https://kivgraph.dev/cross-repository-code-graph/ Markdown: https://kivgraph.dev/raw/cross-repository-code-graph.md **Kivgraph is a local cross-repository code intelligence MCP server for AI coding agents.** Register multiple repositories in one workspace and query their proven symbol and dependency relationships without collapsing their identities. This is a **semantic code graph across repositories**. It answers relationships observed by the language analyzers: symbols, references, callers, callees, packages and consumers in another repository. ## What the workspace graph answers Use Kivgraph to ask: - Which repository consumes this symbol? - Who calls this function across the workspace? - What dependencies reach this package? - What breaks if I change this shared type? - Which files and repositories are in the bounded impact radius? The relevant MCP tools are [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/), [`find_references`](/docs/tools/find-references/), [`trace_dependencies`](/docs/tools/trace-dependencies/) and [`get_blast_radius`](/docs/tools/get-blast-radius/). ## Register repositories explicitly Each repository gets an explicit name and path: ```bash kivgraph init \ --repository frontend=/workspace/frontend \ --repository api=/workspace/api \ --repository shared=/workspace/shared \ --languages go,typescript,rust kivgraph index --full ``` The repository identity travels with the graph facts. A cross-repository consumer is not confused with a local caller, and every returned row carries the repository and repository-relative path needed for the next query. ## What that is worth, measured One question from the published benchmark asks which files outside `platform-lib` consume the `StatusCode` enum it declares. Three files do. `find_cross_repo_consumers` returned all three in one call for 530 tokens, and reported the 22 package-level rows separately rather than counting them as uses. `grep` spent 12,200 tokens over seven calls and found two of the three: the consumer in `client-sdk:src/index.ts` never spells the symbol, so no text search reaches it. The benchmark corpus is private, so the repository, file and symbol names here are substituted. The counts, the calls and the token figures are the measured ones. That margin is not universal. On five of the 29 questions `grep` is the cheaper arm, all of them single-repository lookups of a rare name. Read the [comparison](/comparison/) before assuming the graph is always the shorter path. ## Semantic code graph versus architecture graph Kivgraph focuses on **code relationships**: declarations, references, calls, imports, packages and analyzer-backed dependencies. It is not an architecture-observability product that automatically promises to infer every HTTP, gRPC, Kafka or database runtime flow. That boundary is deliberate. Kivgraph publishes `EXACT`, `CANDIDATE` and `UNRESOLVED` results with confidence and provenance instead of turning a plausible service name into a proven edge. For the broader query model, read [repository relationships](/repository-relationships/) and [code intelligence](/code-intelligence/). --- # Workspace Code Intelligence for AI Coding Agents URL: https://kivgraph.dev/workspace-code-intelligence/ Markdown: https://kivgraph.dev/raw/workspace-code-intelligence.md **Workspace code intelligence** means giving an AI coding agent the relationships it needs across projects, not only the contents of the file currently open. Kivgraph indexes named repositories into one canonical graph and serves local MCP queries for symbols, references, repository relationships and change impact. ## A workspace example ```text workspace/ ├── frontend/ ├── api/ ├── auth-service/ ├── payments-service/ └── shared-types/ ``` The graph keeps these repositories distinct while allowing supported relationships to cross their boundaries. An agent can start from a symbol in `shared-types`, find consumers in `frontend` and `api`, then inspect the bounded impact before editing. ## Why this is useful to an agent Without a repository-aware index, an agent may need repeated search and file reads to discover: - where a symbol is declared; - which repository owns it; - who references it; - which package provides an import; - what downstream code may be affected. Kivgraph exposes those questions through MCP tools and preserves the evidence attached to each result. This can reduce exploratory tool calls while keeping the answer addressable by repository, path, symbol and line range. It does not win every question. On the 29-question benchmark plain `grep` costs fewer tokens on five of them, all single-repository lookups of a rare name where both approaches answer correctly. The workspace graph earns its cost on common names, on transitive impact and on consumers in another repository; the split is in the [comparison](/comparison/). ## Supported workspace model The workspace graph is strongest when repositories share analyzable code contracts, packages and symbols. It does not claim automatic discovery of every runtime relationship between independent services. HTTP, gRPC, event-bus and database edges require explicit supported evidence or remain outside the semantic graph. Start with the [cross-repository code graph guide](/cross-repository-code-graph/) and then [register Kivgraph with an MCP client](/mcp/clients/). `kivgraph mcp install` has five targets: `claude-code`, `claude-desktop`, `codex`, `opencode` and `oh-my-pi`. Claude Desktop is the exception in two ways — it is user-scope only, and it is the one target that installs no local skill. --- # Kivgraph FAQ: Code Graphs, MCP and Workspaces URL: https://kivgraph.dev/kivgraph-faq/ Markdown: https://kivgraph.dev/raw/kivgraph-faq.md ## What is Kivgraph? Kivgraph is a local **cross-repository code intelligence MCP server** for AI coding agents. It indexes Go, TypeScript, Rust, Python and Dart repositories into a canonical semantic code graph and serves that graph to a client over stdio. ## Which languages are supported, and are they equal? Go, TypeScript, Rust, Python and Dart — and no, they are not equal. Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. The response carries that distinction, so a Python candidate is never presented as a proven call. `--languages` accepts ten tokens: the five names plus the aliases `javascript`, `ts`, `js`, `rs` and `py`. ## Does Kivgraph replace `grep`? No, and the benchmark names where it loses. On the current 29-question run `grep` costs fewer tokens on five questions — `A1_go_absent`, `A2_ts_absent`, `I1_go_depth2`, `A3_rs_absent` and `T1_go_trivial` — with both arms at recall 1.00; looking up a Go constructor that occurs twice in the whole corpus cost Kivgraph 123 tokens against `grep`'s 65. Kivgraph earns its cost on common names, transitive impact, cross-repository consumers and on proving an absence, which is where the run's widest margins are. See the [comparison](/comparison/). ## Does Kivgraph save tokens? On the current run it does: 35,961 tokens over 36 tool calls against 267,980 tokens over 101 calls for `grep` plus reading, at precision 1.00 on both sides. That is 7.45x on totals, but the median per-question ratio is 5.95x — the total is pulled up by a few wide questions, the widest at 86.8x. Because the spread is that large, Kivgraph publishes the whole run rather than one headline percentage; see [token-efficient code understanding](/token-efficient-code-understanding/). ## Does my code leave my machine? No. The indexer, the canonical store, the published snapshot and the MCP server all run locally, and the server speaks stdio to a client on the same machine. One caveat deserves to be stated plainly: `kivgraph ui` is a local HTTP listener that binds `0.0.0.0:7777` by default and carries **no authentication**, so anything that can reach that port reads your repository paths, file paths, symbol names and signatures. Bind it to loopback with `--addr 127.0.0.1:7777`, or set `web.address`, unless you intend it to be reachable — see [limits](/limits/) and the [viewer guide](/guides/viewer/). ## Does Kivgraph require an LLM or an API key? No. Indexing is done by language analyzers and a query is a lookup in a published snapshot, so there is no model call anywhere in the pipeline and no key in the configuration. The only language model involved is whichever agent sits on the other end of the MCP connection, and even that is optional: `kivgraph index`, `kivgraph doctor` and `kivgraph ui` work with no client attached. ## How is it different from a vector database or embedding search? An embedding index answers "what looks similar to this"; Kivgraph answers "what is provably connected to this". Its edges come from language analyzers or are explicitly marked `CANDIDATE` or `UNRESOLVED` — never from name matching or from a similarity score above a threshold. The practical difference is that an empty answer is evidence here and is not there: a nearest-neighbour search always returns neighbours, while a type-checked reference query returning nothing means nobody calls the symbol. ## How does cross-repository analysis work? Each repository is registered with an explicit name and path, and that identity travels inside every fact the graph stores, so a consumer in another repository is never confused with a local caller. [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) reports symbol uses from outside the declaring repository and keeps package-level dependencies in a separate count, because depending on a package does not prove using a symbol. On the benchmark question about a shared `StatusCode` enum it returned all three external consumers in one call for 530 tokens, reporting 22 package-level rows separately; `grep` spent 12,200 tokens over seven calls and missed one consumer, which never spells the symbol. The benchmark corpus is private, so the repository, file and symbol names in these answers are substituted; the counts and the token figures are the measured ones. ## What happens when Kivgraph finds no references? An empty list is a claim, not a failure — but it is only as strong as the evidence behind it, so read the confidence and completeness the response reports before treating it as proof of absence. Facts the analyzers could not attribute are not discarded: they are published as `UNRESOLVED` carrying their reason, repository and language, which is what separates "nobody calls this" from "the index could not load that part". A bare name that several declarations share is refused rather than guessed: asking for `withBackoff` in a corpus that declares it seven times returns `AMBIGUOUS_SYMBOL` with all seven candidates, for 129 tokens — a figure measured against the real identifiers, which the substituted name here stands in for. ## How much does indexing cost, and how often must I re-index? A rebuild is always a full pass over every registered repository; there is no incremental mode. In the cold-index measurement of the five-way comparison, a 37-repository corpus took 37.6 s, produced 1,423 MB of on-disk state and published 96,482 symbols, and required the Go module cache per indexed module and `cargo` per Rust workspace. Re-index when the code you are asking about has moved: [`graph_status`](/docs/tools/graph-status/) reports per-repository freshness against the commit each repository was indexed at, so a stale answer is visible rather than silent. ## What happens if I query before publishing a generation? There is no query surface at all. A server with no published generation registers `index_project` alone — not the other ten tools — and puts the rebuild command in its MCP `instructions`, because publishing ten tools that all answer `INDEX_NOT_READY` would teach the agent that the tools do not work. It does not exit either: a client launches the process itself, so exiting reads as a crash. Run `kivgraph index --full`, or let the agent call `index_project`; see the [Quickstart](/quickstart/). ## Which MCP clients does Kivgraph support? `kivgraph mcp install` has five targets: `claude-code`, `claude-desktop`, `codex`, `opencode` and `oh-my-pi`. It takes `--target`, `--scope user|project` (default `user`), `--dry-run` and `--force`. Claude Desktop is the exception twice over — it is user-scope only, and it is the one target that installs no local skill. See the [client registration guide](/mcp/clients/), [Claude Code integration](/mcp/claude-code/), [Codex integration](/mcp/codex/) and [Oh My Pi integration](/mcp/oh-my-pi/). ## Is Kivgraph a multi-repository code graph? Yes. Kivgraph registers repositories explicitly, preserves repository identity and can report supported symbol and dependency relationships across repository boundaries. See [cross-repository code graphs](/cross-repository-code-graph/). ## Is Kivgraph an architecture graph for microservices? Not primarily. Kivgraph focuses on semantic code relationships: symbols, declarations, references, calls, packages and analyzer-backed dependencies. It does not claim automatic discovery of every HTTP, gRPC, Kafka or database runtime relationship. ## Is the project called Kivgraph or KiroGraph? The project name is **Kivgraph**. It is a distinct project and is not KiroGraph. --- # CLI URL: https://kivgraph.dev/docs/cli/ Markdown: https://kivgraph.dev/raw/docs/cli.md `kivgraph --help`, `-h` and `help` write to `stdout` and exit `0`. So does a bare `kivgraph`: an invocation with no command asks what the program does, and it is answered with this same table. An **unknown** command writes a single line to `stderr` and exits `2`, rather than dumping the whole surface at someone who mistyped one word. ## Getting started | Invocation | Summary | | --- | --- | | `init [--repository NAME=PATH] [--languages LIST]` | Write the configuration and register repositories | | `index --full [--json]` | Index every registered repository and publish a generation | | `serve` | Run the MCP server over stdio | | `daemon [--addr HOST:PORT] [--allow-remote]` | Serve MCP to many clients from one process, over HTTP and a unix socket | | `daemon install [--addr HOST:PORT] [--allow-remote]` | Give the daemon an owner, so the platform starts it and restarts it | | `daemon remove` | Stop the daemon and take its supervisor entry out | | `ui [--addr HOST:PORT]` | Serve the read-only graph viewer, every interface by default | | `stop [--dry-run]` | Stop every running serve, daemon and ui of this user | ## Diagnostics | Invocation | Summary | | --- | --- | | `doctor` | Check configuration, toolchains and the published graph | | `doctor repositories [--repository NAME] [--json]` | Audit whether every registered repository can be indexed, and say what to change | | `doctor storage --database PATH` | Inspect one LadybugDB database file | | `doctor graph --database PATH` | Validate the canonical graph of a database | | `graph status --root PATH` | Report the active and backup generations | | `daemon status` | Report whether the daemon has an owner, and where its unit lives | | `stats [--interval D] [--once] [--json]` | Watch what every kivgraph process on this machine costs | | `logs [--follow] [--kind K] [--tool NAME] [--since D] [--limit N] [--failures] [--json]` | Read what this machine indexed, served and answered | | `tool-stats [--tool NAME] [--since D] [--json]` | Report the cost and the failures of every tool | | `version [--json]` | Print the release, with --json for full provenance | ## Maintenance | Invocation | Summary | | --- | --- | | `upgrade` | Rebuild the graph after a schema change | | `clean [--keep-active] [--yes]` | Remove published graph generations | | `rollback --root PATH [--generation ID]` | Return to the previous generation | | `snapshot --root PATH [--generation ID]` | Rebuild the hot snapshot of a generation | | `update [--check] [--stop]` | Install the latest published release | ## Integrations | Invocation | Summary | | --- | --- | | `mcp install [--scope user\|project] [--stdio]` | Detect and register one or more MCP clients, against the daemon by default | | `mcp status --target TARGET [--scope user\|project] [--stdio]` | Inspect a client MCP registration | | `mcp remove --target TARGET [--scope user\|project] [--stdio]` | Remove only Kivgraph's MCP registration | | `skill install [--scope user\|project]` | Detect and install the Agent Skill in one or more clients | | `skill status --target TARGET [--scope user\|project]` | Inspect the installed Agent Skill | | `skill remove --target TARGET [--scope user\|project]` | Remove only Kivgraph's Agent Skill | | `completion bash\|zsh\|fish` | Print the shell completion script for one shell | ### Shell completion ```bash kivgraph completion bash > /usr/local/etc/bash_completion.d/kivgraph # bash 3.2 and newer kivgraph completion zsh > "${fpath[1]}/_kivgraph" kivgraph completion fish > ~/.config/fish/completions/kivgraph.fish ``` The script is a fixed stub: it carries no command name, no flag and no vocabulary, and forwards the words typed so far to `kivgraph __complete`. That is what keeps it from going out of date when a flag is added, and it is what lets completion answer the questions a static script cannot: `--generation` completes the generations on disk, `--target` the clients this machine has, and `--tool` the tools this installation has actually been called with. A flag that takes a path defers to the shell's own file completion. ## Pipeline | Invocation | Summary | | --- | --- | | `rebuild --facts PATH --root PATH ...` | Publish a generation from a fact set | | `benchmark generate-graph` | Generate a synthetic corpus | ## Builds without the viewer `ui` needs the web bundle, which is only linked in when the binary is built with the `webassets` tag. A build without it marks the command as unavailable in the help and refuses to start rather than serving a "bundle not available" page. The published release bundle carries the viewer. ## Logging A one-shot command reports in plain text when `stderr` is a terminal and as a JSON record when it is not; `serve` and `ui` always log JSON, because a client reads their `stderr`. Progress is `INFO` and only a failure is `ERROR`. --- # MCP tools URL: https://kivgraph.dev/docs/mcp-tools/ Markdown: https://kivgraph.dev/raw/docs/mcp-tools.md `kivgraph serve` registers twelve tools over stdio *once a generation is published*. Eleven are read-only. `index_project` is the only one that mutates anything, and only after explicit consent. A server with no published generation registers `index_project` alone; see [Before a generation is published](#before-a-generation-is-published). Every tool answers from the published HotSnapshot. `serve` does not open the database and does not run the TypeScript worker. ## Pick the tool by the question | The question | The tool | | --- | --- | | I do not know its name; which files do I open | [`find_by_intent`](/docs/tools/find-by-intent/) | | Who calls this, what references this | [`find_references`](/docs/tools/find-references/) | | What breaks if I change this | [`get_blast_radius`](/docs/tools/get-blast-radius/) | | What does this reach outwards | [`trace_dependencies`](/docs/tools/trace-dependencies/) | | Who uses it from another repository | [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) | | Where is it declared | [`find_symbol`](/docs/tools/find-symbol/) | | What is declared in this package | [`get_file_outline`](/docs/tools/get-file-outline/) | | Give me the code of these symbols | [`get_source`](/docs/tools/get-source/) | | Everything known about one symbol | [`get_symbol`](/docs/tools/get-symbol/) | | What is indexed, and is it current | [`graph_status`](/docs/tools/graph-status/) | | Which repositories are registered | [`list_repositories`](/docs/tools/list-repositories/) | | Index a project and rebuild | [`index_project`](/docs/tools/index-project/) | Edges are resolved by language analyzers, never by matching names, and the resolution is not uniform across the five languages: Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. Where the edge is type-checked, an empty reference list means nobody calls it, and two homonymous methods are two different symbols. `grep` can say neither. It is not always the cheaper route either way. On the 29-question benchmark `grep` costs fewer tokens than these tools on five questions — rare names in a single repository, where both sides answer correctly. See the [comparison](/comparison/). See [Using it from an agent](/mcp/usage/) for the response envelope, the cursor contract, and how to read `coverage`, `guidance` and `completeness`. ## Reference Read-only, symbols and source: - [`find_by_intent`](/docs/tools/find-by-intent/) - [`find_symbol`](/docs/tools/find-symbol/) - [`get_symbol`](/docs/tools/get-symbol/) - [`get_source`](/docs/tools/get-source/) - [`get_file_outline`](/docs/tools/get-file-outline/) Read-only, graph traversal: - [`find_references`](/docs/tools/find-references/) - [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) - [`trace_dependencies`](/docs/tools/trace-dependencies/) - [`get_blast_radius`](/docs/tools/get-blast-radius/) Read-only, the index itself: - [`list_repositories`](/docs/tools/list-repositories/) - [`graph_status`](/docs/tools/graph-status/) Mutating: - [`index_project`](/docs/tools/index-project/) ## Addressing a symbol Every tool that takes a symbol accepts either a `stable_key` or the triple `repository`, repository-relative `path` and `qualified_name` — exactly one of the two. Supplying both is rejected rather than resolved quietly, because two selectors can disagree and answering one of them answers a question nobody asked. Every row a tool returns already carries a repository, a path, a qualified name and a line range, so the next call is built from the answer just received and stable keys never have to enter the conversation. ## Before a generation is published With no published generation there is no query surface. The server completes the handshake, publishes only `index_project`, and puts the rebuild command in its `instructions`. A client launches the process itself, so exiting would read as a crash, and publishing tools that answer `INDEX_NOT_READY` to everything would teach the agent that the tools do not work. See [Troubleshooting](/mcp/troubleshooting/). ## One channel per response No tool publishes an `outputSchema`. With one, the SDK marshals the typed result into `structuredContent` and repeats the same JSON in the text block, so the response is paid for twice. [`get_source`](/docs/tools/get-source/) goes further and answers in prose: source inside a JSON string costs more than the same source as text. --- # Configuration URL: https://kivgraph.dev/docs/configuration/ Markdown: https://kivgraph.dev/raw/docs/configuration.md `kivgraph init` writes `~/.config/kivgraph/config.yaml` and `~/.config/kivgraph/repositories.yaml`. Paths use the `~` notation until they are expanded at load time; after expansion every path key must be absolute. A configuration written outside the default location is self-contained: its state, its cache and its registry hang from its own directory. A `--config` in `/tmp` never publishes over the real graph. ## `version` | Key | Default | Notes | | --- | --- | --- | | `version` | `1` | Configuration schema version. | ## `workspace` | Key | Default | Notes | | --- | --- | --- | | `repositories_file` | `~/.config/kivgraph/repositories.yaml` | The repository registry document. | ## `storage` | Key | Default | Notes | | --- | --- | --- | | `database_path` | `~/.local/state/kivgraph/graph.lbdb` | The canonical LadybugDB database. A published generation lives in its own directory beside it, and carries the graph, the snapshot and the digests that prove they belong together. | | `backups_path` | `~/.local/state/kivgraph/backups` | What `rollback` restores from. | ### Retired keys `snapshots_path` and `retain_snapshots` no longer exist. Neither ever did anything: nothing was written to the first, and nothing read the second. A configuration that still carries them **loads normally** — they are accepted, ignored, and named by `kivgraph doctor` as `config.retired` so you can delete them. Rejecting a file that was valid when it was written would be a worse answer than the keys were a mistake. ## `web` | Key | Default | Notes | | --- | --- | --- | | `address` | `0.0.0.0:7777` | Bind of `kivgraph ui`. Must be a valid `host:port`. Every non-loopback bind logs what it exposes; there is no authentication. | ## `mcp` | Key | Default | Notes | | --- | --- | --- | | `transport` | `stdio` | The only accepted value, and a decision rather than a gap. The reason to want a shared transport was memory: every client launching its own server used to mean another private copy of the graph. A server now maps the published snapshot, so the largest part of it is one copy however many are running — see [what a second server costs](/guides/indexing/#what-a-second-server-costs). A client launching its own server is not something to negotiate with the user. | | `default_limit` | `50` | Must be positive. | | `maximum_limit` | `500` | Must be at least `default_limit`. | | `maximum_depth` | `5` | Must be between `1` and `5`. | | `maximum_visited_nodes` | `25000` | Must be positive. | ## `indexing` | Key | Default | Notes | | --- | --- | --- | | `generated_files` | `include` | Accepts only `include`. | | `unresolved_references` | `retain` | Accepts only `retain`. | | `syntax_acceleration` | `true` | | | `full_rebuild_on_schema_change` | `true` | | | `fact_cache` | `on` | `off`, `on` or `verify`. `verify` analyses everything and fails the pass when a servable entry disagrees with the analysis. | | `fact_cache_path` | `~/.local/state/kivgraph/factcache` | One entry per analysis unit, outside every indexed repository. Must not be empty unless the cache is `off`. | The two words are not decoration. `generated_files` and `unresolved_references` accept exactly one value each because that is exactly what the pass does: it indexes generated files, and it retains every unresolved reference. Accepting another word would promise behaviour no code implements. ## `watcher` | Key | Default | Notes | | --- | --- | --- | | `enabled` | `true` | | | `debounce_ms` | `150` | Must be positive. | | `maximum_batch_ms` | `500` | Must be at least `debounce_ms`. | | `reconciliation_interval` | `10m` | A Go duration string; must be positive. | ## `typescript` | Key | Default | Notes | | --- | --- | --- | | `worker_command` | `kivgraph-ts-worker` | Must not be empty. | | `maximum_workers` | `3` | Must be positive. Bounds concurrent worker processes. | | `project_idle_timeout` | `30m` | Must be positive. | | `include_unclaimed_sources` | `false` | Indexes the `.ts`/`.tsx`/`.mts`/`.cts` files no project claims, through TypeScript's inferred project. Those files belong to no program, so they are invisible by construction; enabling this checks them under compiler options Kivgraph chose rather than any the project declared. JavaScript is excluded: whether a `.mjs` is a source is the project's `allowJs` to answer, and this walk resolves no project. See ADR 0050. | ## `go` | Key | Default | Notes | | --- | --- | --- | | `synthetic_work_file` | `~/.local/state/kivgraph/go.work` | The synthetic workspace, outside every indexed repository. | | `include_tests` | `false` | | | `build_tags` | *(empty)* | The constraints every Go load satisfies. No tag may be empty or contain a comma or whitespace. Indexing the Kivgraph repository itself requires `ladybug`. | | `allow_network` | `false` | The one declared escape from a hermetic pass: lets the go command reach a module proxy. | | `maximum_loads` | `0` | Bounds concurrent Go loads; each holds a complete type universe. `0` uses the processor count, capped. Must not be negative. | ## `rust` | Key | Default | Notes | | --- | --- | --- | | `analyzer_command` | `rust-analyzer` | Must not be empty. The bundled binary beside the executable wins, then this path, then the `PATH`. | | `maximum_workspaces` | `0` | Bounds concurrent `rust-analyzer` invocations; each holds a whole Cargo workspace in memory. `0` uses the processor count, capped. | | `features` | *(empty)* | Cargo features to activate. Cannot be combined with `all_features`. | | `all_features` | `false` | | | `no_default_features` | `false` | | | `cfgs` | *(empty)* | Additional `--cfg` values the analysis assumes. | | `build_scripts` | `true` | | | `proc_macros` | `true` | | | `include_tests` | `true` | Sets `cfg(test)`. Turning it off removes every test item from the graph, and the grammar then reports each one as a declaration the index does not carry. | | `allow_network` | `false` | Lets cargo reach a registry while the analyzer loads a workspace. | | `target_directory` | `~/.local/state/kivgraph/rust-target` | Build artifacts of the analysis, outside every indexed repository. | | `sysroot` | `discover` | `discover`, `none`, or a path. Where the standard library is, never whether it enters the graph: loading it is what lets the analyzer resolve `Vec` at all. | | `index_sysroot` | `false` | Publishes the standard library as a synthetic provider repository named after the toolchain release, such as `rust:1.96.1`. | A symbol behind an inactive feature is absent from the graph and reported as unresolved. Feature selection is therefore part of what the graph *is*, not a performance knob. ### The standard library With `index_sysroot` off, four things leave no edge at all: `#[derive(...)]`, an overloaded operator, the `?` operator, and every call into the standard library. Each one resolves to a symbol of `core`, `alloc` or `std`, and nothing in the graph declares it. The pass says so, per crate, as `CRATE_PROVIDER_NOT_FOUND`. With it on, they become exact edges. It costs one extra analysis unit — around `19.500` symbols and one cold pass per toolchain — and the fact cache serves it afterwards, because the cache fingerprint includes `rustc --version`. A machine with no toolchain, or one without the `rust-src` component, indexes its repositories and reports why the standard library is absent; it is never a failure. Read tools withhold it by default. `find_symbol`, `find_references`, `trace_dependencies` and `get_blast_radius` accept `include_derived: true` to ask for it, and naming the repository in `repo` is a request for it too. `graph_status` reports what it contributes under `derived` — including its own unresolved references, which the standard library declares by the thousand — and `list_repositories` marks the row. ## `telemetry` | Key | Default | Notes | | --- | --- | --- | | `metrics` | `true` | | | `traces` | `false` | | OpenTelemetry integration is optional; exporters and collectors stay disabled by default and the configured provider belongs to the caller. ## `logging` | Key | Default | Notes | | --- | --- | --- | | `format` | `json` | `json` or `text`. | | `level` | `info` | `debug`, `info`, `warn` or `error`. | | `event_log_path` | `~/.local/state/kivgraph/events.jsonl` | The append-only record `kivgraph logs` and `kivgraph tool-stats` read. | `event_log_path` is state, not configuration: it holds one JSON object per line describing an indexing pass, a tool call or a server's lifecycle. It rotates at 8 MiB and keeps one rotation, so the history costs at most 16 MiB and a store that outgrows that drops its oldest records. Deleting it loses history and nothing else. An empty value is refused rather than defaulted, because the default lives in the shared state directory and substituting it would make an isolated configuration write into the real installation. ## The repository registry `repositories.yaml` carries a `version` and a list of `repositories`: | Key | Notes | | --- | --- | | `name` | The identifier. Compared exactly, case included, and carried inside stable keys. | | `path` | Absolute path to the checkout. | | `languages` | Any of `go`, `typescript`, `rust`. Validated when the registry is written, not only when a pass runs. | | `manifests` | Optional. Explicit manifest paths. | | `roots` | Optional. Explicit analysis roots. | | `exclusions` | Optional. Directories the discovery never walks, and therefore never analyses. | ```yaml version: 1 repositories: - name: project path: /absolute/path/to/project languages: [go, typescript, rust] exclusions: [vendor, third_party] ``` --- # Resolution vocabulary URL: https://kivgraph.dev/docs/resolution/ Markdown: https://kivgraph.dev/raw/docs/resolution.md The vocabulary is the product. A graph that calls a guess a fact is worse than no graph, because nothing downstream can tell the difference. ## The three outcomes **`EXACT`** requires sufficient observed evidence and the correct provenance. It is never created from a coincidence of name, text, path or alias, and never from being the only remaining candidate. **`CANDIDATE`** is a plausible resolution that was not proven. It is a distinct outcome, not a weaker exactness. **`UNRESOLVED`** is a reference the pass could not resolve at all. It is retained, not discarded: a declared hole is a fact the graph owes its readers. Each one keeps its reason, repository and language, and where there is a concrete occurrence, its file, position and observed detail. A repository-level module failure may have no file at all, and evidence is never invented for it. ## What every edge carries Each canonical edge carries `confidence`, `provenance` and, where applicable, an `evidence_key`. The evidence must have been observed in a `File`. Two edges that are both exact are not necessarily equal. In TypeScript, a symbol placed by the provider's own `.d.ts.map` is `EXACT_TYPECHECKED` / `TYPESCRIPT_CHECKER`; a symbol placed by asking the provider's checker what its module exports is `EXACT_PACKAGE_MAPPED` / `TYPESCRIPT_PROJECT_REFERENCE`, because the step from artifact to source is asserted by the provider's build configuration rather than by a map it emitted. With no named source, the reference stays `UNRESOLVED`. ## Stable keys Stable keys are persistent. The algorithm, the canonical identity and the historical `luque-stable-key` namespace do not change without a data migration and an ADR. A Rust symbol's stable identity is its SCIP string — crate, descriptor path and suffix — never its signature: rust-analyzer emits no `SymbolInformation` for a declaration outside the workspace root, so a consumer keyed on the signature could not name the key its provider publishes. ## The six invariants A healthy graph has zero of each. `kivgraph doctor graph` prints one line per rule with its status and violation count, and up to twenty samples beneath each failure with the table, key and row detail that breaks it. ```bash kivgraph doctor graph --database /var/lib/kivgraph/graph/CURRENT/graph.db ``` - **`exact_edge_without_source`** — a semantic edge with exact `confidence` whose source node is not declared, for example a `Symbol` with no incoming `DEFINES` edge from any `File`. - **`exact_edge_without_target`** — the same condition for the target node. - **`missing_evidence_file`** — an edge with an `evidence_key` whose `Evidence` does not exist, or exists without an `OBSERVED_IN` edge to a `File`. - **`duplicate_stable_key`** — the same `stable_key` used by two different node tables. - **`unknown_confidence`** — a `confidence` or `provenance` outside the `facts.Confidence` / `facts.Provenance` vocabulary, or an edge that declares exactness backed by non-exact provenance. - **`invalid_repository_ownership`** — a node whose `repository_key` does not match the repository reachable through containment (`Package` via `CONTAINS_PACKAGE`, `File` via `CONTAINS_FILE`, `Symbol` via `DEFINES`, `Evidence` via `OBSERVED_IN`), or points at a nonexistent `Repository`. LadybugDB guarantees that every relationship has both endpoints, so "missing source" never means the node does not exist: it means no fact declared it. An exact edge anchored to a symbol that no file declares is a failure of the corresponding invariant, not an acceptable degradation. The command returns `0` only if all six rules pass, and never modifies the database it inspects. A candidate generation that fails these checks never becomes `CURRENT`. ## Incremental deltas Every fact asserted by a file is withdrawn and re-asserted together with that file. Package edges are withdrawn by their evidence too, even when both of their endpoints survive. --- # find_by_intent URL: https://kivgraph.dev/docs/tools/find-by-intent/ Markdown: https://kivgraph.dev/raw/docs/tools/find-by-intent.md > Which symbols a plain-language description likely names, and the files to open. It is the one tool here that matches text rather than edges, and every row says so in `match`. Use it to find the entry point, then ask the resolved tools about it. `view: "files"` answers which files to open without a symbol row each. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `cursor` | string | none | Opaque token taken from `next_cursor`. Resumes the same query at the next offset. | | `intent` | string | none | The question, in plain language. Required: an empty or whitespace-only value is rejected with `INVALID_ARGUMENT`. It is a question and not a document, so more than 400 characters is rejected with the instruction to shorten it and pass the vocabulary as `keywords`. | | `keywords` | array of string | none | Extra terms that extend the question rather than replacing it, and where you supply the vocabulary the code uses when it differs from the vocabulary the question used. At most 16; more is rejected with `INVALID_ARGUMENT`, and so is an empty or whitespace-only entry. There is no thesaurus and no embedding here: the model asking already knows more synonyms than a table would hold. | | `kind` | string | none | Keeps only candidates of this symbol kind. Compared exactly; surrounding whitespace is rejected. | | `limit` | integer | `10` | Rows in this page. Must be between 1 and 50. A row is whatever the view spells, so under `view: "files"` it counts files. | | `path_prefix` | string | none | Keeps only candidates whose repository-relative path starts with this prefix. Compared exactly; surrounding whitespace is rejected. | | `repo` | string | none | Keeps only candidates belonging to this repository, by name, compared exactly. Surrounding whitespace is rejected. | | `response_format` | string | `concise` | `concise` or `detailed`. Detailed adds the derived `stable_key` to the rows of the `full` view. Anything else is rejected with `INVALID_ARGUMENT`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` lifts into a header what every row shares. `full` is the field-per-row shape. `files` answers only which files hold candidates and how many each holds. Any other value is rejected with `INVALID_ARGUMENT`. | `repo`, `path_prefix` and `kind` narrow which candidates are *considered*, so they change the answer rather than trimming it. That is the opposite of the traversals on this surface: a retrieval has no reachability to preserve, so a narrower corpus is simply a narrower question. ## Answers A ranked page of candidates and an account of the terms that produced them. Each row names a symbol the way every row of this surface does -- repository, repository-relative path, qualified name and line range -- so the next call is built from the answer just received, without a key ever appearing. Two things this answer deliberately withholds. **No score travels**: it orders candidates inside one answer and means nothing on its own, since scaling every weight leaves the order identical, so publishing it would invite a reader to treat it as a confidence this layer cannot claim. And **`match` is on every row**, because these rows are not like the others on this surface: every other row this server returns is an edge an analyser resolved, and these are text that looked alike. They must not be read with the same authority, nor counted in the same coverage. ## Example The question, with a page of five. Nothing asks for a `view`, so this is the `compact` answer a caller gets by default: ```json { "intent": "retry a failed request with exponential backoff", "limit": 5 } ``` ```json { "snapshot_id": 87, "total": 4000, "returned": 5, "truncated": true, "next_cursor": "AlcFcqX-7dppmbTFEY7BKdLJjBhyncQ", "guidance": "showing 5 of 4000; narrow with keywords with the identifier words you would guess this code uses, or repo, kind or path_prefix, or pass the cursor for the next page", "results": { "unmatched_terms": ["exponential"], "symbols": [ { "qualified_name": "withRetry", "kind": "function", "repository": "workspace", "file_path": "packages/shared/src/retry.ts", "start_line": 135, "end_line": 163, "terms": 2, "match": "lexical+calls" }, { "qualified_name": "expBackoff", "kind": "function", "repository": "workspace", "file_path": "packages/shared/src/retry.ts", "start_line": 112, "end_line": 114, "terms": 2, "match": "lexical" }, { "qualified_name": "expBackoffJitter", "kind": "function", "repository": "workspace", "file_path": "packages/shared/src/retry.ts", "start_line": 124, "end_line": 128, "terms": 2, "match": "lexical" }, { "qualified_name": "withRetry", "kind": "function", "repository": "runtime-env", "file_path": "src/retry.ts", "start_line": 41, "end_line": 63, "terms": 2, "match": "lexical" }, { "qualified_name": "connectWithRetry", "kind": "function", "repository": "media-service", "file_path": "src/shared/connect-with-retry.ts", "start_line": 40, "end_line": 78, "terms": 2, "match": "lexical" } ] } } ``` `match` stayed on the rows here because they disagreed: the first row was also credited for what it calls, and the rest matched text alone. That is the same unanimous-or-nothing hoist the other compact views use -- one row disagreeing is enough to push a column back down to every row. `exponential` is in `unmatched_terms`, and reading it is the point: the index holds names, qualified names, kinds and paths, not prose, so a word the code never spells reached nothing. The answer is still good, because the other terms carried it. ### The `files` view When the question is which files to open, the symbol rows are noise. The same question at that granularity: ```json { "intent": "retry a failed request with exponential backoff", "view": "files", "limit": 5 } ``` ```json { "snapshot_id": 87, "total": 507, "returned": 5, "truncated": true, "next_cursor": "AlcHcqX-7dppmbTFEY7BKFwcCJNAwr8", "guidance": "showing 5 of 507; narrow with keywords with the identifier words you would guess this code uses, or repo, kind or path_prefix, or pass the cursor for the next page", "results": { "unmatched_terms": ["exponential"], "files": [ { "file": "packages/shared/src/retry.ts", "repo": "workspace", "symbols": 3 }, { "file": "src/retry.ts", "repo": "runtime-env", "symbols": 1 }, { "file": "src/shared/connect-with-retry.ts", "repo": "media-service", "symbols": 1 }, { "file": "src/sdk/types/ModuleResult.ts", "repo": "sdk-types", "symbols": 1 }, { "file": "app/lib/request-context.server.ts", "repo": "proxy-ui", "symbols": 1 } ] } } ``` `total` moved from `4000` to `507` between the two answers and nothing about the question changed. It counts in the unit the view spells: 4000 candidates, which sit in 507 files. `limit` counts in that unit too, so a page of five files walks the same ranking until it has five *files* rather than stopping at five symbols that turn out to be three. ### Keywords, and the shared header `keywords` is where you supply the vocabulary you would guess the code uses: ```json { "intent": "where do we cache a value with an expiry", "keywords": ["ttl", "evict"], "limit": 4 } ``` ```json { "snapshot_id": 87, "total": 4000, "returned": 4, "truncated": true, "next_cursor": "AlcEkVbPdNyJ1HLFEY7BQKua4YuHibQ", "guidance": "showing 4 of 4000; narrow with repo, kind or path_prefix, or ask with view=files first, or pass the cursor for the next page", "results": { "unmatched_terms": ["we"], "match": "lexical", "symbols": [ { "qualified_name": "cache::impl::Store::set_value", "kind": "method", "repository": "media-service", "file_path": "src/cache/mod.rs", "start_line": 117, "end_line": 125, "terms": 2, "match": "" }, { "qualified_name": "cache::codec::serialize_value", "kind": "function", "repository": "media-service", "file_path": "src/cache/codec.rs", "start_line": 44, "end_line": 74, "terms": 2, "match": "" }, { "qualified_name": "cache::impl::Store::get_value", "kind": "method", "repository": "media-service", "file_path": "src/cache/mod.rs", "start_line": 204, "end_line": 209, "terms": 2, "match": "" }, { "qualified_name": "cache::impl::Store::hgetall_value", "kind": "method", "repository": "media-service", "file_path": "src/cache/mod.rs", "start_line": 261, "end_line": 271, "terms": 2, "match": "" } ] } } ``` Here all four rows matched text alone, so `match` hoisted into the header and the rows carry an empty one. A field missing from a row is not a field nobody knows: the header states it. `we` is unmatched, and that is the question's grammar rather than a defect. Only two kinds of term earn a line of their own in `terms`: the one that matched nothing, which is in `unmatched_terms`, and the one carried by so much of the corpus that it separated nothing. A term that matched four symbols and produced the answer needs no line, because the rows *are* the line. The three answers above come from snapshot `87` of a fifty-repository graph. That graph is the private benchmark corpus, so repository names, paths and symbol names are substituted, exactly as the ambiguity refusal on [`find_references`](/docs/tools/find-references/#one-call-instead-of-two) is. Everything else -- the counts, the order, `terms`, `match`, `unmatched_terms`, `guidance` and the cursors -- is what was measured. ## Reading the result `terms` on a row is how many of the question's terms that candidate carries. It is a count and not a score: two rows with the same `terms` are ordered by the ranking, which weights how rare each term is across the corpus, and ties break on symbol id -- stable-key order, the order every page of this surface uses. That tie-break is not cosmetic: the cursor pages over this sequence, so two calls of one question must produce the same sequence or a second page would skip and repeat rows. `match` is `lexical` when the candidate matched text alone, and `lexical+calls` when it was also credited for the terms its callees carry. Neither is an edge this tool resolved. A row here is a *candidate*, in the sense this project uses that word everywhere else: plausible and not proven. Confirm it with a tool that answers from resolved edges -- [`find_symbol`](/docs/tools/find-symbol/) to locate the declaration, [`find_references`](/docs/tools/find-references/) to learn who calls it, [`get_source`](/docs/tools/get-source/) to read it. `unmatched_terms` lists the words of the question that appear in no name, qualified name, kind or path. On a good answer it is usually the grammar. On an empty one it is the whole diagnosis. ## The three empty answers, which are not the same An empty page from this tool means one of three different things, and the `guidance` says which. Confusing them is how a caller concludes that code does not exist when the question simply missed. **Nothing matched.** No word of the question appears anywhere in the index: ```json { "snapshot_id": 2, "total": 0, "returned": 0, "guidance": "no word of this question appears in any name, qualified name, kind or path of the graph; the index holds no prose, so rephrase with the vocabulary the code would use, or pass keywords", "results": { "unmatched_terms": ["read", "the", "configuration", "file"], "symbols": [] } } ``` Every term is in `unmatched_terms`, which is the instruction: the index holds identifiers, not prose. Rephrase in the vocabulary the code would use, or pass `keywords`. **The terms matched, and the narrowing excluded every candidate.** A different answer with the same row count: ```json { "snapshot_id": 87, "total": 0, "returned": 0, "guidance": "the terms matched symbols, but every one of them was excluded by repo, kind or path_prefix; widen the narrowing", "results": { "symbols": [] } } ``` Nothing is unmatched here. The question was fine and the filter was wrong. **And the answer no retrieval can give: a proven absence.** This tool never gives one. A page of zero rows is "I found nothing that looks like this", never "this does not exist" -- text that did not match is not evidence. The tools that can prove an absence are the ones backed by an analyser; [`find_references`](/docs/tools/find-references/) with a `COMPLETE` verdict is the one that answers "nobody calls this". ## Limits `total` saturates at `4000`. That is the candidate bound, not a count of the graph: a question whose terms are carried by more of the corpus than that stops accumulating, and both example pages above sit exactly on it. A `total` of `4000` should be read as "at least this many, and the question is too broad to rank well" -- narrow it with `keywords`, `repo`, `kind` or `path_prefix`, or ask with `view: "files"` first, which is what the `guidance` on those pages says. `limit` is capped at 50, well under the 500 of the traversal tools. Ranked text candidates past the first page are rarely the answer; the fix for a page that did not contain what you wanted is a better question, not a longer page. A question with more than 32 terms still answers. Its later terms simply earn no credit for what a candidate's callees carry, which is the cheap end of a bound that keeps one question from walking the graph. `truncated` is `true` when rows remain, and `next_cursor` then carries the token to continue. It is opaque, about 31 characters, and pages over the ranking described above. `limit`, `view` and `response_format` are not part of the cursor identity, so changing one of them mid-pagination is accepted; changing the `intent`, the `keywords` or any of the three filters is not, and yields `CURSOR_INVALID`. A cursor minted against a generation that has since been replaced yields `CURSOR_SNAPSHOT_EXPIRED`. Like every tool on this surface, it answers from the published HotSnapshot: if the tree moved since it was indexed, the answer describes the code that was indexed. [`graph_status`](/docs/tools/graph-status/) reports that. ## Where it loses Everywhere the name is already known. If you can spell the symbol, [`find_symbol`](/docs/tools/find-symbol/) resolves it and costs no ranking; if you can spell a rare string, `grep` is cheaper than any call here. This tool earns its place on exactly one question -- *I do not know what this is called* -- and its answer is the input to a resolved one, never the conclusion. --- # find_symbol URL: https://kivgraph.dev/docs/tools/find-symbol/ Markdown: https://kivgraph.dev/raw/docs/tools/find-symbol.md > Where a symbol is declared, by name, qualified name, prefix or substring. Narrow with kind, repo and path_prefix. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `name` | string | none, required | The text to search for. Rejected with `INVALID_ARGUMENT` when it is empty or carries surrounding whitespace. | | `mode` | string | `exact` | One of `exact`, `qualified_exact`, `prefix`, `substring`. Any other value is rejected with `INVALID_ARGUMENT`. `exact` and `qualified_exact` match a whole interned string; `prefix` and `substring` walk every symbol name in the snapshot. | | `kind` | string | unset | Keeps only symbols whose `kind` is exactly this string. | | `repo` | string | unset | Keeps only symbols whose repository name is exactly this string. Naming a derived repository here is a request for it and overrides the default of `include_derived`. | | `path_prefix` | string | unset | Keeps only symbols whose repository-relative file path starts with this string. Plain prefix matching, not a glob. | | `include_derived` | boolean | `false` | When false, rows from the derived Rust standard-library repository, whose name starts with `rust:`, are withheld. | | `limit` | integer | `50` | Rows per page. Must be between 1 and 500; anything else is rejected with `INVALID_ARGUMENT`. | | `cursor` | string | unset | The opaque `next_cursor` of a previous page of the same query. | | `response_format` | string | `concise` | `concise` or `detailed`. In the compact view `detailed` adds `stable_key` and `canonical_identity` to each row, and it is the only way a stable key reaches a compact page; in the full view the key is always there and `detailed` adds `canonical_identity`. Any other value is rejected with `INVALID_ARGUMENT`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` states what every row shares once and addresses each declaration by `path:line`. `full` is the field-per-row array. This tool answers about declarations and not about files, so `files` is rejected with `INVALID_ARGUMENT`, as is any other value. | ## Answers This is the entry point when you have a name and nothing else. By default the answer is the `compact` view: `results` is an object whose header carries whatever the whole page agrees on -- `name`, `kind`, `exported`, `repository` -- and whose `symbols` array holds one entry per declaration. An entry addresses itself with `at`, which is `path:line` under a header that names the repository and the whole `repository:path:line` triple when the rows come from more than one. `end` appears only when the declaration does not start and finish on the same line, `qn` only when the qualified name differs from the name, `name` only when the header does not carry it and the qualified name does not end with it, and `sig` only for the kinds a signature tells you how to call: `function`, `func`, `method` and `class`. With `view: "full"`, `results` is an array of rows, each carrying `stable_key`, `name`, `qualified_name`, `kind`, `signature`, `exported`, `repository`, `file_path`, `start_line` and `end_line`. Either way every row is addressable: `get_symbol`, `get_source`, `find_references` and the traversal tools all accept the `repository`, `path` and `qualified_name` of a row you already have -- which is why the compact view drops `stable_key`. It was `885` of the `2.293` tokens of one 22-row page over the private benchmark corpus; the compact page cost `901` before the second grouping tier below and `773` after it. `find_symbol` publishes no `exact` counter: a declaration lookup returns declarations rather than resolved relations, so that count could only repeat `returned`. `coverage` on this tool carries `unresolved_related` alone -- unresolved references that name the same string -- and it is absent entirely when that counter is zero. A search that found nothing and reports no uncertainty is claiming the name does not exist, when it may only mean that whatever declares it was never indexed. So the answer also carries a `completeness` object, whose `verdict` is `COMPLETE` or `LOWER_BOUND`; see [the completeness verdict](/mcp/usage/#read-the-answer). What bounds a declaration lookup is the scopes the index could not read, and the scope follows the question -- `repo` narrows it to that repository, because a lookup charged for one unreadable package anywhere in the graph would read `LOWER_BOUND` on every call of the corpus and the verdict would carry no information. This is the most frequent call in the surface, so the block is spent where the answer could be mistaken for proof -- an empty or a truncated page -- and on every lower bound; a full page of declarations claims no absence and does not carry it. When `kind` and `exported` do not both hoist to the header -- a page mixing methods, functions and variables in various combinations of visibility -- `results.groups` replaces `symbols`: each entry states its own `kind` and `exported` and holds the declarations that share that pair. See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools, and the [example](#example-grouped) below for a captured page. ## Example ```json { "name": "MergeAll", "repo": "kivgraph", "limit": 3 } ``` ```json {"snapshot_id":30,"total":1,"returned":1,"results":{"name":"MergeAll","kind":"func","exported":true,"repository":"kivgraph","symbols":[{"at":"internal/facts/facts.go:516","end":542,"sig":"func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set"}]}} ``` One row, so everything except the location is in the header and the entry is the location plus how to call it. The same call with `"view": "full"` is the field-per-row shape: ```json { "name": "MergeAll", "repo": "kivgraph", "limit": 3, "view": "full" } ``` ```json {"snapshot_id":30,"snapshot_age_ms":9019,"total":1,"returned":1,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":[{"stable_key":"KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA","name":"MergeAll","qualified_name":"MergeAll","kind":"func","signature":"func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set","exported":true,"repository":"kivgraph","file_path":"internal/facts/facts.go","start_line":516,"end_line":542}]} ``` The same query in `substring` mode, which matches anywhere in the unqualified name: ```json { "name": "Merge", "mode": "substring", "repo": "kivgraph", "limit": 5 } ``` ```json {"snapshot_id":30,"total":3,"returned":3,"results":{"exported":true,"repository":"kivgraph","symbols":[{"at":"internal/facts/facts.go:516","end":542,"name":"MergeAll","kind":"func","sig":"func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set"},{"at":"internal/indexer/full.go:50","name":"PhaseMerge","kind":"const"},{"at":"internal/facts/facts.go:505","end":507,"qn":"Set.Merge","kind":"method","sig":"func(other github.com/Luqueee/kivgraph/internal/facts.Set)"}]}} ``` Three declarations that agree only on their repository and their visibility, so `name` and `kind` came back down to the rows. `PhaseMerge` carries no `sig`: a constant's type does not tell you how to call it, and the compact view keeps the signature for the four kinds where it does. `Set.Merge` carries `qn` instead of `name`, because the qualified name already ends with the name. The same query in the `full` view: ```json { "name": "Merge", "mode": "substring", "repo": "kivgraph", "limit": 5, "view": "full" } ``` ```json {"snapshot_id":30,"snapshot_age_ms":21769,"total":3,"returned":3,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":[{"stable_key":"KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA","name":"MergeAll","qualified_name":"MergeAll","kind":"func","signature":"func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set","exported":true,"repository":"kivgraph","file_path":"internal/facts/facts.go","start_line":516,"end_line":542},{"stable_key":"VTNOLFOCZDSMBNROBRVHV2NM5MKA37B72M2K5LPCUPR3UJG2GGIA","name":"PhaseMerge","qualified_name":"PhaseMerge","kind":"const","signature":"github.com/Luqueee/kivgraph/internal/indexer.ProgressPhase","exported":true,"repository":"kivgraph","file_path":"internal/indexer/full.go","start_line":50,"end_line":50},{"stable_key":"XKK3NUCVCH57YKL36U4SUIL3NB7FLCJ2DTSTUH3YV4Q7EW7E5ZWA","name":"Merge","qualified_name":"Set.Merge","kind":"method","signature":"func(other github.com/Luqueee/kivgraph/internal/facts.Set)","exported":true,"repository":"kivgraph","file_path":"internal/facts/facts.go","start_line":505,"end_line":507}]} ``` A name nobody declares is an empty `symbols` list -- an empty array in the `full` view -- and not an error. With every counter at zero, `coverage` is absent too: ```json { "name": "ThisSymbolDoesNotExistAnywhere" } ``` ```json {"snapshot_id":30,"total":0,"returned":0,"results":{"symbols":[]}} ``` The same absence in the `full` view keeps the counters and the nulls: ```json {"snapshot_id":30,"snapshot_age_ms":21768,"total":0,"returned":0,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":[]} ``` These responses come from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Example: grouped A search across two repositories with no name and no view requested, so `mode` defaults to `exact` -- here widened to `substring` on purpose to produce more than one kind: ```json { "name": "handle", "mode": "substring", "limit": 80 } ``` ```json { "snapshot_id": 1, "total": 8, "returned": 8, "results": { "exported": false, "groups": [ { "kind": "method", "symbols": [ { "at": "mole:internal/clip/server.go:147", "end": 174, "qn": "Server.handleLatest", "sig": "func(w net/http.ResponseWriter, r *net/http.Request)" }, { "at": "mole:internal/admin/admin.go:106", "end": 129, "qn": "Server.handleStatus", "sig": "func(w net/http.ResponseWriter, _ *net/http.Request)" }, { "at": "mole:internal/admin/admin.go:150", "end": 169, "qn": "Server.handlePortAdd", "sig": "func(w net/http.ResponseWriter, r *net/http.Request)" }, { "at": "mole:internal/clip/server.go:89", "end": 145, "qn": "Server.handlePut", "sig": "func(w net/http.ResponseWriter, r *net/http.Request)" }, { "at": "mole:internal/admin/admin.go:131", "end": 134, "qn": "Server.handleHealth", "sig": "func(w net/http.ResponseWriter, _ *net/http.Request)" }, { "at": "mole:internal/admin/admin.go:175", "end": 188, "qn": "Server.handlePortDelete", "sig": "func(w net/http.ResponseWriter, r *net/http.Request)" } ] }, { "kind": "variable", "symbols": [ { "at": "kivgraph:web/src/hooks/useFrameRate.ts:19", "end": 28, "qn": "useFrameRate.handle" } ] }, { "kind": "func", "symbols": [ { "at": "mole:internal/proxy/proxy.go:44", "end": 88, "name": "handle", "sig": "func(local net.Conn, dial github.com/Luqueee/mole/internal/proxy.Dialer, remoteAddr string, hooks github.com/Luqueee/mole/internal/proxy.Hooks, log *log/slog.Logger)" } ] } ] } } ``` All eight declarations are unexported, so `exported` stays in the header and never repeats on a group or a row. `kind` is the opposite: three values, so it drops out of the header and each group states its own instead. The `func` group carries `name` because `handle` is the whole qualified name of a closure with no receiver to prefix it, while every method above spells `qn` instead, since `Server.handlePut` is not implied by `handle` alone. `repository` never reaches the page header at all here, because the page spans both `mole` and `kivgraph` and a group carries no `repository` field of its own to hoist into -- so every row of every group, six-strong or alone, spells its own `repo:` prefix in `at`. ## Limits `limit` defaults to 50 and cannot exceed 500. A value outside 1 to 500 is rejected rather than clamped. `truncated` is true and `next_cursor` is a token whenever the page did not exhaust `total`; the compact view omits both when it does not. The cursor is an opaque, checksummed base64url token about 31 characters long -- a binary body, not the 314 characters of base64-wrapped JSON the previous version spelled -- and it is bound to the snapshot identifier, to the sorting version `stable-key-v1` and to the query identity: the tool name, `name`, `mode`, `kind`, `repo` and `path_prefix`. Change any of those and the token no longer matches, which fails as `CURSOR_INVALID`, and so does a token edited, truncated, re-encoded or left with trailing bytes after its checksum. A token minted by a server of the previous cursor version fails closed the same way: the body declares version 2 and version 1 is never reinterpreted under the new layout. A token issued against an older generation fails as `CURSOR_SNAPSHOT_EXPIRED`, because the rows it indexed into no longer exist. All of them mean the same thing for a caller: restart the pagination. `view` is not part of the identity, so one cursor can continue a query in another view. `find_symbol` emits `guidance` where a count alone would mislead -- an empty page or a truncated one -- and stays silent on a full page of rows, because fifteen tokens of advice on the most frequent call of the surface is how a saving becomes a cost. The other signal is `coverage.unresolved_related`: it counts unresolved references naming the same string, so a zero-row answer with `unresolved_related` at zero means the published graph declares no symbol of that name inside your filters, and a non-zero value means something names it that the indexer could not resolve to a declaration. It is the only counter this tool reports. In the compact view a zero counter is not written at all, so when `unresolved_related` is zero the whole `coverage` object is omitted. `response_format` accepts `concise` and `detailed`. Concise omits `canonical_identity`, which is the concatenation of language, repository, package, qualified name, kind and discriminator, all of which the row already spells out. In the compact view it also omits `stable_key`, for the same reason: the `at` of a row plus the header's repository is the triple every tool accepts. Withholding derived rows is a page decision and never a claim about what was observed. The edges into `rust:` stay published with their exact confidence; `include_derived: true` only stops the page from hiding them. ## Where it loses A rare, unique name in one small repository is cheaper to grep: the answer is one process and no snapshot has to be current. `find_symbol` earns its cost on common names, where grep cannot separate the homonyms, and on `prefix` and `substring` searches across several repositories at once. If you already hold a row from another tool, you do not need this one at all. --- # get_symbol URL: https://kivgraph.dev/docs/tools/get-symbol/ Markdown: https://kivgraph.dev/raw/docs/tools/get-symbol.md > One symbol's package, signature, visibility and line range, by stable key or by repository, path and qualified name. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `stable_key` | string | unset | The durable key of one symbol. Exactly one of `stable_key` or `qualified_name` is required; passing both is rejected. Passing it together with `repository` or `path` is also rejected, because a key already names one symbol. | | `qualified_name` | string | unset | The qualified name of the symbol, as every row of this surface returns it. Exactly one of `stable_key` or `qualified_name` is required. | | `repository` | string | unset | Narrows a `qualified_name` to one repository, matched by name. A repository the published graph does not hold is rejected with `REPOSITORY_NOT_FOUND`. | | `path` | string | unset | Narrows a `qualified_name` to a repository-relative path prefix. It requires `repository`. An absolute path or any `..` segment is rejected; a trailing `/` is trimmed. | | `response_format` | string | `concise` | `concise` or `detailed`. `detailed` adds `canonical_identity` and `repository_key`. Any other value is rejected with `INVALID_ARGUMENT`. | Any of the four selector fields carrying surrounding whitespace is rejected with `INVALID_ARGUMENT` naming the field. ## Answers This is the detail view of one symbol. `results` is a single object, not an array: `stable_key`, `repository`, `repository_path`, `package_name`, `module_path`, `file_path`, `name`, `qualified_name`, `kind`, `signature`, `exported`, `start_line` and `end_line`. Compared with a `find_symbol` row it adds the package, the module path and the absolute repository path, which is what you need to tell two same-named declarations apart by where they live. `total` and `returned` are always 1. ## Example ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll" } ``` ```json {"snapshot_id":30,"snapshot_age_ms":9019,"total":1,"returned":1,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":{"stable_key":"KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA","repository":"kivgraph","repository_path":"/Users/adria/Documents/programacion/projects/kivgraph","package_name":"github.com/Luqueee/kivgraph/internal/facts","module_path":"github.com/Luqueee/kivgraph","file_path":"internal/facts/facts.go","name":"MergeAll","qualified_name":"MergeAll","kind":"func","signature":"func(sets []github.com/Luqueee/kivgraph/internal/facts.Set) github.com/Luqueee/kivgraph/internal/facts.Set","exported":true,"start_line":516,"end_line":542}} ``` A qualified name on its own searches the whole graph. Here `Set.Merge` names exactly one symbol, so it resolves without narrowing: ```json { "qualified_name": "Set.Merge" } ``` ```json {"snapshot_id":30,"snapshot_age_ms":21768,"total":1,"returned":1,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":{"stable_key":"XKK3NUCVCH57YKL36U4SUIL3NB7FLCJ2DTSTUH3YV4Q7EW7E5ZWA","repository":"kivgraph","repository_path":"/Users/adria/Documents/programacion/projects/kivgraph","package_name":"github.com/Luqueee/kivgraph/internal/facts","module_path":"github.com/Luqueee/kivgraph","file_path":"internal/facts/facts.go","name":"Merge","qualified_name":"Set.Merge","kind":"method","signature":"func(other github.com/Luqueee/kivgraph/internal/facts.Set)","exported":true,"start_line":505,"end_line":507}} ``` A name the narrowing excluded fails, and the message says how to widen it: ```json { "repository": "kivgraph", "qualified_name": "NoSuchThing" } ``` ```text SYMBOL_NOT_FOUND: qualified name "NoSuchThing" was not found under kivgraph; call it without repository and path to search the whole graph ``` These three responses come from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Limits There is no `limit` and no `cursor`: the answer is one symbol, so `total` and `returned` are 1, `truncated` is false and `next_cursor` is null. `guidance` is never emitted; it belongs to the tools whose counts can mislead. `response_format` accepts `concise` and `detailed`. Concise omits the two derived identifiers, `canonical_identity` and `repository_key`, whose value the name and the path beside them already spell out. `SYMBOL_NOT_FOUND` has two shapes, because a name nobody declares and a name the narrowing excluded need different fixes. With no `repository` and no `path`, the message is only that the qualified name was not found. With either of them set, it names where it looked and tells you to call again without them, as the capture above shows. A `stable_key` that resolves to nothing reports the key itself. A qualified name that matches more than one symbol is `AMBIGUOUS_SYMBOL`, never a silent pick. What the message offers depends on what is left to narrow. With no `repository` and no `path`, it names each candidate by where it is, as `repository path:start-end`, because that is a narrowing you can express in the next call. Once both `repository` and `path` were given and the name still matches twice, only the key separates them, and the message lists the stable keys. Other codes this tool can return: `INVALID_ARGUMENT` for a malformed or double selector, and `REPOSITORY_NOT_FOUND` for a repository outside the published graph. ## Where it loses If you got the row from `find_symbol` or `get_file_outline`, you already have the name, the kind, the signature and the range; `get_symbol` adds only the package, the module path and the absolute repository path, and a second call for those three fields is rarely worth it. For a single small file, opening the file is cheaper than resolving a selector against the graph. --- # get_source URL: https://kivgraph.dev/docs/tools/get-source/ Markdown: https://kivgraph.dev/raw/docs/tools/get-source.md > The code of several symbols in one call. Prefer it to reading each range: no line numbers, one call across files and repositories. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `symbols` | array | none, required | The symbols to read. At least one and at most 20; an empty array or more than 20 is rejected with `INVALID_ARGUMENT`. | | `symbols[].stable_key` | string | unset | The durable key of one symbol. Exactly one of `stable_key` or `qualified_name` per entry; both is rejected, and a key together with `repository` or `path` is rejected. | | `symbols[].qualified_name` | string | unset | The qualified name of the symbol, as every row of this surface returns it. | | `symbols[].repository` | string | unset | Narrows that entry's `qualified_name` to one repository, matched by name. | | `symbols[].path` | string | unset | Narrows that entry's `qualified_name` to a repository-relative path prefix. It requires `repository`. An absolute path or any `..` segment is rejected. | | `context_lines` | integer | `0` | Lines kept before and after the declaration. Must be between 0 and 100; anything else is rejected with `INVALID_ARGUMENT`. The window is clamped at the first and last line of the file. | | `response_format` | string | `concise` | `concise` or `detailed`. `detailed` appends the stable key to each header line. Any other value is rejected with `INVALID_ARGUMENT`. | ## Answers This is the only tool that answers in prose rather than JSON, and the reason is a measurement: a 26-line declaration worth 302 tokens of source costs 374 tokens inside a JSON string and 430 as a full row, which is what the host's own range read costs. Serving code through the envelope buys nothing, so the code travels as code and the counters travel in the header line. The first line is `snapshot bodies context `. Each body then opens with `@ :- `, followed by the bytes, unescaped and unnumbered. A row that has no bytes opens with `!` instead and names the reason. Files shared by several requested symbols are read once. ## Example ```json { "symbols": [ { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll" } ], "context_lines": 0 } ``` ```text snapshot 30 1 bodies context 0 @ kivgraph internal/facts/facts.go:516-542 func MergeAll [file changed, re-anchored +0] func MergeAll(sets []Set) Set { merged := Set{ Repositories: mergeAllBy(sets, func(set Set) []Repository { return set.Repositories }, func(value Repository) string { return value.Key }), Packages: mergeAllBy(sets, func(set Set) []Package { return set.Packages }, func(value Package) string { return value.Key }), Files: mergeAllBy(sets, func(set Set) []File { return set.Files }, func(value File) string { return value.Key }), Symbols: mergeAllBy(sets, func(set Set) []Symbol { return set.Symbols }, func(value Symbol) string { return value.Key }), Evidence: mergeAllBy(sets, func(set Set) []Evidence { return set.Evidence }, func(value Evidence) string { return value.Key }), Edges: mergeAllBy(sets, func(set Set) []Edge { return set.Edges }, edgeIdentityOf), Unresolved: mergeAllBy(sets, func(set Set) []UnresolvedReference { return set.Unresolved }, func(value UnresolvedReference) unresolvedIdentity { return unresolvedIdentity{ repository: value.RepositoryKey, file: value.FileKey, reason: value.Reason, requestedPackage: value.RequestedPackage, requestedSymbol: value.RequestedSymbol, offset: value.Start.Offset, } }), } merged.Sort() return merged } ``` The response comes from snapshot `30` of two repositories, `kivgraph` and `mole`. The `[file changed, re-anchored +0]` marker on the header is the freshness contract at work: the file on disk no longer hashed to what the generation recorded, the declaration was found again by name, and it had not moved. ## Limits One call reads at most 20 symbols. `context_lines` is capped at 100; above that you want the file, and the host reads files. There is no `limit` and no `cursor`, so there is no paging. The size bound is a 262144-byte ceiling on the assembled bodies. When it stops the response short, the header says so, as `trimmed at the 262144 byte ceiling`, and the envelope's `truncated` is set. A response that quietly stopped halfway would be worse than one that says it stopped. `guidance` is never emitted by this tool. `serve` may read the files of registered repositories only in order to deliver bytes. Nothing outside a repository is read, and no component of a path may be a symbolic link; a path that escapes its repository or crosses a symlink yields no bytes for that row. Freshness fails open on bytes and closed on claims. When the file still hashes to the generation's `ContentDigest`, the range served is the graph's own. When it does not, the file is the authority, because the file is what you will edit: the declaration is re-anchored by name to the nearest matching line, the served range is corrected, and the offset is declared in the header as `[file changed, re-anchored +N]`. Re-anchoring creates no edge and asserts no graph fact; it only answers "these lines" with the lines the declaration now occupies. The re-anchoring can refuse, and it refuses one row at a time. A declaration that no longer exists in the file, one that now appears twice equally far from its recorded position, or one that no longer spans as many lines, yields no bytes for that row. So does a symbol for which the generation records no line range. Those rows are rendered with the leading `!` and their reason, and every other row of the same answer still carries its code. ## Where it loses For one small file, reading the file is cheaper than naming its declarations. `get_source` wins when the symbols are scattered: several declarations across several files, or across repositories, arrive in one call and without line numbers to strip. If you only need to know where something is, the range is already in the `find_symbol` or `get_file_outline` row and no bytes are needed. --- # get_file_outline URL: https://kivgraph.dev/docs/tools/get-file-outline/ Markdown: https://kivgraph.dev/raw/docs/tools/get-file-outline.md > Declarations under a path, grouped by file, with kind, signature and range. Use it for a package; one small file is cheaper to read. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `repository` | string | none, required | The repository name. Rejected with `INVALID_ARGUMENT` when empty or padded with whitespace, and with `REPOSITORY_NOT_FOUND` when the published graph does not hold it. | | `path` | string | none, required | A repository-relative file, or a directory whose files are all wanted. An absolute path is rejected, and so is any `..` segment; a trailing `/` is trimmed. | | `kind` | string | unset | Keeps only rows whose `kind` is exactly this string. | | `include_members` | boolean | `false` | Adds the rows that belong to the declaration above them: `field`, `property`, `enum_member` and `variant`. They are off by default because on a real file they are about half the payload. | | `limit` | integer | `200` | Symbols per page. Must be between 1 and 500; anything else is rejected with `INVALID_ARGUMENT`. | | `cursor` | string | unset | The opaque `next_cursor` of a previous page of the same query. | | `response_format` | string | `concise` | `concise` or `detailed`. `detailed` adds `stable_key` and `canonical_identity`, and restores the fully qualified `signature`; in the compact view it is also what puts a signature on a row at all. Any other value is rejected with `INVALID_ARGUMENT`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` states the repository, the package and whatever every declaration shares once, and lists the declarations by file as `name@start-end` entries. `full` is the field-per-row shape. `files` answers only which files hold the page's declarations and how many each holds. Any other value is rejected with `INVALID_ARGUMENT`. | ## Answers This is how to read the shape of code without opening it: what is declared under a path, of what kind and on which lines. By default the answer is the `compact` view. `results` is one object stating the `repository`, the `path` asked about, the `package` when the whole page sits in one -- `packages` when it does not -- and then whatever every declaration of the page shares: `kind` when they are all of one kind, `exported` when they are all visible or all not. `files` groups the declarations by file, and an entry of a group's `at` is `name@start`, or `name@start-end` when the declaration spans more than one line, using the qualified name when the row has one. An entry becomes an array when the page could not hoist a column: the elements after the label are appended in a fixed order -- the name, the kind, `exported` or `unexported`, the signature, the stable key, the canonical identity -- skipping everything the header already states and everything `concise` withholds. There is no `languages` field: a path's extension says the language. With `view: "full"`, `results` carries `repository`, `path`, `packages`, `languages` and `files`, each entry of `files` having a `path` and its `symbols`, and each symbol row carrying `name`, `kind`, `signature`, `exported`, `start_line` and `end_line`, plus `qualified_name` only when it differs from `name`. Those rows are what the rest of the surface accepts: the repository, the path of the group and the qualified name are exactly the triple `get_symbol`, `get_source`, `find_references` and the traversal tools take, so the next call is built out of the answer you just got. Under `response_format: "detailed"` each row also carries its stable key. Measured over the private benchmark corpus, one directory outline went from `633` to `248` tokens with a single shared `kind`; a larger directory with no single shared `kind` or `exported` went from `3.667` to `3.184` once the second grouping tier below applied. When `kind` and `exported` do not both hoist to the header, `results.groups` replaces `files`: each entry states its own `kind` and `exported` pair and holds the declarations that share it, grouped by file exactly like the flat page. See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools, and the [grouped example](#example-grouped) below for a captured page. `get_file_outline` publishes no `coverage` counters at all. The four categories grade resolved relations and the confidence behind each one, and an outline returns the declarations of one repository, so none of them applies -- `total` and `returned` already say how many declarations there are. The compact view omits the `coverage` key entirely; the `full` view still writes the four zeros, because that view writes every field by contract. `completeness` states how far the page reaches, and the question names a place rather than a symbol, so no failed request for a name can bound it: the only failure that can is a scope of the repository asked for that the index could not read, named in `invisible_scopes`. There is no `fallback` pattern here -- without a symbol there is nothing to grep for. An outline that lists nothing under a path is saying nothing is declared there, so the `verdict` is spent where the answer could be mistaken for that proof -- a page with no rows, a truncated one, and every `LOWER_BOUND` -- while a page of declarations claims no absence and carries no verdict at all. See [the completeness verdict](/mcp/usage/#read-the-answer) for the two values and the rest of the block. ## Example ```json { "repository": "kivgraph", "path": "internal/mcp/instructions.go" } ``` ```json {"snapshot_id":30,"total":2,"returned":2,"results":{"repository":"kivgraph","path":"internal/mcp/instructions.go","package":"github.com/Luqueee/kivgraph/internal/mcp","kind":"const","exported":false,"files":[{"file":"internal/mcp/instructions.go","at":["staleServerInstructions@36","serverInstructions@21-27"]}]}} ``` Both declarations are unexported constants of the same package, so the header carries the package, the kind and the visibility and each entry is the bare label: one line for `staleServerInstructions`, lines 21 to 27 for `serverInstructions`. Nothing was dropped -- there was nothing left to say per row. The same call in the `full` view, the field-per-row shape: ```json { "repository": "kivgraph", "path": "internal/mcp/instructions.go", "view": "full" } ``` ```json {"snapshot_id":30,"snapshot_age_ms":9020,"total":2,"returned":2,"truncated":false,"next_cursor":null,"coverage":{"exact":0,"candidate":0,"unresolved_related":0,"package_level":0},"results":{"repository":"kivgraph","path":"internal/mcp/instructions.go","packages":["github.com/Luqueee/kivgraph/internal/mcp"],"languages":["go"],"files":[{"path":"internal/mcp/instructions.go","symbols":[{"name":"staleServerInstructions","kind":"const","signature":"untyped string","exported":false,"start_line":36,"end_line":36},{"name":"serverInstructions","kind":"const","signature":"untyped string","exported":false,"start_line":21,"end_line":27}]}]}} ``` ### The `files` view When the question is which files declare anything, `view: "files"` answers with the file list and a count each, and nothing about what is declared: ```json { "repository": "kivgraph", "path": "internal/mcp/instructions.go", "view": "files" } ``` ```json {"snapshot_id":30,"total":2,"returned":2,"results":{"repository":"kivgraph","path":"internal/mcp/instructions.go","files":[{"file":"internal/mcp/instructions.go","declarations":2}]}} ``` It is the shape of the question over a directory, where the answer is a dozen files rather than one. `declarations` counts what this page holds for the file, so on a truncated page it is the page's count and not the file's total. These responses come from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Example: grouped A directory whose declarations do not share one `kind` or one `exported`: ```json { "repository": "mole", "path": "internal/admin" } ``` ```json { "snapshot_id": 1, "total": 32, "returned": 19, "results": { "repository": "mole", "path": "internal/admin", "package": "github.com/Luqueee/mole/internal/admin", "groups": [ { "kind": "method", "exported": true, "files": [{ "file": "internal/admin/admin.go", "at": [ "Stats.OnConnect@30-33", "Server.WithPortController@89-92", "PortController.RemoveDiscover@74", "Server.Handler@95-104", "Server.WithPorts@81-84", "Stats.OnDialFail@41-43", "Stats.OnDisconnect@36-38", "PortController.AddDiscover@73" ] }] }, { "kind": "method", "exported": false, "files": [{ "file": "internal/admin/admin.go", "at": [ "Server.handleStatus@106-129", "Server.handlePortAdd@150-169", "Server.handleHealth@131-134", "Server.handlePortDelete@175-188" ] }] }, { "kind": "func", "exported": true, "files": [{ "file": "internal/admin/admin.go", "at": ["New@54-56", "NewStats@25-27"] }] }, { "kind": "type", "exported": true, "files": [{ "file": "internal/admin/admin.go", "at": ["PortController@72-75", "Server@58-63", "Stats@16-22"] }] }, { "kind": "type", "exported": false, "files": [{ "file": "internal/admin/admin.go", "at": ["portRequest@139-141", "snapshot@45-50"] }] } ] } } ``` Five groups over one file: `repository`, `path` and `package` cover the whole page and stay in the header as always, but `kind` and `exported` each take four or more values between them, so neither hoists and every group states its own pair. Nothing here needed a column beyond the pair -- no group mixed two signatures or two visibilities inside itself -- so every entry is still the bare `name@start-end` label; a group with a residual disagreement of its own would turn an entry into an array exactly as the flat page does. ## Limits `limit` defaults to 200 and cannot exceed 500. A value outside 1 to 500 is rejected rather than clamped. `truncated` is true and `next_cursor` is a token whenever the page did not exhaust `total`; the compact view omits both when it did. The cursor is an opaque, checksummed base64url token about 31 characters long -- a binary body, where the previous version spelled 314 characters of base64-wrapped JSON -- and it is bound to the snapshot identifier, to the sorting version `stable-key-v1` and to the query identity: the tool name, `repository`, `path`, `kind` and `include_members`. Change any of those and the token no longer matches, which fails as `CURSOR_INVALID`, and so does a token that was edited, truncated, re-encoded or decodes with trailing bytes after its checksum. A token minted by a server of the previous cursor version fails closed the same way: the body declares version 2 and version 1 is never reinterpreted under the new layout. A token issued against an older generation fails as `CURSOR_SNAPSHOT_EXPIRED`. All of them mean the pagination has to restart. `view` is not part of the identity, so one cursor can continue the outline in another view. `kind` and `include_members` filter the page after it is taken, so `total` counts every symbol under the path and `returned` counts what survived the filter. A page can therefore return fewer rows than `limit` and still be truncated. A path the graph does not know is an error naming both the repository and the path, `SYMBOL_NOT_FOUND`, never an empty page. An empty page would read as "nothing is declared here", which is a different and more misleading answer. `guidance` is never emitted by this tool. Groups follow the order the page first mentions each file, and `packages` is sorted, so two calls over the same page of the same snapshot produce byte-identical responses. `response_format` accepts `concise` and `detailed`. Concise drops the stable key, which on a 155-declaration file was half the tokens, and prints the signature the way the declaring source reads it, with the symbol's own package path removed. Types from other packages keep their path. `detailed` restores the key, the full signature and `canonical_identity`. In the compact view the signature is on a row only under `detailed`: it is the largest field a row can carry, and a reader choosing between declarations is choosing between names. ## Where it loses One small file is cheaper to read than to outline: the outline is a second round trip and it gives you names where the file gives you the code. `get_file_outline` earns its cost on a directory or a package, where it replaces a dozen reads with one answer, and when you need the ranges before deciding which body to fetch with `get_source`. --- # find_references URL: https://kivgraph.dev/docs/tools/find-references/ Markdown: https://kivgraph.dev/raw/docs/tools/find-references.md > Who calls or references a symbol. Type-checked, not name-matched: grep cannot separate homonyms, and an empty answer means nobody calls it. A bare name suffices: an ambiguous one returns its candidates, so no lookup call first. `view: "files"` answers which files without a line each. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `confidence` | string | none | Keeps only edges carrying exactly this confidence. Accepted: `EXACT_TYPECHECKED`, `EXACT_DECLARATION_MAPPED`, `EXACT_PACKAGE_MAPPED`, `STRUCTURAL_CERTAIN`, `CANDIDATE`, `UNRESOLVED`. One value, not a list. Anything else is rejected with `INVALID_ARGUMENT`. | | `cursor` | string | none | Opaque token taken from `next_cursor`. Resumes the same query at the next offset. | | `direction` | string | `incoming` | `incoming` returns the symbols that reference this one. `outgoing` returns the ones it reaches. Any other value is rejected with `INVALID_ARGUMENT`. | | `edge_kinds` | array of string | none, meaning every reference kind except `EXPORTS` and `REEXPORTS` | Restricts rows to these relations. The default leaves out the forwarding bindings: an `export { x }` or `export { x } from "./y"` names a path to the declaration rather than a use of it, and the response says so under `edge_kinds_default_excluded`. Nothing becomes unreachable -- the checker resolves an import through however many barrels stand in the way, so every consumer behind one carries its own `IMPORTS_SYMBOL` edge and is listed without them. A list replaces that default; `["*"]` reports every reference kind, and the wildcard stands alone since mixing it with names asks for a filter and for no filter at once. Accepted: `IMPORTS_SYMBOL`, `EXPORTS`, `REEXPORTS`, `REFERENCES`, `CALLS_DIRECT`, `PASSES_AS_CALLBACK`, `ASSIGNS_FUNCTION`, `RETURNS_FUNCTION`, `TYPE_USES`, `IMPLEMENTS`, `EXTENDS`, `EMBEDS`, `OVERRIDES`. Containment and package kinds are rejected. Duplicates collapse; an empty or space-padded entry is rejected. | | `include_derived` | boolean | `false` | Includes rows from providers Kivgraph derives from the machine, which take the `rust:` namespace, such as a Rust toolchain's standard library. Naming one of them in `repo` has the same effect. | | `language` | string | none | Keeps rows whose symbol carries this language: `go`, `typescript` or `rust`. Compared exactly; surrounding whitespace is rejected. | | `limit` | integer | `50` | Rows in this page. Must be between 1 and 500. | | `name` | string | none | The unqualified name of the declaration to ask about, so the common question costs one call instead of two. One declaration carrying that name is answered directly; several are rejected with `AMBIGUOUS_SYMBOL` naming the candidates as `repository:path:line`. Cannot be combined with `stable_key` or `qualified_name`; `repository`, or `repository` plus `path`, narrows it. A name that is only imported or re-exported and never declared is `SYMBOL_NOT_FOUND`, because the declaration is what a reference question is about. | | `path` | string | none | Repository-relative path narrowing `qualified_name`. Requires `repository`. Rejected together with `stable_key`. | | `qualified_name` | string | none | Names the symbol to ask about. One of `qualified_name`, `stable_key` or `name` is required, never two of them. A name matching more than one symbol is rejected with `AMBIGUOUS_SYMBOL` instead of being resolved silently. | | `repo` | string | none | Keeps only rows belonging to this repository, by name, compared exactly. It filters the answer; `repository` selects the subject. | | `repository` | string | none | Repository of the symbol being asked about. Narrows `qualified_name`. | | `response_format` | string | `concise` | `concise` or `detailed`. Detailed adds the derived identifiers -- `stable_key`, `file_key`, `repository_key` and `evidence_kind` -- to the rows of the `full` view. The compact and files views carry none of them under either format: a row is addressed by the header's repository plus its own path and qualified name. Anything else is rejected with `INVALID_ARGUMENT`. | | `stable_key` | string | none | Names the symbol directly. Cannot be combined with `qualified_name`, `name`, `repository` or `path`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` lifts into a header what every row shares and groups the rows by file. `full` is the field-per-row shape. `files` answers only which files hold references and how many each holds, and raises the default `limit` to 500 so a file list is never cut by a page. Any other value is rejected with `INVALID_ARGUMENT`. | ## Answers Who calls or references one symbol, and what that symbol reaches directly. It is a single hop, not a walk: for a chain use [`trace_dependencies`](/docs/tools/trace-dependencies/) or [`get_blast_radius`](/docs/tools/get-blast-radius/). The response states the subject once, echoes the `direction` it answered in, and returns one page of the matching edges. Each row names a symbol with its repository, repository-relative path, qualified name and line range, so the next call is built from the answer just received. By default the answer arrives in the `compact` view: what every row shares is stated once in the header and the rows are grouped by file. It is the same edges, with the same confidence and the same provenance, as the `full` view -- `confidence` and `provenance` alone were `1.200` of the `4.236` tokens of one fifty-row page over the private benchmark corpus, the same pair on all fifty rows. Measured over that corpus, the page went from `4.236` to `874` tokens. That page-wide hoist is unanimous or nothing: on a real `66`-row page, `65` rows shared `kind` and `edge_kind` and the `66`th, a re-export, was enough to push both columns back down to every row. `results.groups` is the second tier that catches it, grouping the rows by whatever exact tuple of the remaining columns they still share instead of `files` repeating it row by row. Measured over the same two questions of that corpus, `1.205` and `1.143` tokens fell to `788` and `779`. See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools, and [reading a grouped page](#reading-a-grouped-page) below for a captured page. ## Example Incoming references, three at a time. Nothing asks for a `view`, so this is the `compact` answer a caller gets by default: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "incoming", "limit": 3 } ``` ```json { "snapshot_id": 30, "total": 3, "returned": 3, "coverage": { "exact": 3 }, "results": { "subject": "kivgraph:internal/facts/facts.go:516", "qn": "MergeAll", "direction": "incoming", "repository": "kivgraph", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL", "files": [ { "file": "internal/indexer/full.go", "at": [ ["mergeSets@681-712", "func"], ["closeCrossRepositoryEdges@735-783", "func"] ] }, { "file": "internal/facts/facts.go", "at": [["Set.Merge@505-507", "method"]] } ] } } ``` All three rows are calls a type checker resolved, so `edge_kind`, `confidence` and `provenance` are in the header rather than on each row. `kind` is not: two of the callers are functions and one is a method, so each row carries its own. The same question, the same three edges, in the `full` view -- the field-per-row shape, which is what a client written against the previous output should ask for explicitly: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "incoming", "limit": 3, "view": "full" } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9020, "total": 3, "returned": 3, "truncated": false, "next_cursor": null, "coverage": { "exact": 3, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "results": { "subject": { "stable_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "name": "MergeAll", "qualified_name": "MergeAll", "kind": "func", "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 516 }, "direction": "incoming", "references": [ { "name": "mergeSets", "qualified_name": "mergeSets", "kind": "func", "repository": "kivgraph", "file_path": "internal/indexer/full.go", "start_line": 681, "end_line": 712, "language": "go", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL" }, { "name": "closeCrossRepositoryEdges", "qualified_name": "closeCrossRepositoryEdges", "kind": "func", "repository": "kivgraph", "file_path": "internal/indexer/full.go", "start_line": 735, "end_line": 783, "language": "go", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL" }, { "name": "Merge", "qualified_name": "Set.Merge", "kind": "method", "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 505, "end_line": 507, "language": "go", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL" } ] } } ``` The same symbol, the other direction, with a page smaller than the answer: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "outgoing", "limit": 2 } ``` ```json { "snapshot_id": 30, "total": 66, "returned": 2, "truncated": true, "next_cursor": "Ah4CAHjbgtIy3kG-GzdYiDefgr2UKDg", "coverage": { "exact": 66 }, "guidance": "showing 2 of 66; narrow with edge_kinds, confidence, repo or language, or pass the cursor for the next page", "results": { "subject": "kivgraph:internal/facts/facts.go:516", "qn": "MergeAll", "direction": "outgoing", "repository": "kivgraph", "kind": "field", "edge_kind": "REFERENCES", "confidence": "EXACT_TYPECHECKED", "files": [ { "file": "internal/facts/facts.go", "at": [ ["Set.Symbols@251", "GO_TYPES_USE"], ["Set.Symbols@251", "GO_TYPES_SELECTION"] ] } ] } } ``` Here `provenance` stayed on the rows. The two entries are the same symbol on the same line under the same relation, observed twice by two different mechanisms, so no single value covered the page and each row carries its own as the tail of an array. `truncated` and `next_cursor` are present because rows remain; on the complete page above, both were absent rather than `false` and `null`. The same page in the `full` view: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "outgoing", "limit": 2, "view": "full" } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 21769, "total": 66, "returned": 2, "truncated": true, "next_cursor": "Ah4CAHjbgtIy3kG-GzdYiDefgr2UKDg", "coverage": { "exact": 66, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "guidance": "showing 2 of 66; narrow with edge_kinds, confidence, repo or language, or pass the cursor for the next page", "results": { "subject": { "stable_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "name": "MergeAll", "qualified_name": "MergeAll", "kind": "func", "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 516 }, "direction": "outgoing", "references": [ { "name": "Symbols", "qualified_name": "Set.Symbols", "kind": "field", "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 251, "end_line": 251, "language": "go", "edge_kind": "REFERENCES", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_TYPES_USE" }, { "name": "Symbols", "qualified_name": "Set.Symbols", "kind": "field", "repository": "kivgraph", "file_path": "internal/facts/facts.go", "start_line": 251, "end_line": 251, "language": "go", "edge_kind": "REFERENCES", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_TYPES_SELECTION" } ] } } ``` ### The `files` view When the question is which files to open, the rows are noise: seven calls in one file are one file. `view: "files"` answers with the file list and a count each, and it pages at 500 by default so the list is whole: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "direction": "incoming", "view": "files" } ``` ```json { "snapshot_id": 30, "total": 3, "returned": 3, "coverage": { "exact": 3 }, "results": { "subject": "kivgraph:internal/facts/facts.go:516", "qn": "MergeAll", "direction": "incoming", "files": [ { "file": "kivgraph/internal/indexer/full.go", "count": 2 }, { "file": "kivgraph/internal/facts/facts.go", "count": 1 } ] } } ``` A `file` here is prefixed with its repository, because a file list spanning two repositories has no header to hoist the repository into. Over the four reference questions of `benchmarks/codebase-memory-comparison`, this view answered in `963` tokens against the `2.883` of the compact rows and the `13.594` of the previous shape. ### One call instead of two `name` resolves the declaration itself, so a reference question does not have to be preceded by a [`find_symbol`](/docs/tools/find-symbol/) call: ```json { "name": "MergeAll", "direction": "incoming" } ``` That answers exactly the page above, because one symbol in the graph declares `MergeAll`. When several do, the tool refuses to pick and says which ones it refused between: ```text AMBIGUOUS_SYMBOL: name "withBackoff" declares 7 symbols; repeat with the repository and path of the one you mean: workspace:apps/api/src/retry.ts:14, workspace:apps/web/src/lib/retry.ts:22, … ``` The message lists all seven; the two above are the shape, the rest are cut here with the `…`. Those candidates are the same `repository:path:line` triple every tool accepts, so narrowing is a matter of copying one of them into `repository` and `path`. The refusal costs `49` to `144` tokens over the benchmark corpus, against the `2.293` of listing every `find_symbol` row for the name, imports and re-exports included. A name the graph knows only as an import or a re-export is `SYMBOL_NOT_FOUND` with the same instruction: name the repository and path that declares it. All the answers above come from snapshot `30` of two repositories, `kivgraph` and `mole`. The refusal is not one of them: it comes from the private benchmark corpus, so its repository, path and symbol names are substituted. The line numbers, the count of seven declarations and the token figures are the measured ones, and the `49` to `144` range was measured against the real identifiers, whose length the message carries. ## Reading the result `subject` is the symbol the query resolved to, stated once rather than on every row, and it is worth checking: it is the proof that the selector picked the symbol you meant. The compact view spells it as the `repository:path:line` triple in one string, with the resolved qualified name beside it in `qn`; the full view spells the same facts as an object of fields. `direction` echoes which question was answered, so a cached response cannot be misread later. `dispatch_through` is present when the subject is the one implementation of an interface method and the answer therefore also holds the references to it: a call through the interface can reach nothing else, so leaving them out answered that nothing called the implementation. Every row that arrived that way repeats the interface method in `via`, which hoists and groups like the other columns, so a bridged row is never read as a direct call. With two implementations nothing is bridged -- a call reaches one of them, and naming both would trade a false absence for a false presence -- and [`get_blast_radius`](/docs/tools/get-blast-radius/) crosses `IMPLEMENTS` in either direction regardless. `edge_kinds_default_excluded` is present only when a filter you did not ask for ran. Its value, `["EXPORTS", "REEXPORTS"]`, is what the answer left out: the export bindings that forward the name rather than use it. It is on the page and not on a row because it describes the query, and `total` counts what the answer holds, so a filtered page never reports a larger number than it can show. Pass `edge_kinds: ["*"]` to turn the filter off, or name `REEXPORTS` to ask only for the barrels -- which is the question a rename has, since a rename must edit them. ### Reading a compact row The header holds what every row of the page shares: any of `repository`, `kind`, `edge_kind`, `confidence` and `provenance`. A field missing from the header is not a field nobody knows -- it means the rows disagreed and each carries its own. `confidence` and `provenance` are therefore always readable in one of the two places, never in neither. `files` groups the rows by the file holding them. Inside a group, an entry of `at` is one of two shapes: - `qualified_name@start`, or `qualified_name@start-end` when the declaration spans more than one line. Nothing was left over: the header answers the rest. - An array whose first element is that same label, when the row had to carry a column the header could not hoist. The remaining elements are appended in a fixed order -- `edge_kind`, `kind`, `confidence`, `provenance` -- skipping every column the header already states. That is why `["mergeSets@681-712", "func"]` above is a kind and `["Set.Symbols@251", "GO_TYPES_USE"]` is a provenance: the header says which columns are still on the rows, and the order says which is which. A group carries a `repo` of its own only when its file is not in the header's repository, which on a single-repository answer never happens. The triple for the next call is assembled from both levels: `repository` from the header (or the group's `repo` when it has one), `path` from the group's `file`, and the qualified name and line from the label. That is the same `repository` + `path` + `qualified_name` every other tool accepts, so no row needs a stable key to be followed. Two things a compact row never carries. `language` is gone, because the file extension beside it says the same thing. `name` is gone whenever the qualified name already ends with it, which for a Go or TypeScript declaration it does. Each row carries three facts describing the relationship and not the symbol -- in the header when the whole page agrees on them, on the row when it does not: - `edge_kind` is the relation. `CALLS_DIRECT` above is a call site; `REFERENCES` is a use that is not a call. The full accepted vocabulary is the `edge_kinds` list in the table above. - `confidence` is how well the relation is proven. `EXACT_TYPECHECKED` means a type checker resolved it. `CANDIDATE` is plausible and not proven, and `UNRESOLVED` carries no target identity at all; they are distinct results from an exact edge and are never promoted into one. - `provenance` is the mechanism that produced it. `GO_AST_CALL` is a call expression over a `go/types` resolution; `GO_TYPES_USE` and `GO_TYPES_SELECTION` are two different observations of a use. Two rows for the same symbol pair with different provenance, as in the outgoing example above, are two observed facts, not a duplicate. The line range -- `start_line` and `end_line` in the full view, the `@start-end` of a compact label -- bounds the declaration of the symbol holding the reference, never the position of the token. The snapshot records which symbol contains a reference and not where inside it, and publishing a line nobody observed would be inventing evidence. The range is there so the row can be opened with [`get_source`](/docs/tools/get-source/) without a second lookup. A label with a single line, `Set.Symbols@251`, is a declaration that starts and ends there, not a range somebody dropped. `coverage` classifies every matching edge, including the ones this page did not return: `exact` counts edges whose confidence is exact, `candidate` the plausible ones, `unresolved_related` the related references that carry no target identity. `package_level` counts facts about a package rather than about the symbol asked for; this tool produces none. In the full view it stays `0`; in the compact view a category that counted nothing is absent, and `coverage` as a whole is absent when all four are, because four zeros only say that the tool has four counters. Only [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) fills it, because a package dependency proves a dependency on the provider and never a use of the symbol. `completeness` states how far the answer reaches, and it rides on every response, in either view: a `verdict` of `COMPLETE` means nothing the index recorded could add to this page, `LOWER_BOUND` that the page is a floor. What can bound a reference answer is what the index could not read that asks for this name -- the references it recorded and could not resolve for the subject's name, in `blind_spots`, and the scopes of the subject's repository it could not read at all, in `invisible_scopes` -- with `fallback` carrying the pattern and the paths that close the gap. The distinction is load-bearing on an empty page: `COMPLETE` is the absence, `LOWER_BOUND` is a minimum, `guidance` says which of the two you got, and a reader who takes "nobody references this" from a `LOWER_BOUND` page can delete live code. See [the completeness verdict](/mcp/usage/#read-the-answer) for the semantics the six checking tools share. An empty answer -- an empty `files` list in the compact view, an empty `references` list in the full one -- with `total` at `0` and a `COMPLETE` verdict is a proven absence, not a miss. The edges came from `go/types`, the TypeScript checker and `rust-analyzer`, not from matching names, so nothing referencing the symbol was left out by a spelling. Grep cannot make that claim. The response says so itself, in `guidance`. ### Reading a grouped page A homonym with declarations of two kinds in two repositories, so no single `kind` covers the page: ```json { "name": "NewRegistry", "repository": "kivgraph", "path": "internal/workspace/registry.go", "direction": "incoming", "limit": 100 } ``` ```json { "snapshot_id": 1, "total": 6, "returned": 6, "coverage": { "exact": 6 }, "results": { "subject": "kivgraph:internal/workspace/registry.go:68", "qn": "NewRegistry", "direction": "incoming", "repository": "kivgraph", "edge_kind": "CALLS_DIRECT", "confidence": "EXACT_TYPECHECKED", "provenance": "GO_AST_CALL", "groups": [ { "kind": "method", "files": [ { "file": "internal/indexing/service.go", "at": ["Service.IndexProjects@140-232", "Service.Reindex@241-277"] } ] }, { "kind": "func", "files": [ { "file": "cmd/kivgraph/main.go", "at": ["runDoctor@1164-1289", "resyncOnBranchChange@432-490", "runUpgrade@973-1050", "runIndexFull@786-903"] } ] } ] } } ``` All six calls are the same relation with the same confidence from the same mechanism, so `edge_kind`, `confidence` and `provenance` still hoist to the page header exactly as on a flat answer. `kind` is what breaks the unanimous vote -- two methods against four functions -- so `results.groups` replaces `results.files`, and each group states its own `kind` once instead of repeating it on six rows. Inside a group, `files` reads exactly like the flat view: a `file` and a label per declaration, becoming an array only if a row still disagrees with its own group. Neither `total`, `returned` nor `coverage` moved for grouping; six rows are six rows either way. ## Limits `truncated` is `true` when rows remain after this page, and `next_cursor` then carries the token to continue. When the page holds everything the full view says `truncated: false` and `next_cursor: null`, and the compact view omits both fields: a false flag and a cursor that does not exist are not facts worth a line. This tool walks no graph, so it has no `traversal_truncated` field; that one belongs to [`trace_dependencies`](/docs/tools/trace-dependencies/) and [`get_blast_radius`](/docs/tools/get-blast-radius/), where the bound is on the walk rather than on the page. The cursor is an opaque base64url token over a binary body: the format version, the snapshot id, the offset, a digest of the query identity, a digest of the sorting contract `references-v1` and a checksum over all of it. It is about 31 characters -- `Ah4CAHjbgtIy3kG-GzdYiDefgr2UKDg` above -- where the previous format spent 314 characters of base64-wrapped JSON, `221` tokens on every truncated page. Pass it back unchanged. It fails closed: | What changed | What you get | | --- | --- | | A new generation was published | `CURSOR_SNAPSHOT_EXPIRED`; restart pagination | | The selector, `direction`, `repo`, `language`, `edge_kinds` or `confidence` differ from the call that produced it | `CURSOR_INVALID` | | The token was edited, re-encoded, truncated, or decodes with trailing bytes after the checksum | `CURSOR_INVALID` | | The token was minted by a server of the previous cursor version | `CURSOR_INVALID`; the body is version 2 and version 1 is not reinterpreted under the new layout | `limit`, `view`, `response_format` and `include_derived` are not part of the cursor identity, so changing one of them mid-pagination is accepted: the same cursor can continue a query in a different view, and changing `limit` shifts what the remaining pages contain. A page taken through `name` is bound to the qualified name the name resolved to, not to the name, so it keeps working while the snapshot does. `guidance` is present only when the count alone would mislead: when nothing was found, and when the page is truncated. It stays absent on a complete non-empty answer. The zero-row sentence differs by direction, and both are worth reading literally: incoming says the absence is type-checked and points at [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) and [`graph_status`](/docs/tools/graph-status/); outgoing says the symbol reaches nothing and suggests asking the other direction. `include_derived` is `false` by default and that default is load-bearing. With a Rust toolchain in the graph, references to a name like `Clone` reach most of the corpus, and a page of the standard library is not what a question about your own code wants. Withholding those rows is a decision about the page and never a claim about the graph: the edge stays published with its exact confidence, and `include_derived: true`, or naming the provider in `repo`, brings it back. ## Where it loses A rare name in one small repository is cheaper to grep, and this tool costs a symbol resolution before it answers. It is a single hop, so it cannot tell you what happens two calls away. It also depends on the snapshot being current: if the tree moved since it was indexed, the answer describes the code that was indexed, not the code on disk. [`graph_status`](/docs/tools/graph-status/) reports that. --- # find_cross_repo_consumers URL: https://kivgraph.dev/docs/tools/find-cross-repo-consumers/ Markdown: https://kivgraph.dev/raw/docs/tools/find-cross-repo-consumers.md > Consumers of a symbol in other repositories, exact uses kept apart from package-level dependencies. A language server stops at its workspace. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `cursor` | string | none | Opaque token taken from `next_cursor`. Resumes the same query at the next offset. | | `language` | string | none | Keeps only consumers carrying this language: `go`, `typescript` or `rust`. Compared exactly; surrounding whitespace is rejected. | | `limit` | integer | `50` | Rows in this page. Must be between 1 and 500. | | `path` | string | none | Repository-relative path narrowing `qualified_name`. Requires `repository`. Rejected together with `stable_key`. | | `qualified_name` | string | none | Names the symbol to ask about. Either this or `stable_key` is required, never both. A name matching more than one symbol is rejected with `AMBIGUOUS_SYMBOL` instead of being resolved silently. | | `repo` | string | none | Keeps only consumers in this repository, by name, compared exactly. It filters the answer; `repository` selects the subject. | | `repository` | string | none | Repository of the symbol being asked about. Narrows `qualified_name`. | | `response_format` | string | `concise` | `concise` or `detailed`. Detailed adds the derived identifiers, such as consumer symbol, repository, package and file keys. Anything else is rejected with `INVALID_ARGUMENT`. | | `stable_key` | string | none | Names the symbol directly. Cannot be combined with `qualified_name`, `repository` or `path`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` lifts the columns every consumer repeats into the header and gives a repository one entry for its package dependencies. `full` is the field-per-row shape. The answer is a set of consuming repositories and not a set of files, so `files` is rejected with `INVALID_ARGUMENT`, as is any other value. | ## Answers Who, outside the symbol's own repository, uses it or depends on the package that declares it. A language server stops at its workspace, so this is the question no editor answers. The response states the subject once and returns a `consumers` list where every row carries a `category`, so an exact use is never mixed with a dependency between packages. Rows inside the symbol's own repository are excluded by construction; ask [`find_references`](/docs/tools/find-references/) for those. By default the page arrives in the `compact` view: `category`, `edge_kind`, `confidence`, `provenance`, `evidence_kind` and `reason` rise into the header whenever every row that has them agrees, and `requested_package` and `requested_symbol` do too, because the request is a property of the call and not of the consumer. A row keeps only what the header does not state. Measured over a 35-row page on the benchmark corpus, the compact view alone brought `2.456` tokens down to `2.202`; grouping the rows that still disagreed took it to `926` -- see [reading a grouped page](#reading-a-grouped-page) below. That corpus is private, so the repository and package names quoted from it below are substituted; the counts, the edge kinds and the token figures are the measured ones. ## Example ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll" } ``` ```json { "snapshot_id": 30, "total": 0, "returned": 0, "guidance": "no repository in the published graph consumes this symbol. Check graph_status if a consumer is registered but was not indexed, and find_references for uses inside its own repository", "results": { "subject": { "qualified_name": "MergeAll", "at": "kivgraph:internal/facts/facts.go:516", "pkg": "github.com/Luqueee/kivgraph/internal/facts", "module_path": "github.com/Luqueee/kivgraph", "end_line": 542 }, "consumers": [] } } ``` The subject is the `repository:path:line` triple in `at` with its package in `pkg`, and `end_line` appears only because the declaration spans more than one line. There is no `coverage` at all: all four counters were zero, and four zeros say only that the tool has four counters. `truncated` and `next_cursor` are absent for the same reason. The same answer in the `full` view: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "view": "full" } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9020, "total": 0, "returned": 0, "truncated": false, "next_cursor": null, "coverage": { "exact": 0, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "guidance": "no repository in the published graph consumes this symbol. Check graph_status if a consumer is registered but was not indexed, and find_references for uses inside its own repository", "results": { "subject": { "qualified_name": "MergeAll", "repository": "kivgraph", "package_name": "github.com/Luqueee/kivgraph/internal/facts", "module_path": "github.com/Luqueee/kivgraph", "file_path": "internal/facts/facts.go", "start_line": 516, "end_line": 542 }, "consumers": null } } ``` This answer comes from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Reading the result The captured answer has no consumers, and that is the interesting case. `total` is `0`, `consumers` is an empty list -- `null` in the full view -- and `guidance` says what the zero means: no repository in the published graph consumes this symbol, plus the two ways the answer could still be wrong, both of which are about the index and not about the code. That sentence is how the surface distinguishes a checked absence from an empty page. Without it, zero rows read as "no such thing" and the session goes back to grep. When there are consumers, every row carries a `category`, in the header when the whole page shares one and on the row when it does not: | `category` | What it means | | --- | --- | | `exact_symbol` | A use of the queried symbol, resolved by a type checker. | | `candidate` | A relation to the symbol that is plausible and not proven. | | `package` | A `PACKAGE_DEPENDS_ON` dependency on the package that declares it. | | `unresolved` | A recorded reference the resolver could not follow, with the strings it asked for. | An `exact_symbol` or `candidate` row is a reference row and reads like one from [`find_references`](/docs/tools/find-references/): `edge_kind` is the relation, `confidence` is how well it is proven, `provenance` is the mechanism that observed it. It also carries the consumer's repository, package, file and line range, so it can be opened without another call. In the compact view the consumer's repository is `repo`, its package is `pkg`, and the file and line are one `at` of the form `path:line`, with `end_line` beside it only when the declaration spans more than one line -- `repo` plus the path in `at` plus `qualified_name` is the triple the next call takes. A `package` row carries no symbol, no file and no range, and it is not an omission. The evidence is a dependency between packages: it has an `edge_kind`, a `confidence` and a `provenance` of its own, and it names a consuming repository and package, because that is all anybody observed. The compact view gives a repository one entry for those dependencies, with `pkg` holding the bare package name while one package of the repository carries the fact and the list of names when several do; `total`, `returned` and `coverage` still count every dependency, so one entry can stand for two package-level facts. An `unresolved` row is evidence about a failed request, not a relationship. It carries `reason`, `requested_package`, `requested_symbol` and `detail`, which are the strings the resolver used and never graph keys. `reason` rises into the header, or a group's own header, whenever every row that carries it agrees; `detail` never reaches the page header -- across the whole answer it is usually several distinct sentences -- but a group built on a shared `reason` often turns out to share one `detail` too, because the sentence is a template keyed by the failure and not prose composed per file. `requested_package` and `requested_symbol` rise into the page header whenever every row that names them names the same one, because the request is a property of the call and not of the consumer. ### Reading a grouped page `category`, `edge_kind`, `confidence`, `provenance`, `evidence_kind` and `reason` are a page-wide hoist or nothing: mix one package dependency into a page of exact uses and every one of those six columns drops back onto every row. `results.groups` is the second tier that catches it, grouping rows by whichever exact tuple of those six they still share -- never `detail`, which gets its own hoist attempt once a group is otherwise fixed, exactly as `reached_from` does for [`get_blast_radius`](/docs/tools/get-blast-radius/). See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools. Measured over the real 35-consumer page on the private benchmark corpus that motivated this tier: `22` package-level dependencies on `@workspace/platform`, each its own repository, all sharing `category: "package"`, `edge_kind: "PACKAGE_DEPENDS_ON"` and the same confidence and provenance -- one group, `22` bare `{ "repo", "pkg" }` entries. The other `13` rows are unresolved imports of the same package, but for two different reasons: `7` files across one repository fail with `DECLARATION_SOURCE_NOT_MAPPED`, all naming the identical `.d.ts` path as `detail`; the other `6` fail with `PROVIDER_SOURCE_UNAVAILABLE` and the identical sentence "no declaration map places this symbol in the provider's source". Three groups, one per `(category, reason)` pair actually present, and both `detail` sentences hoist to their group instead of repeating on `7` and `6` rows -- the assumption that `detail` was each row's own prose held for a page small enough that it never had to be tested, and cost `2.202` tokens more than the `926` the grouped page now spends on the same evidence. ### Why `package_level` is counted separately `coverage` reports four counters, and the split is the point of this page. A query about a symbol only counts as a consumer what was observed about that symbol. `exact` counts uses proven by a type checker; `candidate` the plausible ones; `unresolved_related` the references that named the symbol but could not be followed. Package dependencies land in `package_level` and are never summed into `exact`. The compact view writes only the counters that counted something, and drops `coverage` entirely when none did, so an absent counter is a zero and never a category the tool forgot. The rule behind it is short: a package dependency proves that the consumer depends on the provider and never that it uses the symbol. Adding it to `exact` would report a use nobody saw. By the same rule, a resolution failure that named no symbol at all, an unreadable module or an absent provider, belongs to the package: it is declared with `requested_package` and never attributed to every symbol that package exports. So `package_level` is not a weaker `exact`. It answers a different question: "could this repository be reaching me at all", where `exact` answers "does it". A migration plan built on `exact` is a list of call sites; a migration plan built on `package_level` is a list of places to go and look. Every answer carries a `completeness` object whose `verdict` is `COMPLETE` when nothing the index recorded could add to it and `LOWER_BOUND` when the answer is a floor, with `blind_spots` for the individual references the resolver could not follow and `invisible_scopes` for the packages it could not read at all. What bounds this tool is deliberately wider than the rest: the scope half of the check is global, so an unreadable package in any repository counts and not only in the one the question names, because a package nobody could read anywhere is exactly where an outside consumer hides. That matters most here, because this is the tool with no native `grep` competitor and its empty answer gets read as a finding -- "nobody outside uses this" -- so on `LOWER_BOUND` the zero-row `guidance` refuses that reading and sends you to those two lists first. See [the completeness verdict](/mcp/usage/#read-the-answer) for the shape shared by the six tools that check. ## Limits `truncated` is `true` when rows remain after this page, and `next_cursor` then carries the token to continue; otherwise the full view says `false` and `null` and the compact view omits both. This tool walks no graph, so it has no `traversal_truncated` field: that one belongs to [`trace_dependencies`](/docs/tools/trace-dependencies/) and [`get_blast_radius`](/docs/tools/get-blast-radius/), where the bound is on the walk rather than on the page. The cursor is an opaque base64url token over a binary body: the format version, the snapshot id, the offset, a digest of the query identity, a digest of the sorting contract `consumers-v1` and a checksum over all of it. It is about 31 characters, where the previous format spent 314 of base64-wrapped JSON. Pass it back unchanged. It fails closed: | What changed | What you get | | --- | --- | | A new generation was published | `CURSOR_SNAPSHOT_EXPIRED`; restart pagination | | The selector, `repo` or `language` differ from the call that produced it | `CURSOR_INVALID` | | The token was edited, re-encoded, truncated, or decodes with trailing bytes after the checksum | `CURSOR_INVALID` | | The token was minted by a server of the previous cursor version | `CURSOR_INVALID`; the body declares version 2 and version 1 is not reinterpreted under the new layout | `limit`, `view` and `response_format` are not part of the cursor identity, so changing one of them mid-pagination is accepted: the same cursor continues the page in another view, and changing `limit` shifts what the remaining pages contain. `guidance` appears only when the count alone would mislead: on zero rows, with the sentence quoted above, and on a truncated page, where it names `repo` and `language` as the way to narrow. It stays absent on a complete non-empty answer. This tool takes no `include_derived` argument. Providers derived from the machine, the ones in the `rust:` namespace, are withheld by default only from [`find_symbol`](/docs/tools/find-symbol/), [`find_references`](/docs/tools/find-references/), [`trace_dependencies`](/docs/tools/trace-dependencies/) and [`get_blast_radius`](/docs/tools/get-blast-radius/). ## Where it loses The answer is bounded by what is registered. A consumer nobody indexed produces no row, and the zero-consumer guidance says so rather than pretending otherwise; [`graph_status`](/docs/tools/graph-status/) and [`list_repositories`](/docs/tools/list-repositories/) are where you check. It also says nothing about uses inside the symbol's own repository, which are the common case and belong to [`find_references`](/docs/tools/find-references/). For a single-repository corpus this tool has nothing to add. --- # trace_dependencies URL: https://kivgraph.dev/docs/tools/trace-dependencies/ Markdown: https://kivgraph.dev/raw/docs/tools/trace-dependencies.md > What this symbol reaches outward, bounded by depth. Grep does not follow a chain. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `confidence` | string | none | Gates which edges the traversal may follow, so it changes what is reachable. Accepted: `EXACT_TYPECHECKED`, `EXACT_DECLARATION_MAPPED`, `EXACT_PACKAGE_MAPPED`, `STRUCTURAL_CERTAIN`, `CANDIDATE`, `UNRESOLVED`. One value, not a list. Anything else is rejected with `INVALID_ARGUMENT`. | | `cursor` | string | none | Opaque token taken from `next_cursor`. Resumes the same query at the next offset. | | `depth` | integer | `3` | How many hops the walk may take. Must be between 1 and 5. | | `edge_kinds` | array of string | none, meaning every reference kind | Gates which relations the traversal may follow, so it also changes what is reachable. Accepted: `IMPORTS_SYMBOL`, `EXPORTS`, `REEXPORTS`, `REFERENCES`, `CALLS_DIRECT`, `PASSES_AS_CALLBACK`, `ASSIGNS_FUNCTION`, `RETURNS_FUNCTION`, `TYPE_USES`, `IMPLEMENTS`, `EXTENDS`, `EMBEDS`, `OVERRIDES`. Containment and package kinds are rejected. Duplicates collapse; an empty or space-padded entry is rejected. | | `include_derived` | boolean | `false` | Includes rows from providers Kivgraph derives from the machine, which take the `rust:` namespace, such as a Rust toolchain's standard library. Naming one of them in `repo` has the same effect. | | `language` | string | none | Selects which reached symbols are returned: `go`, `typescript` or `rust`. It filters rows after reachability and never changes the walk. | | `limit` | integer | `50` | Rows in this page. Must be between 1 and 500. | | `max_nodes` | integer | `5000` | Ceiling on how many symbols the walk may discover, the root included. Must be between 1 and 25000. Hitting it sets `traversal_truncated`. | | `path` | string | none | Repository-relative path narrowing `qualified_name`. Requires `repository`. Rejected together with `stable_key`. | | `qualified_name` | string | none | Names the symbol to start from. Either this or `stable_key` is required, never both. A name matching more than one symbol is rejected with `AMBIGUOUS_SYMBOL` instead of being resolved silently. | | `repo` | string | none | Selects which reached symbols are returned, by repository name, compared exactly. A dependency found through a symbol in another repository is still reported. | | `repository` | string | none | Repository of the symbol being asked about. Narrows `qualified_name`. | | `response_format` | string | `concise` | `concise` or `detailed`. Detailed adds the derived identifiers: `stable_key`, `file_key` and `reached_from_key` in the `full` view, and the stable key alone in the compact one, which is the only way a key reaches a compact row. Anything else is rejected with `INVALID_ARGUMENT`. | | `stable_key` | string | none | Names the starting symbol directly. Cannot be combined with `qualified_name`, `repository` or `path`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` lifts into a header what every reached symbol shares and groups the rows by file. `full` is the field-per-row shape. The answer is a set of reached symbols and not a set of files, so `files` is rejected with `INVALID_ARGUMENT`, as is any other value. | ## Answers What one symbol reaches outward, following the chain rather than stopping at the first hop. The walk is breadth-first and bounded twice, by `depth` and by `max_nodes`. The response echoes those bounds, reports how many symbols the walk reached and how deep it got, and returns one page of them. The start symbol is the root and is never listed as its own dependency. By default the page arrives in the `compact` view: whatever every reached symbol shares -- `repository`, `kind`, `hop_depth`, `reached_from`, `via_kind`, `via_confidence`, `via_provenance` -- is stated once above the rows, and the rows are grouped by file under `files`. It is the same edges with the same confidence and the same provenance as `full`; what leaves the rows is only what every row repeated. `view: "full"` keeps the field-per-row `nodes` array. That page-wide hoist needs every row to agree: one row with its own `kind`, `hop_depth`, `reached_from`, `via_kind`, `via_confidence` or `via_provenance` pushes the whole column back down onto every row. `results.groups` is the second tier that catches it, sharing its mechanism and its `compactReachedGroup` shape with [`get_blast_radius`](/docs/tools/get-blast-radius/): the rows group by whatever exact tuple of those columns they still share, instead of repeating it once per row. See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools, and [reading a grouped page](#reading-a-grouped-page) below for a captured page. ## Example ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "depth": 1, "limit": 3 } ``` ```json { "snapshot_id": 30, "total": 37, "returned": 3, "truncated": true, "next_cursor": "Ah4DYbLgOYhUrECElsmz0oFlyLUGpgE", "coverage": { "exact": 37 }, "guidance": "showing 3 of 37; narrow with depth, max_nodes, edge_kinds or confidence, or pass the cursor for the next page", "results": { "root_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "root_repository": "kivgraph", "depth": 1, "max_nodes": 5000, "reached": 37, "deepest_depth": 1, "repository": "kivgraph", "kind": "field", "hop_depth": 1, "reached_from": "MergeAll", "via_kind": "REFERENCES", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_TYPES_USE", "files": [ { "file": "internal/facts/facts.go", "at": [ "Set.Symbols@251", "unresolvedIdentity.file@568", "unresolvedIdentity.reason@569" ] } ] } } ``` All three rows are fields of the same repository, in the same file, one hop from the root, reached from `MergeAll` by the same kind of edge with the same confidence and the same provenance. Every column was therefore hoisted and each entry is the bare `qualified_name@line` label -- nothing was left to say per row. `hop_depth` in the header is the depth of the rows, which is a different fact from `depth`, the bound the walk was given; they only share a name in the full view. `traversal_truncated` is absent because it is false. The same page in the `full` view: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "depth": 1, "limit": 3, "view": "full" } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9020, "total": 37, "returned": 3, "truncated": true, "next_cursor": "Ah4DYbLgOYhUrECElsmz0oFlyLUGpgE", "coverage": { "exact": 37, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "guidance": "showing 3 of 37; narrow with depth, max_nodes, edge_kinds or confidence, or pass the cursor for the next page", "results": { "root_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "root_repository": "kivgraph", "depth": 1, "max_nodes": 5000, "reached": 37, "deepest_depth": 1, "traversal_truncated": false, "nodes": [ { "name": "Symbols", "qualified_name": "Set.Symbols", "kind": "field", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/facts/facts.go", "start_line": 251, "end_line": 251, "reached_from": "MergeAll", "via_kind": "REFERENCES", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_TYPES_USE" }, { "name": "file", "qualified_name": "unresolvedIdentity.file", "kind": "field", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/facts/facts.go", "start_line": 568, "end_line": 568, "reached_from": "MergeAll", "via_kind": "REFERENCES", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_TYPES_USE" }, { "name": "reason", "qualified_name": "unresolvedIdentity.reason", "kind": "field", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/facts/facts.go", "start_line": 569, "end_line": 569, "reached_from": "MergeAll", "via_kind": "REFERENCES", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_TYPES_USE" } ] } } ``` This answer comes from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Reading the result `root_key` and `root_repository` name the symbol the walk started from, so a stored answer still says what it was about. `depth` and `max_nodes` echo the bounds that produced it, including the defaults you did not pass: the request above set `depth` to `1` and left `max_nodes` at `5000`. `reached` is how many symbols the traversal visited, root excluded. `deepest_depth` is the deepest level among the rows that survived the filters, not only the ones on this page; here the walk was bounded at one hop, so it is `1`. `total` is how many rows survived the `repo`, `language` and derived-provider filters, and `returned` is how many of them fit in this page: 3 of 37. ### Reading a compact row The header states what the whole page agrees on; a field absent from it means the rows disagreed and carry their own. Inside `files`, an entry of `at` is `qualified_name@start`, or `qualified_name@start-end` when the declaration spans more than one line, and it becomes an array when the row had to carry a column the page could not hoist. The elements after the label are appended in one fixed order -- the name, the kind, the depth, the symbol it was reached from, the edge's kind, its confidence, its provenance, and the stable key -- skipping every column the header already states, so on a page whose kind was hoisted but whose rows sit at different depths, `["pkg.Middle@40-58", "2", "pkg.Entry"]` is a depth of `2` reached from `pkg.Entry`. Numbers in a tail are written as strings, because a tail is a list of columns and not a record. A group carries a `repo` of its own only when its file is outside the header's repository. The triple for the next call comes from both levels: the header's `repository` (or the group's `repo`), the group's `file`, and the qualified name from the label. `language` is never on a row, because the file extension says it, and `name` is absent whenever the qualified name already ends with it. Every row is a reached symbol plus the edge it was first arrived by -- in the header when the page agrees, on the row when it does not: - `depth` is how many hops from the root it sits at. - `reached_from` is the qualified name of the already-reached symbol it was discovered from. It is a name, not a key, because the symbol it names is another row of the same walk. - `via_kind` is that edge's relation: `REFERENCES` above is a use that is not a call, `CALLS_DIRECT` is a call site. - `via_confidence` is how well that edge is proven. `EXACT_TYPECHECKED` means a type checker resolved it. `CANDIDATE` is plausible and not proven, and `UNRESOLVED` carries no target identity; neither is ever promoted into an exact edge. - `via_provenance` is the mechanism that observed it, such as `GO_TYPES_USE` for a use `go/types` resolved. The `via_*` triple describes one route and not the only one. A breadth-first frontier records the shortest edge it found to each symbol, so a symbol reachable by both a call and a type use is reported once, by whichever edge arrived first. For the full set of relations into a symbol, ask [`find_references`](/docs/tools/find-references/), or read `by_kind` in [`get_blast_radius`](/docs/tools/get-blast-radius/), which counts every relation instead of only the discovering one. The line range -- `start_line` and `end_line` in the full view, the `@start-end` of a compact label -- bounds the declaration of the reached symbol, so any row can be opened with [`get_source`](/docs/tools/get-source/) without a second lookup. A label naming one line is a declaration that starts and ends there. `coverage` classifies the edges the rows were reached by: `exact` for exact confidences, `candidate` for the plausible ones, `unresolved_related` for related references with no target identity. `package_level` counts facts about a package rather than about a symbol; this tool produces none, so in the full view it stays `0` and in the compact view it is simply absent, as is any other counter at zero and `coverage` itself when all four are. Only [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) fills it, because a package dependency proves a dependency on the provider and never a use of the symbol, and summing the two would report a use nobody saw. `completeness` states how far the answer reaches, and its `verdict` is either `COMPLETE` -- nothing the index recorded could add to this walk -- or `LOWER_BOUND`, meaning the page is a floor. What bounds an outward answer is not what bounds an inward one: this verdict is charged with the failures the symbol itself made -- the references it makes that the resolver could not follow, in `blind_spots` -- and never with the failures that asked for its name. Somebody else's unreadable call to this symbol hides a caller, not a dependency. A scope of its own repository that the index could not read counts too, in `invisible_scopes`: a package nobody could open may hold anything this symbol reaches. The distinction matters because a walk bounded at `depth` or `max_nodes` that reached nothing reads exactly like a symbol that depends on nothing, and the verdict is what separates the bound from the graph. See [the completeness verdict](/mcp/usage/#read-the-answer) for the shared shape and what each tool is charged with. ### Reading a grouped page A private struct reached by a field selection and then by its own type, and that type reached again from a second file -- three different `(kind, hop_depth, via_kind)` tuples out of a walk of seven: ```json { "repository": "kivgraph", "path": "internal/app/lifecycle.go", "qualified_name": "Lifecycle.waitRunners", "depth": 2, "limit": 100 } ``` ```json { "snapshot_id": 1, "total": 7, "returned": 7, "coverage": { "exact": 7 }, "results": { "root_key": "EOVNCHGUQS43STPNTZICMD4MI35UUDJQFFRFGLKXRPQFOCTHBUJA", "root_repository": "kivgraph", "depth": 2, "max_nodes": 5000, "reached": 7, "deepest_depth": 2, "repository": "kivgraph", "via_confidence": "EXACT_TYPECHECKED", "groups": [ { "kind": "field", "hop_depth": 1, "reached_from": "Lifecycle.waitRunners", "via_kind": "REFERENCES", "via_provenance": "GO_TYPES_SELECTION", "files": [{ "file": "internal/app/lifecycle.go", "at": [ "Lifecycle.runErrs@37", "Lifecycle.runners@35", "Lifecycle.runDone@36", "Lifecycle.waiting@33", "Lifecycle.mu@30" ] }] }, { "kind": "type", "hop_depth": 1, "reached_from": "Lifecycle.waitRunners", "via_kind": "TYPE_USES", "via_provenance": "GO_TYPES_USE", "files": [{ "file": "internal/app/lifecycle.go", "at": ["Lifecycle@26-42"] }] }, { "kind": "type", "hop_depth": 2, "reached_from": "Lifecycle", "via_kind": "TYPE_USES", "via_provenance": "GO_TYPES_USE", "files": [{ "file": "internal/app/shutdown.go", "at": ["Resource@18-21"] }] } ] } } ``` `repository` and `via_confidence` still hoist to the page header -- the whole walk stays inside `kivgraph` on edges a type checker resolved -- but `kind`, `hop_depth` and the edge that reached each symbol split three ways between five struct fields, the struct itself, and the one type it in turn exposes, so every group states its own four columns instead of stamping a residual tail onto seven rows. `reached_from` still hoists inside the first two groups even though it is not part of the tuple that built them: both share `Lifecycle.waitRunners` for a different reason, one because it is the field's container and one because it is the type the field returns. The third group, alone at depth `2`, is `Resource` reached through `Lifecycle` itself -- the qualified name a compact row never needs to repeat, because it is right there in `reached_from`. ## Limits There are two different truncations and they mean different things: - `truncated` is about the page. It is `true` when rows remain after this one, and `next_cursor` then carries the token to continue. The captured answer shows it: 3 rows returned out of 37, `truncated: true`, a token present. On a complete page the compact view omits both fields instead of writing `false` and `null`. - `traversal_truncated` is about the walk. It is `true` when the walk hit `max_nodes` and stopped discovering new symbols, so rows are missing from the answer itself and no cursor will produce them. Raise `max_nodes`, lower `depth`, or narrow with `edge_kinds` or `confidence`. In the captured answer it is `false`: the walk was complete within its bounds, only the page was short -- which is why the compact payload above does not carry the field at all, while the full one says `false`. To take the next page, repeat the same call and add `cursor` set to the `next_cursor` value, unchanged. The token is an opaque base64url wrapper around a binary body: the format version, the snapshot id, the offset, a digest of the query identity, a digest of the sorting contract `dependencies-v1` and a checksum over all of it. It is about 31 characters -- `Ah4DYbLgOYhUrECElsmz0oFlyLUGpgE` above -- where the previous format spent 314 characters of base64-wrapped JSON. It fails closed: | What changed | What you get | | --- | --- | | A new generation was published | `CURSOR_SNAPSHOT_EXPIRED`; restart pagination | | The selector, `depth`, `max_nodes`, `repo`, `language`, `edge_kinds` or `confidence` differ from the call that produced it | `CURSOR_INVALID` | | The token was edited, re-encoded, truncated, or decodes with trailing bytes after the checksum | `CURSOR_INVALID` | | The token was minted by a server of the previous cursor version | `CURSOR_INVALID`; the body declares version 2 and version 1 is not reinterpreted under the new layout | `limit`, `view`, `response_format` and `include_derived` are not part of the cursor identity, so changing one of them mid-pagination is accepted: the same cursor continues the walk in another view, and changing `limit` shifts what the remaining pages contain. `guidance` appears only when the count alone would mislead. On a truncated page it names the bounds to narrow and the cursor, exactly as in the capture above. On zero rows it says the traversal reached nothing within its bounds and suggests raising `depth` or asking [`find_references`](/docs/tools/find-references/) for the direct relations only. On a complete non-empty answer it is absent. `include_derived` is `false` by default. With a Rust toolchain in the graph a walk over common trait methods reaches most of the corpus, and a page of the standard library is not what a question about your own code wants. Withholding those rows is a decision about the page and never a claim about the graph: the edge stays published with its exact confidence, and `include_derived: true`, or naming the provider in `repo`, brings it back. A walk that exceeds the deadline the client set on the request fails with `TRAVERSAL_LIMIT_REACHED` rather than returning a partial answer as if it were whole. ## Where it loses Five hops is the ceiling and three is the default, so this is not a whole-program closure. It answers what one symbol reaches, which is the less common question: for a change you are about to make, the question is who reaches it, and that is [`get_blast_radius`](/docs/tools/get-blast-radius/). On a symbol with wide fan-out the answer is large and mostly uninteresting, and the first thing to do is narrow `edge_kinds` rather than page through it. --- # get_blast_radius URL: https://kivgraph.dev/docs/tools/get-blast-radius/ Markdown: https://kivgraph.dev/raw/docs/tools/get-blast-radius.md > What a change to this symbol reaches, by repository, package, depth and relation kind. Grep does not follow a chain. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `confidence` | string | none | Gates which incoming edges the traversal may follow, so it changes what counts as affected. Accepted: `EXACT_TYPECHECKED`, `EXACT_DECLARATION_MAPPED`, `EXACT_PACKAGE_MAPPED`, `STRUCTURAL_CERTAIN`, `CANDIDATE`, `UNRESOLVED`. One value, not a list. Anything else is rejected with `INVALID_ARGUMENT`. | | `cursor` | string | none | Opaque token taken from `next_cursor`. Resumes the same query at the next offset. | | `depth` | integer | `3` | How many hops the walk may take backwards. Must be between 1 and 5. | | `edge_kinds` | array of string | none, meaning every reference kind | Gates which relations may be followed, so it also changes what counts as affected. Accepted: `IMPORTS_SYMBOL`, `EXPORTS`, `REEXPORTS`, `REFERENCES`, `CALLS_DIRECT`, `PASSES_AS_CALLBACK`, `ASSIGNS_FUNCTION`, `RETURNS_FUNCTION`, `TYPE_USES`, `IMPLEMENTS`, `EXTENDS`, `EMBEDS`, `OVERRIDES`. Containment and package kinds are rejected. Duplicates collapse; an empty or space-padded entry is rejected. | | `include_derived` | boolean | `false` | Includes rows from providers Kivgraph derives from the machine, which take the `rust:` namespace, such as a Rust toolchain's standard library. This tool takes no `repo` argument, so the flag is the only way to ask for them. | | `kinds` | array of string | none, meaning every kind except `variable` and `field` | Which symbol kinds count as affected. The default leaves out the local bindings a backwards walk crosses on its way to the consumers a reviewer is looking for, and the response says so under `kinds_default_excluded`. A list replaces that default and is echoed, sorted, under `kinds`; `["*"]` reports every kind the snapshot holds and is echoed as `kinds: ["*"]`. The wildcard stands alone -- mixing it with names asks for a filter and for no filter at once -- and a kind outside the accepted vocabulary is rejected with `INVALID_ARGUMENT` rather than silently matching nothing. Accepted: `alias`, `associated_type`, `attribute`, `class`, `const`, `constant`, `enum`, `enum_member`, `export`, `field`, `func`, `function`, `implementation`, `import`, `interface`, `macro`, `method`, `module`, `namespace`, `parameter`, `property`, `self_parameter`, `static`, `static_method`, `struct`, `trait`, `trait_method`, `type`, `type_alias`, `type_parameter`, `union`, `var`, `variable`. | | `limit` | integer | `50` | Rows of `symbols` in this page. Must be between 1 and 500. The aggregates are never paged. | | `max_nodes` | integer | `5000` | Ceiling on how many symbols the walk may discover, the root included. Must be between 1 and 25000. Hitting it sets `traversal_truncated`. | | `path` | string | none | Repository-relative path narrowing `qualified_name`. Requires `repository`. Rejected together with `stable_key`. | | `qualified_name` | string | none | Names the symbol to ask about. Either this or `stable_key` is required, never both. A name matching more than one symbol is rejected with `AMBIGUOUS_SYMBOL` instead of being resolved silently. | | `repository` | string | none | Repository of the symbol being asked about. Narrows `qualified_name`. | | `response_format` | string | `concise` | `concise` or `detailed`. Detailed adds the derived identifiers: `stable_key`, `file_key` and `reached_from_key` in the `full` view, and the stable key alone in the compact one, which is the only way a key reaches a compact row. Anything else is rejected with `INVALID_ARGUMENT`. | | `stable_key` | string | none | Names the symbol directly. Cannot be combined with `qualified_name`, `repository` or `path`. | | `view` | string | `compact` | The granularity of the answer, never a different answer. `compact` leads with the filter and the four axes, states what every affected symbol shares once and groups the rows by file. `full` is the field-per-row shape. The answer is a set of affected symbols and not a set of files, so `files` is rejected with `INVALID_ARGUMENT`, as is any other value. | ## Answers What a change to one symbol reaches: the same bounded walk as [`trace_dependencies`](/docs/tools/trace-dependencies/), run backwards over incoming edges. The response is the affected symbols plus four aggregations a reviewer acts on, by repository, by package, by depth and by relation kind. The root is excluded everywhere, because a symbol is not affected by its own change. It states how far its answer reaches in a `completeness` object, as the five other tools whose empty answer could be read as proof now do. Two things shape the answer before you read it. The kind filter decides what counts as affected, and the response always states which one ran. The `view` decides how it is spelled: by default the `compact` one, which leads with the filter and the four axes -- "how far does this reach" is the question, and the page behind them only names what the axes counted -- states what every affected symbol shares once, and groups the rows by file. Measured over the private benchmark corpus, one depth-2 answer went from `5.102` tokens to `921`. That page-wide hoist is unanimous or nothing: one row that disagrees on `kind`, `hop_depth`, `reached_from`, `via_kind`, `via_confidence` or `via_provenance` pushes the whole column back down to every row. `results.groups` is the second tier that catches it, grouping the rows by whatever exact tuple of those columns they still share instead of repeating it on each one. A real `29`-row answer went from `921` to `821` tokens this way; it gains less than the other five tools because most of its rows carry their own distinct `reached_from`, the one column excluded from the grouping tuple on purpose -- folding it in would fragment every group back down to one row. See [when a page groups](/mcp/usage/#when-a-page-groups) for the mechanism shared by six tools, and [reading a grouped page](#reading-a-grouped-page) below for a captured page. ## Example ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "depth": 2 } ``` ```json { "snapshot_id": 30, "total": 5, "returned": 5, "coverage": { "exact": 5, "unresolved_related": 7 }, "completeness": { … }, "results": { "root": "kivgraph:internal/facts/facts.go:516", "depth": 2, "max_nodes": 5000, "kinds_default_excluded": ["field", "variable"], "affected": 5, "deepest_depth": 2, "by_depth": { "1": 3, "2": 2 }, "by_kind": { "CALLS_DIRECT": 5 }, "by_repository": { "kivgraph": 5 }, "by_package": [ { "package": "github.com/Luqueee/kivgraph/internal/facts", "count": 2 }, { "package": "github.com/Luqueee/kivgraph/internal/indexer", "count": 3 } ], "repository": "kivgraph", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL", "files": [ { "file": "internal/indexer/full.go", "at": [ ["mergeSets@681-712", "func", "1", "MergeAll"], ["closeCrossRepositoryEdges@735-783", "func", "1", "MergeAll"], ["Full@199-363", "func", "2", "mergeSets"] ] }, { "file": "internal/facts/facts.go", "at": [ ["Set.Merge@505-507", "method", "1", "MergeAll"], ["Diff@240-317", "func", "2", "Set.Merge"] ] } ] } } ``` The `completeness` block is cut here with `{ … }` only to keep the payload short: the compact view spells it exactly as the full view below does, and it is not a field either view abbreviates. The root is the `repository:path:line` triple every tool accepts as a selector, not the stable key the full view carries. Every row was reached by a call a type checker resolved, so `via_kind`, `via_confidence` and `via_provenance` are stated once; the kinds, the depths and the symbol each row was reached from disagree, so those three travel on the rows, in a tail after the label, in the fixed order name, kind, depth, `reached_from`, `via_kind`, `via_confidence`, `via_provenance`, stable key -- skipping whatever the header already states. The name is absent from all five tails because each qualified name already ends with it. Reading `["Diff@240-317", "func", "2", "Set.Merge"]`: a function declared on lines 240 to 317 of the group's file, two hops out, reached from `Set.Merge`, and its next call is `repository` `kivgraph`, `path` `internal/facts/facts.go`, `qualified_name` `Diff`. The same answer in the `full` view -- the field-per-row shape, with the stable key as the root and every column spelled on every row: ```json { "repository": "kivgraph", "path": "internal/facts/facts.go", "qualified_name": "MergeAll", "depth": 2, "view": "full" } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9021, "total": 5, "returned": 5, "truncated": false, "next_cursor": null, "coverage": { "exact": 5, "candidate": 0, "unresolved_related": 7, "package_level": 0 }, "completeness": { "verdict": "LOWER_BOUND", "invisible_scopes": [ { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-delta-profile", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile" }, { "reason": "PACKAGE_NOT_BUILDABLE", "repository": "kivgraph", "requested_package": "github.com/Luqueee/kivgraph/benchmarks/ladybug-recovery", "detail": "LIST: build constraints exclude all Go files in /Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" }, { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "@astrojs/node" }, … { "reason": "PACKAGE_PROVIDER_NOT_FOUND", "repository": "kivgraph", "requested_package": "vitest" } ], "fallback": { "pattern": "\\bMergeAll\\b", "paths": [ "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-delta-profile", "/Users/adria/Documents/programacion/projects/kivgraph/benchmarks/ladybug-recovery" ] } }, "results": { "root_key": "KHXAWFM5ED2YEIFIEMB5NALA7L7YXNHSUJEBLU7SDAMLGKX24UAA", "root_repository": "kivgraph", "depth": 2, "max_nodes": 5000, "kinds_default_excluded": [ "field", "variable" ], "affected": 5, "deepest_depth": 2, "traversal_truncated": false, "symbols": [ { "name": "mergeSets", "qualified_name": "mergeSets", "kind": "func", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/indexer/full.go", "start_line": 681, "end_line": 712, "reached_from": "MergeAll", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL" }, { "name": "closeCrossRepositoryEdges", "qualified_name": "closeCrossRepositoryEdges", "kind": "func", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/indexer/full.go", "start_line": 735, "end_line": 783, "reached_from": "MergeAll", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL" }, { "name": "Merge", "qualified_name": "Set.Merge", "kind": "method", "depth": 1, "repository": "kivgraph", "language": "go", "file_path": "internal/facts/facts.go", "start_line": 505, "end_line": 507, "reached_from": "MergeAll", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL" }, { "name": "Full", "qualified_name": "Full", "kind": "func", "depth": 2, "repository": "kivgraph", "language": "go", "file_path": "internal/indexer/full.go", "start_line": 199, "end_line": 363, "reached_from": "mergeSets", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL" }, { "name": "Diff", "qualified_name": "Diff", "kind": "func", "depth": 2, "repository": "kivgraph", "language": "go", "file_path": "internal/facts/delta.go", "start_line": 240, "end_line": 317, "reached_from": "Set.Merge", "via_kind": "CALLS_DIRECT", "via_confidence": "EXACT_TYPECHECKED", "via_provenance": "GO_AST_CALL" } ], "by_repository": [ { "key": "kivgraph", "count": 5 } ], "by_depth": [ { "depth": 1, "count": 3 }, { "depth": 2, "count": 2 } ], "by_kind": [ { "key": "CALLS_DIRECT", "count": 5 } ], "by_package": [ { "package_key": "package:go:kivgraph:github.com/Luqueee/kivgraph/internal/facts", "package_name": "github.com/Luqueee/kivgraph/internal/facts", "repository": "kivgraph", "count": 2 }, { "package_key": "package:go:kivgraph:github.com/Luqueee/kivgraph/internal/indexer", "package_name": "github.com/Luqueee/kivgraph/internal/indexer", "repository": "kivgraph", "count": 3 } ] } } ``` Three of the seven `invisible_scopes` entries are elided above, marked with the `…` line; the response carries them all. This answer comes from snapshot `30` of two repositories, `kivgraph` and `mole`. ## Reading the result The root is named once: `root` as `repository:path:line` in the compact view, `root_key` plus `root_repository` in the full one, so a stored answer still says what it was about. `depth` and `max_nodes` echo the bounds, including the defaults you did not pass: the request set `depth` to `2` and left `max_nodes` at `5000`. ### What the answer says about its own filter Exactly one of two fields is present, in both views, and reading it is how you know what `affected` counted: | Field | What it means | | --- | --- | | `kinds_default_excluded` | No `kinds` was passed. Its value, `["field", "variable"]`, is what the answer left out: the local bindings a backwards walk crosses on its way to real consumers. | | `kinds` | A `kinds` was passed. Its value is that selection, sorted and canonical, or `["*"]` for the wildcard that reports everything. | A filtered count that did not say it was filtered would be a lie about the size of an impact, which is why one of the two is always there. The filter applies to the page, to `affected`, to `total` and to all four aggregates alike, so every number in the response describes the same set. It never applies to the traversal itself: a consumer reached *through* an excluded binding is still reported behind it, at its own depth. It is also part of the cursor identity. A page taken under one filter cannot be resumed under another -- the token fails as `CURSOR_INVALID` rather than continuing into a set that no longer means the same thing -- and a cursor minted before the filter existed fails the same way. The default is the one that changes answers rather than shapes. Measured over the private benchmark corpus, `48` of the first `50` rows of a `get_blast_radius` page were local variables, and of the `118` symbols the same walk reached at depth 2, `29` are invocable and the rest were local bindings. `kinds: ["*"]` restores every one of them; a list such as `["func", "method"]` narrows further; a kind the loaders never publish is `INVALID_ARGUMENT`, because a typo that silently matched nothing would understate an impact. `affected` is how many symbols the change reaches, root excluded. `deepest_depth` is the deepest level any of them sits at, here `2`. Each affected symbol is a row plus the edge the walk first arrived by. `depth` is its distance from the root. `reached_from` is the qualified name of the symbol it was discovered from, and it composes into a chain you can read off the page: `Diff` at depth 2 was reached from `Set.Merge`, which was reached from `MergeAll`. `via_kind`, `via_confidence` and `via_provenance` describe that one edge: the relation, how well it is proven, and the mechanism that observed it. Every row above is `CALLS_DIRECT` / `EXACT_TYPECHECKED` / `GO_AST_CALL`, a call expression resolved by `go/types`. `CANDIDATE` and `UNRESOLVED` are distinct results from an exact edge and are never promoted into one. The `via_*` triple is the route the breadth-first frontier took, not the only one. `by_kind` is the field that does not have that limitation. ### Reading a compact row The header above `files` states what every row of the page shares, out of `repository`, `kind`, `hop_depth`, `reached_from`, `via_kind`, `via_confidence` and `via_provenance`; a field absent from it means the rows disagreed and carry their own. Inside a group, an entry of `at` is `qualified_name@start`, or `qualified_name@start-end` when the declaration spans more than one line, and it becomes an array whose elements after the label are the columns the header could not hoist, appended in the order name, kind, depth, `reached_from`, `via_kind`, `via_confidence`, `via_provenance`, stable key. Numbers in that tail are written as strings: it is a list of columns, not a record. `language` is never on a row -- the file extension says it -- and `name` is absent whenever the qualified name already ends with it. The triple for the next call is the header's `repository` (or a group's own `repo`, present only when its file sits outside the hoisted repository), the group's `file`, and the qualified name from the label. `hop_depth` in the header is the depth of the rows; `depth` beside `max_nodes` is the bound the walk was given. They are different facts that share a name in the full view. ### Reading a grouped page A type used from both methods and a top-level function, so `kind` cannot hoist to the page header: ```json { "repository": "mole", "path": "internal/admin/admin.go", "qualified_name": "Server", "depth": 2 } ``` ```json { "snapshot_id": 1, "total": 9, "returned": 9, "coverage": { "exact": 9 }, "completeness": { … }, "results": { "root": "mole:internal/admin/admin.go:58", "depth": 2, "max_nodes": 5000, "kinds_default_excluded": ["field", "variable"], "affected": 9, "deepest_depth": 2, "by_depth": { "1": 8, "2": 1 }, "by_kind": { "CALLS_DIRECT": 1, "PASSES_AS_CALLBACK": 1, "TYPE_USES": 8 }, "by_repository": { "mole": 9 }, "by_package": [ { "package": "github.com/Luqueee/mole/cmd/mole", "count": 1 }, { "package": "github.com/Luqueee/mole/internal/admin", "count": 8 } ], "repository": "mole", "via_confidence": "EXACT_TYPECHECKED", "groups": [ { "kind": "method", "hop_depth": 1, "reached_from": "Server", "via_kind": "TYPE_USES", "via_provenance": "GO_TYPES_USE", "files": [{ "file": "internal/admin/admin.go", "at": [ "Server.WithPortController@89-92", "Server.handleStatus@106-129", "Server.Handler@95-104", "Server.WithPorts@81-84", "Server.handlePortAdd@150-169", "Server.handleHealth@131-134", "Server.handlePortDelete@175-188" ] }] }, { "kind": "func", "hop_depth": 1, "reached_from": "Server", "via_kind": "TYPE_USES", "via_provenance": "GO_TYPES_USE", "files": [{ "file": "internal/admin/admin.go", "at": ["New@54-56"] }] }, { "kind": "func", "hop_depth": 2, "reached_from": "Server.WithPortController", "via_kind": "CALLS_DIRECT", "via_provenance": "GO_AST_CALL", "files": [{ "file": "cmd/mole/main.go", "at": ["runUp@117-321"] }] } ] } } ``` The `completeness` block is cut here with `{ … }` for the same reason the first example cuts it: it is not the field this page is illustrating, and [`completeness`](#completeness) below spells it in full elsewhere. `repository` and `via_confidence` still hoist to the page header -- everything affected sits in `mole`, reached by an edge a type checker resolved -- but `kind` splits three ways and `hop_depth` with it, so every group states its own pair instead of forcing one row, `Server.WithPortController@89-92`, to carry a residual tail the other eight rows do not need. The third group is alone because it is the only symbol reached at depth `2`; grouping still applies to a group of one when the alternative is a page that agrees on nothing. ### The four axes Every axis is computed over the whole traversal, not over the page, so the numbers stay stable while you page through the rows. All four count the same filtered set as `affected`. The compact view writes the three counting axes as one object each -- `by_depth` keyed by the depth, `by_kind` by the relation kind, `by_repository` by the repository name -- and keeps `by_package` as rows, because two packages of the same name in different repositories are two facts an object keyed by name would add together. A compact package row carries `package` and `count`, plus `repository` only when the package is outside the hoisted one; the package key is `package:::`, which the row already spells, and the full view keeps it. An axis that counted nothing is absent rather than empty. | Field | What it groups | | --- | --- | | `by_repository` | Affected symbols per repository `key`. Partitions `affected`. | | `by_package` | Affected symbols per package, with `package_key`, `package_name`, `repository` and `count`. Partitions `affected`. | | `by_depth` | Affected symbols per hop distance. Partitions `affected`. | | `by_kind` | Distinct relation kinds through which each affected symbol touches the traversed subgraph. | `by_kind` is counted differently on purpose. A consumer can reach the changed code through several relations at once, calling a function and using its type, and reporting only the edge the walk happened to take first would hide the others. So each affected symbol is counted once per distinct kind, and `by_kind` can therefore sum to more than `affected`. `by_repository` and `by_package` always partition it exactly. ### `coverage` `exact` counts the edges with an exact confidence, `candidate` the plausible ones. `package_level` counts facts about a package rather than about a symbol; this tool produces none, so the full view says `0` and the compact view leaves the counter out, as it does any counter at zero. Only [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) fills it, because a package dependency proves a dependency on the provider and never a use of the symbol, and summing the two would report a use nobody saw. `unresolved_related` is `7` in the answer above while `exact` is `5`. That is not an inconsistency: it counts the recorded resolution failures that could belong to this question, and it is exactly the number of entries `completeness` lists. ### `completeness` This is the field that makes the answer safe to act on. Its `verdict` is one of two values: | `verdict` | What it means | | --- | --- | | `COMPLETE` | Nothing the index recorded could add to this answer. The list is the whole list. | | `LOWER_BOUND` | The answer is a floor. The index recorded places it could not read that this question reaches, and they are named. | The captured answer is `LOWER_BOUND`. It does not say "something might be missing"; it says exactly what it could not see, in `invisible_scopes`. Each entry is evidence about a failed request, never an inferred relationship, and carries a `reason`, the `repository` it belongs to, the `requested_package` the resolver asked for, and a `detail` when the loader wrote one. Two reasons appear above: - `PACKAGE_NOT_BUILDABLE`: the package exists in the tree and the Go build configuration excludes all of its files, so the index never type-checked it. The `detail` quotes the loader verbatim. - `PACKAGE_PROVIDER_NOT_FOUND`: no repository in the corpus provides that package name, so nothing the resolver asked it for could be followed. A failure that named no symbol belongs to the package, and that is why these entries carry `requested_package` rather than a symbol: attributing an unreadable module to every symbol that package exports would invent uses nobody observed. `fallback` closes the gap instead of leaving you with a warning. `pattern` is a literal-word regular expression for the root symbol name, `\bMergeAll\b`, and `paths` are the absolute directories the graph could not read. Grep that pattern in those paths and you have covered the difference between the floor and the answer. A warning without the recovery action would force a whole-repository sweep, which costs more than not warning at all. Two more fields appear when there is more to say than one response should carry. `blind_spots` lists recorded references that named this same symbol and could not be followed, with the same shape as an invisible scope plus a `file_path` and a `start_line`. Each list is capped at 20 entries, and `more_blind_spots` and `more_invisible_scopes` carry the remainder: the count is always exact even when the list is cut, because a truncated warning must not read as a smaller problem than it is. ## Limits There are two different truncations: - `truncated` is about the page of rows. It is `true` when rows remain, and `next_cursor` then carries the token to continue. The aggregates do not page and do not change between pages. On a complete page the compact view omits both fields rather than writing `false` and `null`. - `traversal_truncated` is about the walk. It is `true` when the walk hit `max_nodes` and stopped discovering symbols, so the impact itself is understated and no cursor will recover it. Raise `max_nodes`, lower `depth`, or narrow with `edge_kinds` or `confidence`. In the captured answer both are `false`, which is why the compact payload carries neither. To take the next page, repeat the same call and add `cursor` set to the `next_cursor` value, unchanged. The token is an opaque base64url wrapper around a binary body: the format version, the snapshot id, the offset, a digest of the query identity, a digest of the sorting contract `blast-radius-v1` and a checksum over all of it -- about 31 characters, where the previous format spent 314 characters of base64-wrapped JSON. It fails closed: | What changed | What you get | | --- | --- | | A new generation was published | `CURSOR_SNAPSHOT_EXPIRED`; restart pagination | | The selector, `depth`, `max_nodes`, `edge_kinds`, `confidence` or `kinds` differ from the call that produced it | `CURSOR_INVALID` | | The token was edited, re-encoded, truncated, or decodes with trailing bytes after the checksum | `CURSOR_INVALID` | | The token was minted by a server of the previous cursor version, or before `kinds` existed | `CURSOR_INVALID`; the body declares version 2 and the default filter is part of the identity, so an older page fails closed instead of resuming a different set | `limit`, `view`, `response_format` and `include_derived` are not part of the cursor identity, so changing one of them mid-pagination is accepted: the same cursor continues the impact in another view, and changing `limit` shifts what the remaining pages contain. `kinds` is the opposite: it decides which rows exist, so it is in the identity. `guidance` appears only when the count alone would mislead: on a truncated page, naming `depth`, `max_nodes`, `edge_kinds` and `confidence` as the ways to narrow, and on zero rows, where it says the traversal reached nothing within its bounds and suggests raising `depth` or asking [`find_references`](/docs/tools/find-references/) for the direct relations only. On a complete non-empty answer it is absent. `include_derived` is `false` by default. With a Rust toolchain in the graph, the impact of a common trait method reaches most of the corpus. Withholding those rows is a decision about the page and never a claim about the graph: the edge stays published with its exact confidence. A walk that exceeds the deadline the client set on the request fails with `TRAVERSAL_LIMIT_REACHED` rather than returning a partial answer as if it were whole. ## Where it loses The impact stops at five hops and at `max_nodes`, so on a widely used symbol the honest answer is a bounded one and the `completeness` verdict is what tells you so. It reports symbols, not behaviour: a caller that is reached may still be unaffected by your particular change, and that judgement is yours. And an unindexed or unbuildable package produces no rows at all, which is why the `LOWER_BOUND` verdict and its `fallback` pattern exist rather than a number presented as the whole truth. --- # list_repositories URL: https://kivgraph.dev/docs/tools/list-repositories/ Markdown: https://kivgraph.dev/raw/docs/tools/list-repositories.md > The repositories the published graph covers, with the commit each was indexed at. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `limit` | integer | `50` | Rows to return in one page. Accepted range is 1 to 500; anything else is `INVALID_ARGUMENT`. | | `cursor` | string | none | Opaque token from `next_cursor` of a previous call, to continue the same listing. | ## Answers Which repositories the published graph actually covers, and whether each one still holds the code the graph describes. Every row carries the commit and branch the graph was built from next to the commit and branch the working tree holds right now, so one call is enough to decide whether a path or a line number from any other tool can still be trusted. Reading the current position costs two small file reads per repository and is done inline, because a caller that needs a second call to learn the first one is stale will not make it. ## Example ```json { "name": "list_repositories", "arguments": {} } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9019, "total": 2, "returned": 2, "truncated": false, "next_cursor": null, "coverage": { "exact": 0, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "results": [ { "name": "kivgraph", "path": "/Users/adria/Documents/programacion/projects/kivgraph", "languages": [ "go", "typescript" ], "indexed_commit": "d67bc0ebfb3b002f7c52fb9b048b688bd24bd28b", "indexed_branch": "main", "current_commit": "d67bc0ebfb3b002f7c52fb9b048b688bd24bd28b", "current_branch": "main", "moved": false }, { "name": "mole", "path": "/Users/adria/Documents/programacion/projects/mole", "languages": [ "go", "typescript" ], "indexed_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "indexed_branch": "main", "indexed_dirty": true, "current_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "current_branch": "main", "moved": false } ] } ``` Corpus: snapshot `30` of two repositories, `kivgraph` and `mole`. ## The row | Field | Meaning | | --- | --- | | `name` | The registered identifier of the repository. | | `path` | The absolute directory the repository was registered at. | | `languages` | The languages the repository declares, lowercased. | | `indexed_commit` | The commit the graph was built from. | | `indexed_branch` | The branch that commit was on. Absent means a detached HEAD. | | `current_commit` | The commit the working tree holds now. Absent means HEAD could not be read. | | `current_branch` | The branch the working tree is on now. | | `indexed_dirty` | Present and `true` when the tree had uncommitted changes at the time it was indexed. | | `moved` | `true` when `current_commit` differs from `indexed_commit`. | Two more fields appear only when they say something. `moved_detail` carries the reason in prose: the two commits the tree moved between, or why the comparison could not be made at all. `derived` marks a provider Kivgraph built from the machine rather than from the registry. Movement is decided by the commit alone. A branch renamed or recreated over the same commit leaves every path and every line exactly where the graph says they are, and reporting that as a move would train a caller to ignore the field. ## Names are identifiers Repository names are compared exactly. A name is an identifier, never a path component, and the stable keys that carry it are case-sensitive: two repositories differing only in case are two repositories and both must be able to register under their real name. The `rust:` namespace is reserved. The Rust standard library enters the graph as a synthetic repository named `rust:`, its row is marked `derived`, and a user-registered name that takes the namespace is rejected. That reservation is what makes the name authoritative: no extra column is needed to tell a derived provider from an indexed repository. A derived row carries no `indexed_commit`, no `indexed_branch` and no `indexed_dirty`. Nothing clones it and nothing can move it, so its `moved_detail` says that it has no commit to compare rather than reporting a freshness problem that does not exist. ## Limits - With no published generation the tool is not registered at all. See [Troubleshooting](/mcp/troubleshooting/). - A published store with no active snapshot answers `INDEX_NOT_READY`. - A `cursor` is bound to the snapshot it was issued from. After a rebuild it answers `CURSOR_SNAPSHOT_EXPIRED` and pagination has to restart, because the page it named no longer exists. - A `cursor` from a different tool or a malformed one answers `CURSOR_INVALID`. - A HEAD that cannot be read is never reported as agreement. The `current_*` fields stay empty, `moved` stays `false`, and `moved_detail` carries the reason. An unknown answer must not read as a good one. - The row describes registration and position, not size. For counts, breakdowns and per-repository freshness in one answer, call [`graph_status`](/docs/tools/graph-status/). --- # graph_status URL: https://kivgraph.dev/docs/tools/graph-status/ Markdown: https://kivgraph.dev/raw/docs/tools/graph-status.md > The published generation: counts, provenance, and whether a repository moved since it was indexed. Call it when an answer looks stale. ## Arguments None. The input schema declares no properties and sets `"additionalProperties": false`, so the only valid argument object is `{}`. ## Answers Whether there is a graph, what it was built from, how big it is, how much of it failed to resolve, and whether the repositories it describes still hold the code it describes. It is the tool a client calls when another tool answers `INDEX_NOT_READY` or returns something that looks stale, so it never fails on a missing snapshot: reporting that the index is empty is its job. ## Example ```json { "name": "graph_status", "arguments": {} } ``` ```json { "snapshot_id": 30, "snapshot_age_ms": 9018, "total": 1, "returned": 1, "truncated": false, "next_cursor": null, "coverage": { "exact": 0, "candidate": 0, "unresolved_related": 0, "package_level": 0 }, "results": { "status": "ready", "snapshot_id": 30, "snapshot_built_at": "2026-08-15T11:14:10Z", "snapshot_age_ms": 9016, "snapshot_row_format_version": 3, "schema_version": 2, "resolver_version": "0.5.0", "repositories": 2, "packages": 57, "files": 311, "symbols": 10957, "evidence": 40125, "edges": 40125, "package_edges": 123, "unresolved": 1642, "edges_by_kind": [ { "key": "ASSIGNS_FUNCTION", "count": 22 }, { "key": "CALLS_DIRECT", "count": 6198 }, { "key": "EMBEDS", "count": 8 }, { "key": "EXPORTS", "count": 312 }, … { "key": "REFERENCES", "count": 25335 }, { "key": "RETURNS_FUNCTION", "count": 62 }, { "key": "TYPE_USES", "count": 8036 } ], "unresolved_by_reason": [ { "key": "DECLARATION_NOT_RESOLVED", "count": 4 }, { "key": "MODULE_PROVIDER_NOT_FOUND", "count": 1487 }, { "key": "PACKAGE_NOT_BUILDABLE", "count": 2 }, { "key": "PACKAGE_PROVIDER_NOT_FOUND", "count": 149 } ], "repository_freshness": [ { "name": "kivgraph", "path": "/Users/adria/Documents/programacion/projects/kivgraph", "languages": ["go", "typescript"], "indexed_commit": "d67bc0ebfb3b002f7c52fb9b048b688bd24bd28b", "indexed_branch": "main", "current_commit": "d67bc0ebfb3b002f7c52fb9b048b688bd24bd28b", "current_branch": "main", "moved": false }, { "name": "mole", "path": "/Users/adria/Documents/programacion/projects/mole", "languages": ["go", "typescript"], "indexed_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "indexed_branch": "main", "indexed_dirty": true, "current_commit": "4cc05cdc2c73cb7111b7b38447639c1444ab8410", "current_branch": "main", "moved": false } ], "repositories_moved": 0, "worker": { "state": "not_applicable", "detail": "the TypeScript worker runs during indexing, not in this server" }, "storage": { "state": "not_applicable", "detail": "this server answers from the published snapshot and never opens the database" }, "metrics": { "queries": {}, "snapshot": { "id": 30, "created_at": "2026-08-15T11:14:10.983894Z", "age": 9018245000, "build_duration": 0, "bytes": 0 } } } } ``` Corpus: snapshot `30` of two repositories, `kivgraph` and `mole`. ## Identity and freshness | Field | Meaning | | --- | --- | | `status` | `ready` when a snapshot is published and queryable, `empty` when none is. With `empty`, every query tool answers `INDEX_NOT_READY`. | | `snapshot_id` | The published generation being served. Publication accepts only a strictly newer generation, so a lower id than an earlier answer means the store was rebuilt from empty. | | `snapshot_built_at` | When that generation was built. | | `snapshot_age_ms` | How long ago, in milliseconds. This is the number that says whether an answer is minutes or days old. | | `schema_version` | The canonical graph schema the generation was written with. | | `schema_version_expected` | The canonical schema this binary builds. Present only when it differs. | | `schema_outdated` | The comparison between the two above, stated rather than left for the reader to make. | | `resolver_version` | The resolver that produced its edges. | | `snapshot_row_format_version` | The format version of the hot snapshot itself, as distinct from the graph schema it was derived from. | | `snapshot_unreadable` | Why the generation this server holds could not be mapped, when that is what happened. | | `last_rebuild_at` | When a full rebuild last completed in this deployment. | | `last_update_at` | When the graph was last updated, rebuild or reconciliation. | A `schema_version` older than the binary expects is what `kivgraph upgrade` exists for. A generation published by an older binary **stays readable** -- the snapshot is a projection with its own row format, so every query answers -- but it cannot carry facts its resolver never emitted. That answer looks complete and is not, which is why the comparison is reported instead of inferred. `snapshot_unreadable` distinguishes two states that share a `status` of `empty`. The graph is read by the first query that needs it rather than at startup, so a snapshot that cannot be mapped reaches a caller instead of killing the process. Without this field, «could not be read» would look like «never indexed», which has a different fix. Absent when nothing was refused. Two answers that disagree on `snapshot_id` came from two different graphs. ## Counts | Field | Meaning | | --- | --- | | `repositories` | Registered repositories in the generation, derived providers included. | | `packages` | Packages, modules or crates across them. | | `files` | Indexed files. | | `symbols` | Indexed symbols. | | `edges` | Resolved symbol edges. | | `evidence` | Evidence records. Every edge is attributed to one, which is what lets a row name the position it was observed at. | | `package_edges` | Edges between packages rather than symbols. | `repositories` is the count. The per-repository array is called `repository_freshness`, and the two are deliberately not the same key. ## Breakdowns `edges_by_kind` counts every resolved symbol edge once, under its kind. Every edge is held in the forward adjacency under its source, so walking it counts each one exactly once; the reverse adjacency is the same multiset seen from the other end. `unresolved` is the total of references the passes could not attribute to a declaration, and `unresolved_by_reason` says why: | Reason | Meaning | | --- | --- | | `MODULE_PROVIDER_NOT_FOUND` | No registered repository declares the module that owns the target. | | `PACKAGE_PROVIDER_NOT_FOUND` | Nothing registered provides that package specifier. | | `PACKAGE_NOT_BUILDABLE` | Build constraints selected no file in the package, so there was nothing to type-check. | | `DECLARATION_NOT_RESOLVED` | The checker resolved no declaration for the reference. | The vocabulary is open. Each language pass emits its own reasons and the server does not validate them against a fixed list, because rejecting a reason it had not heard of would discard a real fact. A large `unresolved` beside a healthy `edges` count is normal for a corpus whose dependencies are outside the registry: nothing registered provides them, which is exactly what the reason says. ## Repository freshness `repository_freshness` carries one row per repository, in the same shape [`list_repositories`](/docs/tools/list-repositories/) returns. It answers, in the one call an agent makes before trusting anything else, whether what it is about to be told is stale. | Reading | What it means | | --- | --- | | `indexed_commit` equals `current_commit` | The graph describes the tree on disk. Paths and line ranges are current. | | `indexed_commit` differs from `current_commit` | The graph is behind the working tree. `moved` is `true` and `moved_detail` names both commits. Line numbers may be wrong and symbols may be gone. | | `indexed_dirty` is `true` | The tree had uncommitted changes when it was indexed, so the commit alone does not identify what was read. | | `moved` is `true` and `path` no longer exists | The registered directory is gone. Nothing under it can be read. | | `current_commit` absent | HEAD could not be read. `moved_detail` carries the reason. | `repositories_moved` counts the rows that left their indexed commit. A repository whose HEAD could not be read is not one of them and is not counted as fresh either; its own row says why. Reading each HEAD is what makes the answer worth anything: a status that only repeated what the snapshot remembers could not tell a caller that the snapshot is no longer true. ## It reports only what was used and measured `worker` and `storage` are both `not_applicable` in the captured response, each with a reason: ```text the TypeScript worker runs during indexing, not in this server ``` ```text this server answers from the published snapshot and never opens the database ``` That is the rule this tool follows, and it is the point of the page. `serve` answers from the published hot snapshot. It never opens the database and never runs the TypeScript worker, so reporting those two as unconfigured would suggest a misconfiguration where there is none, and reporting them as healthy would claim a probe nobody ran. `not_applicable` with a reason is the only honest answer. What is or is not being served is `status`, `snapshot_id` and `snapshot_age_ms`. The same rule governs the optional sections. `metrics` is present only when the hosting process wires a metrics registry; without one the key is absent rather than an object of zeros. `derived` is absent when the graph holds no derived provider. A section reporting zeros would claim a measurement nobody made. ## The derived breakdown The Rust standard library enters the graph as a synthetic repository named `rust:`. It is large, and folded into the totals silently it would answer "how big is my code" with a number about Rust: without a separate breakdown a ten-symbol repository appears to answer for tens of thousands of symbols. So when the graph holds one, `derived` appears beside the counts: | Field | Meaning | | --- | --- | | `repositories` | The name of every derived provider in the snapshot. | | `packages` | Packages belonging to them. | | `files` | Files belonging to them. | | `symbols` | Symbols declared in those files. | | `edges_within` | Edges whose source is a derived symbol: the standard library referring to itself. | | `edges_inbound` | Edges that leave a registered repository and land in a derived provider. This is the number the feature exists for. | | `unresolved` | Gaps the derived provider declares about its own code, none of them the caller's. | An edge is attributed to the repository of its source symbol, the side that made the observation. Counting a registered repository's use of `core` as the standard library's own edge would hide exactly what indexing it was for. ## Limits - It reports the published generation, not the one being built. A rebuild in another process becomes visible when its generation is published. - The tool is registered only once a generation exists. Before that, `serve` registers `index_project` alone; see [`index_project`](/docs/tools/index-project/). - Counts come from the snapshot metadata. They describe what the passes indexed, not what exists on disk: a reference the passes could not attribute appears in `unresolved` and `unresolved_by_reason`, never as an edge. - Freshness is per repository and per commit. It cannot tell you that a single file changed under an unchanged commit; `indexed_dirty` is the only signal that uncommitted work was involved. --- # index_project URL: https://kivgraph.dev/docs/tools/index-project/ Markdown: https://kivgraph.dev/raw/docs/tools/index-project.md > Registers projects and rebuilds the graph once, after explicit user approval. Pass every project in one call: a rebuild costs the whole corpus. It never writes inside the source projects. ## Arguments | Argument | Type | Default | Meaning | | --- | --- | --- | --- | | `projects` | array of objects | none | The batch to register and index. Each entry requires `name`, `path` and `languages`, and accepts nothing else. This is the form to use. | | `name` | string | none | Single-project form: the repository identifier. | | `path` | string | none | Single-project form: the repository directory. Absolute, `~`-prefixed, or relative to the working directory of the server process. | | `languages` | array of strings | none | Single-project form: the languages to index. | | `confirmed` | boolean or null | `null` | Approval from a client that cannot use MCP elicitation. Only `true` proceeds. | Naming both forms in one request is rejected with `INVALID_ARGUMENT`, because two selectors can disagree and there is nothing to decide between them. Naming neither is rejected the same way. The accepted `languages` values are the supported language vocabulary, and nothing else: ```text go, typescript, javascript, ts, js, rust, rs ``` Values are lowercased and trimmed. A duplicate in one entry, an empty string, or a word outside the list is rejected before anything is written. ## Answers Nothing, in the sense the other ten tools do. It registers the projects it is given in the repository registry, rebuilds the complete canonical graph once, and publishes the result as a new generation. On success it reports the generation and snapshot it published together with the per-language counters of the pass. It is the only mutating tool on the surface. ## Consent It refuses without explicit approval. A client that declares the MCP elicitation capability is asked directly, and the request proceeds only when the user accepts. A client that does not implement elicitation must obtain approval itself and then send `confirmed: true`; sending it without having asked is a lie the server cannot detect. Called without either, it fails: ```text PERMISSION_REQUIRED: user approval is required; confirm the operation before setting confirmed=true ``` An elicitation the user declines fails with `PERMISSION_DENIED`. An elicitation the client cannot deliver fails with `PERMISSION_REQUIRED`. The prompt names what approval covers: the project and its path for a single project, or the count and every name for a batch. Approving "11 projects" without seeing which ones is not approval. ## Example ```json { "name": "index_project", "arguments": { "name": "kivgraph", "path": "/Users/adria/Documents/programacion/projects/kivgraph", "languages": ["go"] } } ``` ```text PERMISSION_REQUIRED: user approval is required; confirm the operation before setting confirmed=true ``` Corpus: snapshot `30` of two repositories, `kivgraph` and `mole`. The captured call sent no `confirmed` and the client declared no elicitation capability, so the refusal is the whole response. The batch form, which is the one to send: ```json { "name": "index_project", "arguments": { "projects": [ { "name": "kivgraph", "path": "/Users/adria/Documents/programacion/projects/kivgraph", "languages": ["go", "typescript"] }, { "name": "mole", "path": "/Users/adria/Documents/programacion/projects/mole", "languages": ["go", "typescript"] } ], "confirmed": true } } ``` ## Pass every project in one call This is not a style preference. A rebuild resolves cross-repository edges over the complete fact set, so there is no cheaper unit than the whole corpus: adding one project costs exactly what adding eleven costs. Calling the tool once per project pays that full cost once per project and throws away every result but the last, because only the newest generation is published. Eleven projects registered one call at a time cost eleven rebuilds for one useful graph. Registered together they cost one. The whole batch is registered before anything is built. If the index or the rebuild then fails, the prior registry is restored and the previous generation stays published. ## When it is available The tool exists only on a configured `serve` route. Without a configured indexer it is not registered at all, so the read-only server cannot gain filesystem or storage access by accident. It is also the only tool registered when no generation has been published yet. That is how a client with no graph builds its first one: the server completes the handshake, publishes `index_project` alone, and the other ten appear once a generation exists. See [Troubleshooting](/mcp/troubleshooting/). ## Registering the same project twice Indexing a project that is already registered is not a conflict. The caller asked for that repository to be in the graph, and it is. - A project already registered with the same directory is reindexed without touching the registry. An identical request changes nothing on disk at all. - A change of `languages` for the same directory replaces the entry, and the `exclusions` on file survive it. The request cannot express exclusions, and dropping them would silently widen the index to directories that were excluded on purpose. - Only a name already held by a **different** directory is a real conflict, because then nothing can decide which of the two repositories the name means. The error names the registered path: `project "mole" is already registered at "/repos/mole": choose another name or remove that entry`. Names are compared exactly, and a name is an identifier: `.`, `..` and anything containing a path separator are rejected. The `rust:` namespace is reserved for the providers Kivgraph derives from the toolchain and cannot be taken. ## Progress A full rebuild takes minutes on a large registry, while an MCP client applies its own timeout to the call, thirty seconds in some, and cancels work that is progressing fine. A request that carries a `progressToken` gets `notifications/progress` for every unit of work: the phase, the repository and a detail. A client that honours them waits for as long as the work reports. Without a token no progress callback is installed at all, so the index does not pay for a channel nobody reads. Progress counts up and never repeats a value, as the protocol requires. A notification that cannot be delivered is dropped rather than failing the index. The pass itself runs as a child process — `index --full --json` — and the server forwards the progress it reports. That is what keeps the peak of an index out of the process answering queries; see [Indexing](/guides/indexing/#where-a-pass-runs). ## Limits - It never writes inside the source projects. It writes the repository registry and the Kivgraph state directory; the checkouts it reads are left untouched. - One index runs at a time inside a process. An `index_project` and a resynchronisation cannot overlap, and across processes a lock elects the single writer: the loser does not wait, because a rebuild lasting minutes would look like a hang. - A failure is reported as `INDEXING_FAILED` with the observed cause in the message: a module that needs a newer toolchain, a path that is not a repository, a dependency the module cache does not hold. - `path` must exist and be a directory. A relative path is resolved against the working directory of the server process, which is the client's choice, not yours; prefer an absolute path. - Rust is indexed through an external `rust-analyzer scip` process and needs it configured. See [Configuration](/docs/configuration/). - It publishes a new generation, so every cursor issued from the previous one expires with `CURSOR_SNAPSHOT_EXPIRED`. --- # Code Intelligence MCP Benchmark URL: https://kivgraph.dev/comparison/ Markdown: https://kivgraph.dev/raw/comparison.md Five tools that call themselves code graphs, plus a sixth arm that is not a tool at all — `grep` and reading the files, which is what an agent already has — asked the same questions over the same corpus, against a hand-written ground truth. This page is a benchmark report, not a product page. Two passes are published below. They were run a day apart against different versions of one arm and a different number of questions, so they are kept in **two separate tables and never averaged together**. Every figure names the JSON file and the commit it came from, and the raw response of every call is committed next to the harness, so any number here can be checked against the bytes it came from. ## Methodology Each arm is driven by the harness in `benchmarks/graph-tools-comparison/`, one Go file per arm. A question is put to every arm **in that arm's own vocabulary** — `callers_of` in one, `affected` in another, `trace_path`, `impact` or Cypher in a third. What is compared is the answer, never the spelling. Scored per question: - **tokens** — every byte the arm returned, counted with `o200k_base`, including the calls that were wrong or empty. - **calls** — how many round trips the answer took. - **precision** and **recall** against the ground truth for that question. - **exact** — precision and recall both `1.00`. ### Why three families of question Five tools that share a category do not share a question. Asking only "who calls this" would have put two of them at zero for being outside their purpose rather than for being wrong: graphify is a BFS over an extracted graph, and code-review-graph is built around blast radius. So the seven-question `measured` set is three families, each one the question some tool's own documentation says it answers: | family | questions | truth | | --- | --- | --- | | references | 4 — one per language, one cross-package | the files holding a call or reference | | impact | 1 — transitive, two hops | the files that reach the subject | | outline | 2 — one large file, one small | the names declared at the top level | The small outline is there on purpose: three declarations in 78 lines is where [Limits](/limits/) already says an index costs more than reading the file, and a benchmark that only asked the flattering size would be measuring its own question selection. The 29-question `all` set unions that seven-question set with six further sets (`hard`, `impact`, `reach`, `chain`, `rust`, `trivial`) and spans eight families, adding cross-repository consumers, outward dependencies, symbol location, fact lookup and source-body retrieval. ### Isolation Nothing was written inside the corpus. Checked with `git status` across all 37 repositories before and after: only the two `go.sum` files that were already dirty. Each arm's state lives outside the corpus — a `--data-dir`, an isolated `HOME`, or a context directory under `/private/tmp`. One arm needs saying plainly: **graphify writes `graphify-out/` beside the code it reads**, so it only ever ran against a private copy of the corpus. Anyone pointing it at their own repository gets a new directory inside it. Every index was timed cold, with the derived state deleted first, because "cold" has to mean the same thing in every row. ## Corpus 37 git repositories in one private monorepo, in Go, TypeScript, Rust, Python and Dart. On Pass A, indexing the whole corpus published `96,482` symbols. The corpus is private. The questions, the ground truth and the captured responses are all published; the code they run over is not. That is the single largest thing a reader cannot independently re-run. Because it is private, the repository, file, package and symbol names on this page are substituted throughout. Every count, every hop, every edge kind, every precision figure and every token figure is the measured one; only the names were changed. Where a token figure depends on the length of the identifiers themselves, that is noted at the figure: it was measured against the real names, not the substitutes printed here. ## Results ### Pass A — five code graphs and grep, seven questions Source: `benchmarks/graph-tools-comparison/results.json`, commit `4c1bfae`, generated 2026-08-21, tokenizer `o200k_base`, kivgraph `0.3.2`, question set `measured`. This is the only pass in which all six arms produced real measurements. | tool | version | tokens | calls | precision | recall | exact | | --- | --- | --- | --- | --- | --- | --- | | kivgraph | `0.3.2` | `4,449` | 11 | `0.81` | `0.84` | `4/7` | | graphify | `0.8.31` | `2,469` | 9 | `0.54` | `0.35` | `1/7` | | graft | `0.10.1` | `8,942` | 7 | `0.14` | `0.14` | `1/7` | | codebase-memory-mcp | `0.8.1` | `25,961` | 21 | `0.67` | `0.81` | `3/7` | | code-review-graph | `2.3.7` | `109,298` | 10 | `0.67` | `0.85` | `3/7` | | `grep` + reading | — | `63,531` | 27 | `1.00` | `1.00` | `7/7` | Two readings, and they should not be mixed. **`grep` plus reading answers all seven**, and it is the honest denominator: the real alternative to these tools is not being wrong, it is spending `63,531` tokens. **Among the five graphs, the cheapest is not the most accurate**: graphify is the cheapest row on the table and answers one of seven. At `0.3.2` Kivgraph answered four of seven — the three it missed are what the next pass was run to check. ### Pass B — the released version, twenty-nine questions Source: `benchmarks/graph-tools-comparison/results-all.json`, commit `954b9eb`, generated 2026-08-22, tokenizer `o200k_base`, kivgraph `0.5.0`, question set `all`, same 37-repository corpus. | tool | version | tokens | calls | precision | recall | exact | | --- | --- | --- | --- | --- | --- | --- | | kivgraph | `0.5.0` | `35,961` | 36 | `1.00` | `0.9962` | `28/29` | | `grep` + reading | — | `267,980` | 101 | `1.00` | `0.9885` | `28/29` | **Only two arms are published here, because only two arms ran.** In this pass the other four failed to start, and their token columns in `results-all.json` hold error strings rather than measurements: - code-review-graph — `exit status 1` - codebase-memory-mcp — `project not found or not indexed` - graft — `chdir /private/tmp/st11/graft-ctx: no such file or directory` - graphify started, but answered only 4 of the 29. A failure to start is an operational fact about one machine on one afternoon, not a measurement of a tool's accuracy, so those columns are not published in any form. That is why Pass A above is still the five-way table even though it measures an older Kivgraph: it is the last pass in which every arm answered. On totals the ratio is `7.45x` in Kivgraph's favour; the median per-question ratio is `5.95x`. Both arms reach precision `1.00`, and both miss exactly one question. Kivgraph's miss is `R3_ts_intra`, recall `0.889` — one TypeScript test file. `grep`'s miss is `X1_ts_shared_enum`, recall `0.667`, where the harness note reads: "searched 5330 code files, read 6 declaring file(s); 1 true consumer(s) never spell the symbol, so no text search reaches them". The widest per-question margins are `X3_go_reach_depth1` at `86.8x`, `H5_rs_trait` at `83.4x` and `X9_go_reach_depth1` at `55.4x`. ### Five questions in detail All from Pass B, `results-all.json`. | question | truth | kivgraph `0.5.0` | `grep` + reading | | --- | --- | --- | --- | | `R1_ts_xrepo` — call sites of the `withBackoff` declared in `platform-lib/src/util/retry.ts` | 5 files in 3 repositories | `332` tokens, 2 calls, exact | `10,054` tokens, 8 calls, exact | | `X1_ts_shared_enum` — files outside `platform-lib` consuming the `StatusCode` it declares | 3 files | `530` tokens, 1 call, 3 of 3 | `12,200` tokens, 7 calls, 2 of 3 | | `I1_go_depth2` — files holding something that reaches `jitterFor` within two hops | 2 files | `2,380` tokens, 1 call, exact | `897` tokens, 2 calls, exact | | `H5_rs_trait` — call sites of the Rust trait method `remove_entry` | 3 files | `264` tokens, 2 calls, exact | `22,016` tokens, 4 calls, exact | | `T1_go_trivial` — `newMetricsClient`, two occurrences in the whole corpus | 2 files | `123` tokens, exact | `65` tokens, exact | `R1_ts_xrepo`'s truth is `client-sdk:src/client/session.ts`, `runtime-core:src/queue/dispatcher.ts`, `runtime-core:src/queue/worker.ts`, `runtime-core:src/queue/partitioning.ts` and `edge-service:src/rpc/server.ts`. On `X1_ts_shared_enum` the single call was `find_cross_repo_consumers`, which also reported 22 package-level rows separately and did not count them as uses; `grep` missed `client-sdk:src/index.ts`. On `I1_go_depth2` `grep` is the cheaper arm and both are exact. On `T1_go_trivial` the graph costs `1.9x` what reading costs. ### A name is not a symbol `withBackoff` is declared **seven times** in this corpus — four of them TypeScript and three of them Go, spread across five repositories. `epoch_ms`, four times, all of them Rust. That case is what splits the five graphs in Pass A, and three of them fail it the same way. - **codebase-memory-mcp** points its `CALLS` edges at the name, so the callers of all seven `withBackoff` collapse onto one node. On the Go question it recovers both correct files and drags in four TypeScript ones — `P=0.33`. On the TypeScript one the answer is empty: every caller attached to the Go homonym, so the TypeScript node has in-degree zero. - **code-review-graph** disambiguates the *subject* — it refuses to choose and names both candidates with file and line — but not the call sites. Narrowed to the `storage` declaration it still returns `internal/secrets/provider_test.go`, which calls the other one: impossible in Go, different packages, unexported function. `P=0.67`. - **graft**, given an ambiguous name, **drops the cross-file callers and warns that it may undercount**. That is the opposite of inventing an edge, and it is honest — and it leaves the Go and Rust questions at zero. Kivgraph's edges come from `go/types`, the TypeScript checker and `rust-analyzer` rather than from matching names, so the homonyms stay apart. The graph holds 22 symbols named `withBackoff` — the other 15 are TypeScript barrel `import`/`export` symbols — and the name appears in 22 files. Asked by bare name the server refuses rather than guessing: ```text AMBIGUOUS_SYMBOL: name "withBackoff" declares 7 symbols; repeat with the repository and path of the one you mean: config-lib:src/retry.ts:41, media-service:internal/secrets/provider.go:240, data-service:internal/secrets/provider.go:143, client-sdk:src/managers/command.ts:19, data-service:internal/storage/retry.go:49, platform-lib:src/util/retry.ts:135, client-sdk:src/types/result.ts:91 ``` That refusal is not free. It costs `129` tokens — measured against the real identifiers rather than the substituted ones printed above — and it is charged to Kivgraph in every table on this page. ### Two numbers that are not mistakes **code-review-graph's `82,057` tokens on impact in Pass A are a different question, not an error.** Its `impact` takes changed *files*, not declarations: asking about `retry.go::jitterFor` answers "0 nodes changed". Asked about the file, two hops reach `390` nodes in `255` files, and against a two-file truth the precision is `0.01`. It finds both — recall `1.00`. That `0.01` measures granularity, not correctness. **graphify's `affected` is not reverse reachability.** Its `graph.json` is written `"directed": false`, so networkx loads an undirected graph with no in-edges and the fallback walks edges whose stored orientation ends at the seed — and that orientation is node insertion order, which is file walk order, not call direction. Its own output shows it: `affected` on `withBackoff` returns its *callees*, on `jitterFor` it returns nothing despite three incoming call edges, and the same command in another repository returns 43 genuine callers. ## Where each tool loses **kivgraph.** It is the slowest arm to index and the heaviest on disk: `37.6 s` cold and `1,423 MB` of state, both measured on Pass A at `0.3.2`, not on Pass B — Pass B recorded no index times at all. It is the only arm that needs a toolchain: without the Go module cache or `cargo`, a load fails and those symbols are simply absent. `grep` is cheaper than it on 5 of the 29 Pass B questions, with both arms at recall `1.00` on all five — `A1_go_absent` `0.26x`, `A2_ts_absent` `0.38x`, `I1_go_depth2` `0.38x`, `A3_rs_absent` `0.47x`, `T1_go_trivial` `0.53x`. Its compact label for a module-owned use is lossy: `at` names the declaration holding the reference, and a use with no enclosing declaration is held by its module, so four calls in one test file produce four identical `module@1` labels instead of four call lines. And 28 of 29 is **no known miss on twenty-nine questions, not an absence of misses**; the row that would belong here is the question nobody has written yet. **`grep` + reading.** It is the most expensive arm in both passes — `63,531` tokens on seven questions, `267,980` on twenty-nine — and in Pass B it took 101 calls to do what the graph did in 36. Its accuracy failure is narrow but real: `X1_ts_shared_enum`, where a true consumer never spells the symbol, so no text search reaches it. Text search cannot distinguish "no result" from "no occurrence of this spelling". **codebase-memory-mcp.** Name-keyed `CALLS` edges, so homonyms merge and the callers of one declaration are attributed to another: `0.67` precision, `0.81` recall, 3 of 7 exact in Pass A. It is also the one arm whose score moved between identical cold passes. In Pass B it did not run at all. **code-review-graph.** The most expensive graph in Pass A by an order of magnitude — `109,298` tokens on seven questions, most of it the file-granularity impact answer above. It disambiguates subjects but not call sites, and its graph is per-repository, so a cross-package reference is structurally invisible to it. In Pass B it did not run at all. **graft.** The lowest accuracy in Pass A, `0.14` precision and `0.14` recall, 1 of 7 exact, because it declines ambiguous names rather than answering them. Only the free structural tier was measured; `graft --deep` needs a provider key. In Pass B it did not run at all. **graphify.** `0.54` precision, `0.35` recall, 1 of 7 exact in Pass A. Its `affected` walks an undirected graph, so the answer is not reverse reachability at all. Its graph is per-repository, and its `build` truncates the data directory it is given, so each repository needs its own. It writes `graphify-out/` inside the tree it indexes. In Pass B it answered 4 of 29. ## Entry cost From the `indexing` block of `results.json` — Pass A only, commit `4c1bfae`, kivgraph `0.3.2`. **Pass B measured no index times**: `results-all.json` carries an empty `indexing` block, so nothing on this table describes kivgraph `0.5.0`. | tool | cold | disk | scope | needs | | --- | --- | --- | --- | --- | | codebase-memory-mcp | `5.1 s` | `221 MB` | whole corpus, 37 repositories | nothing beyond the binary | | code-review-graph | `7.2 s` | `201 MB` | one graph per repository; built the 4 the questions name | nothing beyond the binary | | graphify | `11.1 s` | — | one graph per repository; built the 4 the questions name | nothing for the structural pass | | graft | `24.6 s` | `181 MB` | whole corpus, 37 repositories | nothing for the structural tier | | kivgraph | `37.6 s` | `1,423 MB` | whole corpus, `96,482` symbols published | Go module cache, `cargo` | | `grep` + reading | `0` | — | nothing is indexed | nothing | A per-repository graph has a consequence beyond cost: a cross-package reference is structurally invisible to it, which is why code-review-graph and graphify both answer zero on that question. ## Raw outputs Everything the tables are computed from is committed in `benchmarks/graph-tools-comparison/`. - **Harness** — `main.go` drives the run, `questions.go` holds every question, and one file per arm (`arm_kivgraph.go`, `arm_graft.go`, `arm_graphify.go`, `arm_cmm.go`, `arm_crg.go`) translates a question into that arm's vocabulary. - **Ground truth** — written by hand in `questions.go` and mirrored verbatim into the `ground_truth` field of every question in every results JSON, so the scoring can be checked without reading the harness. - **Results** — `results.json` backs the Pass A table and the Entry cost table. `results-all.json` backs the Pass B table and the per-question detail. The individual sets that `all` unions are also committed on their own: `results-hard.json`, `results-impact.json`, `results-reach.json`, `results-chain.json`, `results-rust.json` and `results-trivial.json`. - **Captures** — the verbatim stdout of every call, one file per question and arm: `raw/` (the `measured` set), `raw-all/`, `raw-trivial/`, `raw-hard/`, `raw-impact/`, `raw-reach/`, `raw-chain/`, `raw-rust/` and `raw-0.3.6/`. That last directory and its `results-0.3.6.json` are misnamed: the pass they hold records kivgraph `0.5.0`, not the version in the filename, and no number on this page is attributed to the version that filename suggests. ## Reproduction ```sh go run ./benchmarks/graph-tools-comparison --set all ``` `--set` selects the question set: `measured` (the seven of Pass A, and the default), `hard`, `impact`, `reach`, `chain`, `rust`, `trivial`, or `all` (the 29 of Pass B). An unknown name is a failure rather than a fallback, so a run cannot silently measure the wrong set. The other flags point each arm at its executable, the corpus at its root, and every arm's state at an isolated directory; `--skip-indexing` reuses existing indexes instead of rebuilding them cold. The corpus is private, so the command reproduces the method rather than the numbers. Pointing it at another corpus needs new ground truth in `questions.go`. ## Limitations - 29 questions, one corpus, one machine. Not a general measure of quality for any of the arms. - `o200k_base` is a proxy for the Claude tokenizer: the ratios between rows are the claim, the absolute values are not. - Three full cold passes, and the spread was measured rather than assumed: kivgraph, graft, graphify and `grep` returned **the same number all three times**; code-review-graph moved by `0.1%`; codebase-memory-mcp by `1.8%` in tokens **and between `3` and `4` exact answers out of seven**, so it is the one row whose accuracy depends on the pass. - Only the free, model-free tiers. `graft --deep`, graphify's semantic pass and code-review-graph's embeddings need a provider key and were not measured. - code-review-graph and graphify indexed the four repositories the questions name rather than all 37, because their graph is per-repository. - Kivgraph was measured with its default view, not the `files` view that answers the same reference questions for a fraction of the tokens. Taking the discount only one arm has would have compared our summary against everyone else's detail. - The corpus is private. The questions, the ground truth and the captured responses are publishable; the code is not, so nobody outside this machine can re-run the measurement itself. --- # Limits URL: https://kivgraph.dev/limits/ Markdown: https://kivgraph.dev/raw/limits.md A declared hole is a fact. Everything on this page is a known limitation, not a bug waiting to be discovered. ## `grep` is cheaper on five of the 29 benchmark questions On the current run — 29 questions, 37 repositories, Kivgraph `0.5.0`, tokenizer `o200k_base` — plain text search costs fewer tokens than the graph on five questions, and both arms answer all five at recall 1.00: | Question | `grep` cost, relative to Kivgraph | | --- | --- | | `A1_go_absent` | 0.26x | | `A2_ts_absent` | 0.38x | | `I1_go_depth2` | 0.38x | | `A3_rs_absent` | 0.47x | | `T1_go_trivial` | 0.53x | They share a shape: a rare name, one repository, few files to open. A graph query has a fixed price that a two-hit `grep` does not, and no amount of indexing removes it. See the [comparison](/comparison/) for the whole run. The same run records one recall miss of our own: `R3_ts_intra`, where the answer omitted a single TypeScript test file (recall 0.889). Every other question in the set is exact. ## Python resolves exactly only with a configured analyzer The five languages are not resolved to the same standard. Go, TypeScript and Rust edges are type-checked; Dart edges are resolved by Dart Analysis Server; Python uses exact semantic facts when a configured analyzer provides them and `CANDIDATE` facts in its bundled AST fallback. A `CANDIDATE` Python edge is not a proven call. Treating an empty Python answer as proof of absence is only safe when the response reports the confidence and completeness to back it. ## Nothing answers before a generation is published `serve` with no published graph registers `index_project` and nothing else. That is deliberate — publishing ten tools that all answer `INDEX_NOT_READY` teaches an agent that the tools do not work — but it does mean a fresh install cannot answer a single question until `kivgraph index --full` has completed. There is no incremental path: a rebuild is a full pass over every registered repository. ## Rust does not index `core`, `std` or `alloc` That single absence explains four measured silences: - `#[derive(...)]` produces no relation. - Operator overloading does not reach its local `impl`: `a + b` is attributed to `core::ops::Add::add`. - The `?` operator lands in `Try::branch`. - Every call into the standard library disappears. Fabricating those edges against a target nobody publishes is forbidden by the graph contract, and emitting an `UNRESOLVED` for every `derive` would be worse than the current silence. Indexing the sysroot changes the size and the versioning of the graph and is tracked as its own task. Related Rust limits: a symbol behind an inactive Cargo feature is absent from the graph and reported as unresolved; a crate version the analyzer does not know (`.`) identifies no code and never resolves; local symbols (`local N`) are a per-document counter, are not addressable, and never enter the graph. ## Go type-checking has a version ceiling `go/types` travels linked into the binary, so Kivgraph type-checks only up to the language version of the toolchain that compiled it. A registered module above that ceiling is rejected by name — repository, module and version — rather than being allowed to escalate the synthetic `go.work` toolchain and break the load of every other repository inside the standard library. `kivgraph doctor` reports that ceiling. It is not the `go` on your `PATH`, and it is the number that decides whether a repository can be indexed. ## Published platforms Linux `amd64` and macOS `arm64`, and only those. `darwin/amd64` is out of scope by decision, not by cost, and the installer says so when it refuses. A bundle is always built on a host of its own platform: cgo links the native library and there is no cross-compilation. ## macOS artifacts are not notarized The project uses no Developer ID. The binary carries an ad-hoc signature, which is what Apple Silicon requires in order to execute. Gatekeeper only blocks a file carrying `com.apple.quarantine`, which neither `curl` nor `tar` writes. ## A full disk is a recorded `FAIL` The LadybugDB recovery suite passes the crash, reopen, truncation and permissions scenarios. It retains an explicit `FAIL` for a full disk: `Writer.Apply` returned success and the first intercepted `ENOSPC` appeared during shutdown, leaving the copy unable to reopen. The recovery command returns a nonzero status while this limitation exists. Immutable generations and durable `CURRENT` publication protect the *active* database against `ENOSPC`; the qualification is recorded as `ACCEPT_LADYBUGDB_WITH_LIMITS`. ## The viewer is unauthenticated `kivgraph ui` binds `0.0.0.0:7777` by default and carries no authentication. Its responses contain repository and file paths, symbol names and signatures. The bind is a deliberate default — the graph is built where the repositories are and the viewer is usually opened from another machine — but it is not a safe one on a shared network. Restrict it with `--addr 127.0.0.1:7777` or `web.address`. See the [viewer guide](/guides/viewer/). --- # Releases URL: https://kivgraph.dev/releases/ ## v0.8.1 (2026-08-27) Requires reindex: false A gate in front of `grep`, and a skill you can actually change. Nothing stored changed shape and no pass has to run again: both additions read the graph that is already published. - **`kivgraph hook install` registers a gate that runs before the agent's own tools and refuses a search when the graph answers it better.** It closes on one fact — two or more things declare the name, so a text search cannot separate what it finds — and that is the only threshold the measurements support. Over the six questions of `benchmarks/mcp-token-cost` the factor splits by how many things share a name and not at all by how many references a symbol has: the three we lose (`DiscoverGo` `0,83x`, `MergeAll` `0,85x`, `CanonicalColumns` `0,91x`) are all unambiguous names, and the two we win biggest (`Publish` `6,42x`, `BuildPlan` `1,92x`) are the shared ones. A reference-count floor was written first and removed: the corpus has nothing above four references, so any number would have been invented. - **The refusal names the call to make instead**, and quotes the rows it is talking about, because a refusal without a next step is an obstacle rather than a redirect. `KIVGRAPH_DISABLE_HOOK=1` runs one anyway. - **Four clients host it**: Claude Code, Claude Desktop, Codex and OpenCode. The first three read the same verdict off stdout, so one command serves them and only the file it is registered in differs. OpenCode gets a generated plugin, because its `tool.execute.before` returns `Promise` and the only way to stop a tool there is to throw. Oh My Pi is the one that cannot: its own documentation calls its hook subsystem legacy. - **It costs `2 ms` on the path that allows and `5,6 ms` when it asks the daemon**, measured on this repository's graph. Classification is pure and runs before the configuration is read, so nearly every call leaves by the first path. Every failure — unreadable payload, no configuration, a directory no registered repository holds, no daemon — writes nothing and exits `0`. An explicit allow on that wire does not mean "carry on", it means "skip the permission prompt", so the gate never sends one. - **`kivgraph skill install` now writes one file and links the clients to it.** Three installed clients used to mean three copies and no source, which left the obvious question unanswered: what you do to change it. Editing one copy did not reach the others and the next install took it away. The canonical file is `~/.config/kivgraph/skills/kivgraph/SKILL.md`, beside `config.yaml`, and one edit reaches every client. - **An upgrade keeps that edit.** Reinstalling is what an upgrade does, so `install` writes the canonical only when it is absent or still holds the shipped skill; `skill status` says when it carries local edits, and `--force` takes the shipped version back. A copy from an earlier install becomes a link without asking, because the bytes are the ones we would have written. Project scope still copies: that path is committed, and a link to an absolute path under one machine's home would arrive broken on every other clone. - **The desktop app is detected by the entry it actually ships.** The Linux marker this repository looked for, `claude.desktop`, does not exist — the package installs `com.anthropic.Claude.desktop` — so `mcp install` and `skill install` were not detecting Claude Desktop either. ## v0.8.0 (2026-08-26) Requires reindex: true A tool for the question that names no symbol, a traversal that shows its route, and an index that no longer dies whole. **Reindex after updating**: the TypeScript loader reads more of a project than it did, and a published graph only grows when the pass runs again. - **`find_by_intent` takes a plain-language description and returns ranked symbols** — or, under `view: "files"`, just the files to open. Measured against `git grep -l -i` on 24 questions over three repositories, none of which can contain an identifier of its own answer: the phrase alone finds 6 of 24, the phrase plus the identifier words a caller can guess finds 11, and naming `repo` as well finds 17, against 7 for the native search. A session costs the same either way — `0.96x`, ceiling `1.09x` — so the accuracy is free. It reads no code from disk: the term index is derived when the snapshot is read, in `22 ms`, and nothing about the stored format changed. - **`trace_dependencies` returns the path.** It could say whether A reaches B; now it shows the route it took, so a claim of reachability can be checked instead of trusted. - **A `go.mod` that holds no Go is not a failed index**, and neither is a repository whose analyzer this machine lacks. Either one used to fail the whole pass: on a machine where a Hugo theme sat inside an indexed repository, a real index of five repositories produced nothing at all. Both are absences now — counted, named, and carried in `index --full --json`. Thanks to [@Tutitoos](https://github.com/Tutitoos) for finding and fixing it. - **TypeScript reads the project the compiler reads**: a `jsconfig` as the project of a JavaScript package, an `include` entry naming a directory as the tree under it, and the source extensions the compiler accepts. On this workspace that is `22.299` symbols before and `32.419` after — `+45 %` — which is why this release asks for a reindex. - **`kivgraph audit` inspects registered repositories without indexing them**, and a bare `kivgraph` prints the command table instead of an error. - **What an index could not load is now published, not just counted.** All four languages carry `not_loaded` in the report and in the `--json` result; two of those counters existed and were printed nowhere. A language with no code reports zero symbols and so does a repository that could not be read, so a count that only lives in a log is a warning nobody has. - Details in `docs/adr/0072-la-recuperacion-por-intencion-y-las-tres-senales-que-no-entraron.md` and `docs/adr/0073-la-prosa-no-paga-el-esquema.md`. ## v0.7.0 (2026-08-25) Requires reindex: false `kivgraph mcp install` now registers a client against **one daemon** by default, and the daemon has an owner that starts it and restarts it. Nothing to reindex. - **The daemon is the default registration.** `mcp install` writes a `url` entry instead of a `command`, so every editor shares one process. On a workspace of 108.737 symbols with eight idle clients that is 11 MB of private pages against 80, and 61 against 328 while they are answering; peak is 26-29 MB against 179-186. `--stdio` writes exactly what the previous default wrote, and `--daemon` still parses. - **An entry a previous install wrote is replaced, not refused.** Switching transports leaves one registration and not two, without `--force`. An entry naming a different kivgraph installation still needs it. - **`kivgraph daemon install` gives the daemon an owner** — a launchd agent on macOS, a systemd user unit on Linux — keyed by state directory, so two configurations can hold two supervised daemons. `daemon status` and `doctor` report whether one exists; `daemon remove` takes it out. A platform with no supported supervisor says so instead of pretending it installed something. - **`kivgraph daemon --addr` and `--allow-remote` work.** They were declared in the flag set and printed by the help, and the daemon read them before they were parsed: it could only ever bind `127.0.0.1:7788`. Anyone who passed `--addr` got the default port and no warning. - Three conditions still write the stdio entry, and each names itself: `--scope project`, because a url entry carries a token and that file gets committed; a platform with no supervisor; and a machine that has not run `kivgraph init` yet. None of them depends on whether a daemon happens to be running. - Details in `docs/adr/0068-el-demonio-tiene-dueno.md` and `docs/adr/0069-el-demonio-es-el-defecto.md`. ## v0.6.0 (2026-08-24) Requires reindex: false One process can now serve MCP to every editor you have open, and a server that nobody queries no longer pays for the graph. No reindex needed. - **`kivgraph daemon` serves many clients from one process**, over HTTP and a unix socket at once. `kivgraph mcp install --daemon` registers a client against it, writing a `url` entry instead of a `command`. HTTP is the door that matters: no MCP client configuration dials a unix socket. See [Register with a client](/mcp/clients/). - **The graph is read by the first query that needs it, not at startup.** On a workspace of `108.737` symbols, a server nobody asks anything holds `10 MB` per client instead of `33`, eight editors starting at once peak at `183 MB` instead of `994`, and a new client connects in `14`–`23 ms` instead of `96`–`151`. Measured in `benchmarks/daemon-cost`; with load the figures are unchanged. Details in `docs/adr/0067-el-grafo-se-lee-cuando-alguien-pregunta.md`. - **An unreadable snapshot no longer kills the server at startup.** It reaches a caller as `INDEX_NOT_READY`, and `graph_status` gains an optional `snapshot_unreadable` field naming the reason, so «could not be read» stops looking like «never indexed». Additive: a client that ignores it sees no change. - At eight clients the daemon costs `61 MB` against `328` while answering, and `11` against `80` while idle. At one client it costs a couple of megabytes more than a plain `serve`, so it is worth running from the second client on. - Fixes: the daemon publishes its HTTP endpoint before binding the socket, so a client can no longer reach a daemon whose `daemon.json` does not exist yet; the unix socket is created with its mode already set, which is what made it work on bind-mounted filesystems; and the global help and shell completion now name the daemon's `--addr` and `--allow-remote`. ## v0.6.1 (2026-08-24) Requires reindex: false Everything described under v0.6.0, which was tagged but never published: its build failed before the release step, so v0.6.1 is the first downloadable version of that work. Nothing to reindex. - The check that every release note names a real tag ran for the first time in this release and failed in CI while passing locally. CI checks out shallow and fetches no tags, so `git tag` returns nothing there — and an unreadable tag list is not evidence that a tag does not exist. It now skips and says so, instead of failing and pointing at the wrong thing. The check itself is unchanged wherever tags are present. ## v0.6.2 (2026-08-24) Requires reindex: false Everything described under v0.6.0, which is the first version of this work that actually publishes. v0.6.0 and v0.6.1 were tagged and both builds failed before the release step, so neither exists as a download. Nothing to reindex. - What failed was a check on this repository's own release notes, not the product: it verified that every note names a real git tag, and it read that from whatever tags the checkout happened to carry. CI fetches none by default, and a checkout on a tag ref carries exactly one — the version being built, which is a partial list that looks complete. CI now fetches the tags, and the check skips instead of failing when it cannot tell a partial list from a complete one. Where the list is real it is unchanged. ## v0.6.3 (2026-08-24) Requires reindex: false Everything described under v0.6.0, which is the first version of this work that actually publishes. v0.6.0, v0.6.1 and v0.6.2 were tagged and every build failed before the release step, so none of them exists as a download. Nothing to reindex. - All three failures came from one check on this repository's own release notes, never from the product. It verified that every note names a real git tag, reading that from whatever tags the checkout happened to carry: none by default, and exactly one — the version being built — when the checkout is a tag. `fetch-tags` cannot fix it either, because combined with a tag ref it makes git fetch the commit and the tag to the same place and the checkout fails outright. The check now runs where the tag list is real, a developer's clone, and skips with a reason where it is not. ## v0.6.4 (2026-08-24) Requires reindex: false Everything described under v0.6.0, which is the first version of this work that publishes. v0.6.0 through v0.6.3 were tagged and every build failed before the release step, so none of them exists as a download. Nothing to reindex. - Three of those failures were one check on this repository's own release notes, fixed in v0.6.3. The fourth was a test of the reconciliation tick that builds a published generation as its fixture: publishing enforces the production space policy — at least 15 % of the filesystem free — and the macOS runner had less. That is a precondition of the host, not of the code under test, so it now skips and names the reason instead of failing. It still runs, and still defends the invariant, wherever the host has the space. ## v0.5.0 (2026-08-22) Requires reindex: true A method now belongs to the type that declares it as its receiver, and that changes what a Go or Rust type reaches. - `trace_dependencies` on a Go `struct` or a Rust type now includes the reach of its methods. It did not before: a method is declared outside its type's own span — `func (h *T) M()` in Go, an `impl` block in Rust — while a TypeScript class holds its methods inside its braces. The same question returned a complete answer for TypeScript and a silently incomplete one for the other two. Measured on the private benchmark corpus at `depth: 1`, a Go handler with nine methods went from 3 rows to 53, and a Rust type with two `impl` blocks from 2 to 18. - `get_blast_radius` is unchanged. A type's own methods are not its consumers, so containment is not an answer to what breaks if you change it. - `graph_status` now reports `schema_version_expected` and `schema_outdated` beside `schema_version`, so a graph built by an older binary says so instead of leaving the comparison to you. The canonical schema goes from 3 to 4. A generation published by the previous version still opens and answers normally — it is outdated, not corrupt — but it cannot carry facts its resolver never emitted, so a Go or Rust type's reach stays incomplete until you rebuild it.