- Rust 98.6%
- Shell 0.8%
- PowerShell 0.3%
- Python 0.2%
- WebAssembly 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
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 |
||
| .cursor/rules | ||
| .forgejo | ||
| .github | ||
| _prdoc | ||
| crates | ||
| distribution | ||
| fuzz | ||
| tests | ||
| .clinerules | ||
| .cursorrules | ||
| .gitattributes | ||
| .gitignore | ||
| .mcp.json.example | ||
| .windsurfrules | ||
| AGENTS.md | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| deny.toml | ||
| install.ps1 | ||
| install.sh | ||
| README.md | ||
| rust-toolchain.toml | ||
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
.sha256sidecar published beside every archive is downloaded and compared. If neithersha256sumnorshasumis onPATHit 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-indexis 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
unameprints what it saw. - Windows is handed over, not half-served. Windows ships a
.zip, not a.tar.gz; this script says so and points atinstall.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
.sha256sidecar published beside the archive is downloaded and compared. If neitherGet-FileHashnorSystem.Security.Cryptography.SHA256can 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-Archivedoes 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.exeis asked its own--versionfirst, 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 indexonce 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 withstate: "reconciling"meanwhile. A one-shotcode-index querywith no daemon running (a hook, a pre-commit check, CI) cannot finish it: each call answerswarming_upwith areconcile_pendingblock (converges_on_retry: false) and exits before the reconcile completes. Onecode-index indexsettles it for good. If a daemon is already serving the project (an MCP session is open),code-index indexdoes not index a second time: it names the daemon's pid, says how to follow its progress (code-index query project_overview '{}', orcode-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 atest_*method of aTest*class, or of a fixture binds the@pytest.fixtureof that name, found the way pytest finds it: the test's class, then its module, then the nearestconftest.pyupward.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.parametrizesupplies, 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.dband its sidecars are now owner-only. They previously inherited the parent directory's ACLs, whiledaemon.toml— which holds only the token guarding access to that index — did not. See the note below on what the index actually holds. WatchOptsgainsignore_change_rewalk;WatcherStatswas 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
.gitor.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 withcode-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.gitinternals could end up in the index — contents included, retrievable throughsearch_text. Those rows go away on upgrade. Nothing you index today is lost: the cold walk never yielded them. project_overviewcan reportstate: "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.WatcherStatsis removed fromcode-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:
- Restart Claude Code in the project dir and approve the prompt. When a
project-scoped
.mcp.jsonis detected for the first time, Claude Code asks the user to approve each declared server. Decline once and it stays declined. - 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_codeonly when it actually needs bytes. - Paginated, token-bounded responses. Lists return
{results, total, next_cursor};read_codeis hard-capped at 4000 tokens with atruncated: trueflag 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) andname_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'scount_basismeasures which zeros are uninformative on it.0is the informative case: nothing unresolved could be this symbol, soref_countis 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?" withindexed/pending/never— andneveronly 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.envis 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_indexednames the files in that directory the index cannot see at all, with the rule that hid each one.read_codetakes a bare path too, so reading a file you have not outlined yet is one call and a truncated read reportstotal_linesrather 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
getno 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, soVec::new()-style externals stay honestly unresolved. The rate is a property of your repository, not a headline number:project_overviewreportsrefsandrefs_resolvedas counts,resolutionas the per-language percentage, andresolution_by_kindas 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_referencesandfind_callerstakeexclude_tests: trueto hide test-file references;excluded_test_refsthen reports the hidden count split intoresolvedvsname_fallback, because the filtered mode is the one where you can no longer readresolutionper row. Both tools carrytotal_resolvedbesidetotal, and everyconfidenceblock carriespage_rowsso apage_resolved: 0can be told apart from an empty page.- Fewer calls, fewer re-reads.
read_codetakes a LIST of targets (up to 8, onebatchesentry each, sharing an 8,000-token content budget that names any entry it could not read), assearch_textandsearch_symbolstake a list of queries.search_text'smax_lines_per_file(default 20, hard cap 1,000) lists every matching line of a file for a rename check;lines_wideningstates the cap that applied and names any file it could not widen.find_callerson a type answerstarget_is_type— call edges attach to its members — with the members that do have callers. envelope: "minimal", on any tool, opt-in. Drops thesemanticsprose and the per-callanswer_provenance/index_snapshotblocks and keeps every disclosure FIELD — counts,*_unmeasuredmarkers, verdicts, refusals, freshnessstate/incomplete, and a skeweddaemon_build, hoisted. The reply says what it omitted in oneenvelopefield. The default reply is unchanged. Measured on a 14-call mix over this repository it saved 18.3% of the characters (index_coverage68%,project_overview23%, a longfile_outlineunder 3%);code-index://docs/envelopehas 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)
enabledis decided for you once.code-index initdetects the languages in the tree and writes exactly those to.code-index.toml. The first daemon orcode-index-mcpstart in a project with noenabledkey 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 inproject_overview.language_ownership.enablement(written_to,change_with). After that nothing is enabled silently: a language that is present and not enabled is reported aslanguage_not_enabled, with the exact command (code-index languages enable <lang>).- Precedence:
.code-index.tomlalways wins. Its[languages] enabledis used whenever it is present;.code-index/languages.tomlis read only while it is not. Onlycode-index initandcode-index languages enable|ownerwrite.code-index.toml.language_ownership.enabled_sourcesays which one is in force (config,stateornone). - In this release the compiled-in plugins still index every language,
enabled or not.
enabledselects the owner underauto; 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_switchandindex_freshness.owner_switchsay 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 inlangto the builtin's, so filters, baselines and bridges do not move when the owner changes. Those ids are reserved — seeplugin trust add --first-partybelow. - Compatibility: a
code-indexolder than this release refuses a[languages] <lang> = …key. Onlyenabled(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
.cipformat 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] versionis strictly NEWER than the one in force, by semver precedence and not by string order —0.10.0is newer than0.9.0, and build metadata (1.2.3+a) is not part of the comparison at all; - and
extraction_identityunchanged — 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:
.code-index.toml— innermost wins. Drop one in any directory to pin that as the root regardless of any marker above it.code-index initcreates one for you..git/— innermost wins. Stops the walk: a workspace atrepo/with sub-crates atrepo/crates/foo/is correctly detected asrepo/even when you runcode-indexfrom inside a sub-crate. A stray~/.git(dotfiles repo) won't trip detection because a closer.git/always wins.- 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/.