v0.5.0
v0.5.0 is a minor release, not a patch, by the same rule v0.3.0, v0.3.1 and v0.4.0 were versioned under: it adds new public capability (five new search modes — cochange, cochange_gaps, doc_drift, skipped, change — a min_confidence search parameter, a CLI --scope flag, a [cochange] configuration section, and a per-root file census in list_roots) and changes on-disk and query behavior in ways an existing user will notice: the graph cache schema moves from 27 to 28, and repo_map's hotspot ranking now means something different. It also carries the two data-survival fixes (#901) that caused the first release-gate run on this range to be refused.
Upgrade if you want git-history coupling in the graph (which files change together, and which usual partner your diff is missing); hotspots ranked by churn x complexity instead of definition count; a verifier for markdown that points at code which no longer exists; human-written .oh/ notes surfaced on the code symbols they are about; an accounting of every file the scanner saw and why it is or is not in the index; or one call that turns a diff into changed symbols, blast radius, tests to run, co-change misses and risk. Read "Behavior changes" first: the first scan after upgrading re-extracts every root.
Everything below was verified against a release binary built from the release commit on a fresh clone with no cache: scripts/test-suite.sh 183 passed / 0 failed / 0 skipped, and the two #901 defects were re-driven the way they were originally found (clean scan, no-op scan, MCP server call, enrich persist, scan --full, no-op scan again) with co-change partners, hotspot suffixes and the census unchanged at every step.
New capabilities
Git-history co-change edges in the graph, and a "usual partner is missing" check
Before: the graph knew only what static analysis could see: imports, calls, tests, ownership. Two files that are edited together in most commits but share no import or call — a handler and its README section, a scanner and its roots resolver — had no relationship in RNA at all. An agent preparing a change had no way to ask "what usually moves with this file?", and nothing could tell it that the diff it was about to submit was missing the partner file that history says belongs in it.
After: scan mines the repository's first-parent history (bounded by [cochange] max_commits, max_age_days, max_files_per_commit in .oh/config.toml; defaults 500 / 365 / 50) and persists a first-class CoChanges edge between per-file anchor nodes with two typed columns, cochange_support (commits touching both) and cochange_confidence (support / min(changes_a, changes_b)). search(node=<file>, mode="cochange", min_confidence=…) returns ranked partners; search(mode="cochange_gaps") compares the confident partners of every file in the working tree, staged, or base..head diff against the diff itself and names what is missing. Mining is incremental behind a per-root watermark (ScanState.cochange_watermark_sha) that advances only after the LanceDB persist succeeds, so a failed persist re-mines rather than losing history; a watermark that is no longer on the first-parent chain is bounded by max_commits rather than walking all history. Large refactor or vendoring commits above max_files_per_commit are skipped entirely so they do not couple everything to everything. Non-git roots report Co-change: not available (no .git) in list_roots and are otherwise unaffected. repo_map's hotspot rows carry the strongest partner of each file. On this repository the mining pass costs about 1.0–1.2 s of a ~5.5 s full scan. (#884 via #885)
Per-file churn, and hotspots that mean what the heading says
Before: repo_map printed a ## Hotspot files section ranked by definition count and labelled "N definitions" — a size ranking wearing a risk label. Churn was already computed inside the co-change history walk and thrown away. Cyclomatic complexity was persisted and rendered per symbol but never combined with anything.
After: the same history walk returns per-file change counts, which are persisted as churn metadata on the file anchor and rendered wherever complexity already was. ## Hotspot files is now ranked by churn x summed cyclomatic complexity and names its basis in the heading and in every row, for example src/server/graph.rs -- churn 73 x complexity 318 = 23214 (co-changes with src/server/enrichment.rs: support=40, confidence=0.71). Files in roots that were not mined, or that have no .git, print churn: not available with the reason instead of a silent zero. The graph viewer sizes nodes from the same numbers. (#889 via #890, #894)
Documentation-drift verifier: search(mode="doc_drift")
Before: .oh/ artifacts, docs/, session notes and skill files accumulate references to code — src/graph/index.rs:588, a backticked Scanner.commit_state, a bare src/server.rs — and nothing checked them. Once the line moved, the symbol was renamed, or the file was split, the reference silently pointed at nothing, and an agent reading the artifact was steered toward code that did not exist. The only prior check covered proper markdown link destinations.
After: search(mode="doc_drift") (CLI and MCP, on demand; scan is untouched) walks every indexed markdown heading section and checks three reference forms against the graph and the working tree: path:line (file missing, or line past end of file), a backticked identifier bound by its section to exactly one indexed code file (symbol absent from that file's graph names, from every other file's names, and from the file's text), and bare paths in prose. Every candidate is either proven dead or unresolvable; unresolvable candidates are counted in the header and never reported as drift. Path resolution walks from the document's directory up to the root so metis/x.md inside .oh/guardrails/ resolves to .oh/metis/x.md. Owner-qualified graph identities (Owner.method, since #859) match on their lexical leaf. On this repository the first real run found 230 genuine dead references across 274 markdown files in 25 ms; nine false-positive classes discovered on that run (dot-directory paths, identifiers bound to TOML/JSON/shell files, receiver.method() spans, bare filenames in backticks, placeholder ... paths, and others) were closed with regression tests before merge. (#891 via #892)
Human-written notes surfaced on the code symbols they are bound to
Before: a .oh/ guardrail, metis or signal could declare an rna.relationships target naming a code symbol, but the target never resolved: extraction emitted a placeholder node that could not equal a real Function/Struct node, so every code-symbol relationship was a permanently dangling edge. An agent looking at format_node_entry_with_root had no way to learn that a guardrail was written specifically about that function.
After: a post-extraction binding pass rewrites those placeholder edges to the real node on an unambiguous exact or owner-qualified-leaf match; ambiguous or missing targets are marked validation_status: unresolved / diagnostic_code: content.unresolved_relationship on the artifact, following the existing content.* convention. Compact search rows show notes:N; detailed rows render a bounded Notes: block ([kind] id (path): first sentence, at most three, +M more); repo_map top-symbol rows carry the marker. Sources are .oh/{metis,guardrails,signals,outcomes} sections and rna: knowledge nodes only; excerpts are verbatim artifact text; nothing is generated and synthetic nodes never appear. Editing the bound code file no longer drops its notes until the next full scan: artifacts bound to a changed file are re-extracted and re-bound on the incremental path. This repository ships one real binding — .oh/guardrails/computed-but-not-delivered.md → format_node_entry_with_root in src/server/helpers.rs. (#897 via #898)
Skipped-file census: what the index does not contain, and why
Before: when a search returned nothing for a file the user knew existed, there was no way to distinguish "the query was wrong" from "that file is invisible to the index". Excluded, git-ignored, binary, unclaimed-extension and extractor-error files all looked the same: absent.
After: list_roots prints a per-root Files: line whose equation is checkable, for example Files: 503 seen = 497 indexed + 1 git-ignored + 5 no extractor; 5 directories pruned by exclude (config: benchmark/, .claude/, .omp/ +2 more), contents not enumerated. search(mode="skipped", kind=<class>) lists the files in one class with the reason for each (Cargo.lock — .lock is not claimed by any registered extractor), bounded with a +M more tail; classes are indexed, excluded_by_config, git_ignored, binary_skipped, no_extractor, extractor_error. no_extractor distinguishes an unclaimed extension from an extractor that listed the extension and declined in can_handle. Pruned directories are recorded once and never enumerated (the first cut walked 45,955 target/ artifacts to count them; scan time is back to baseline). Extractor errors are counted rather than only logged. Incremental scans report the full current population, not just the files that changed. Census roots come from configuration plus persisted state, so a root whose files are all binary or excluded is still reportable. (#895 via #896)
Diff-scoped change bundle: search(mode="change")
Before: answering "what does this diff touch, what might it break, which tests should I run, what am I forgetting" took an agent five or six graph calls and a hand-rolled intersection of hunk ranges with symbol spans; most agents skipped the intersection and reported every symbol in every changed file.
After: search(mode="change", query=<working_tree|staged|base..head>) — CLI --scope — composes one bounded report from the actual repository state. The one new computation is hunk-intersected changed symbols: the diff is built with zero context lines and rename/copy detection, and a symbol is changed only when its line span intersects a changed range (one line inserted inside Scanner::commit_state reports two symbols, not the file's other ~120). Everything else is composed from existing machinery and labelled with its evidence class: blast radius via impact (reads name-based (Calls coverage not_injectable -- dependents are a structural guess, not confirmed) on a --no-lsp scan, never "confirmed" without coverage), tests via tests_for plus tests in changed files with an explicit gap line (8 of 8 changed symbol(s) are reached by no test), co-change misses via the cochange_gaps logic, and risk from churn, complexity, bound notes and doc-drift findings that point into changed files. A clean tree says No changes in scope -- clean working tree.; a three-dot range is rejected with the same message cochange_gaps uses; --scope is rejected for modes that do not take a diff scope. This is distinct from context_mode: graph-delta-beta, which evaluates a proposal rather than the repository. (#899 via #900)
Fixes
Co-change statistics survived exactly one operation
Before: persist_graph_to_lance delegated with an empty statistics map, so every caller that did not itself carry mining results — the CLI's no-change branch refreshing the manifest, LSP enrichment, background scans, the viewer, roughly twenty call sites — rewrote all CoChanges rows with null cochange_support/cochange_confidence. The next load hydrated nothing: search(mode="cochange") answered No co-change data available and every hotspot lost its partner suffix, immediately after a routine no-op scan. The suite passed because its checks ran at the one moment the data existed. This is what refused the first release-gate run on this range.
After: the writer reads the persisted statistics at the committed scan_version and fills them in for every co-change edge the caller did not supply; mining results still win. A failed read of existing statistics is propagated as an error instead of being written back as nulls; only the first-persist TableNotFound case yields an empty map. Verified on a fresh clone with the release binary: partners for src/server/tools.rs 20 → 20 → 20 → 20 → 20 across clean scan, no-op scan, MCP server call, enrich persist and scan --full, hotspot suffixes 10 throughout, zero No co-change data available messages. Suite check: co-change partners survive a no-op scan (#901) and two siblings. (#901 via #902)
CLI first scan and scan --full left the census empty
Before: the CLI's Scanner and the rebuild-owned Scanner inside the full-graph build share scan-state.json. The CLI one, loaded before the rebuild, committed its stale (empty) file classifications over the census the rebuild had just written, so on the CLI path list_roots reported 0 indexed after a first scan and again after --full. The same two-writer pattern had already lost the co-change watermark once during #885.
After: Scanner::commit_state starts from the persisted census and applies only this scanner's recorded updates and removals unless it deliberately holds a full replacement. Verified on a fresh clone: Files: 503 seen = 497 indexed + … after the first CLI scan, unchanged after --full. Suite checks: census reports a real indexed magnitude on the CLI path (#901), census survives scan --full on the CLI path (#901). (#901 via #902)
target*/ excluded a file named targeted-…md
Before: a glob directory exclude pattern such as target*/ was matched against file names too, so .oh/metis/targeted-reindex-better-than-full-reindex.md was silently counted as excluded by config and left out of the index.
After: a glob directory pattern matches directory components only. Exact-name directory patterns keep the documented behavior: data/ still also excludes a file named data. docs/scanner.md states both. Verified: the census on a fresh clone lists zero targeted-* rows under excluded_by_config and the note is indexed. (#902, no separate issue)
Suite harness could fail a passing check on large output
check() piped echo into grep -q under pipefail; once the expected text was found grep exited, echo died of SIGPIPE, and a matching check was recorded as FAIL. First tripped by the doc-drift report on this repository, which is hundreds of lines. Now a here-string. The suite grew from 158 checks at v0.4.0 to 183. (#892, #894, #902)
Behavior changes
Read these before upgrading. None requires manual migration; some change what a query returns.
- Graph cache schema 27 → 28 (
CoChangesedges withcochange_support/cochange_confidencecolumns, #884). The firstscanafter upgrading re-extracts every root and mines co-change history for it. Persisted LSP work ledgers, embedding generations and operation history keep their schemas. Scan state gainscochange_watermark_shaandcensus_version; state written before the census (census_version0) forces one full re-extraction andlist_rootsprintsFiles: census pending for N tracked file(s)until it runs. repo_maphotspots are a different ranking. The## Hotspot filessection was ordered by definition count and labelled "N definitions"; it is now ordered by churn x summed cyclomatic complexity, the heading names the basis, each row shows the arithmetic and the strongest co-change partner, and files whose root was not mined printchurn: not availablewith a reason. Expect a different set of files at the top. (#889)searchsurface.modeaccepts five new values —cochange,cochange_gaps,doc_drift,skipped,change— and a newmin_confidenceparameter (CLI--min-confidence); the CLI gains--scope, which is rejected unless--modeischangeorcochange_gapsand rejected alongside a positional query. The MCP tool count is unchanged at six (search,convergence,repo_map,outcome_progress,list_roots,resolve_references); thesearchtool description changed. A v0.4.0 server rejectsmin_confidenceas an unknown field, so clients should gate on server version.- Exclude semantics for glob directory patterns.
target*/now matches directory components only; before, it also matched a file whose name started withtarget. Exact-name directory patterns (data/) still match a file of that name, as documented. (#902) - Scan time. A full scan now includes a bounded first-parent history walk for co-change and churn: about 1.0–1.2 s on this repository (743 commits, 500-commit window). Incremental scans skip it when HEAD has not moved past the watermark.
- Co-change file anchors are
NodeKind::Other("file")synthetic nodes. They are excluded from embedding, from doc-drift symbol binding and from notes rendering. Node and edge totals inlist_rootsandscanoutput include them.
Known limitations
- CodeRabbit posted no review on #892 (doc-drift) or #898 (artifact notes). It hit its usage rate limit on both and produced zero review events; those two PRs were merged on the ship pipeline's RNA-grounded review, the independent code-review agent, and the human oversight spot-checks recorded on the PR threads, not on CodeRabbit. It also reported the limit on at least one push to #894, #896 and #902 before reviewing later revisions of each. All CodeRabbit findings that were posted on #885, #890, #894, #896, #900 and #902 (Major and Minor) have a "Fixed in " reply and a CodeRabbit confirmation, except: two withdrawn by CodeRabbit with evidence (#885 repository-open semantics; #890 the shared-walk performance finding, which became #893), and eight on #896 that targeted files a checkpoint commit had swept in and that were removed from the PR before merge. No finding was left standing without a disposition.
- Intermittent fail-closed abort in
enrichimmediately afterscan --full(#876, pre-existing since v0.3.0).LSP work-item recovery failed closed: N required persisted item(s) no longer matched the enrichable node set. Not reproduced during this gate, not fixed here; re-runningenrichis the workaround. Unchanged from v0.4.0. - No MCP-path CI coverage for
mode="cochange"/"cochange_gaps"(#887). The CI smoke fixture has no multi-commit history, so the MCP path is exercised only by this repository's suite and by the hand-driven JSON-RPC round-trip recorded on #885 and repeated for this release (20 partners returned over MCP on a fresh clone). Not a blocker: the CLI and MCP paths share one dispatch, and the CLI path is covered by five suite checks. - Two first-parent history walks per full scan (#893). PR-merge extraction and co-change/churn mining each walk the same commits; only ~13 merge commits overlap, and the measured cost is dominated by per-commit tree diffing intrinsic to mining, not by the duplicate walk. Disclosed as a cost (~1.2 s here), not a defect; the shared walk is the follow-up.
ChangeFileKindduplicates aserver-private enum (#903). Both seven-variant enums are consistent today; lifting one shared type is the follow-up.doc_driftdeliberate limits. Single lowercase words in backticks are never candidates (dominant emphasis-as-code class), so a one-word dead symbol is missed. A section that discusses file X but names identifiers from file Y is reported as drift about X; the evidence is real, the intent was Y. Numeric-constant drift is out of scope.scan --fullwith no changed files reuses the cached graph. It runs the full pipeline (Full pipeline scan:…No changes detected -- reusing cached graph) but is not a forced re-extraction; identical behavior in v0.4.0. The census and co-change fixes above were verified through that path as well as through the first-scan andenrichpersist paths.- LSP enrichment in the gate environment. On the fresh clones used for this gate,
enrich --capability call-referencesended degraded withLSP enrichment failed for rust root …: Missing Content-Length headerfrom rust-analyzer, identically for the v0.4.0 binary on its own clone; no LSP code changed in this range (src/extract/lspis byte-identical to v0.4.0). The degraded run still performed the full persist that previously erased co-change statistics, and the statistics survived it. No claim is made here about LSP call-edge coverage. - Carried forward from v0.4.0, unchanged: CUDA feature build not verified in CI (#865); Python module-scope import rebinding still emits a Tree-sitter cross-file
Callsedge (#883); Go cross-file calls remain LSP-only; thesmokeCI job runs only on tags and manual dispatch, so it did not run on the merged commits in this range and will run on this tag.
Not in this release
- No shared history walk (#893), no MCP smoke coverage for co-change (#887), no fix for the post-
--fullenrich abort (#876), no shared change-kind enum (#903). All filed; none blocks the capabilities above. - No change to the Linux or macOS release build feature sets. Linux is still
--no-default-features; macOS builds still use default features (no embeddings). Release binaries do not perform semantic search. - No performance or efficacy claim beyond the scan-time figures stated above.
Issues addressed
- #884 — Add git-history co-change edges to the RNA graph and a "usually changes with this but wasn't touched" check (#885)
- #889 — Surface per-file churn and redefine
repo_maphotspots as churn x complexity (#890, #894) - #891 — Documentation-drift verifier: flag dead code anchors in markdown against the graph (#892)
- #895 — Skipped-file census: show which files are invisible to the index and why (#896)
- #897 — Surface artifact notes on the code symbols they are bound to (#898)
- #899 — Diff-scoped change bundle: changed symbols, blast radius, tests to run, co-change misses, risk in one call (#900)
- #901 — Release blockers: co-change stats erased by non-mining persists; CLI first scan leaves the census empty (#902)
- #876 — Intermittent enrich abort after
scan --full(known limitation, not fixed here) - #887 — MCP smoke coverage for co-change modes (filed; known limitation)
- #893 — Share the first-parent git walk between PR-merge extraction and co-change mining (filed; known limitation)
- #903 — Share one change-kind enum between
git::cochangeandserver::changed_file_plan(filed; known limitation)
Install
Download the archive for your platform, extract, and put repo-native-alignment on your PATH.
repo-native-alignment-darwin-arm64-fast.tar.gz— Apple Silicon, tuned for M4repo-native-alignment-darwin-arm64.tar.gz— Apple Silicon, M1 baselinerepo-native-alignment-linux-x86_64.tar.gz— Linux x86_64
On macOS, if the binary is killed on launch after copying, re-sign it: codesign --force --sign - <path>.