Code Indexing MCP Server
  • Rust 98.6%
  • Shell 0.8%
  • PowerShell 0.3%
  • Python 0.2%
  • WebAssembly 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Dirk Hoyer 4ecf930e05
All checks were successful
CI / cargo fmt (pull_request) Successful in 50s
CI / OSS corpus tier-3 scale (nightly) (pull_request) Has been skipped
CI / Grammar rebuild from source (nightly) (pull_request) Has been skipped
CI / CI lane wall-clock headroom (pull_request) Successful in 1m27s
CI / guest crates (fmt, clippy, doc) (pull_request) Successful in 1m28s
CI / cargo doc (intra-doc links) (pull_request) Successful in 5m10s
CI / cargo deny (pull_request) Successful in 5m50s
CI / cargo check (MSRV 1.98) (pull_request) Successful in 6m28s
CI / cargo test (abi, 32-bit + wasm32) (pull_request) Successful in 6m30s
CI / cargo clippy (pull_request) Successful in 6m32s
CI / cargo check (windows-gnu) (pull_request) Successful in 6m55s
CI / OSS corpus (tier 1) (pull_request) Successful in 30m41s
CI / cargo test (pull_request) Successful in 33m51s
CI / cargo test (daemon transport) (pull_request) Successful in 10m4s
CI / Plugin path cost + pool throughput (nightly) (pull_request) Has been skipped
CI (Windows) / fmt + clippy + build + test (windows) (pull_request) Successful in 1h6m7s
CI / cargo fmt (push) Successful in 50s
CI / OSS corpus tier-3 scale (nightly) (push) Has been skipped
CI / Grammar rebuild from source (nightly) (push) Has been skipped
CI / guest crates (fmt, clippy, doc) (push) Successful in 1m22s
CI / CI lane wall-clock headroom (push) Successful in 1m31s
CI / cargo doc (intra-doc links) (push) Successful in 5m6s
CI / cargo deny (push) Successful in 6m32s
CI / cargo test (abi, 32-bit + wasm32) (push) Successful in 6m33s
CI / cargo check (MSRV 1.98) (push) Successful in 6m39s
CI / cargo clippy (push) Successful in 6m49s
CI / cargo check (windows-gnu) (push) Successful in 6m59s
CI / OSS corpus (tier 1) (push) Successful in 33m8s
CI / cargo test (push) Successful in 53m40s
CI (Windows) / fmt + clippy + build + test (windows) (push) Successful in 1h11m8s
CI / cargo test (daemon transport) (push) Successful in 24m14s
CI / Plugin path cost + pool throughput (nightly) (push) Has been skipped
psr4_vs_builtin: hand the hook /-separated paths, as the host does
Native Windows CI mapped 0 of 4 fixture files: the test's own walk used
Path::display(), giving src\Client.php, which no src/ mapping matches.
Production is unaffected — module_map::build_input receives the host's
project-relative paths, which are /-separated. Linux cannot exercise
the difference; the Windows job is this change's check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126PDDLB4wNHxKXvWM1VNmu
2026-09-27 13:45:50 +02:00
.cursor/rules docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
.forgejo ci: run the guest-kit corpus differentials under the require floor 2026-09-27 01:52:59 +02:00
.github docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
_prdoc Merge branch 'p1-ids-ownership-enablement' into p1-integration 2026-09-27 00:12:41 +02:00
crates psr4_vs_builtin: hand the hook /-separated paths, as the host does 2026-09-27 13:45:50 +02:00
distribution release: v0.32.2 2026-09-26 14:46:23 +02:00
fuzz fix: close the ABI's five canonicality and structural gaps, and split 2026-08-27 09:18:14 +02:00
tests ruby-package-cost: re-measured under schema 73 2026-09-27 01:46:16 +02:00
.clinerules docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
.cursorrules docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
.gitattributes fix: pin LF, because six gates in this workspace scan their own source 2026-08-30 18:18:29 +02:00
.gitignore feat(svelte): a Svelte package, and the ABI input it needed to be worth shipping 2026-09-15 16:57:34 +02:00
.mcp.json.example chore: gitignore .mcp.json, keep .mcp.json.example 2026-05-13 07:19:35 +02:00
.windsurfrules docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
AGENTS.md docs(agents): the 1.00 precision figure is precision, not recall 2026-09-23 19:03:55 +02:00
ARCHITECTURE.md fix: the watch set stops shrinking, and a state that will not change says so 2026-09-05 18:41:46 +02:00
Cargo.lock Merge branch 'p1-ids-ownership-enablement' into p1-integration 2026-09-27 00:12:41 +02:00
Cargo.toml #112 phase 1: multi-grammar manifests — one package, several (grammar, extractor) lanes 2026-09-26 21:51:07 +02:00
CLAUDE.md docs: update agent instructions to strictly mandate code-index over grep 2026-09-18 18:17:06 +02:00
CONTRIBUTING.md feat: a lane can search its own worktree, and a link says which package set answered (#238, #137, #160) 2026-09-09 23:54:09 +02:00
deny.toml plugin-host: precompiled grammars through a verified wasmtime module cache 2026-09-26 21:41:42 +02:00
install.ps1 release: v0.31.0, and a version bump that cannot half-land (#284) 2026-09-22 11:38:48 +02:00
install.sh feat(install): a Windows installer and updater, and the gates that make it real 2026-09-14 22:02:04 +02:00
README.md Automatic starts write the language decision to .code-index/, never the root 2026-09-26 23:36:54 +02:00
rust-toolchain.toml chore(ci): re-earn what #274 made stale — prose claims and two mutations 2026-09-14 22:47:52 +02:00

code-index

Fast structural code index for AI coding agents, exposed over MCP.

Six languages indexed today: Rust, Python, TypeScript / JavaScript, C#, PHP, Ruby. Live updates via filesystem watcher. Symbols, references, imports, full-text search. Local SQLite, no cloud, no telemetry, no account. The daemon is a local process on a token-authenticated loopback socket, not a network service.

Quick start

Install, and update, with one command

curl -sSfL https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.sh | sh

The same command installs and updates: it asks the release API for the latest tag, and if that version is already installed it says so and exits without downloading anything. There is no separate self-update subcommand, deliberately — a self-update cannot install the first copy, so it can only ever be half a mechanism, and the half that rots.

sh install.sh --check                  # installed vs published; writes nothing
sh install.sh --tag v0.27.1            # a specific release, including an older one
sh install.sh --prefix "$HOME/.local"  # default: /usr/local if writable, else ~/.local
sh install.sh --with-plugin de.h-dv.xaml   # also put this package in the machine's
                                           #   store; it GRANTS NOTHING

--with-plugin <id> installs a package and grants it nothing. It resolves the id through the distribution catalog and writes the bytes into the machine-wide package store — which runs no code: an installed package extracts nothing until a project approves it, and approving is the separate step that names the capabilities. That step is code-index plugin add <id>, run by you, from inside the project, and the installer prints the line rather than running it. It used to run plugin add --grant requested --yes, which made the FIRST capability grant on a fresh machine non-interactively, inside a curl | sh, for whichever project the working directory happened to detect. The first grant on a machine stays a human decision. If the package install fails the script stops and names the state you are in: the four binaries ARE installed and answering, the failure is about the plugin only, and nothing was granted to any project.

It refuses rather than guesses, and each refusal is a hazard that has a name:

  • It never installs bytes it has not verified. The .sha256 sidecar published beside every archive is downloaded and compared. If neither sha256sum nor shasum is on PATH it refuses instead of skipping the check — a verification that silently does not run is worse than none, because it reads as having run.
  • A partial archive installs nothing, and neither does a partial write. The four binaries are staged in a directory inside the target, verified there, and only then renamed into place — so a failure part way through cannot leave two new binaries beside two old ones. That mixture is a state nothing tests, and the build-skew disclosure would then be describing a set rather than a build.
  • A release that cannot run here is refused before anything is replaced. The staged code-index is asked its own version first, so a wrong-libc or mis-rolled archive leaves your existing install untouched instead of bricking it with no way back.
  • An archive member that is a symlink is refused. A symlink resolves against your machine, so it would install local bytes under one of our names — and the default prefix is /usr/local.
  • macOS is refused by name, citing #59, because no macOS archive is published and a Linux binary placed on a Mac fails later and less clearly. An unrecognised uname prints what it saw.
  • Windows is handed over, not half-served. Windows ships a .zip, not a .tar.gz; this script says so and points at install.ps1, which does the same job natively — see Windows.
  • It never signals a daemon. If it finds a daemon lockfile it says an older build may still be answering and names code-index doctor.

crates/cli/tests/installer_e2e.rs grades every one of those refusals against a real archive, and asserts in each case that nothing was installed — not merely that the exit code was non-zero. Which platforms the script must handle is not a list anyone maintains: crates/cli/tests/installer_targets.rs derives the published set from the release workflow's own matrices and fails if an archive is neither installable nor waived by name.

Anything else you would rather do by hand: grab an archive from git.h-dv.de/h-dv/code-index/releases (linux-x86_64, linux-x86_64-musl, linux-aarch64, windows-x86_64; bundles the four binaries plus a per-platform README and .code-index.toml.example) and verify it against the .sha256 beside it.

Windows: one command, and the same command updates

install.ps1 is a first-class installer and updater for Windows, and like install.sh it is one script for both directions — the install path and the update path are the same code, so an update cannot rot while installs keep working.

irm https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.ps1 | iex

That form takes no arguments. iex runs the downloaded text, and there is nowhere in that pipeline to put a -Tag — anything after iex is an argument to iex, not to the script. So for anything with flags, download it and run it:

irm https://git.h-dv.de/h-dv/code-index/raw/branch/master/install.ps1 -OutFile install.ps1
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Check
.\install.ps1 -Check                     # installed vs published; writes NOTHING
.\install.ps1 -Tag v0.27.1               # a specific release, including an older one
.\install.ps1 -Prefix D:\tools\ci        # default: $env:LOCALAPPDATA\Programs\code-index
.\install.ps1 -BaseUrl D:\offline\assets # a directory works, which is what makes an
                                         #   offline install possible
.\install.ps1 -WithPlugin de.h-dv.xaml   # also put this package in the machine's
                                         #   store; it GRANTS NOTHING
.\install.ps1 -AddToPath                 # opt in to a USER PATH edit
.\install.ps1 -Help                      # the full list

The four .exe files go directly in the prefix — %LOCALAPPDATA%\Programs\code-index\code-index.exe, not ...\bin\code-index.exe. That is where this deliberately differs from install.sh: a bin\ level is a POSIX shape, and Windows software does not install that way.

-AddToPath is opt-in, and the default is to print the line rather than run it. The default prefix is not on anybody's PATH, so the installer knows perfectly well that you will want it there — and editing your PATH is still a mutation you did not ask for. Without the flag your environment is not touched; with it the edit is to the user environment only, never the machine one.

-WithPlugin <id> installs a package and grants it nothing, exactly as on POSIX: the bytes go into the machine-wide package store, which runs no code, and code-index plugin add <id> — the step that names the capabilities — is printed for you to run from inside the project. The first grant on a machine stays a human decision. If the package install fails, the script stops and names the state you are in: the four binaries ARE installed and answering, and nothing was granted to any project.

It refuses rather than guesses, and each refusal is a hazard that has a name:

  • It never installs bytes it has not verified. The .sha256 sidecar published beside the archive is downloaded and compared. If neither Get-FileHash nor System.Security.Cryptography.SHA256 can be reached it refuses instead of skipping the check — a verification that silently does not run is worse than none, because it reads as having run.
  • A hostile archive member refuses the whole archive, before a byte is written. Expand-Archive does not protect you from one. So every entry is inspected first: an absolute name, a .. segment, a backslash, a name that escapes the destination once normalised, a symlink, a reparse point, or anything that is not a plain file or directory, and the install stops with nothing unpacked.
  • A partial set installs nothing. All four binaries are staged inside the install directory, verified there, and only then moved into place. A failure part way through rolls back, so a truncated archive cannot leave two new binaries beside two old ones.
  • A release that cannot run here is refused before anything is replaced. The staged code-index.exe is asked its own --version first, so a mis-rolled or wrong-architecture archive leaves your existing install whole.
  • It never signals a daemon. If it finds one it says so and names the command; it does not kill a process it did not start.

The running-daemon case, which is a Windows problem and not a POSIX one. Windows will not open a running image for writing, so an update while the daemon is alive would fail with access denied half way through. A running .exe can be renamed, though — so each target is renamed aside to <name>.old-<stamp> and the new file is moved into the name it vacated, and if any of the four fails the whole set is rolled back. Deleting an .old- file then fails for as long as the old process lives, which is fine and expected: those files are swept by the next run. You may see them beside the binaries in the meantime.

Supported on Windows also means what it meant before: windows-x86_64 is built for every release, the whole suite is fmt/clippy/build/tested natively on a Windows runner, and since 2026-08-11 that job gates releases — a red Windows leg blocks publishing rather than being noted afterwards. The cfg(windows) code paths (payload-DACL hardening, the child-process e2e tests) run there and nowhere else.

install.sh is still a POSIX shell script and still refuses by name under MinGW, MSYS, Cygwin or with Windows_NT in the environment, rather than half-unpacking a .tar.gz that was never published for you — and that refusal now prints the install.ps1 command instead of a manual download. install.ps1 refuses the mirror case — pwsh on Linux or macOS — by name, and points back at install.sh.

There is still no winget package and no MSI.

The archive by hand is the fallback, not the route. If you would rather not run a script at all:

$Tag = "v0.32.2"
$Base = "https://git.h-dv.de/h-dv/code-index/releases/download/$Tag"
Invoke-WebRequest "$Base/code-index-$Tag-windows-x86_64.zip"        -OutFile ci.zip
Invoke-WebRequest "$Base/code-index-$Tag-windows-x86_64.zip.sha256" -OutFile ci.zip.sha256

# VERIFY BEFORE UNPACKING. The sidecar is `<hash>  <filename>`; compare the hash.
(Get-FileHash ci.zip -Algorithm SHA256).Hash.ToLower()
Get-Content ci.zip.sha256

Expand-Archive ci.zip -DestinationPath "$env:LOCALAPPDATA\Programs"
# then put the extracted folder on PATH

The archive contains the four .exe binaries, a per-platform README.md, VERSION.txt and .code-index.toml.example. Keep all four binaries together — see Binaries. Note that Expand-Archive there is doing the unpacking unguarded, which is the check install.ps1 adds and the reason to prefer it.

For automation, new releases attach install.sh, install.ps1 and a .sha256 for each to the release itself. Pin both the script URL and its tag argument; pinning the URL alone still asks the script to install the latest binaries:

TAG=v0.32.2
curl -sSfLO "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG/install.sh"
curl -sSfLO "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG/install.sh.sha256"
sha256sum -c install.sh.sha256 && sh install.sh --tag "$TAG"
$TAG = "v0.32.2"
$Base = "https://git.h-dv.de/h-dv/code-index/releases/download/$TAG"
Invoke-WebRequest "$Base/install.ps1"        -OutFile install.ps1
Invoke-WebRequest "$Base/install.ps1.sha256" -OutFile install.ps1.sha256

# the sidecar is `<hash>  install.ps1`; compare before running the script
(Get-FileHash install.ps1 -Algorithm SHA256).Hash.ToLower()
(Get-Content install.ps1.sha256).Split()[0]

powershell -ExecutionPolicy Bypass -File .\install.ps1 -Tag $TAG

Release-pinned install.sh assets are available starting with v0.28.1; install.ps1 is attached from the first release published after it was added. Older releases use the source URL above — and for a release older than that, the archive-by-hand recipe above is the only path.

Or build from source

# build
cargo build --release

# initialise a project (one-off)
./target/release/code-index init
./target/release/code-index index

# health check
./target/release/code-index doctor

macOS: no prebuilt archive, and no macOS job in CI. macOS release builds are deliberately deferred (#59); no date is promised. The source carries macOS-only code paths — the FSEvents watcher subtree subscription, the daemon's stale-PID detection and its flock handling — and nothing in CI compiles or runs them. A check-only gate is not a cheap substitute: cargo check --target x86_64-apple-darwin still runs build scripts, and the tree-sitter grammars compile C through cc, which fails on this image with cc: error: unrecognized command-line option '-arch'. So building from source on macOS is not something we have verified; treat it as unsupported rather than untested-but-probably-working.

Wire into Claude Code (or any MCP client):

// claude_desktop_config.json or equivalent
{
  "mcpServers": {
    "code-index": {
      "command": "/path/to/code-index-mcp",
      "args": ["--root", "/path/to/your/project"]
    }
  }
}

That's it. The MCP server auto-spawns the daemon on first connect and initialize returns in milliseconds (see I015) regardless of project size; the daemon runs its initial reconciliation in the background and lives until 30 minutes of idle (configurable) or your editor session ends. All 23 MCP tools, 14 resources, and four prompts are available immediately — calls in the cold-start window either succeed, return state: "reconciling", or return a structured warming_up error the agent retries automatically.

If startup reconciliation fails, the RPC daemon stays alive and serves the last durable snapshot instead of entering an index/fail/respawn loop. project_overview reports state: "degraded" with state_detail, and the daemon retries in place with an exponential cooldown that is never shorter than the failed attempt itself. The state clears after recovery.

Upgrading to 0.32

  • Run code-index index once in each project after upgrading. This release changes what the extractor records, so the first reconcile reparses and re-resolves every code file: about a minute on a django-sized repository, roughly the cost of a cold index. An MCP session's daemon does this on its own and answers with state: "reconciling" meanwhile. A one-shot code-index query with no daemon running (a hook, a pre-commit check, CI) cannot finish it: each call answers warming_up with a reconcile_pending block (converges_on_retry: false) and exits before the reconcile completes. One code-index index settles it for good. If a daemon is already serving the project (an MCP session is open), code-index index does not index a second time: it names the daemon's pid, says how to follow its progress (code-index query project_overview '{}', or code-index doctor), and exits 0. The daemon is already doing the reconcile.
  • pytest fixture parameters bind the fixture they request. A parameter of a test_* function, of a test_* method of a Test* class, or of a fixture binds the @pytest.fixture of that name, found the way pytest finds it: the test's class, then its module, then the nearest conftest.py upward. name= aliases are honoured and a fixture never resolves to itself. Nothing is bound for two fixtures of one name at the deciding level, for a name @pytest.mark.parametrize supplies, or for a call of the parameter (that calls the fixture's value).

Upgrading to 0.13

  • Gitignored files are no longer indexed by the live watcher. It now applies the same ignore-FILE stack the cold walk does, so the two agree. If you were relying on a gitignored path being searchable, it was only ever searchable until the next re-walk deleted it.
  • On Windows, index.db and its sidecars are now owner-only. They previously inherited the parent directory's ACLs, while daemon.toml — which holds only the token guarding access to that index — did not. See the note below on what the index actually holds.
  • WatchOpts gains ignore_change_rewalk; WatcherStats was removed in 0.12.

Upgrading to 0.12

Four changes you will notice:

  • A nested git checkout is no longer indexed as part of the project. A subdirectory carrying its own .git or .jj — a submodule, a linked worktree, a nested clone — belongs to another project at another commit, and indexing it here duplicated every symbol into these tables. If you relied on that, index it as its own project with code-index link add <name> <path>, which also gives it correct resolution. The walker logs each one it prunes.
  • Hidden paths are purged from existing indexes at the next reconcile. The live watcher used to index what the cold walk excluded, so .env, .claude/, .vscode/ and .git internals could end up in the index — contents included, retrievable through search_text. Those rows go away on upgrade. Nothing you index today is lost: the cold walk never yielded them.
  • project_overview can report state: "resyncing". A full re-walk is in progress. Results stay readable and every tool still answers; freshness is unverified until it clears, which it does on its own.
  • WatcherStats is removed from code-index-indexer's public API. Nothing ever populated it.

A branch switch now heals in seconds instead of waiting out the periodic reconcile, and on Windows a large event burst — where the OS gives no overflow signal at all — triggers recovery once the filesystem goes quiet.

The live watcher now applies the ignore-FILE stack too (.gitignore, .ignore, .git/info/exclude, .code-index-ignore, and your global core.excludesFile), so it agrees with a cold index rather than indexing gitignored files and deleting them again on the next re-walk. Editing a .gitignore while the daemon runs is honoured immediately.

The index holds what your working tree holds

.code-index/index.db is a structural copy of every file the walker indexes, plus their text. Treat it with the same care as the source tree itself — it is not a filtered or sanitised view, and no exclusion rule makes it one. What the exclusion rules give you is AGREEMENT between a cold index and a live one, not a security boundary.

So the index is protected at the level source code is: the state directory is created 0700, index.db is pre-created 0600 before SQLite opens it (a chmod afterwards would leave a window), the WAL and shm sidecars inherit that, and on Windows all three get a protected owner-only DACL — the same hardening daemon.toml gets, because protecting the capability token but not the index it guards is not a threat model. .code-index/ is added to .git/info/exclude so the index never follows your source into a commit.

Project-scoped .mcp.json: gotcha

If you drop a per-project .mcp.json (instead of the user-scope claude_desktop_config.json shown above), Claude Code will not register the server retroactively in an already-running session. Symptom: code-index tools don't appear in the agent's tool list, and the agent silently does without them. Two paths out:

  1. Restart Claude Code in the project dir and approve the prompt. When a project-scoped .mcp.json is detected for the first time, Claude Code asks the user to approve each declared server. Decline once and it stays declined.
  2. Or pin code-index at user scope so it's available everywhere without per-project approval:
    claude mcp add code-index code-index-mcp
    claude mcp list   # verify
    

code-index doctor includes an mcp registration check that surfaces the most common cause ("project hasn't been opened in Claude Code yet") with a pointer to claude mcp list.

What the agent gets

  • Handles, not blobs. Symbol responses carry IDs, locations, and signatures — never embedded source. The agent calls read_code only when it actually needs bytes.
  • Paginated, token-bounded responses. Lists return {results, total, next_cursor}; read_code is hard-capped at 4000 tokens with a truncated: true flag on overflow.
  • Structured errors. {error, query?, did_you_mean[], hint?} — not free-text strings.
  • Live freshness. Edits, creates, renames, and deletes in your editor flow into the DB within a few hundred milliseconds. Atomic saves are coalesced, and references re-resolve per change batch — cheaply, thanks to an outline-hash firewall that skips the global pass when only function bodies changed.
  • Two denominators, never one. Symbol rows carry ref_count (references that RESOLVED) and name_fallback_count (same-name references that resolved to nothing). The second is an upper bound on how much the first undercounts — not more references: a common method name is dominated by unrelated same-name calls, nearly all of them stdlib. Both numbers ship in the row, so ask your own repository rather than reading a figure from here; project_overview's count_basis measures which zeros are uninformative on it. 0 is the informative case: nothing unresolved could be this symbol, so ref_count is tight for the shapes that counter can see (it cannot see alias-qualified calls, Rust/PHP fields, or Rust consts — so 0 is never proof of "unused"). A blast radius you can trust is worth more than a number that flatters.
  • Coverage you can ask about. index_coverage(path) answers "is this indexed, and if not, why not?" with indexed / pending / never — and never only ever with proof, naming the rule that fired (hidden, skip_dir, ignore_file, too_large, …). No static documentation can answer this, because half the rule is your own .gitignore: deploy/dev.env is indexed in one repo and excluded in the next.
  • An inventory, not just a search. list_files(path_glob) answers "what is HERE" — path, lang, line and byte counts measured from disk, symbol count from the index, and per row whether the file is parsed for symbols or reachable only as text. Beside it, not_indexed names the files in that directory the index cannot see at all, with the rule that hid each one. read_code takes a bare path too, so reading a file you have not outlined yet is one call and a truncated read reports total_lines rather than a silent prefix.
  • Precise-enough references. Resolution is three-tiered: symbol visibility (extracted for all six languages) gates candidates, cross-file bindings additionally require reachability (same dir, or an import naming the candidate's file, identifier, or container type — so a lone exported get no longer swallows every stdlib .get() call), same-file definitions win over same-name twins elsewhere, and the ref's own imports break remaining ties; unqualified heuristic shortcuts never apply to qualified calls, so Vec::new()-style externals stay honestly unresolved. The rate is a property of your repository, not a headline number: project_overview reports refs and refs_resolved as counts, resolution as the per-language percentage, and resolution_by_kind as the split that says which shapes the rate is coming from.
  • response_format: "concise" on the six list tools cuts rows to handles + signatures (roughly half the tokens) when scanning many results. find_references and find_callers take exclude_tests: true to hide test-file references; excluded_test_refs then reports the hidden count split into resolved vs name_fallback, because the filtered mode is the one where you can no longer read resolution per row. Both tools carry total_resolved beside total, and every confidence block carries page_rows so a page_resolved: 0 can be told apart from an empty page.
  • Fewer calls, fewer re-reads. read_code takes a LIST of targets (up to 8, one batches entry each, sharing an 8,000-token content budget that names any entry it could not read), as search_text and search_symbols take a list of queries. search_text's max_lines_per_file (default 20, hard cap 1,000) lists every matching line of a file for a rename check; lines_widening states the cap that applied and names any file it could not widen. find_callers on a type answers target_is_type — call edges attach to its members — with the members that do have callers.
  • envelope: "minimal", on any tool, opt-in. Drops the semantics prose and the per-call answer_provenance / index_snapshot blocks and keeps every disclosure FIELD — counts, *_unmeasured markers, verdicts, refusals, freshness state/incomplete, and a skewed daemon_build, hoisted. The reply says what it omitted in one envelope field. The default reply is unchanged. Measured on a 14-call mix over this repository it saved 18.3% of the characters (index_coverage 68%, project_overview 23%, a long file_outline under 3%); code-index://docs/envelope has the table.

Telling the agent how to read the answers

Every response here carries disclosure fields that change what it means — two denominators, an evidence_gaps block, a count_basis. An agent that has not been told about them reads a 0 as "none" and a count as the count, and both readings are wrong. So the doctrine ships with the server, not on this page.

code-index init writes it into the files agent runtimes actually read, and code-index rules refreshes them:

AGENTS.md    CLAUDE.md    .clinerules    .cursorrules    .windsurfrules
.claude/skills/code-index/SKILL.md       .cursor/rules/code-index.mdc
.gemini/rules/code-index.md              .github/copilot-instructions.md

init writes them once, at first run. Nothing used to refresh them, so a project stayed frozen on whatever copy shipped the day it was initialised — rules --check is the CI form, and it writes nothing.

The MCP server serves the same document. Under the Skills extension (io.modelcontextprotocol/skills) a client calls skills/list and skills/get, or reads the resource skill://code-index/SKILL.md. It gets the agent-facing doctrine: which tool answers which question, what each disclosure field means, which zeros are earned and which are merely empty, and what a payload does and does not license you to claim.

A host that verifies the published sha256 and byte size against what it received will find them equal. The served copy, the on-disk copy and the manifest all come from one include_str!, and that is graded by digest — a contains() check would pass against a trimmed newline, a re-wrapped line or a normalised dash, which are exactly the rewrites that make a host discard the document as corrupt.

Orientation and diff awareness

Two tools answer the questions agents ask at the start and end of a task:

// "Where do I even look?" — token-budgeted architectural map, ranked
// by personalized PageRank over the reference graph. Pass `focus`
// to pull a task-relevant neighborhood to the top.
repo_map { "token_budget": 1500, "focus": ["resolve_ref_targets"] }

// "What did I just touch, and what depends on it?" — symbols
// intersecting your git diff, with per-symbol direct_callers.
// Replaces the git-diff + N-greps ritual before a commit.
changed_symbols { "base": "HEAD", "staged": false }

In watcher-backed sessions, changed_symbols and review_diff wait briefly for changed source files whose index row is behind disk. If reconciliation does not finish inside the bounded wait they return index_updating; they never return shifted symbol ids/spans as usable output. Snapshot mode (--no-daemon) cannot heal and instead preserves the explicit per-file index_stale: true diagnostic. A changed file the index does not cover at all — a skip directory (dist/, target/, node_modules/, …), any per-directory ignore rule the walker honours (.code-index-ignore, a nested .gitignore, .git/info/exclude), a hidden dot-file, one over the file-size cap, or one the OS will not let the indexer read at all (a permissions problem, a lock, a directory sitting where a file belongs) — never heals, so it is disclosed per file as not_indexed: true instead of blocking the call. When the bounded wait does run out, the index_updating refusal NAMES the paths it is still waiting on (up to five, and it says when it is showing only a prefix): a path still named on the next call is not watcher lag, and index_coverage will give it a verdict with the rule that proved it. The project root may sit anywhere inside the work tree (a monorepo member, a workspace crate): git's output is rebased onto it, so paths stay project-relative and a sibling project's changes never appear. A file git reports as deleted that is still on disk was untracked (git rm --cached), not removed: it is reported as change: "untracked" with no symbol rows, never as an API break. Other corrective misses (for example an unknown focus) carry did_you_mean suggestions.

The six languages

Language Extensions Plugin
Rust .rs tree-sitter-rust
Python .py, .pyi, .pyw tree-sitter-python
TypeScript / JavaScript .ts .tsx .js .jsx .mjs .cjs tree-sitter-typescript / -javascript
C# .cs (skips *.Designer.cs, *.g.cs) tree-sitter-c-sharp
PHP .php .php3 .php4 .php5 .phtml tree-sitter-php
Ruby .rb .rake .gemspec, Rakefile, Gemfile tree-sitter-ruby

Ruby extraction is Rails-aware: association macros (has_many, belongs_to, has_one, has_and_belongs_to_many) emit a type reference to the associated model class (honoring class_name: and singularizing plural names), and mixins (include/extend/prepend) reference the mixed-in module.

Default-skipped directories: node_modules, vendor, __pycache__, .venv, .tox, dist, build, .next, .code-index, plus target/ (Rust roots only) and bin//obj/ (.NET roots only). Override via .code-index-ignore (same syntax as .gitignore).

Binaries

Binary Purpose
code-index CLI: init, rules, query, index, watch, doctor, link, languages, plugin
code-index-daemon Long-lived watcher + RPC server
code-index-mcp MCP stdio bridge for AI agents
code-index-plugin-host Bounded worker that runs plugin packages

You normally only invoke code-index-mcp; it spawns the daemon for you, and the daemon spawns code-index-plugin-host when a plugin package is active. Keep all four together — the host is what enforces the bounds a package runs under, so a missing one is not a degraded feature.

CLI reference

code-index init                           # write a default .code-index.toml — with
                                          #   [languages] enabled = the languages it
                                          #   finds — and the agent instruction files
code-index rules                          # refresh those instruction files after an
                                          #   upgrade; --check reports stale and exits
                                          #   non-zero, which is the form for CI
code-index query <tool> '<json>'          # ask the index from a shell: the MCP tools,
                                          #   reachable by a hook, a pre-commit or a
                                          #   human. Exit 0 answered, 1 refused, 2 usage;
                                          #   a batch exits 1 only if EVERY entry refused
code-index index                          # one-shot index of the current project
code-index watch                          # initial index + live updates (foreground)
code-index doctor                         # diagnose: DB, schema, daemon, index freshness,
                                          #   resolver health, watch set, free disk space
code-index link add <name> <path>         # add a [[links]] entry (workspace links, I011)
code-index link list [--json]             # list configured links + live daemon status
code-index link remove <name>             # remove a [[links]] entry
code-index languages                      # which languages are enabled, who owns
                                          #   each, and which are present but not
                                          #   enabled (with the command)
code-index languages enable <lang>...     # opt a language in
code-index languages owner <lang> auto|builtin|package
                                          # who extracts it; see "Languages" below

Add --root <path> to operate on a project other than the current dir.

Languages: enabled, and who owns each one

First-party language packages are opt-in. [languages] in .code-index.toml records an explicit decision:

[languages]
enabled = ["ruby", "rust"]   # the first-party languages this project opted into
ruby = "auto"                # owner: auto | builtin | package  (default: auto)
  • enabled is decided for you once. code-index init detects the languages in the tree and writes exactly those to .code-index.toml. The first daemon or code-index-mcp start in a project with no enabled key does the same — from the index when one exists, so an upgraded project keeps every language it already indexes — but writes it only to .code-index/languages.toml, inside the index state directory: an automatic start never writes into your project root and never dirties your git tree. It says so in project_overview.language_ownership.enablement (written_to, change_with). After that nothing is enabled silently: a language that is present and not enabled is reported as language_not_enabled, with the exact command (code-index languages enable <lang>).
  • Precedence: .code-index.toml always wins. Its [languages] enabled is used whenever it is present; .code-index/languages.toml is read only while it is not. Only code-index init and code-index languages enable|owner write .code-index.toml. language_ownership.enabled_source says which one is in force (config, state or none).
  • In this release the compiled-in plugins still index every language, enabled or not. enabled selects the owner under auto; it never removes coverage.
  • The owner setting is the fallback while languages move to packages. auto: the package when the language is enabled and the package is approved and healthy, otherwise the builtin. builtin: the compiled-in plugin, whatever is installed. package: the package whenever one is healthy (without one, the builtin keeps indexing and the reply says the package is unavailable). A package can only take a language from the builtin through a [[displaces]] you approved, so the setting only ever narrows what you already granted.
  • Switching the owner re-extracts exactly the files that change hands, and nothing else. A running daemon re-arms within a few seconds of the edit; project_overview.language_ownership.owner_switch and index_freshness.owner_switch say which languages moved and how many files were re-extracted.
  • First-party packages keep the plain ids (ruby, php, rust, …): their rows are byte-identical in lang to the builtin's, so filters, baselines and bridges do not move when the owner changes. Those ids are reserved — see plugin trust add --first-party below.
  • Compatibility: a code-index older than this release refuses a [languages] <lang> = … key. Only enabled (which every release parses) is ever written automatically.

code-index plugin — language packages (EXPERIMENTAL)

A plugin package (.cip) is a language extractor that runs inside code-index-plugin-host, an isolated worker process. The extractor is wasm with zero imports, so the guest as loaded has no way to name a syscall at all — no file, no socket, no clock. That property is structural and holds on every platform.

The worker is not a kernel sandbox, and we do not claim it is one. There is no namespace and no capability drop. Code that escaped wasmtime inside a worker could still read any file the invoking user can read; the cwd is private and empty, but absolute paths are unaffected by that. Network denial is a seccomp filter that denies a named set of syscalls, and it exists only on Linux x86_64 and aarch64 — on every other target there is no filter at all.

Two surfaces say so, rather than leaving you to know. code-index plugin status prints a containment line for this build's target, and project_overview carries plugin_activation.worker_containment whenever a plugin host is running. Both report filesystem: unconfined on every platform, and neither ever reports the network filter as enforced: a worker's kernel outcome reaches only that worker's self-report, which no shipped path enables, so the honest states are requested_unverified (a filter is installed and nobody upstream can confirm the kernel took it), unsupported (this target has none) and not_requested. An ABSENT block means no plugin host is running, or an older daemon — never that a worker is contained. _prdoc/guides/80-threat-model.md §6 enumerates exactly what an escaped guest could still reach, and §7 lists what that document could not verify.

A package must be signed by a trusted key: unsigned is refused, at install and again at every load, and there is no --allow-unsigned. Installing one grants it nothing; approval for a project is a separate, explicit step that names the capabilities being granted.

Our own publisher key ships inside the binary (sha256:1cb03259a8c870b6db02360abd9351e17e67724d1f8c3509d85c4bf6b06fa72c, shown as [BUILTIN] by plugin trust list), so a package we publish installs with nothing to set up first. It extends no trust you had not already extended — forging that anchor means forging the binary — and it is revocable like any other: plugin trust remove <fingerprint> records a denial in your trust directory and the key stops verifying at the next load. Anchoring a THIRD-PARTY publisher is still a deliberate act: plugin trust add, with a fingerprint you obtained somewhere other than the key file.

Plain language ids are reserved for first-party packages. A package that declares [[languages]] id = "ruby" (or any id a compiled-in plugin stamps: rust, python, typescript, javascript, csharp, php, ruby) stamps its rows with that plain id, exactly as the builtin does. So it is refused — language_reserved, by name, at install and again at every load — unless the key that signed it is the one shipped with this build or one you anchored with --first-party (for a rebuild that publishes its own first-party languages). plugin trust list marks such keys [FIRST-PARTY].

# the one command: verify, show what approving does, ask once, do it all
code-index plugin add [<file.cip|digest>] [--grant requested|none|<c,...>] [--yes]
code-index plugin add <id>[@<version>]        # resolve through the distribution
                                              # catalog; the catalog IS the pin
code-index plugin add <https://…/pkg.cip> --sha256 <digest> [--signature-url <url>]
                                              # fetches the .cip AND the .cips
                                              # beside it; --sha256 is REQUIRED
                                              # for a URL and optional for a file
                                              # --registry-url <url|path> overrides
                                              # which catalog an id resolves in

# authoring
code-index plugin pack <dir> [--out <file>]   # build a .cip from a directory tree
code-index plugin inspect <pkg> [--json]      # print what a package declares
code-index plugin validate <pkg>              # parse + validate, printing any refusal
code-index plugin digest <pkg>                # install identity + extraction identity
code-index plugin sign <pkg> --key <keyfile>  # write the detached .cips beside it

# publisher keys and trust (machine-wide; no project)
code-index plugin key generate <keyfile>      # Ed25519 pair; prints the fingerprint
code-index plugin key fingerprint <key.pub>   # read one back; READ ONLY, no store
code-index plugin trust add <key.pub> --fingerprint <fp> --name <label>
code-index plugin trust add <key.pub> --fingerprint <fp> --name <label> --first-party
                                              # this key may publish RESERVED plain
                                              # language ids (ruby, php, rust, ...)
code-index plugin trust list                  # every anchor this machine holds
code-index plugin trust remove <fingerprint>  # takes effect at the NEXT LOAD
                                              # (a [BUILTIN] key is denied, not deleted)

# operating
code-index plugin install <pkg> --sha256 <d>  # into the user store, pinned by digest
code-index plugin install <id>                # same, resolved through the catalog:
                                              # --sha256 optional (the catalog pins),
                                              # and it needs NO project — this is
                                              # what `install.sh --with-plugin` runs
code-index plugin check <digest>              # run it on its own fixtures, record a verdict
code-index plugin check <digest> --repo <dir> # run it over THAT tree, record nothing
code-index plugin enable <digest> \
    [--grant requested|none|<c,...>] \
    [--capabilities <c,...>] [--bridges <b,...>]   # approve for THIS project
code-index plugin disable <digest>            # withdraw approval; stays installed
code-index plugin remove <digest>             # delete from the store and withdraw

# updating (see "Updating a package" below)
code-index plugin update                      # check every [update."<id>"] entry; apply
                                              # only where the AUTHORITY is unchanged
code-index plugin update --check              # check and record only; never applies

# the distribution catalog (read only; no project)
code-index plugin catalog                     # the catalog compiled into this binary
code-index plugin catalog <url|path>          # fetch/read it, VERIFY <source>.sig, report

# state
code-index plugin status                      # installed, approved, granted, what is owed
code-index plugin doctor                      # diagnose the installation (read only)
code-index plugin retry                       # finish what an interrupted activation owes
code-index plugin rollback                    # switch back to the retained generation
code-index plugin gc [--dry-run]              # reclaim unreachable generations and bytes

add verifies the signature before it renders anything, shows the publisher under your label for the key plus the grant and the measured reindex domain, and asks once — a bare Enter is no. Given a URL it fetches the .cip and its .cips into a staging directory outside the store — delegating the transfer to curl, because the transport is trusted for nothing here — and then runs exactly those checks. --sha256 is required for a URL and optional for a file, and the difference is not caution: the signature already decides who published the bytes, but the bytes at a URL are chosen by whoever controls the URL and the network, so a validly signed but different package can be served. The digest pin closes that, and nothing else. enable refuses a package that has not passed check, and rollback executes no package at all. A signature says these bytes are the ones that key signed; it does not say they are safe.

catalog is the read-only leaf for the distribution catalog — the document that says which package versions exist, at which URLs, pinned to which digests. With no argument it reports the catalog compiled into this binary. Given a URL or a path, or with COSI_REGISTRY_URL / COSI_REGISTRY_PATH set, it reads that one and requires <source>.sig beside it, verified against the keys you anchor — so plugin trust remove reaches this door exactly as it reaches an installed package. There is no flag that accepts an unsigned catalog: a catalog decides which bytes plugin add <id> fetches and which digest they are pinned to, so an unsigned one moves the pin. The compiled-in catalog is the one exception, because forging it means forging the binary. It prints the source, the byte count, the fingerprint that verified it, the platform state and the plugin list, and it exits non-zero on any refusal — which is how the release checks its own signature through the same code path a customer takes.

The operator guides under _prdoc/guides/ cover packaging, the capability model, recovery and the threat model in full. Four packages are published and signed, and all four are in the distribution catalog, so plugin add <id> resolves them without you copying a URL:

id claims notes
de.h-dv.xaml .xaml binds x:Class / Click= into the paired C# code-behind
de.h-dv.timeline .dataset .xsql .shd .lgd two languages out of one extractor; resolves inside a definition file only
de.h-dv.ruby .rbx the compiled-in Ruby extractor as an EXTERNAL package — it does NOT claim .rb, and installing it changes nothing about how your Ruby is indexed. It is the migration proof for running a language out of a package, published so it can be run rather than described. check and enable both require --derived-names
de.h-dv.svelte .svelte the TEMPLATE half of a Svelte single-file component — markup, mustaches, {#snippet} and {@render}. It claims .svelte and nothing else: .svelte.ts and .svelte.js are runes modules the built-in TypeScript and JavaScript plugins already read correctly. One capability, same_file_candidate

Every release attaches <id>-<version>.cips beside each .cip, and this release's publisher key is compiled into the binaries, so a pinned install works on a machine that has anchored nobody. Download the .cips and keep it beside the .cip — an install without it is refused with signature_missing. If a package you installed has stopped loading, read _prdoc/guides/80-operator-recovery.md §1.2, which is the repair for a package that really is unsigned: one you built yourself, or one whose publisher ships no .cips.

EXPERIMENTAL: the plugin ABI and the .cip format are not stable across releases yet.

Updating a package

Approvals are keyed by digest, so a new version of a package is a package this project has not approved. plugin update is how that gets answered without a human retyping four commands — and it is the only command in the family that can act with no operator present, so what bounds it is the whole design.

You do not have to type this from memory. code-index plugin add <URL> --sha256 <digest> prints the stanza for the package it just installed, with the id and the URL already filled in, on update_config lines — that command is the one moment the tool holds both halves, so it offers them there rather than in a paragraph. Where an entry already exists it says so instead, with the auto_apply in force, and it names both URLs when the configured source is not the one you installed from. plugin add <FILE> prints no source — there is no URL to put in one — and says that is why.

It is opt-in per package, in the project's own .code-index.toml:

[update."de.h-dv.xaml"]
source     = "https://git.h-dv.de/api/packages/h-dv/generic/code-index/<release>/de.h-dv.xaml-<pkgver>.cip"
auto_apply = false   # the default: CHECK AND NOTIFY ONLY

That is the real shape of the URL here — <release> is the code-index version the package was published with and <pkgver> is the package's own version, both as they appear in the release notes. <release> carries NO leading v: the tag is v0.27.1 and the path segment is 0.27.1, because the publishing step strips it (PKG_VERSION="${TAG#v}"). Measured — .../code-index/v0.27.1/de.h-dv.xaml-0.2.0.cip answers 404 and .../code-index/0.27.1/de.h-dv.xaml-0.2.0.cip answers 200. The same URL with .cips, .cip.sha256 and .cip.digest.txt appended serves the signature, the checksum and the digest record; the package_digest line of .cip.digest.txt is what --sha256 wants elsewhere in this file (the other line, extraction_identity, is not).

The catalog is the OTHER door, and it is a separate key:

[update."de.h-dv.timeline"]
from       = "registry"   # and NO `source` — the catalog resolves the id
auto_apply = false

from says which door the stanza uses, and the two are mutually exclusive: "url" — the default, and the state of an unstated field — requires source, and "registry" requires source to be absent. A stanza naming both is refused, quoting both values it read; a from = "url" stanza with no source is refused too, and that refusal names the other door. An operator who wrote both has not said which they meant, and choosing for them would be this tool choosing where your code comes from.

It is a key rather than a value inside source, for a measured reason. The test used to be source.starts_with("registry") — and source = "registry.example.com/pkg.cip" is a URL an operator may really write. It was swallowed by that prefix and served the EMBEDDED catalog instead of that server, silently. A sentinel living in a field whose values are hostnames cannot be told apart from a hostname; two keys cannot collide.

The registry door resolves the package id through the catalog this machine is configured with — COSI_REGISTRY_URL / COSI_REGISTRY_PATH, or the one compiled into the binary — and a repository cannot name a different catalog. It changes only WHERE the candidate bytes come from: everything below is evaluated the same way, so a catalog offering an older or equal version is stopped by the same clause a URL would be. It does add one check the URL door has no way to make — the catalog names the version's package_digest, and bytes whose digest is not that one are refused as unreachable rather than assessed.

source is a URL the .cip is fetched from; the .cips beside it is fetched too. There is no --sha256 here and that is deliberate — a pin would have to be edited on every release, so the digest is not the control. The control is that the AUTHORITY must be unchanged, and plugin update applies a candidate only when all of the following hold against the version whose grant is in force:

  • the same publisher key, by fingerprint, out of your trust store — "the signature verifies" is not the test, "the same publisher" is;
  • a grant that is a subset of the one already granted (no new capability, no new bridge, no derived_names);
  • no new [[displaces]] row;
  • the same package id;
  • the digest was not withdrawn here by plugin disable;
  • the candidate's [package] version is strictly NEWER than the one in force, by semver precedence and not by string order — 0.10.0 is newer than 0.9.0, and build metadata (1.2.3+a) is not part of the comparison at all;
  • and extraction_identity unchanged — not an authority question but a cost one, since moving it reindexes every file the package claims.

Anything else is recorded, reported with the specific reason that stopped it, and left for a human. The reasons are codes, not prose — update_publisher_changed, update_grant_increase, update_displacement_added, update_package_id_changed, update_version_not_newer, update_version_not_comparable, update_extraction_identity_changed, withdrawn_here, plus update_deferred_by_check and update_apply_failed for the two non-authority stops — and each carries a witness naming the actual capability, bridge, fingerprint, id or version pair. They appear on plugin update, on plugin status, on plugin doctor, and in project_overview's package_updates block as blocked_by {reason, witness} pairs.

A version that is not newer is refused, and an EQUAL version is refused too (#255 — until it was fixed, an older package by the same publisher satisfied every other condition and applied: measured at 0.0.1 over an installed 9.9.9, reported applied). Two codes, because they are two different statements:

  • update_version_not_newer — measured. Either a downgrade (a rollback: the same publisher, the same grant, an older version) or an equal version with different bytes (a republish — the extractor changed without a version bump). Both refuse, because "newer" is the entire warrant for acting with no operator present, and the witness says which of the two it is.
  • update_version_not_comparable — not measured. One of the two versions is not one this host can order, so no comparison happened. This cannot arise from a published .cip (the manifest validator is the version parser, so anything that parsed can be ordered), and it refuses rather than falling through precisely so that it never becomes an unattended apply nobody graded.

To take a republished or older version deliberately, plugin add is the command — it asks.

Nothing runs this for you. No daemon, no timer and no MCP tool applies an update; auto_apply = true means "unattended when it runs", not "runs by itself". Until code-index plugin update is invoked — by you, or by a scheduler you own — whether an update exists has not been asked, and every surface says so with NOT MEASURED rather than implying you are current.

To update manually instead, or to take a version update refused, plugin add is the one command: it verifies, shows what the new grant would do, and asks once.

code-index plugin update --check    # what would happen, and why not
code-index plugin update           # apply where the authority is unchanged

The link subcommands manage the [[links]] array in .code-index.toml without you having to hand-edit TOML. Comments and whitespace in the file are preserved; writes are atomic; validation matches the MCP runtime exactly (reserved name, duplicates, self-cycle, link-link path collisions all caught at write time).

Auto-detected project root

If you don't pass --root, code-index walks up from your CWD looking for:

  1. .code-index.toml — innermost wins. Drop one in any directory to pin that as the root regardless of any marker above it. code-index init creates one for you.
  2. .git/ — innermost wins. Stops the walk: a workspace at repo/ with sub-crates at repo/crates/foo/ is correctly detected as repo/ even when you run code-index from inside a sub-crate. A stray ~/.git (dotfiles repo) won't trip detection because a closer .git/ always wins.
  3. Outermost Cargo.toml / package.json / pyproject.toml / composer.json / *.csproj / *.sln — only when no .git/ is found anywhere up the chain. Catches non-git workspaces.

The walk is bounded at $HOME so a marker far above your home directory can't poison detection.

Workspace links — querying multiple projects from one MCP entry

A customer install that extends a base product, a fork that needs to compare against upstream, an app that sits on a vendored library — common patterns where one Claude / Cursor / Cline session needs to query two indices at once.

Drop a [workspace] + [[links]] block into the primary project's .code-index.toml:

[workspace]
name = "h-dv"

[[links]]
name         = "mainproject"                       # routing key for tool calls
path         = "../../code/mainproject/24.0/source"  # absolute, `~/…`, or relative to this file
relationship = "base"                           # base | dependency | fork_source | sibling
description  = "mainproject 24.0 — base product"

That's it. Your existing MCP client config keeps one entry pointing at the primary root; the server attaches a daemon for each link at startup. Tools route across projects three different ways:

// (a) Name/phrase searches FAN OUT by default — primary + every
//     available link. Each hit carries a `project` tag.
search_symbols { "query": "mainprojectService" }
// → [{ name: "mainprojectService", project: "primary", … },
//    { name: "mainprojectService", project: "mainproject", … }]

// Pin a single project by passing its name:
search_symbols { "query": "mainprojectService", "project": "mainproject" }

// (b) Path-shaped tools (file_outline, read_code, get_dependencies)
//     AUTO-ROUTE: an absolute path picks the owning project by
//     longest root-prefix; relative paths default to primary.
file_outline { "path": "/abs/path/under/mainproject/foo.cs" }   // → mainproject
file_outline { "path": "src/foo.rs" }                         // → primary

// (c) Symbol-id tools (get_symbol, find_references, find_callers,
//     find_callees) are project-local: pass the `project` from
//     the row that produced the id.
find_references { "symbol_id": 42, "project": "mainproject" }

project_overview on primary lists every linked project with its index health, and the MCP info.instructions block names each project + canonical root on initialize — so a fresh agent discovers the topology without any extra calls.

What it does: fan-out + path auto-routing + isolation, structured errors when a project name is unknown (project_not_found with did_you_mean), configured-but-unavailable (project_not_available — daemon failed to spawn or path was unreachable), or a path lives outside every indexed root (path_outside_known_roots with the known roots in did_you_mean).

What it doesn't do (yet): cross-project ref resolution (find_callers on a mainproject symbol won't surface h-dv callers). Phase 2 adds find_base_definition / find_overrides / find_call_into_base via qualified_name joins over ATTACH DATABASE.

See _prdoc/missions/I011-workspace-links.md for the full design.

Architecture

See ARCHITECTURE.md for the design rationale and the diagrams. See CONTRIBUTING.md to build, test, and add a language plugin. Operator-facing guides — package authoring, the extractor ABI, the capability model, grammar-build reproducibility, recovery and the threat model — live under _prdoc/guides/; specs and adaptation reports live elsewhere under _prdoc/.

Status

Current release: v0.32.2 (changelog).

Per-release detail — what shipped, what it fixed, and the measurements — lives in the release notes: git.h-dv.de/h-dv/code-index/releases. Design records for each mission are under _prdoc/missions/.