npx agentspec-cli install c07edd74-89fa-4ba3-bf3c-e7a0820e382e --file-type agents-md→ .agents/
# CodeSage
Code intelligence engine with structural graph and semantic search. Rust workspace, tree-sitter parsing, ONNX embedding inference, cross-encoder reranking, SQLite storage, MCP interface.
## Build
```bash
cargo build # all crates
cargo build --release -p codesage --features cuda # release binary with GPU
cargo test --workspace # all tests
cargo clippy --workspace # lint
```
> Always build with `--features cuda` when targeting GPU. Without it, CUDA silently falls back to CPU. The binary will error out if GPU is requested in config but the cuda feature is missing.
## Sanity check before pushing
Run `bash scripts/sanity-check.sh` before `git push` when you've made code changes. It checks changelog/plugin metadata, Rust formatting and clippy, shell lint/format, workspace tests, and script/plugin regressions, stopping on the first failure. Pass `--cuda` to include CUDA clippy; the release workflow requires it. `--fast` skips workspace tests and script/plugin regressions while retaining the validation and lint gates; do not use it for releases.
`cargo fmt --all` applies changes in place; CI runs `cargo fmt --all -- --check`, which only reports the diff and exits nonzero. The script catches an edit made after the last format run (as in commit `a43c51d`) before CI does.
## Crate map
| Crate | Role | Depends on |
|-------|------|------------|
| `protocol` | Shared types (Symbol, Reference, SearchResult, etc.) | nothing |
| `parser` | File discovery, language detection, tree-sitter symbol/reference extraction | protocol |
| `storage` | SQLite schema, CRUD, sqlite-vec KNN | protocol |
| `embed` | ONNX embedding inference (Embedder), cross-encoder reranking (Reranker), chunking | protocol, ort, tokenizers, hf-hub |
| `features` | Feature-slice mapping, trust-boundary derivation | parser, storage, protocol |
| `graph` | Indexing orchestration, search pipeline, query API | parser, storage, embed, features, protocol |
| `cli` | `codesage` binary: CLI subcommands + MCP stdio shim + Unix-socket daemon | everything |
## Search pipeline
Query flows through these stages in order:
1. **Embed query**: Jina embeddings v2 base-code (768d) via ONNX Runtime
2. **KNN retrieval**: sqlite-vec, overfetch 5x when the reranker is active
3. **BM25 fusion**: code-literal queries (backticks, `::`, glob patterns) merge BM25 candidates via RRF; fused scores are rescaled onto the semantic score span so downstream boosts can't swamp them
4. **Symbol boost**: +0.1 per query token that matches a known symbol in the chunk, plus definition/qualified-name boosts, path penalties, and directory/file saturation (each env-toggleable)
5. **Symbol annotation**: attach overlapping symbol names to each result
6. **Cross-encoder rerank**: ms-marco-MiniLM-L6-v2, adaptive blend weight (0.35 identifier-shaped / 0.6 natural-language / 0.5 default); skipped when BM25 fusion already ran
7. **Query-mention anchoring**: a path, dotted module, or `Type::method` named outright in the query lifts its matching rows onto a slot ladder directly under the top score (each anchored row ends at or above `top * 0.95^(slot+1)`; at most 5 rows, 4 files x 3 chunks, first page only). A path suffix that matches several indexed files anchors nothing. Inert when the query names nothing; disable with `CODESAGE_MENTION_ANCHOR=0`.
8. **Truncate** to requested limit
9. **Relevance-cliff disclosure**: the page reports `confidence` (`high` when the largest adjacent relative score drop rounds to ≥20%, else `low`), `margin_pct`, and `cliff_at`. Opt-in `adaptive_limit: true` (CLI `--adaptive-limit`) cuts the page at that drop when `confidence` is `high`; a flat page is returned in full. The cut is page-local, so it composes poorly with `offset` paging.
The reranker is optional (configured per-project in config.toml). Without it, the remaining stages still run.
## Config
Per-project config lives at `.codesage/config.toml`:
```toml
[project]
name = "my-project"
[embedding]
model = "jinaai/jina-embeddings-v2-base-code"
device = "gpu"
reranker = "cross-encoder/ms-marco-MiniLM-L6-v2"
[index]
exclude_patterns = []
[docs]
exclude_patterns = ["docs/spikes/**"]
```
Built-in exclusions cover dependencies, build outputs, and caches; configured patterns add to them. Tests are indexed structurally and semantically by default, then demoted during search. Explicitly excluding tests reduces graph-based test discovery and test-gap evidence. The optional `[docs]` example above controls documentation checks, not indexing.
`[embedding] model` / `reranker` values are validated against a built-in allowlist before any download, then loaded only from pinned Hugging Face revisions with sha256-verified tokenizer/ONNX artifacts, because repo-local config is untrusted input (a cloned repo must not be able to pick the graph that gets loaded). To run a non-allowlisted or unpinned model deliberately, set `CODESAGE_ALLOW_ANY_MODEL=1`; the allowlist and pins live in `crates/embed/src/model.rs`.
## CUDA setup
ONNX Runtime loads dynamically. CUDA libraries come from pip-installed `nvidia-*-cu12` packages. At first use, the binary discovers them in this order:
1. `CODESAGE_NVIDIA_LIBS` env var, if set (an explicit nvidia root directory).
2. Python `site.getsitepackages()` + `site.getusersitepackages()`, joined with `/nvidia`. Works with both system-wide pip installs and `--user` installs.
3. Standard system paths: `/usr/lib/x86_64-linux-gnu/nvidia`, `/usr/local/lib/nvidia`, `/opt/nvidia`.
`ORT_DYLIB_PATH` can override the ONNX Runtime library location. Left unset, the binary probes the same site-packages locations for `libonnxruntime.so*`.
`codesage doctor` reports how many nvidia lib dirs were discovered and warns if none.
If CUDA is requested (`device = "gpu"`) but fails to register, the process errors out instead of falling back to CPU. This is intentional: silent CPU fallback produces different embeddings and slower performance. The check inspects `/proc/self/maps` after session creation and hard-fails when libcuda/libcudart aren't mapped. To bypass it deliberately (tests, mixed CPU/GPU setups), set `CODESAGE_ALLOW_CPU_FALLBACK=1`; embeddings are then silently CPU-computed, so never leave it set on a GPU-configured project.
Required pip packages: `onnxruntime-gpu`, `nvidia-cudnn-cu12`, `nvidia-cublas-cu12`, `nvidia-cuda-runtime-cu12`, `nvidia-cufft-cu12`, `nvidia-curand-cu12`, `nvidia-cuda-nvrtc-cu12`.
## CoreML setup (macOS)
On Apple Silicon, set `device = "coreml"` in `.codesage/config.toml`. macOS builds statically link ONNX Runtime with the CoreML EP at compile time (`ort` `coreml` feature via target-specific deps in `crates/embed/Cargo.toml`); Linux/CUDA keeps `load-dynamic`. First session creation compiles CoreML submodels and can take a few minutes; subsequent runs in the same process are faster. Large models (e.g. Jina v2 base-code) may need a lower embed batch size than the default `BATCH_SIZE` in `crates/embed/src/config.rs` if memory pressure causes OOM during indexing.
If CoreML registration fails, the process errors out instead of silently falling back to CPU.
## Conventions
- Rust 2024 edition
- `anyhow` for error handling workspace-wide; shared domain types live in the `protocol` crate
- Tree-sitter queries in `.scm` files under `crates/parser/src/queries/`, embedded via `include_str!`
- JSON output on all query commands (`--json`)
- Model-specific vec0 tables (`chunks_{model}_{dim}`) allow switching models without re-indexing structural data
## Versioning and changelog
This repo follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/) and [SemVer 2.0.0](https://semver.org/spec/v2.0.0.html). Workspace version lives in `[workspace.package] version` in the root `Cargo.toml`; all seven crates inherit it via `version.workspace = true`.
**Every release-notable product change must update `CHANGELOG.md` in the same commit.** Release-notable means: new CLI flags or subcommands, new or changed MCP tools, behavior changes, breaking changes, new dependencies, schema migrations, hook template changes, config surface changes, and security fixes that affect shipped CodeSage behavior.
No changelog entry for pure internal refactors, tests, benchmark/eval harnesses, review-process fixes, doc-only changes, or performance-only internals whose output and operator contract are unchanged.
Write entries in terse style:
- Put entries under `## [Unreleased]` in these sections, in this fixed order: `Added` → `Changed` → `Deprecated` → `Removed` → `Fixed` → `Security`. This is the shared iliaal/* Keep-a-Changelog section ordering (see `~/ai/wiki/architecture/php-extension-c-conventions.md` § CHANGELOG section ordering).
- Skip empty subsections; never carry a placeholder bullet just to populate the structure. Any project-specific section (none today) comes after the standard ones.
- Use one plain bullet per user-visible change. No bold lead-in, no paragraph explanation, no file lists.
- Name the command, MCP tool, config key, or behavior that changed. Stop after the observable effect.
- Prefer consolidation when several fixes share one surface (`codesage daemon status` / `stop`, parser symbol extraction, feature mapping).
- If a reviewer would need the git diff to care, it probably does not belong in the changelog.
`scripts/check-changelog.py` enforces the section set, ordering, and no-empty-section rules on `## [Unreleased]`; it runs as a release pre-flight (`scripts/release.sh` and `/release`). Run it directly any time: `python3 scripts/check-changelog.py`.
### Cutting a release
Use the repository-owned `/release X.Y.Z` workflow and its canonical implementation, `scripts/release.sh X.Y.Z`, rather than manually bumping Cargo or creating a tag.
The script rolls over the changelog, updates the workspace version, both Codex and Claude plugin manifests, and the marketplace metadata and plugin entry. It runs `scripts/check-changelog.py` and `scripts/check-plugin-versions.py` before mutation, then repeats version alignment validation after mutation and before committing/tagging. Keep that gate in the release path; the tag-triggered workflow is not a substitute.
For a non-publishing alignment check on a synthetic release fixture, run `python3 scripts/check-plugin-versions.py --root /path/to/fixture`; do not invoke the publishing release script for that check.
The `Release` workflow (`.github/workflows/release.yml`) fires on the tag push, extracts the matching `[X.Y.Z]` section from `CHANGELOG.md`, and creates a GitHub Release with those notes plus the auto-attached source tarball. If the section is empty or missing, the workflow fails.
Pre-1.0 rule: minor bumps may include breaking changes, patch bumps are backwards-compatible within a minor line.
## Languages
PHP, Python, C, C++, Java, Rust, JavaScript, TypeScript, Go.
C and C++ reference, symbol, and fingerprint extraction skip code inside a literal `#if 0` (and the dead arm of a literal `#if 1`), because a call there exists in no build and would otherwise inflate counts that are published as floors. `#ifdef`, `#ifndef`, and `#if EXPR` stay live in every arm: which of those a build takes is configuration, not dead code. `.h` files default to C. The discovery layer auto-flips them to C++ for any project that also contains an unambiguous C++ extension (`.cpp`, `.cc`, `.cxx`, `.hpp`, etc.). `.c` always stays C. No config knob; if you need to override on a project that mixes both styles awkwardly, raise an issue.
Before parsing C++, macro-shaped identifiers ending in `_API`, `_EXPORT`, `_IMPORT`, `_DLLEXPORT`, or `_DEPRECATED` (at least two characters before the suffix, optionally with a parenthesized argument list) are neutralized when they sit in a class head (`class MYLIB_API Widget`) or at the start of a declaration (`MYLIB_API int f();`), so the class, its members, and the function are indexed. A class-head macro becomes spaces; a macro opening a declaration becomes a same-length `[[...]]` attribute, so the definition still starts at the macro for spans, leading-comment rationale, and `edit_check`. Byte offsets, lines, and columns are unchanged. Comments, strings, preprocessor lines, macro-shaped words used as values or as the declared name or type (`class RENDER_API final`, `MY_TYPE_API const x;`), heads that would keep a second type (`void ZEND_FASTCALL f`), and explicit instantiations (`template class FOO_API Foo<int>;`) are left as written; a macro before `friend` becomes spaces. C sources are parsed as written.
An import or import-binding directive that loads nothing at module load time is recorded with `lazy: true` (omitted when false): one whose node sits inside a Python `def` or a JavaScript/TypeScript function body that is not invoked where it is written (it loads on use), or a TypeScript type-only directive the compiler erases (`import type`, `export type { .. } from`, `import type X = require(..)`, a clause whose every specifier is `type`, or a binding's own `type` specifier). A clause mixing `type` and value specifiers is load-time. A function invoked in place (`(...)()`, `.call`/`.apply` chains, a `.bind(...)` that is itself called, `new (...)()`) is load-time. C/C++ `#include`, Rust `use`, PHP `use`, Java `import`, and Go `import` are never lazy. Import-cycle detection builds its file graph from directives that name an indexed symbol plus JavaScript/TypeScript relative specifiers and C/C++ quoted includes, each resolved to the first indexed file its resolver would load (TypeScript: `.ts`, `.tsx`, `.d.ts`, then JavaScript; JavaScript: `.js` first; C/C++: the includer's directory, then the project root); a `.d.ts` file is never an endpoint, and `list_dependencies` keeps every candidate. Only emitted JavaScript extensions swap to TypeScript sources (`.js`→`.ts`/`.tsx`/`.d.ts`, `.jsx`→`.tsx`, `.mjs`→`.mts`, `.cjs`→`.cts`). Bare JavaScript specifiers, `<...>` system includes, and includes reachable only through other `-I` paths name no file. A Go `import` or a bare JavaScript/TypeScript import specifier (`'debounce'`) names a package, never a symbol: it forms no cycle edge, no `find_references` `to`, and no `impact_analysis` or reachability dependent. Its bindings (`import debounce from 'debounce'`) and same-named calls are still matched by name. Import-cycle detection (`assess_risk`, `assess_risk_diff`, `review_rehearsal`, `session_start`/`session_end`) drops a file pair whose directives are all lazy; one load-time directive keeps the pair. `list_dependencies`, `impact_analysis`, call resolution, and bundles keep lazy rows. Where the cut applies, `lazy_edges` (present only when nonzero) counts the lazy-only pairs that would close or enlarge a cycle: per file on `assess_risk`, per cycle on `cycles_touching_patch[]` and the rehearsal import-cycle evidence, and on the persisted session snapshot and diff.
C, C++, and Rust definitions record `visibility` (`public`, `crate`, `module`, `file`; omitted when unknown); other languages leave it unset. A C/C++ `static` function or file-scope `static` object is `file` unless it lives in a header (`h`, `cuh`, `hpp`, `hh`, `hxx`, `h++`, `tpp`, `ipp`), and a Rust item is `public` / `crate` / `module` from its `pub` modifier: `pub(crate)`, `pub(super)`, and `pub(in path)` are `crate`, `pub(self)` and no modifier are `module`, and `impl Trait for Type` methods are always `public`. Callee resolution (`impact_analysis`, `trace_call_path`, `export_context`, the `to` handle on `find_references` rows, and the `reachable` walk behind `recommend_tests` / `review_rehearsal`) filters candidates by recorded visibility: `file` requires the same file; Rust `module` and `crate` use indexed module descendants and shared targets when known, falling back to path approximations when unresolved. An undecidable crate boundary admits. A gate survivor of a same-named set still needs same-file or import evidence. `find_references` rows stay name-based (a C `static` called from another file is still listed), but such a row carries no `to`.
`is_test` is stored on files and symbols and emitted on symbol and reference rows (omitted when false). `files.is_test` is the discovery path heuristic (`TEST_LIKE_EXCLUDE_PATTERNS`: `tests/` / `test/` / `__tests__/` directories, `*_test.go`, `*Test.php`, `*.test.ts`, `*.spec.js`, `test_*.py`, `*_test.rs`, `*.phpt`, `benches/`, ...) or the parser's file-level verdict: a JavaScript/TypeScript program whose top level calls `describe` / `it` / `test`, or a Rust file headed by `#![cfg(test)]`. Every symbol in a test file inherits the flag, and the watcher applies the same rule. The parser adds symbol-level marks, ancestors included: Rust items inside a module gated by `#[cfg(test)]` or by a `cfg(all(...))` naming `test` anywhere in its list, at any depth, and functions carrying `#[test]`, `#[tokio::test]`, `#[rstest]`, `#[sqlx::test]`, `#[async_std::test]`, or `#[test_case]` (`#[cfg(feature = ...)]`, `#[cfg(not(test))]`, `#[cfg(any(test, ...))]`, and `#[cfg_attr(test, ...)]` do not count; `#[cfg(test)] mod helpers;` marks the declaration and not the `helpers.rs` it names, which falls back to the path heuristic); Python `test_*` functions, `Test*` classes (uppercase or digit after `Test`, so `Testament` is not one) and their methods, and definitions decorated `@pytest.mark.*` or `@pytest.fixture`; JavaScript/TypeScript definitions nested in a `describe` / `it` / `test` call (member forms such as `describe.skip` and `test.each(...)(...)` count) and every definition in a file whose top level makes such a call; Java methods annotated `@Test` or `@ParameterizedTest`, simple or qualified. Go, PHP, C, and C++ rely on the file heuristic only. A reference is test code when its file is, or when the narrowest definition whose line range contains the reference is; `refs` stores no flag, the value is derived at read time. Consumers: `assess_risk` `top_symbols` skips test symbols; `project_overview.top_risk_files` skips test files unless called with `include_tests: true`, which computes a fresh ranking outside the shared cache, while the `session_start` / `session_end` top-50 baseline always skips them; `list_dependencies` moves an import target named only from test code into `test_imports` (a target also named from product code stays in `imports`; in a test file everything is a test import); `search` demotes test chunks from `files.is_test`. A row indexed before `0024_is_test` reads `is_test` 0, so ranking and search fall back to the path heuristic for files whose stored interpretation is older than `STRUCTURAL_INTERPRETATION`, and parser-level symbol marks appear only after the next `codesage index`.
Rust external `mod` declarations establish file dependencies and module context within conventional Cargo targets: `src/lib.rs`, `src/main.rs`, and flat or directory targets under `src/bin/`, `tests/`, `benches/`, and `examples/`. A module declaration does not import its functions into the caller's scope. Included files that could also be standalone targets receive a dependency note. Custom Cargo manifest paths, `#[path]`, `include!`, and inline caller-module scope are not modelled. Context construction stops after 16,384 module contexts; dependency notes, reference `to_resolution.capped`, trace/bundle/impact `bounded`, and test-reachability flags disclose affected incomplete results.
Incremental structural indexing compares detected language, raw content hashes, and each file's structural interpretation identity. Adding or removing the first or last C++ source reinterprets unchanged headers. Parser, extraction, and trust-boundary rule changes must bump the relevant component of `STRUCTURAL_INTERPRETATION` in `crates/graph/src/index.rs`; the next `codesage index` reparses unchanged files carrying an older or missing identity. Each successful per-file transaction records its identity, so failed or untouched files remain stale. Structural rewrites invalidate semantic reuse for that file across models so the next semantic pass refreshes symbol-derived headers. Content hashes remain raw file hashes for watcher, staleness, and feature-map consumers. `codesage status --json` reports `interpretation.current`, stored-version file counts, `unrecorded_files`, and `stale_files`.
Schema migrations `0022_refs_lazy` (`refs.lazy`), `0023_symbols_visibility` (`symbols.visibility`), and `0024_is_test` (`files.is_test`, `symbols.is_test`) are additive; existing rows read as not-lazy, unknown visibility, and not-test until the next `codesage index` reparses them. Schema migration `0019_file_hash_cache` stores verified content hashes with size, nanosecond modification/change times, and observation time. Incremental discovery reuses a hash only when the stat triple matches and both timestamps precede the observation's whole second; full indexing bypasses reuse. Files still undergo access checks. Platforms without the required timestamps hash normally. This cache does not attest structural parsing or semantic fingerprint freshness.
## MCP tools
One target grammar (`crates/graph/src/resolver.rs`) covers the tools that name an entity: a handle (`sym:<path>#<qualified>[@<line>]`, `file:`, `dir:`, `chunk:`, `feat_`), an indexed path (a leading `./` is ignored), `path:line`, a qualified name, a bare name, `route:METHOD path`, or `cmd:name`; anything else is text a semantic search may take. Every tool that names an entity spells it `target`, or `targets` on the file-set tools; the legacy argument (`name`, `symbol_name`, `file_path`, `file_paths`, `feature_id`) stays accepted for one minor release, and the two naming different things is `E_PARAM` quoting both. `find_symbol`, `find_references` (`name` stays its primary spelling; rows are keyed by the bare names of the definitions the input resolves to, so a `sym:` handle returns the union those names already are and the envelope discloses every definition sharing them), `find_similar`, `impact_analysis` (`dir:`, `chunk:`, and `feat_` are `E_PARAM` there), `export_context`, and `feature_bundle` take the whole grammar; `trace_call_path` has no single target and takes it on `from` and `to` instead; `edit_check` takes a `sym:` handle naming its own `file_path`. The file-grained tools (`list_dependencies`, `find_feature`, `find_coupling`, `assess_risk` / `assess_risk_batch` / `assess_risk_diff`, `recommend_tests`, `review_rehearsal`) take the arms that carry a file (`file:`, `sym:`, `chunk:`, an indexed path, `path:line`) and answer for the file the target lives in; a `dir:`, `feat_`, `route:`, or `cmd:` target is `E_PARAM` naming what they take. What a miss costs there depends on the grain: a one-target tool asks the resolver for nearest candidates and refuses with `E_NOT_FOUND` and their handles, so a bare basename or a path spelled from a subdirectory says where the file is (a path the working tree holds as a file is taken as written instead, so a file the caller just wrote reads as unindexed rather than as a miss), while a file-set tool passes an unmatched path through to its own per-input disclosure (`unscored_files`, `unindexed_files`) rather than costing every other input its answer. One ambiguity policy follows from the grammar: tools whose answer is a union of per-definition answers (`find_symbol`, `find_references`, `find_similar`) return the union and disclose it in `target`; tools whose answer would be wrong as a union (`impact_analysis`, `trace_call_path`, `export_context` on a symbol, `feature_bundle`) return `E_AMBIGUOUS` with `candidates` and a remedy that replaces the input with the first candidate's handle, and `edit_check` returns `E_AMBIGUOUS` with a remedy carrying the first matching `line`.
- `edit_check`: compare a complete proposed replacement declaration against a symbol in pinned Git HEAD, without writing source, requiring an index, or starting a watcher. Requires absolute `project`, repository-relative `file_path`, the declaration as `target` (legacy spelling `symbol_name`), and `replacement`; optional one-based `line` disambiguates declarations. Reports arity, visibility, and same-scope overload snapshots plus working-file divergence. Provably incompatible caller checks cover same-file Rust free functions reached through explicit `self::` / `super::` paths, without imports, macros, or attributes. Other languages receive syntax-level declaration diffs; methods, indirect/external callers, and unsupported parameter semantics remain unknown. `target` also accepts a `sym:` handle naming the same `file_path` (its `@<line>` supplies `line`); a handle for another file is `E_PARAM`. Ambiguous `.h` dialects are refused (`E_PARAM`, like a malformed `replacement`); no matching declaration is `E_NOT_FOUND`, several are `E_AMBIGUOUS` carrying `candidates` (`sym:<file>#<name>@<line>`) and a retry with the first matching `line`. This is not a compilation or safety verdict; source and proposed file are capped at 1 MiB, caller output at 100. The response envelope opens an existing index read-only for its `index` block and stale-path annotation; a project without one still answers.
- `project_overview`: one bounded first-call orientation: languages, structural + semantic freshness, feature summary by kind, top-risk files, trust-boundary clusters, per-language test conventions, sample entrypoints, and suggested next calls. Aggregates indexed facts and computes risk analysis without model inference. The daemon shares a generation-validated top-50 ranking with `session_start`; overview shows ten rows, and freshness remains per request. Test files (`is_test`) are excluded from `top_risk_files` unless `include_tests: true`, which computes an uncached ranking for that call. Call once at session start. `freshness` pairs `commits_behind` with `indexed_files_behind`: the indexed files whose HEAD content differs from the index, so a five-commit range that touched only unindexed paths reads `0` and suggests no reindex (absent when the comparison could not run, `indexed_files_behind_bounded` when it stopped at its 2,000-path cap or 500 ms budget; a bounded `0` is unmeasured, not fresh, and still nudges a reindex). A supported source file committed since indexing counts as behind; a path discovery would skip (no parser, hidden, matched by the default or `[index] exclude_patterns`, `.gitignore`d but force-added, or a symlink) does not. The `git diff`, the `.gitignore` and symlink lookups, and the blob comparison share that 500 ms budget, and a `git` child still running when it expires is killed. The HEAD and ancestry lookups before it run under 2 s deadlines, so one check takes at most about 4.5 s unless the kernel cannot interrupt the stalled child; a timed-out lookup reads `unknown` and logs a warning. `hook_health` (`installed_hooks`, `last_run`, `last_exit`, `lock {state, reaped}` plus `pid`, `since_unix`, `since`, and `age_secs` only when the lock is held) reports the indexing-hook lock state; absent when the project is not a Git repository; the overview never reaps, `codesage doctor` does. When the `semantic_files` table cannot be read (missing or malformed), `freshness.semantic_unavailable` names the reason, `semantic_indexed_files` is not a measurement, and the suggested calls include `codesage index --full` (after the stale-index nudge when one applies); the structural half of the overview is still returned. Every writable open recreates a malformed `semantic_files` table empty (forcing a re-embed), so this state is mostly seen through read-only handles.
- `search`: semantic search with embedding + reranking; each page carries `confidence` / `margin_pct` / `cliff_at` (relevance-cliff disclosure) and honors opt-in `adaptive_limit`. Optional `explain: true` (CLI `--explain`) adds a per-row `trace` of score transitions and retrieval/reranker signals; it leaves ranking unchanged and is omitted by default. Each surviving trace is complete and ends at the row's score. MCP budgeting can shorten content or annotations, or drop whole rows, disclosed as `completeness.kind: truncated`. See `bench/search-explain.md` for diagnosis and comparison limits.
- `find_symbol`: symbol definitions by name, handle, or `path:line`. Rows carry `handle` (`sym:<path>#<qualified_name>`, line-independent; `@<line_start>` is appended only when several definitions in that file share the qualified name), `visibility` when known, and `is_test: true` for test code (see Languages; omitted when false). The envelope is `{results, target}`: `target` carries one candidate handle per definition, `ambiguous`, `candidates_total`, and `overloads` (definitions sharing one file and qualified name, omitted when zero). Module declarations (`mod x;`, a module or namespace symbol with no body on its line) are excluded from both unless `kind` is `module` or `namespace` (CLI: `--include-modules`); a `kind` filter narrows both too, so `target.ambiguous` counts only definitions of that kind. A name that matches nothing as written returns no rows and case- or suffix-matched leads in `target.resolved`, each below the `0.8` confidence a caller may act on.
- `find_references`: references to a symbol; `name` (or `target`) takes the target grammar, and since rows are keyed by the bare names of the definitions it resolves to, a `sym:` handle returns the rows that definition's bare name does, with `definition_count`, `ambiguous`, and `note` covering every definition sharing that bare name while `target` stays the input's own resolution (split the union by `to` or `from_file`). An exact qualified spelling keeps its own narrower rows and definition set. Each row's `from_symbol` names the enclosing caller (null at file scope). Rows carry `from` (`sym:` handle of the enclosing symbol, omitted at file scope) and `to` (`sym:` handle of the definition the reference resolves to when the spelling is qualified, the definition is in the same file, or the caller file imports the definition or references its owning type; omitted otherwise). `to` resolution is bounded (256 distinct caller/name pairs, 750 ms); a page that hit the bound carries `to_resolution {resolved_pairs, capped: true}` and rows after the stop have no `to`. `lazy: true` marks a function-body or TypeScript type-only import; `is_test: true` marks a reference made from a test symbol or test file, so rows without it are product callers. The envelope carries `counts_floor: true` (name-based edges; an empty result means none found, not none exist), `definition_count`, `ambiguous` with a `note` when several same-named definitions make the rows a union, and one handle per definition so the union can be split: in `target` when the input was the bare name, in `note` otherwise. `definition_count` and `target` exclude module declarations; the rows are unfiltered, so a `mod render;` line is itself listed as a reference to `render`
- Handles: symbol rows (`find_symbol`, `export_context` / `feature_bundle` `symbol_definitions`, `trace_call_path` steps, `impact_analysis` `sibling_symbols`) carry `sym:` handles; file-shaped rows (`impact_analysis` results, `list_dependencies`, `find_coupling`, `assess_risk*` entries and `top_coupled`, `recommend_tests` coupled/reachable, resolved `from_trace` frames) carry `file:<path>`; `assess_risk_diff` clustered directories carry `dir:<path>`; search and bundle chunks carry `chunk:<path>:<start>-<end>`; features keep `feat_`. Inside a handle's path or name, `%`, `#`, and `@` are written `%25`, `%23`, `%40`; `Handle::parse` in `crates/protocol/src/handle.rs` rejects absolute paths and `..` segments, and no handle is emitted for a path it would reject. Handles are also input: every tool listed at the top of this section takes one as `target` (or as an entry of `targets`), and a `next` follow-up names its subject with the row's own handle whenever the row carries one of the grain the follow-up needs.
- `find_similar`: near-clone detection: functions/methods structurally similar to a named one (MinHash over AST shape, identifiers/literals ignored), ranked by Jaccard. `name` takes the target grammar; fingerprints are keyed by bare name, so a `sym:` handle or `Foo::run` returns the rows `run` does. Rows are the union across every definition sharing that name, with `target` disclosing them. Test files excluded. Needs fingerprints from a reindex.
- `list_dependencies`: file-level imports/imported-by; import targets named only from test code (a `#[cfg(test)]` module's `use`, any directive in a test file) move from `imports` to `test_imports` (omitted when empty); `note` discloses Rust module-context limits or ambiguous target roles, also printed by human `codesage dependencies` output.
- `trace_call_path`: shortest call chain from one symbol to another, breadth-first over resolved callee edges. Each step names the symbol, its file:line, and `call_line` (the line in the previous step's body where it is invoked). `from` and `to` each name one definition: several is `E_AMBIGUOUS` with their handles, and a name that matches nothing returns `found: false` with the nearest candidates in `note`. `bounded: true` means the search stopped at a limit, so an empty answer is not proof no path exists.
- `from_trace`: map a pasted stack trace or sanitizer report (Python, PHP incl. Xdebug, Rust, Java, Go, Node/JS, gdb/ASan/UBSan; Kotlin frames parse with the Java syntax, but a Kotlin frame that names a `.kt` file is always `unresolved` since Kotlin sources are not indexed) onto indexed symbols and `file:line`, innermost-first; `frames[0]` is the innermost frame of stack 0 (Java's deepest `Caused by`, with `Suppressed` blocks last; Python's first traceback; ASan's access stack; Go's `[running]` goroutine; one Rust stack per panicking thread), with `stack` per frame and `stacks` + `root_cause_first` on the report. Each frame is `resolved` (`symbol` may be null between definitions; `with_symbol` counts), `ambiguous` (path suffix or qualified name matched several entries, or only a bare name matched, which is a lead; `candidates` holds at most 10 (5 when the frame names no file), `candidates_total` the count), or `unresolved` (vendor/stdlib/runtime file outside the index); nothing is guessed, and bare names never cross languages. PHP `#N file(line): func()` frames resolve `func`'s definition by qualified name (`Ns\Class\method` as the index stores it), since that `file:line` is the call site. CLI: `codesage from-trace [FILE|-]`.
- `impact_analysis`: files affected by changing a symbol or file, with distance and reasons. A target naming several definitions is `E_AMBIGUOUS` with a handle per definition and a retry remedy (including definitions that share a qualified name, which earlier releases answered for jointly); one naming nothing is `E_NOT_FOUND` with the nearest candidates, not an empty result; a `dir:`, `chunk:`, or `feat_` handle is `E_PARAM` naming the accepted kinds (`sym:` / `file:` handle, indexed path, qualified or bare name). `is_file` is deprecated: a handle names its own kind and outranks it. Opt-in `include_forward` (forward deps), `include_siblings` (same-file symbols), `limit`, and `summary_only` controls; result is an object with `results` plus the requested extras. Optional `bounded: true` discloses incomplete Rust module resolution.
- `export_context`: curated code bundle for a query or symbol, optionally with callers/callees. A `target` that names several definitions is `E_AMBIGUOUS` with their handles; one naming nothing keeps `found: false` and names the nearest candidates in `target_description`. `is_symbol` is deprecated: a `sym:` handle already overrides it. Optional `bounded: true` discloses incomplete Rust module resolution in related context.
- `find_coupling`: files that historically change with a given file (V2b). Each row carries `recurrence` (distinct 90-day calendar windows the pair co-changed in), `span_days` (oldest to newest shared commit), `span_known` (false for rows indexed before recurrence tracking), `recurring` (`span_days >= 30`; `recurrence` alone is informational since a boundary straddle can read 2), `p_cochange` = P(row file changes | this file changes), and `p_reverse` (deprecated spellings `confidence` / `reverse_confidence`, removed in the next minor); both are lower bounds (commits touching >30 files add no pair evidence but count in the denominator). Non-recurring pairs rank at half weight (`CODESAGE_COUPLING_RECURRENCE=0` or `false` disables). A page with no recurring pair carries a `note` that says whether the indexed co-change evidence is too short (<30 days), the index needs `git-index --full`, or the coupling is short-burst evidence. Any returned unknown-span row adds a `span unknown` note with the `git-index --full` remedy, including on mixed pages with recurring rows.
- `assess_risk`: risk score for a single file (V2b slice 1; now blends import-cycle membership alongside churn / fix / blast / coupling / test-gap). A file with no `git_files` row reports `unscored: true` and a note: churn, fix ratio and coupling pressure were not measured rather than measured as zero, so the score covers structural signals only and must not be read as low risk. `top_coupled` is recurrence-ranked like `find_coupling` (rows carry `span_days` / `span_known` / `recurring`); coupling pressure reads the raw top ten, the coupled-test check reads the raw and ranked pages, and a note names coupled tests that fall below the ranked list. Notes distinguish known one-offs from unknown spans and give the `git-index --full` remedy for unknown spans, including hidden coupled tests and mixed pages. `top_symbols` ranks product symbols only; `is_test` symbols (a `#[cfg(test)]` module and its helpers) are skipped. Its ref counts are resolved product callers, not name matches: a callsite counts only when it resolves to that definition under the rules in Languages and is not itself test code, so a same-named definition elsewhere lends nothing. Resolved counts are lower bounds: a spelling the resolver cannot tie to the definition is dropped, and Rust `extern_crate::item` spellings from other workspace crates do not resolve yet, so a library symbol called mostly across crates reads low. `shared: true` marks a count several same-named definitions in the same file still answer, and `bounded: true` a row left on its name count because a per-file resolution limit (40 symbols, 20,000 rows, 750 ms) or the 3 s budget one `assess_risk_diff` / `assess_risk_batch` request shares had run out when its same-named group came up, or because the name's own row count exceeded what was left of the row budget (that name is skipped, not hydrated, and the next name still resolves). The limits are checked before each group is hydrated, not enforced mid-group: the group that finds them satisfied is resolved whole, so a pass can overshoot the symbol and time limits by one group.
- `assess_risk_batch`: per-file risk for N files in one call, no aggregation. Use when you have a list of files and want each one's score; saves the per-file MCP round-trip overhead vs N `assess_risk` calls. For patch-level aggregation use `assess_risk_diff` instead.
- `assess_risk_diff`: aggregate risk for a patch / set of files (V2b slice 2). `max_score` / `mean_score` / `max_risk_file` cover scored files only: unscored files are excluded from the aggregates and named in `unscored_files`, while still appearing in `files` and the structural rollups; `scored_file_count` reports how many files were scored, which is the mean's denominator. `max_risk_file` is omitted when nothing was scored. For indexer-written data the exclusion cannot hide a threshold crossing: the terms reachable without a `git_files` row sum to 0.41, under the 0.50 max-score gate, and `review_rehearsal` reads per-file rows rather than the aggregates. The margin is zero: adding coupling pressure reaches exactly 0.50, which the `>=` gate would fire on. The schema does not itself forbid a visible co-change pair without a `git_files` row, so treat the bound as a property of what the indexer produces, not of the types. Per-file `notes[]` may contain short codes (`"T"`, `"NG"`, `"US"`, `"UI"`); resolve via the top-level `_legend` map.
- `recommend_tests`: tests an agent should run after editing a set of files (V2b slice 2). Three buckets: `primary` (sibling conventions plus any changed file that is itself a test), `coupled` (co-change history; entries carry `span_days` / `recurring` and follow the `find_coupling` ranking, one-offs at half weight; only the top 20 co-change partners per changed file are consulted and a note names inputs where more existed), and `reachable` (test files with a resolved call/import path into the changed set within two hops, ranked by distance then `edge_count`, capped at 50 with `reachable_total` carrying the full count). Changed tests are walked too, so a base test class lists the tests that extend it. Read `reach_walk_capped` before trusting an empty or short list: when it is `true`, `reachable` is a lower bound and `unmodelled` means nothing; `unwalked_files`, `partial_files`, `unindexed_files` (a supported-language path the index does not hold: new file, non-repo-relative path, or excluded by `[index] exclude_patterns`), and `no_symbol_files` say why. `unsupported_files` (no parser: CHANGELOG.md, lockfiles) are skipped and never cap the answer. The walk spends one pool of resolution steps in request order with a floor reserved for each queued input, so put the files you care about first. Fixture directories (`fixtures/`, `stubs/`, `testdata/`, `__snapshots__/`) never appear in any bucket. `CODESAGE_REACH_BUDGET` (default 1,500,000 steps, one pool per request) and `CODESAGE_REACH_DEADLINE_MS` (default 5000; `review_rehearsal` uses 1500 regardless) bound the walk. `commands[]` turns the buckets into runnable shell commands (`command`, `covers`, `framework`, `source` = `inline` | `feature_test_command` | `convention`, in that order): `cargo test -p <crate> <module>::` per inline test module and `cargo test -p <crate> --test <name>` per Rust integration test (more than three targets in one crate collapse to `cargo test -p <crate>`), `pytest <paths>`, `php artisan test` / `vendor/bin/phpunit <paths>`, `php run-tests.php <dir>` per `.phpt` directory, `go test <dirs>`, `npx vitest run` / `npx jest` / `npx mocha` / `node --test` / `bun test` / `npx playwright test` `<paths>`, chosen from the test file's runner import, then the nearest `package.json` (`scripts.test`, a single declared runner dependency, a `jest` key) or a vitest config beside it, walking up to enclosing packages (a nested package's command is `(cd -- ./<dir> && …)`; a path no evidence names a runner for gets a note and no command), `mvn -Dtest=... test` / `gradle test --tests ...`, plus a mapped feature's own `test_command`. Paths are single-quoted when they contain shell-significant bytes and prefixed `./` when they start with `-`; a feature command containing a newline is dropped with a note. `inline_test_modules[]` names Rust `#[cfg(test)]` modules and Python `test_*` functions inside the changed files (`file`, `module`, `test_count`; the Rust count reads `#[test]`, `#[tokio::test`, `#[rstest`, `#[sqlx::test`, `#[async_std::test`, `#[test_case` from source up to 1 MiB, else indexed `test*` function names). Both derive from `primary` + `reachable`, never `coupled`; nothing is executed; omitted when empty. `review_rehearsal` quotes up to five commands in `summary_notes`.
- `review_rehearsal`: predict severity-ranked review objections for a patch (missing tests, unscored risk, high-risk / blast-radius / fix-prone / hotspot files, import cycles, trust-boundary expansion, feature-test gaps, and `scope-spread` when a patch touches ≥4 unrelated feature areas) with hot-symbol evidence. `unscored-risk` is Low for a mixed patch and Medium when every file lacks indexed git history; run `codesage git-index` and repeat the rehearsal. Structural warnings remain available, with unscored risk bands qualified as structural-only. Composes `assess_risk_diff` + `recommend_tests` + drift + feature mapping; read-only, no AI prose. Use as the last step before a commit.
- `session_start` / `session_end`: snapshot structural state at the start of an editing session, diff at the end. Returns `pass: bool` plus new/resolved cycles, per-file risk regressions on the top-50 baseline, and added/removed files. Note the shape divergence: MCP `session_start` returns the compact `SessionStartReport` (`snapshot_path` + counts), while CLI `codesage session-start --json` prints the full `SessionSnapshot` (file list, cycles, top-risk files).
- `list_features`: list mapped feature slices, filterable by `kind` (`route`, `cli-command`, `library`, `test-suite`, `service`, `config`, `job`), `language`, or `tag` (0.7.0).
- `find_feature`: given a file path, return the feature(s) that own it. Routes "what slice owns this file?" without scanning by hand.
- `feature_bundle`: curated code bundle for one feature slice (entry + owned + tests + context as primary/related chunks, plus the entry symbol's definition and optionally its callers/callees). Same shape as `export_context` but anchored on the feature's pre-curated file list. `target` (legacy spelling `feature_id`) also accepts `route:METHOD path` and `cmd:name`; several matches are `E_AMBIGUOUS` with their ids, and an unknown one returns `found: false`.
Every MCP tool advertises an `outputSchema` (0.7.0); agents that consult it know the result shape before they call. Every successful response also carries a shared envelope, with silence meaning the good case: `tool` names the tool, `cost` reports `{ms, bytes}` (wall time from request receipt to response, including admission wait; payload size before the envelope), and `index` reports `{generation, head, structural, semantic, dirty_paths}`. `generation` is a 48-bit digest of the index state (file identity, observer epoch, `data_version`, config digest, coupling policy) whose unequal values prove the state changed, while equal values are only meaningful within one daemon's lifetime, `head` the short indexed SHA, `structural` `behind` (at least one indexed file's HEAD content differs from the index, counted by `files_behind`, with `files_behind_bounded: true` when that count is a lower bound because the comparison stopped at its cap or budget; when the comparison cannot run or stops before finding one it falls back to the indexed SHA not being HEAD), `dirty` (a path in this response differs on disk, which wins over `behind` and `unknown`), or `unknown` (drift could not be determined: git failed, the index carries no commit stamp, or it could not be opened), `semantic` `partial` or `none` for the configured embedding model, and `dirty_paths` the stale paths. `structural` is omitted only when the index is fresh, and `files_behind`, `files_behind_bounded`, `semantic`, and `dirty_paths` are each omitted when they have nothing to report, and the whole `index` block is omitted when the project holds no index; drift and coverage are measured once per index generation and at most every 30s, so a commit without a reindex surfaces within that window. `completeness {kind, kinds[], recover}` appears only when the answer is not exact: `kind` is the most severe of `kinds` in the order `truncated` > `bounded` > `partial` > `clamped` > `unscored` > `floor`, and `recover` is machine-readable, never prose: `{total, returned, arguments}` for `truncated` (`arguments` advances `offset` only for `search`), `{command}` naming the broader CLI surface or `{tool, arguments}` for `bounded`, `{requested, applied}` keyed by parameter for `clamped`, `{command: "codesage git-index"}` for `unscored`, the per-input reason lists for `partial`, and `{reason}` for `floor`. `target {ambiguous, candidates_total}` is injected only on tools that do not already carry a `target`, and only when the input resolved to several entities; `find_symbol`, `find_references`, and `find_similar` always carry their full `target` (`input`, `kind`, `resolved`, `ambiguous`, `candidates_total`, `overloads`) instead. Staleness is derived from the `file:` / `sym:` / `chunk:` / `dir:` handles rows carry, unioned with a residual allowlist for path fields that carry no handle; `edit_check` is annotated too and still starts no watcher.
Deprecation window: this release emits the envelope alongside the unchanged legacy keys. On the request side the legacy entity-naming arguments (`name`, `symbol_name`, `file_path`, `file_paths`, `feature_id`) and the `impact_analysis` `is_file` / `export_context` `is_symbol` hints are deprecated and removed in the next minor; `find_references` keeps `name`. On the response side, `_meta` (`truncated`, `total_results`, `returned`, `dropped_files`, `clamps`, `coverage`, `stale_files`, `stale_warning`, `test_override`, `ranking_recomputed`) and the per-tool incompleteness fields (`counts_floor`, `bounded`, `truncated`, `callers_truncated`, `reachable_capped`, `reach_walk_capped`, `unmodelled`, `unscored`, `unscored_files`, `unwalked_files`, `partial_files`, `unindexed_files`, `no_symbol_files`, `to_resolution.capped`) are deprecated and removed in the next minor, which also moves the payload under `data`. `CODESAGE_ENVELOPE=legacy` in the daemon's environment suppresses every envelope key; it is read once when the daemon builds its state, like `RUST_LOG`, so changing it mid-life needs `codesage daemon stop`. Legacy mode restores the old keys, not the old staleness coverage: `edit_check` still gains `_meta.stale_files`, and `entry_path` and reachable-test `via` paths still enter the staleness check. `embed_texts`, `rerank_pairs`, and `daemon_stats` are internal and never enveloped.
Responses also include `next`, an evidence-derived `{tool, arguments}` call using retained result rows and an absolute project path. `null` marks empty, unsupported, or terminal evidence; context bundles terminate the chain, and `session_start` waits for edits. Error results keep the readable cause as the first text block and add one JSON block: `{"tool", "error": {"code": "E_...", "message", "remedy", "candidates"}, "status", "complete": false, "next": null, ...}` (`candidates` only on a target refusal) where `remedy` is `{"tool", "arguments"}`, `{"command"}`, `{"command", "cwd"}`, or `null`, never prose (`cwd` appears only on `E_NOT_ONBOARDED`; a path is never interpolated into a command string). Codes are `E_PARAM`, `E_PROJECT_PATH`, `E_NOT_ONBOARDED`, `E_SCHEMA_TOO_NEW`, `E_NOT_FOUND`, `E_AMBIGUOUS`, `E_EMPTY_INPUT`, `E_OVER_CAP`, `E_MODEL`, `E_DB_BUSY`, `E_SATURATED`, `E_TIMEOUT`, `E_CANCELLED`, `E_SHUTDOWN`, `E_INCOMPLETE`, `E_INTERNAL`; retry-shaped remedies echo the original arguments, and a target naming several entities returns `E_AMBIGUOUS` with every candidate's handle in `candidates` and the first one substituted for whichever argument carried the ambiguous input (`edit_check` retries with the first matching `line` instead). Parameter validation, project routing, `daemon_stats`, and internal-tool failures all use the same block; legacy `status` values are unchanged. Follow-ups are suggestions, not instructions or evidence that unreturned matches do not exist.
## MCP runtime
`codesage mcp` is the stable client entrypoint. It runs as a stdio shim, starts or connects to the per-user Unix-socket daemon, and forwards MCP JSON-RPC unchanged. The daemon hosts the real MCP server and owns shared project/model/reranker pools across main sessions and subagents.
CLI `search` and `export` reuse the daemon's reranker through internal `rerank_pairs`, omitted from `tools/list`. Requests are capped at 32 documents, 65,536 bytes per text, and 1,048,576 bytes total; the CLI splits batches and uses private inference for oversized individual texts. Without a daemon, private initialization remains eager. Remote inference failures retain warning/pre-rerank-order behavior without loading a second model; already-running native inference cannot be forcibly cancelled.
`codesage mcp --project <abs root>` makes the server default the per-call `project` argument to that root when a `tools/call` omits it. Set automatically by `codesage install` for agents without a CodeSage plugin (Codex, opencode), which otherwise have no way to inject the project path. With no `--project` (the Claude-plugin path) the shim raw-copies stdio with zero overhead.
The daemon co-trusts every process under the same Unix UID. Runtime dirs, socket mode `0o600`, and `SO_PEERCRED` keep other users out, but a compromised same-UID agent can ask the daemon to read any onboarded project index. Use a separate Unix user for untrusted agents that need project isolation. MCP tools are capped for agent safety; CLI commands are the operator surface and intentionally keep broader limits unless a command documents its own cap.
Consistency during reindex: the structural indexer commits in batches of 50 files, so cross-file queries issued while a large reindex is running can see a blend of pre- and post-change state. The per-response `index.dirty_paths` annotation (deprecated `_meta.stale_files`) flags results referencing files that changed on disk but aren't reindexed yet. Both windows close when the index pass finishes.
Use `codesage mcp --direct` only when debugging the old single-process stdio path. Use `codesage daemon` to run the foreground daemon explicitly. Socket state lives under `$CODESAGE_DAEMON_RUNTIME_DIR`, `$XDG_RUNTIME_DIR/codesage`, or `/tmp/codesage-$UID`; the socket name includes the running binary's version and executable metadata so rebuilt binaries don't attach to stale daemons.
### Daemon management
- `codesage daemon` runs the daemon in the foreground (the default action).
- `codesage daemon status` prints the running daemon's pid, socket path, and log path; exit 1 if not running.
- `codesage daemon stats --json --recent N` queries the existing same-build daemon without starting one; `N` is 0 through 256. It reports bounded request/execution detail, aggregate outcomes, cache reuse, and current work limits. The internal `daemon_stats` tool is not advertised to agents.
- `codesage daemon stop` sends SIGTERM, waits up to 10s for cleanup, and reports.
Runtime files per daemon: `mcp-<version>-<key>.sock` (Unix socket, 0o600), `mcp-<version>-<key>.pid` (text pid), `mcp-<version>-<key>.lock` (start-lock during spawn), `mcp-<version>-<key>.log` (daemon stdout + stderr). The log is rotated when it crosses 4 MiB; three generations are retained (`.log`, `.log.1`, `.log.2`).
Logical request completion does not release a physical execution lease. Shared ranking waiters detach independently; native inference and nested download workers retain capacity until actual exit. Work-control error metadata reports `phase`, `work_continuing`, and `complete: false`. Session cancellation after atomic persistence does not roll back the snapshot; retained diagnostics track `persistence_committed` even if delivery was cancelled. See README for the operator inspection path and `bench/daemon-performance/README.md` for the measurement and acceptance procedure.
### Tracing
The daemon inherits the **first** spawning shim's environment, including `RUST_LOG`. Setting `RUST_LOG=codesage=debug` on the initial `codesage mcp` invocation that boots the daemon raises the daemon's log level for its entire lifetime; subsequent shims with different `RUST_LOG` values don't reconfigure the running daemon. To change filters mid-life, `codesage daemon stop` and let the next shim restart it under the new env.
The daemon writes tracing to `mcp-<version>-<key>.log` in the runtime dir; check that file first when a tool call hangs or an MCP session won't initialize. SIGTERM/SIGINT trigger graceful shutdown (socket + pid file removed before exit).
## CLI commands
`brief FILE --json` includes `branch_overlap`; CLI `rehearse` and MCP `review_rehearsal` add branch-overlap objections. Git-only scans exclude stacked branches, duplicate tips, and noise paths. They consider at most the newest 50 local/remote-tracking refs, with 100 ms for brief and 250 ms for rehearsal, and disclose scanned/total counts and incomplete evidence. At most three matching branches and five files per branch are shown with totals. Without an index, branch evidence remains available and rehearsal names skipped indexed checks. An existing unreadable index does not enable branch-only fallback.
`init`, `index`, `overview`, `search`, `brief`, `find-symbol`, `find-references`, `dependencies`, `impact`, `trace`, `from-trace`, `export`, `status`, `mcp`, `daemon`, `watch`, `install-hooks`, `install`, `uninstall`, `cleanup`, `coverage`, `git-index`, `coupling`, `risk`, `risk-batch`, `risk-diff`, `similar`, `tests-for`, `rehearse`, `session-start`, `session-end`, `doctor` (with `--docs`), `map`, `features-list`, `feature-show`, `feature-for`, `feature-bundle`, `trust-boundaries`. (Mirrors the `Commands` enum in `crates/cli/src/main.rs`; audit this list when adding a subcommand.)
`find-symbol`, `impact`, `trace`, `export --symbol`, and `feature-bundle` take the same target grammar as their MCP counterparts. On a target that names several entities they print the candidate handles, one per line (`--json`: `{"error": {"code", "message", "candidates"}}`), and exit nonzero. `find-symbol --include-modules` keeps module declarations.
`from-trace [FILE|-] [--limit N] [--json]` reads a stack trace or sanitizer report from a file or stdin and prints one line per frame, innermost-first: `#i status path:line symbol`, with ambiguity candidates indented beneath (at most 10, or 5 for a frame with no file, then an `… N more candidates (T total)` line). Same parser and resolution as the `from_trace` MCP tool.
`watch run|status|stop|start [project]` controls the live filesystem watcher. By default, the daemon auto-starts a per-project watcher on the first semantic query; `[index] watch = true` starts it on the first index-backed project call. The watcher reuses the daemon's pooled embedder, debounces edits, and reindexes structural + semantic on change; it self-exits after idle (`CODESAGE_WATCH_IDLE_SECS`) and is reaped on daemon shutdown. Disable per project with `[index] watch = false` or globally with `CODESAGE_WATCH=0`. `watch run` is a foreground instance with its own embedder for debugging; `watch stop` writes a `.codesage/watch.disabled` marker the running watcher honors; `watch start` clears it. The watcher complements the git hooks, it does not replace them: it refreshes structural + semantic content live during a session, but git history intelligence (`git-index`, feeding `assess_risk` / `find_coupling`) and feature mapping still refresh only via the hooks or a full `codesage index`, and the watcher only runs while a daemon is alive.
`install <codex|opencode|all> [--global]` registers CodeSage as an MCP server in agents that have no CodeSage plugin (Codex CLI, opencode), writing their native MCP config (`toml_edit` / `jsonc-parser` CST, comment-preserving and idempotent). It registers the command `codesage mcp --project <abs root>`; `uninstall` removes only CodeSage's entry. Claude Code is not a target; it keeps its `claude mcp add` / plugin registration.
`map` runs the feature mappers (Cargo workspace, composer + Laravel routes, php-src `ext/*`, CMake / autotools, Python `pyproject` / `setup.py` / `__main__`, `package.json` bin + Next.js routes, Go `cmd/*`) and persists features. `codesage index` calls `map` between the structural and semantic passes; `--no-features` skips, and a no-op incremental pass (file-hash state unchanged since the last successful map) skips automatically. The skip fingerprint folds in `MAPPER_OUTPUT_VERSION` (`crates/features/src/lib.rs`); bump it when mapper output (ids, routes, tags, file sets) changes for unchanged input, so upgraded projects remap. `features-list` / `feature-show` / `feature-for` / `feature-bundle` are read-side query commands matching the new MCP tools. `trust-boundaries <file>` is the debugging surface for the per-file boundary tags that feed `assess_risk`.
`cleanup` drops orphaned vec tables from previous model switches, keeping only the active model. Use after benchmarking multiple models. Runs VACUUM automatically.
`doctor --docs [PATH...]` scans markdown for claims that can be checked against the index and working tree and reports the ones that no longer hold: backticked repo-relative file paths and relative link targets that no longer exist, `path:line` anchors past the end of the file or whose nearby backticked symbol has moved, `Owner::member` tokens whose owner is indexed but has no such member, and `UPPER_SNAKE` or Google-style `kCamelCase` constants whose stated value (`= 5`, `is 5`, `defaults to 5`, `(default 5)`) differs from the source literal. With no PATH it checks AGENTS.md or CLAUDE.md (following the symlink), README.md, and `docs/**/*.md`, never CHANGELOG.md; a file whose own line is `<!-- codesage-docs: skip-file -->` or that matches `[docs] exclude_patterns` in `.codesage/config.toml` is skipped and named. Explicit PATH arguments bypass `exclude_patterns` (the `git add -f` convention); non-markdown arguments are skipped, and an unreadable directory or non-UTF-8 or unreadable file is named under `files_failed` without aborting the sweep. Fenced code (including blockquote and list containers), indented code (four columns, or four past a list item's content column), HTML comments, globs, URLs, `<placeholder>` tokens, `foo`/`bar`/`baz`/`qux` path segments, `..` segments, env vars, flags, and single words are not claims; a line with an unclosed code span is skipped entirely, links and anchors on it included. Markdown link syntax inside a code span is an example, not a link claim. Deliberately not checked, so they never produce a finding: symbols in foreign code (dependency crates, `std`, unresolvable owners, owners defined only under a fixture or test path, a constant/function/macro homonym owner, bare `foo()` calls, `Type::method`-style placeholders, a camelCase member on a Rust/Python owner, or a method on a type with evidence of unindexed generated or inherited methods), struct fields and enum variants (`Type.field`, `Struct::field`, `Enum::Variant`; enum variants are not indexed, and a field is reported only when its owner is indexed and lacks it, which a C or C++ `const` field with an in-class initializer never is since it indexes as a constant; `Type::method()` written as a call is still checked), anchors whose line falls inside no indexed symbol, paths whose parent directory does not exist on disk or in the index (relative links are exempt: a wrong `../` count is exactly the drift they show), extensionless link targets that no `<t>.md` / `<t>/index.md` / `<t>/README.md` / case-insensitive sibling resolves, paths under gitignored directories (`.codesage/`, `target/`), two-segment paths under a ubiquitous root (`docs/`, `scripts/`, `src/`, …) named from a document outside the repo, constants whose stated value carries a unit (`is 30 seconds`), a constant named through an owner (`Owner::kFoo`, `Owner.kFoo`), which is checked for member existence only and never for its value, and binary, octal, or exponent-form source literals. From a document outside the repo, symbol claims are not reported at all, and a missing path is reported only with a concrete relocation: a single same-named indexed file (not a basename the index holds twice, nor a generic one like `README.md` or `mod.rs`), or the module having become a directory that holds indexed files of the same kind (`<module>.rs` → `<module>/`). A missing-path finding carries a hint in two cases: when exactly one same-named non-generic, non-fixture file is indexed (`did you mean X` if a directory segment matches, else `a file of the same name exists elsewhere: X`), and when the module became a directory (the hint names the directory); otherwise no hint. `--json` findings carry the hinted paths as `candidates` (empty when there is no hint); a missing explicit PATH is a `files_failed` entry, and an explicit CHANGELOG.md or non-regular file is named under `files_skipped`. Read-only over the index; exit 0 unless `--strict` (exit 1 when anything drifted, a file failed, or no documents were checked; a checked document with zero claims still succeeds); `--json` emits `{ files, files_skipped, files_failed, claims_checked, drifted[] }`.
## Benchmarks
Benchmark harness under `bench/` (curated examples; see `bench/` for the full inventory, including ablation, semble-corpus, concurrency-audit, agent-task, and quality-analysis runners):
- `bench/codesage-bench-runner`: Python runner that executes a YAML corpus of ground-truth cases against `codesage search` and reports miss rate, median first-hit, recall@5, recall@10.
- `bench/extract-eval-cases.py`: mines eval cases from Claude Code session transcripts and git commit history.
- `bench/cleanup-orphan-models.sh`: drops orphaned vec tables from prior model switches.
Corpus YAMLs are not bundled; bring your own. `CODESAGE_BENCH_CORPUS_DIR` (consumed by `/codesage-bench` and `/codesage-eval` plugin commands) points the plugin at the directory holding them.
## Plugin
`plugins/codesage-tools/` ships as a Claude Code plugin: one global `codesage` MCP registration serves every onboarded project, routed by an absolute `project` argument. The registered command remains `codesage mcp`; the shim handles daemon startup and reuse. Slash commands: `/codesage-onboard`, `/codesage-reset`, `/codesage-reindex`, `/codesage-bench`, `/codesage-eval`, `/codesage-prompt-override`, and the four feature-slice review commands (`/codesage-review`, `/codesage-triage`, `/codesage-revalidate`, `/codesage-report`). Marketplace manifest at repo root.
## Git history intelligence (V2b slice 1)
Schema migration `0018_git_author_events` retains per-file author events. After `git-index --full`, risk responses include informational `author_concentration`: author count, dominant share, effective authors (inverse squared-share sum), and `bus_factor` (fewest identities covering at least 50% of weighted commits). Events use a 180-day half-life and a 730-day window. Identity is normalized email, falling back to normalized name; it is not a verified person count, and mailmap changes are not applied retroactively. Legacy or absent author history remains unknown. This signal does not affect the risk score.
Author-concentration notes distinguish absent indexed events from events outside the evaluation window. That evaluation uses the file's newest indexed commit as its anchor, or query time when no file commit is available; filtered-event notes name the actual anchor. The indexing window has separate persisted provenance in migration `0021_git_history_anchor`. Legacy or older-writer provenance reads unknown until the next `codesage git-index` pass, which rebuilds in full because such state carries no exclusion fingerprint (see the modes below); pinned-window notes use the recorded source and epoch rather than assuming HEAD.
`codesage git-index` measures its 730-day history window and its decay reference from HEAD's own committer epoch, not the wall clock, so a pinned or archived checkout indexes its history instead of returning nothing; the window is stamped `730d@HEAD`, falling back to `730d@now` only when there is no HEAD. `codesage git-index` runs `git log --numstat` and populates `git_files` (per-file churn score with τ=180d decay, fix count, total commits, last commit), `git_co_changes` (file pair weights, min count 3, plus migration `0017`'s `first_observed_at`, `window_mask` (bit `(ts / 90d) % 64` per shared commit, keyed to the unix epoch), and `windows = popcount(window_mask)`), and `git_index_state` (last indexed SHA). Co-change confidence is not stored: `find_coupling` derives P(other | this) as `count / git_files.total_commits` of each side.
`git-index` reads `git log` with `--no-show-signature` and `--root`, and fails rather than reporting success on output it cannot parse. It walks history children-first and keys renamed files to their HEAD path, including a merged branch's edits to the old path; copies are not followed. An incremental pass re-keys stored rows for paths renamed within its range, and rescans in full when a file is renamed onto a path that already has history, when two stored files are renamed onto one path, or across the test-like boundary. A branch edit to the old path indexed in a later pass than the rename stays on the old path until `git-index --full`; history indexed before rename support needs one `git-index --full` to re-key.
Three modes, selected via flags on `codesage git-index`:
- `--full`: fresh rescan. Drops existing rows and walks the entire history. Use after big rebases that rewrite a lot of history, or to rebaseline weekly.
- `--incremental`: scans only `<last_sha>..HEAD` and additively updates counters. Scales pre-existing weights by `exp(-Δt/τ)` so exponential decay stays mathematically exact across runs. Co-change observations below count 3 are retained across passes but hidden from coupling queries until they reach that threshold. Run `codesage git-index --full` after upgrading to recover observations discarded by earlier versions. `window_mask` composes exactly (`mask |= delta`, `first`/`last` take MIN/MAX) because window bits are keyed to a fixed epoch, so `windows` and `span_days` match a full rescan after every incremental pass for rows written by an 0017-aware `--full` (commits inside the 730-day history window; a `--full` still rebaselines rows whose oldest commits have aged out). Rows indexed before 0017 (`first_observed_at IS NULL`) are left unbaselined by incremental passes and keep `span_days` 0 / `recurrence` 1 until `codesage git-index --full`.
- default (no flag, `Auto`): incremental if valid prior state exists and its SHA is an ancestor of HEAD, else full.
Auto and `--incremental` behave the same way. Both also rescan in full when the effective exclusion policy (`DEFAULT_EXCLUDE_PATTERNS` plus `[index] exclude_patterns`, order- and duplicate-insensitive, together with the test-like pattern set and the per-commit co-change file cap) differs from the fingerprint stored with the last pass (migration `0025_git_exclusion_fingerprint`), including when none is stored: an index written before 0025, or last written by an older binary, whose state update clears the column. So the first pass after upgrading is a full rescan, and editing the exclusions drops the newly excluded paths from `git_files`, `git_co_changes`, and `git_author_events` without `--full`.
`codesage install-hooks` now registers `post-commit`, `post-merge`, `post-checkout`, and `post-rewrite`, each running `codesage git-index --incremental` in the background. Rebased or force-updated history triggers a full rescan automatically (incremental detects when the stored SHA is no longer an ancestor of HEAD and falls back to full).
Hook runs are single-flight through `.codesage/hook-index.lock/`; the winner writes its pid to `<lockdir>/pid` and logs `hook start pid=N` / `hook exit=N pid=N` to `.codesage/hooks.log` from its EXIT trap (SIGINT/SIGTERM route through it; a signal trap runs only after the in-flight indexer child returns). Reaping is single-winner across concurrent fires (a `hook-index.lock.reap` mutex). A lock whose pid is dead or belongs to a non-hook process is reaped by the next fire regardless of age; a lock without a pid file (pre-upgrade hook) is reaped once 30 minutes old; a lock held by a live hook run is never reaped, by the hook or by doctor (a hung run is removed by hand). When `ps` is unusable, liveness alone decides. `codesage doctor` reports the lock state under `hook_health`, reaps dead-pid and stale pidless locks itself, and warns on a live run older than 30 minutes; `project_overview` only reports.
Two MCP tools consume the tables:
- `find_coupling(project, target, limit)`: top-N files that historically change together with the input, weight-sorted with non-recurring pairs at half weight by default; rows carry `recurrence`, `span_days`, `span_known`, `recurring`, `p_cochange`, and `p_reverse` (deprecated spellings `confidence` / `reverse_confidence`). CLI: `codesage coupling <file>` prints the measurements and marks one-offs and unknown spans.
- `assess_risk(project, target, verbose?)`: composite risk score (0..1) from churn percentile + fix ratio + depth-2 reverse-dep pressure + coupling pressure + test gap. Returns the score plus human-readable notes for PR descriptions; `verbose: true` (shared with `assess_risk_batch` / `assess_risk_diff`) adds the per-signal decomposition and `top_coupled`. CLI: `codesage risk <file>` (always verbose).
The indexer filters the same `DEFAULT_EXCLUDE_PATTERNS` as the structural indexer, plus NEWS/UPGRADING/CHANGELOG variants (they touch every commit so they pollute coupling).
## Feature mapping + trust boundaries (shipped 0.7.0)
`crates/features/` runs after structural and before semantic indexing on every `codesage index`. It maps the project into **behavior-keyed slices** (entrypoint + owned files + context files + tests + aggregated trust boundaries + tags) and derives **per-file trust boundaries** from imports/includes/calls.
Mappers are deterministic (no LLM) and language-local:
| Mapper | Detects |
|---|---|
| Rust | `src/main.rs`, `src/bin/*.rs`, `src/lib.rs`, Cargo workspace members, `crates/*`, integration tests under `tests/*.rs` |
| PHP | `composer.json` bins + scripts, PSR-4 autoload roots, php-src `ext/*/config.{m4,w32}`, Laravel `routes/{web,api,console,channels}.php` |
| C / C++ | tree-sitter `main()` detection, `bin_PROGRAMS` / `lib_LTLIBRARIES` from autotools, `add_executable` / `add_library` from CMake |
| Python | `pyproject.toml [project.scripts]` (module-resolved entry path), `setup.py` `entry_points`, top-level `if __name__ == "__main__":` modules |
| JS / TS | `package.json` `bin` + selected scripts (`start`, `build`, `test`, `lint`, `typecheck`, `format`), Next.js `app/**` and `pages/**` routes |
| Go | `cmd/<name>/main.go` and a repo-root `main.go` when declared `package main` |
Tables (schema migration `0009_feature_tables`): `features`, `feature_files` (per-feature path×role refs), `feature_trust_boundaries` (per-feature boundary set).
Trust-boundary rule tables (`crates/features/src/trust_boundary_rules.rs`) cover Rust, PHP, C/C++, Python, Go, JavaScript/TypeScript plus Laravel facades; Java currently parses structurally without dedicated trust-boundary rules. Boundaries are: `network`, `filesystem`, `process-exec`, `secrets`, `database`, `user-input`, `external-api`, `serialization`, `auth`, `concurrency`. Per-file rows live in `file_trust_boundaries` (migration `0008`), with a `boundaries_derived_at` marker (migration `0010`) used by the indexer's targeted backfill to avoid re-running derivation on rule-clean files.
`assess_risk` consumes the per-file rows: `0.10 * min(boundary_count/5, 1.0)` adds to the composite score, capped at 5 boundaries. The `notes[]` line `"crosses N trust boundaries (X, Y, Z) — security review recommended"` fires when ≥3 boundaries are crossed. The signal lands in `RiskAssessment.trust_boundaries: Vec<TrustBoundary>`.
`[index].exclude_patterns` from the project's `.codesage/config.toml` are honored throughout the mapper crate via `MapperContext.excludes`. Mappers emit candidate seeds, the orchestrator filters entry paths and per-record file refs against the globset, so feature output matches the structural indexer's file-set contract.
The MCP surface (`list_features`, `find_feature`, `feature_bundle`) sits on top of these tables. The CLI surface (`map`, `features-list`, `feature-show`, `feature-for`, `feature-bundle`, `trust-boundaries`) mirrors it for terminal use.
## Roadmap
V1: semantic retrieval + structural graph + MCP interface, change impact analysis, context export, plugin-based deployment.
V2b slice 1 (shipped 0.2.0): git history intelligence: `find_coupling` + `assess_risk` MCP tools, `codesage git-index` CLI with incremental hooks.
V2b shipped (0.7.0): feature-slice mapping + trust-boundary derivation + `outputSchema` on every MCP tool. `crates/features/`, the `list_features` / `find_feature` / `feature_bundle` MCP tools, the per-language mappers, and the `file_trust_boundaries` signal feeding `assess_risk`. Ports the clawpatch (`openclaw/clawpatch`) feature-slice donor patterns into Rust.
V2b slice 2 (next): `bus_factor`, `change_pattern`, `find_hotspots` MCP tools. Conditional on slice 1 validating on large real codebases.
V2c (deferred): docs/decision layer (process traces, architecture summaries). Revisited after V2b slice 2 lands.
Mcp Sqlite Configuration
Cursor IDE rules and conventions for AI-assisted development.
by AgentHub Crawler
Mcp Sqlite Configuration
Cursor IDE rules and conventions for AI-assisted development.
by AgentHub Crawler
Advent of Code Rust Agent Guidelines
1. **Read & Analyze**: Read puzzle description from `data/puzzles/<day>.md` 2. **Add Example Input**: Copy example from puzzle into `data/examples/<day>.txt` 3. **Implement Part 1**: 4. **Test & Submit Part 1**:
by AgentHub Crawler
EdgeCrab — Development Guide
Sign in to leave a comment.
Instructions for AI coding assistants and developers working on the edgecrab codebase.
by AgentHub Crawler