Changelog
All notable changes to Osnova are recorded here. The format follows Keep a Changelog, and the project uses Semantic Versioning. Before 1.0, minor versions may change the MCP and CLI contract; each such change is listed under Breaking.
Unreleased
The extraction version moves to structural-9.34 and the edge section format to 12, so the first run after upgrading rebuilds the cache once.
Added
- Java and C# call edges name the overload they bind when the written argument count selects one. Methods record their parameter range (
OsnovaSymbol.parameters), call edges their argument count (OsnovaEdge.arguments), andOsnovaEdge.overloadholds{ line }for the one declaration that accepts the count or{ candidates }when none or several do. Among the declarations in the target's file the edge keeps its method target either way, so callers are unchanged; warp prints the choice under each call site, and a callee answer gives the chosen overload's line. Scored against javac and Roslyn on the pinned gson and humanizer checkouts, false call edges fell from 2045 to 9 on gson and from 617 to 55 on humanizer, and in-repo sites covered moved from 55.1% to 54.3% and from 58.0% to 58.5%; the other four oracle corpora score the same.benchmarks/oracle/osnova-sites.mjsclaims the chosen overload's span and counts edges with no chosen overload inoverloadUndeterminedinstead of claiming one. benchmarks/oracle/scores Go, Java, Rust and C# against each language's own compiler or type checker, beside Python and TypeScript:go/typesthroughgolang.org/x/tools/go/packages(truth-go/, pinned ingo.sum), the javac Compiler Tree API with one task per Maven module (TruthJava.java), rust-analyzer over the shared LSP client (sites-rust.mjs,truth-rust.mjs,lsp.mjs), and Roslyn replaying every csc invocation of a real build with its source-generator output (truth-csharp/, pinned inpackages.lock.json). All write the truth formatscore.mjsalready reads.benchmarks/results/type-checker-oracle-2026-09-30.jsonrecords all six pinned corpora with this release's changes: click 2883 decided edges and 0 false, zod 21593 and 4 false (recall 78.2%), cobra 1980 and 0 false (recall 87.5%), ripgrep 6066 and 29 false (0.48%, recall 85.8%), humanizer 7363 and 3 false (0.04%, recall 62.2%), gson 6330 and 9 false (0.14%, recall 56.2%), and keeps each corpus's 0.11.0 numbers in apreviousblock (gson 2045 false, humanizer 617, ripgrep 112). Every false edge is classified by cause. Each new oracle was spot-checked on 20 random sites (20 of 20 each), gave exactly 25 more false edges for 25 corrupted target spans, and wrote byte-identical truth on a second run. click and zod reproduce the 2026-09-21 counts unchanged.- With
osnova mcp --lsp-server,osnova_plumbchecks a callers claim against the language server'stextDocument/referencestoo, in a section after the unchanged verdicts: how many claims the server confirms, by the graph's verdict for each, the claims it confirms that the graph called name-only or no-call, and the sites the claim left out that the graph does not list as missing. Only a location on the claimed line itself confirms a claim; a neighbouring one, such as an import, is listed where the server put it. The section has its own 1,024-code-unit budget with an exact count of lines it leaves out, and server locations never change a verdict or become graph edges. - With
osnova mcp --lsp-server,osnova_settleasks the language server for references to the changed symbols and lists, in a section after the unchanged impact answer, those not inside a listed dependent or a changed symbol. It asks about at most 8 symbols per call, most graph dependents first, under one request timeout for the whole call that also bounds the wait behind other requests and any server start-up, and counts exactly the symbols it did not ask about (over the cap, past the deadline, after a failure) and the lines its 1,024-code-unit budget leaves out. - With
osnova mcp --lsp-server,osnova_testsgivensymbolsadds a third, separately labelled tier after the unchanged two: test files where the language server finds references to the symbol that neither graph tier holds, with their lines, and counts of the server's other locations (outside test files, in resolved-edge files, in import-only files). It shares settle's cap of 8 symbols and one request timeout per call, with the same exact counts and 1,024-code-unit budget.
Changed
- The Claude Code plugin pins its launcher to the release it ships with:
.mcp.jsonand the three hooks runnpx -y @getdomovoi/osnova@<version>instead of the unpinned package, because the Claude plugin directory rejects an unpinnednpxlauncher. The plugin folder gains aREADME.mdthat the directory shows as the listing and that states what the plugin runs, fetches and writes.test/distribution-manifests.test.tsrequires the launchers and that README to name the package version, so a release bump updates all of them. The plugin also ships a 1024 px listing icon (.claude-plugin/icon.png, fromassets/brand/plugin-icon.svg), and the repository'spnpm-workspace.yamlno longer allows esbuild's install script: the directory blocks a repository whose workspace lets an install run a build script, and esbuild runs from its prebuilt platform package without it. Because the directory still blocks a repository with anallowBuildskey, the plugin is published from https://github.com/getdomovoi/osnova-claude-plugin:.github/workflows/plugin-sync.ymlrunsscripts/sync-claude-plugin.shon each push tomainthat changes the plugin,LICENSEorNOTICE, andintegrations/claude-codestays the source of truth.
Fixed
- Two calls to one method on one line with different argument counts are two edges; they were merged into one.
- A call edge sits at the line of the callee name in every language: the called identifier, member, attribute, field or selector name, or the type named by
new. It sat at the first line of the call expression, so in a chain written one call per line every call reported the line where the receiver starts, and everyfile:lineprinted for such a call (warp, plumb, tests) pointed at that line. Two chained calls of one name, target and binding on different lines are now two edges; two whose names now share a line are one, as on any single line (2 of 21,630 zod edges). Rescored against the type checkers, ripgrep false call edges fell from 112 to 29 and in-repo sites covered rose from 75.6% to 85.8%; covered sites rose on zod (76.9% to 78.2%), gson (54.3% to 56.4%) and humanizer (58.5% to 58.6%), click and cobra score the same, and no corpus gained a false edge. - A Java or C# call's argument count is checked against overloads declared in written-
partialparts of a C# type in the same namespace with the same generic arity and in declared base classes, not only in the target's file. When only one of those accepts the count the edge moves to it andoverload.fromnames the method the call resolved to; when an override sits beside a base overload of the same count, the edge names no declaration andoverload.elsewherelists the base ones. Methods recordparameters.overridesandparameters.access, and C# types recordpartial. Scored against Roslyn on the pinned humanizer checkout, false call edges fell from 55 to 36 and in-repo sites covered rose from 58.5% to 58.8%; gson moved from 54.28% to 54.29% with 9 false edges as before, and the other four oracle corpora score the same. A Java@Overridehides only a base declaration whose parameter types are proved the same from each file's package and imports, since it may also mark an interface method's implementation. A simple type name counts as proved only when no inherited nested type and no same-package type can shadow it, so Java methods record the simple names their types rely on (parameters.names) and Java types record their written supertype count and interfaces (supertypes,interfaces). A Java base written without an import resolves only to a member type that an enclosing type declares or inherits, or to a type of the same package, a package-qualified base is followed, and a base outside the index withdraws a line chosen in the target's file, since an unknown base may declare another accepting overload. - C# files with a primary constructor on a class or struct (C# 12) or a raw string literal (C# 11) no longer lose the rest of the file to parse errors. The bundled grammar cannot read either construct, so such a file is parsed a second time without the constructor's parameter list, its base-type arguments and the body of each raw string (a single-line interpolation hole without quotes or braces stays readable); offsets do not move, so spans and signatures are the source's own. On the pinned humanizer checkout, C# files reported with
syntax-errorsfell from 136 to 36, and scored against Roslyn, false call edges fell from 55 to 23, claimed edges rose from 7264 to 7670 and in-repo sites covered from 58.5% to 62.2%. The remaining errors are mostly collection expressions and bodiless classes, which the grammar also cannot read. - C#
record structdeclarations are indexed asstructsymbols that own their members and declared bases; the type was missing and its methods had no owner, so calls on a record struct receiver did not resolve. On the pinned humanizer checkout, in-repo sites covered rose from 62.2% to 62.6% with no new false edges.
0.11.0 (2026-09-30)
Breaking
osnova_groundover MCP answers without source by default: each hit keeps itsfile:line, kind, qualified name, definition span and signature. Passlean: falsefor the previous shape (short definitions whole, longer ones as an 8-line excerpt) orfull: truefor whole definitions; an explicitlean: truestill overridesfull. The tool names and argument shapes are unchanged, and the CLI keeps source unless--lean. In a paired agent pilot (15 SWE-bench tasks, three runs each, graded twice independently) matched ground answers were 76 percent smaller, file reads were similar (727 against 735), and no ground call asked forlean: falseorfull: true; solves were 37 against 36 of 45, and on the 12 tasks both arms solved, counting their repeats together, the lean arm was faster on 10, while cost and new tokens did not differ measurably.
Added
docs/codex.md: a Codex quickstart coveringosnova setup agents --only codex, trusting the hooks in/hooks, checking withcodex mcp listandosnova doctor, the MCP flags that go inconfig.toml, and removal.osnova setup claude --uninstallandosnova setup agents --uninstallreverse setup, previewing unless--applyis given. They cut theosnovaMCP entry out of each config's text (comments and other keys keep their bytes), remove osnova's own hooks, delete the plugin, extension and skill files osnova installed while they are unchanged, and with--instructions <file>remove the osnova block. What is not osnova's own or was changed is kept and reported, including anything reached through a link; every edited or deleted file is backed up first, and a failure part way prints what was already changed. When a configuration file already held its MCP object and ends with a newline, setup then uninstall leaves it byte-identical, CRLF line endings included.osnova mcp --lsp-server <absolute path> --lsp-languages <list>adds a language server'stextDocument/referencesto callers answers fromosnova_warp, in their own section after the graph answer (afull: trueanswer near the 16,384-code-unit cap is clipped earlier to make room), sorted into the declaration, callers the graph already resolved, unresolved leads the server confirms, and locations the graph does not hold, with its own 1,024-code-unit budget and exact counts of what it leaves out. Server locations never become graph edges. One server session serves the MCP server's lifetime, opens every indexed file in its languages, restarts when the sources change or after a failure, and repeats requests until two answers agree within the request timeout, so a server still loading its projects is reported rather than read as complete; a failure adds oneunavailableline. The server is named on Osnova's command line, never read from stored configuration, andAGENTS.md,SECURITY.mdandPRIVACY.mdstate this launch path.createOsnovaMcpServer(...).close()now returns a promise that resolves once such a server has exited, including one a failed request was already shutting down, or has been killed and a further grace period has passed.benchmarks/oracle/publishes the type-checker scoring harness behind the recorded precision and recall: Python call-site enumeration and pyright truth, TypeScript compiler truth, the Osnova edge export and the scorer. On the pinned click and zod checkouts it reproduces every count inbenchmarks/results/type-checker-oracle-2026-09-21.jsonat 0.10.0. Python call positions are sent to pyright in UTF-16 columns, as LSP counts them, and attribute calls are placed at the attribute token itself.osnova setup claudeandosnova setup agentsset up a whole kind of agent in one command, previewing unless--applyis given.claudewrites the Claude Code MCP entry, hooks and skill.agentscovers every installed harness that readsAGENTS.md(Codex, OpenCode, Kilo, Pi, Cursor; a harness counts as installed when its config folder exists, and--onlynarrows the list): each gets its MCP entry and its hooks, plugin or extension, and all share one skill in~/.agents/skills/osnova/, a folder Codex, OpenCode, Kilo and Pi load skills from. A skill or plugin file that differs from the shipped one, or whose file or folder is a link, is kept and reported instead of stopping the run (setup never writes through a link, which could land in a dotfiles checkout); an MCP or hook conflict still stops it with nothing written.osnova doctorchecks the shared skill. The--clientform is unchanged.
Changed
osnova setup --hooksno longer counts a shell-wrapped osnova hook (bash -c "... osnova hook x") as installed, since what a script runs cannot be checked: it is still left as written, the plain hook is added beside it, and the notice names the command.osnova_testsnames the test functions that make each file's calls: a resolved-edge line adds; in test_x, TestFoo.test_barafter its sites, so a runner can target them without reading the file. The names come from the calls' enclosing definitions, which the index already stored; a call inside an anonymous callback such asit("...", () => ...)has none.- Caller, reach and test answers share one scan of the index's edges per index instead of rescanning every edge on each call. On an 18,425-file repository (1.5 million edges), after the first call a caller lookup fell from 213 ms to under 0.1 ms, a reach count from 121 ms to under 0.1 ms, and a test lookup from 26 ms to under 0.1 ms. Answers do not change; a refresh builds fresh scans.
Fixed
osnova setuprefuses a JSON config that repeats itsmcpServers(ormcp) key or theosnovaentry, as uninstall already did. The client reads only the last copy while setup edited the first, so the entry it reported as added was never read.- After writing an MCP entry,
osnova setup --applyandosnova setup claude|agents --applyno longer tell the user to paste the change in by hand; the notice now says the entry was added and every other entry kept.osnova setup --preview --clientends with the same hint to repeat the command with--applyas the family commands. osnova setupcounts a hook or MCP entry as osnova's only when its whole launch is osnova's program alone or a known runtime running it with plain flags, so a command that only mentions osnova (echo osnova mcp,node -e osnova) is no longer repointed, and each runtime counts only in the form that runs osnova's own program (pnpm osnovaandnpx osnovado not); a path counts only when it exists inside a package named@getdomovoi/osnova, so a user'sosnova.jsor a missing path does not; hook files keep CRLF line endings when setup rewrites them.osnova_threadcaps one row's snippet at 480 code units, starting at the first match, instead of keeping the whole span of every match on a long line; the row's column list still names every match. In an agent pilot, 4 of the 5 clipped thread answers held such rows (one minified line alone took 16,098 of the 16,384-unit response, and one clipped answer showed only the first of its seven groups); the cap removes 31,767 units from them. A cut no longer splits a surrogate pair.- The README no longer says the OpenCode and Kilo plugin puts the tool contract in the system prompt, which 0.10.0 stopped doing.
0.10.0 (2026-09-28)
Changed
- The stdio MCP server (
osnova mcp) starts its first refresh at launch and, after each refresh that publishes a new index, builds the ranking corpus and footing's edge maps between requests. On an 18,425-file repository, with 15 seconds between launch and the first call, the firstosnova_groundfell from 5.7 s to 0.9 s, the firstosnova_footingfrom 9.4 s to 0.6 s and the firstosnova_warpfrom 1.5 s to 0.2 s. A ground or footing call that arrives before the warm-up finishes builds what is missing itself. The final join of the corpus and the edge maps run in one step each, about 0.5 s and 1 s there, and any call that arrives during one of them waits for it, including calls to other tools. Closing the server cancels the warm-up. Answers do not change.osnova mcp --no-prewarmskips the warm-up: on that repository the warm-up raised the server's peak memory from 3,833 MB to 4,033 MB, since the first query builds the same data anyway, and without it the first ground and footing calls took 6.9 s and 2.6 s instead of 1.0 s and 0.8 s.createOsnovaMcpServertakes an optionalprewarmflag, off by default, and its status reportswarm. osnova_footingkeeps room for relationships. Once one seed definition is placed, further seeds take at most 60% of the budget when there are relationships to show, and any room left returns to them after the relationships; until then each seed, and every seed when there are no relationships, has the whole budget. On an 18,425-file repository, two questions went from 2 and 0 relationships to 9 and 4, because short seed definitions kept whole had filled the budget.osnova_footingbuilds relationship evidence once per index and derives each scope's edge maps from it, keeping at most eight scopes, instead of rebuilding them on every call; repeated calls on that repository fell from about 1.3 s to 0.5 s.- The package exports the
RequestedSymbolandOsnovaMcpStatustypes. osnova mcpwaits up to two minutes for a build another osnova process is running, instead of ten seconds. With a session hook's background build of an 18,425-file repository, the first two tool calls each failed after 10 s withcache-lock-timeoutand advice to delete the lock; now the first call waits about 29 s and answers. The lock still reclaims a dead owner at once; a stuck or unverifiable owner now holds the call up to two minutes before the same error. The session hook no longer promises starting points from the next prompt while that build runs.osnova_footingwithsymbolsreports each requested name asreturned,omitted,unknownorout-of-scope: arequested:line gives the counts, then lists the names that need action first, the list of names capped at 512 code units with a+N moresuffix for the rest so a long batch stays inside the budget, andtaskContextreturns every status asrequested. Before, every miss was oneunknown symbolscount, so a batch with one wrong name gave no hint which one to fix. The count stays in the omitted line.- The MCP
instructionssent on initialize end with one line naming the checkout the server indexes. Agents working in a second worktree had queried a server indexing another checkout and repeated failing lookups; 51 such lookup errors were counted in earlier recorded sessions. The tool contract itself is unchanged. osnova_footingfor achangeorreviewtask alternates relationships and candidate tests when filling its budget. Before, test files filled the budget first, so a function with many tests showed none of its callers: on this repositorybuildIndexshowed 36 test files and 0 of its 244 callers, and now shows 12 test files and 10 relationships.understandtasks keep relationships first.- The OpenCode and Kilo plugin no longer adds the hook's tool contract to the system prompt; it only appends starting points to each user message. OpenCode 1.18 and Kilo 7.8 already put the MCP server's instructions into the system prompt, which cover the same tools, so the plugin's text restated them on every request. The plugin still runs the session hook once before the first prompt, discarding its text, so a cold cache starts building as before. The Pi extension is unchanged, because it was not verified that Pi forwards MCP instructions.
0.9.0 (2026-09-26)
The extraction version moves to structural-9.30 and the artifact format to 13, so the first run after upgrading rebuilds the cache once. This release lands the remediation of the 2026-09-22 repository audit, 97 findings across ten categories worked in three waves; the audit itself is a private record, and each entry below states what changed and what was measured.
Breaking
callers()throws aRangeErroron adepththat is not a positive safe integer and on adirectionother thaninorout. Before,NaNreturned an empty caller list and any other direction silently walked callees.callersDetailed, MCP and the CLI already rejected these, and every valid input answers as before.callerstakes the exportedCallersOptionstype.map(),osnova groundworkandosnova_groundworkdefault to eight directory clusters. The library and the CLI defaulted to 16 while the reference and the MCP schema said eight. AmaxDirsbelow one or not an integer is aRangeErroron every surface; MCP used to clamp-3and0to one cluster and pass2.5through.osnova doctorprints a readable report by default: the checks that are not ok with their messages, the 21 languages grouped by support tier, and what a green grammar check proves, which is that the grammar loads and parses a short snippet.--jsonreturns the previous form with every field.- Every MCP tool rejects an argument it does not declare, and an argument of the wrong type, with an error that names the argument and lists the accepted names. Before,
fixed: "true"silently ran a regex search (10 matches instead of 0) and a misspelledscopessilently scanned the whole repository. Ranges are still checked by the engine with the same messages on both surfaces. findTextandfindTextDetailedrefuse a pattern that does not finish within its budget (budgetMs, default 5,000 ms) withosnova: pattern-budget-exceeded, which states that no result is returned and that this is a refusal, not an absence of matches. Before,(a+)+bwedged the MCP server past 30 seconds. A pattern whose worst case is not proven small runs on a worker thread that is terminated at the deadline; a quantifier-free pattern runs inline under the same deadline, checked every 4,096 steps, because one indexed file can hold a million start positions on a single line.osnova_threadand the CLI need no change.
Fixed
- A clipped
osnova_threadorosnova threadanswer told the caller to usefindTextDetailedwithout limits, a library function neither surface can call. It now says what each surface can do: raiselimitfor more groups when groups were left out (a group is one enclosing definition in one file), and, since each group lists at most 10 matches and no argument raises that, run a plain text search on one file for every line in it. - A search scoped to a file above the 1 MB size cap answered
no matches in indexed textwith only the banner to say the file was never indexed; on pyright that file istypeEvaluator.ts. Everyosnova_threadandosnova threadanswer now names the files in its scope that are above the cap and were not searched. - The OpenCode and Kilo plugin and the Pi extension did nothing on Windows. They start
osnova hookwithspawn, and on Windows theosnovacommand is an npm.cmdshim, which Node starts only through a shell; the error was swallowed, so the contract, the starting points and the search guard were all silently missing. On Windows they now start it through the shell with the path quoted, refuse anOSNOVA_BINholding a quote,%,!or a line break (the characters that leave the quoting or expand a variable;&,|,<,>and^stay literal inside quotes), and end the whole process tree withtaskkill /twhen a hook times out, since killing the shell leftosnovarunning, falling back to killing the shell whentaskkillcannot run. The plugin test that skipped Windows now runs there with a.cmdstand-in. osnova settle --base-refandosnova_settlewithbaseRefbuilt an empty base tree when the workspace is a folder below the repository root, such aspackages/zod, becausegit archivenarrows to the current folder a second time. Every symbol came back as added: 7,783 on zod for a one-function rename. The base tree is now archived from the repository top, and the same rename reports 6 changes, the rename itself and 12 dependents.osnova_settlewith a diff whose paths start at the repository root, asgit diffprints them, matched no indexed file in a subfolder workspace and answered0 symbol changes; 0 dependentswith nothing to say why. Such paths are now read relative to the workspace, and any diff file the index does not hold is named in the uncertainty line. A hand-written diff with wrong hunk counts, a refusal agents met in a live trial, now says to callosnova_settlewith no arguments, which settles the uncommitted changes againstHEAD. The whole diff is read on one basis: once a path names the workspace folder from the root, a root file outside that folder is reported as not in the index instead of matching a workspace file of the same name, a rename into or out of a file the index does not hold is noted, and a path that names a workspace file both as written and from the root (a folder nested under its own name) is left out and reported instead of guessed; so is a path that could be a root file when the only workspace path in the diff is one the index skips.unreferencedlisted Python dunders such as a module-level__getattr__or a__repr__method as unused, although the runtime calls them without a call site. In an agent trial on click with two seeded dead helpers, every one of six runs that usedosnova_unreferencedreported the module__getattr__as unused, and every one of three runs without Osnova did not. Python dunders are now an entry-point rule, counted aspython dunderinentryPointsand in theentry points excludedline.- A file above the 1 MB size cap was dropped with no runtime signal, so
threadprinted an exact omission count that was wrong. Such a file now gets a card with languagefallback, its true size and ascan/file-too-largediagnostic;indexHealthreportspartial,coveragelists it with its size and the limit underoversizedFilesin both the JSON and the text output, the MCP banner counts that category first and saysN files above the 1 MB size cap are not indexedinstead of calling it a parse failure, andosnova_outlinenames the requested file's own diagnostic. Incremental refresh stays byte-identical to a full build as a file grows past the cap and shrinks back. - A crafted ignore file could abort a live MCP server with a V8 out-of-memory fatal. An ignore file above 1,000,000 bytes fails the scan with
ignore-file-too-large, and more than 10,000 rule lines across the scan fail it withignore-pattern-limit. On 25 nested directories with 20,000 rules each, the process went from exit 134 after 8.1 seconds and 573 MB to exit 2 in 0.08 seconds, and the server stayed up. tsconfigpath-alias lookup was quadratic in repository content. Wildcard keys are indexed by prefix and suffix once per config: a 1.8 MB workspace with 30,000 keys and 30,000 imports built in 76,780 ms before and 765 ms after. Atsconfig.jsonwith a byte order mark lost itspaths; a malformed tsconfig or jsconfig now carriesparse/config-unparsedinstead of being silently ignored.coverage --jsoncarriesdiagnosticscounted by phase and code.- A process killed between creating a lock directory and writing its owner file, or between removing the owner file and the directory, left a lock that no process could ever reclaim; on the cache-wide eviction lock that blocked every workspace for every process. A lock is now built complete under a private name and renamed into place, so the lock path never exists without its owner; the owner releases it by renaming it aside; and every abandoned state (an exited owner at once, an ownerless or torn directory once nothing in it has changed for five seconds) is reclaimed by removing only the entries the reclaimer observed, never the directory as a whole. A modification time in the future never counts as quiet, so a clock step cannot free a held lock.
cache-lock-timeoutnames the holder and the remedy. Nested locks inherit the caller's timeout and poll interval, a failed release no longer replaces the operation's own error, andSIGINT,SIGTERMand an unhandled rejection release held locks and exit 2 with oneosnova:line. A reclaim that has just emptied a lock directory removes it at once rather than leaving it for the grace period, which Windows cannot take over by rename. --watchcleared its dirty flag before a refresh succeeded, so a failed refresh served the pre-edit index for up to 30 seconds whilestatus()said the server was clean. A change now stays pending until a refresh succeeds.- Dart extracted no call edges, so
warpon a Dart symbol was always empty. Bare, member, null-aware member, named-constructor andnewcalls are now recorded, a function body that the Dart grammar places after the signature is attributed to its definition, andosnova doctorno longer states that Dart has no call edges. OSNOVA_EXTRACT_WORKERSbypassed the worker cap; an explicit value is capped at 32. A worker that never answered hung a build forever; each file now has a 60 second deadline and fails through the existing fail-closed path withextract worker stalled on <path>.osnova doctorexecuted the program named in~/.claude.jsonwhile reportingreadOnly: true. The version check now resolves the configured command to a file and reads the nearestpackage.json, or compares a pinned@getdomovoi/osnova@x.y.zspec, and runs nothing.osnova update-checkcould hang on a stalled response body; the timeout now covers the body and the answer is capped at 1 MiB.- Enrichment refresh launched the executable named in the stored
policy.jsonsidecar.configureLspEnrichmentreturns an approval (a SHA-256 of the canonical policy) that a refresh from stored policy must present asapprove, else it launches nothing and reportspolicy-not-approved; a policy passed in the call is approved by the call. The sidecar's own lock implementation is replaced by the cache lock, so contention reportscache-busyafter 250 ms. osnova_warp'sdepthargument had carriedosnova_plumb's description since 0.7.0, telling agents a walk depth was the depth a claimed list was made at. It describes its own walk, and a test fails if any argument description repeats across tools exceptinandsymbol, which mean the same thing everywhere.- The documents tell the truth about the code.
SECURITY.mdandAGENTS.mdpromised no network access and no writes outside the cache whileosnova update-checkandosnova setup --applyshipped; both now state those two exceptions,PRIVACY.mdscopes its "no key, no service" statement to the installed tool, and both policy files ship in the package. The reference's coverage table printed the record before the one it cited, ten rows wrong and four rows missing; it is generated from the cited record andtest/docs-coverage-table.test.tsholds them equal.AGENTS.mdfroze seven tools while ten shipped andCONTRIBUTING.mdsaid five; the frozen list names all ten andtest/mcp.test.tsholds the server to it. The README named anexportsedge kind that never existed, quoted the type-checker oracle without its version, promised a generation receipt on CLI output that carries none, and showed awarpexample whose bare name is now ambiguous and whose counts had drifted; each is corrected, andtest/docs-readme-warp.test.tsrebuilds the index and holds the example's first two lines. The reference CLI block lackedtests,unreferenced,hookandupdate-checkand thetoolhook event (test/docs-cli-inventory.test.tsdiffs it against the binary's usage), said the cache keeps 8 workspaces (32), documented six internal hook helpers as if they were exports, and gaveosnova footingno flags where-n,--depthand--max-code-unitsexist. Nine uncited benchmark records, one a same-date twin of the record the README stakes its numbers on, moved tobenchmarks/results/superseded/under an index thattest/docs-benchmark-citations.test.tsholds current. The brand table calledplumba path-finder andunreferencedproof. - One process's LRU eviction could delete the
text.binandedges.jsonanother process was still reading. A loaded artifact reads both sidecars lazily after the workspace lock is released, and eviction only checked locks, so a server that had loaded a workspace answeredcache-read-failed: cache text sidecar missinguntil its next refresh. A loader now leaves a lease underreaders/in the workspace directory; eviction spares a workspace whose lease names a live process and removes the leases of processes that have exited. loadIndexreported a lock timeout met while recording access ascache-read-failed; it now reportscache-lock-timeoutwith the holder.
Changed
- The declared range of
@modelcontextprotocol/sdkis^1.30.0. Installs already resolved 1.30.0; the old floor of 1.12.0 was what dependency scanners read, and it carries advisories that never shipped. osnova setup --hooksreconciles instead of only adding. An osnova hook that runs from another install is repointed at this one, keeping its flags, timeout and matcher; an osnova hook this version cannot run, a duplicate, or one under the wrong event is removed; missing hooks are added as before. Moving between installs, or back from a build with extra hooks, used to leave the old commands in place, and a hook the running version lacks failed on every event. A change that repoints or removes is reported asupdateand backed up like an append. Shell-wrapped osnova commands and hooks that are not osnova's are never rewritten.osnova setuprepoints an existingosnovaMCP entry that launches another osnova install instead of refusing it as a conflict, keeping the flags aftermcp, other keys such asenv, and Codex's tool approval tables. Only anosnovaentry that does not launch osnova still stops the apply. A hook or MCP entry counts as osnova's when the program it runs beforehookormcpis theosnovaexecutable, the@getdomovoi/osnovapackage, or adist/bin.jsinside a folder whose name containsosnova; a user's own script that only lives under such a folder is never changed. The entry is found by walking from the top-level object, so a per-projectmcpServersblock earlier in~/.claude.jsonis no longer mistaken for the top-level one; appending used to insert into the firstmcpServersobject in the file.osnova_settlewith neitherdiffnorbaseRefsettles the uncommitted changes againstHEAD, untracked indexed files included, instead of failing with "needs diff or baseRef". In a paid agent trial, 47 of 60 settle errors were exactly that call, and each cost a retry request carrying the whole context; most of the rest were hand-written diffs with wrong hunk counts. The tool description, the MCP instructions, the session hook's tool list and the shipped skill now point at the no-argument call. Outside a git repository, the no-argument call fails with a message that says to passdiff.- The Linux performance budgets in
scripts/perf.mjsare 1.6 times the slowest of 29 CI runs, down from about 2.5 times, so a 1.6x regression now fails CI. macOS and Windows runners vary about 30% between runs and keep their margins; the macOScoreLoadbudget rises from 220 ms to 400 ms after a slow runner measured 367 ms on an unrelated change. - Test runs no longer share one cache folder. Every run used
$TMPDIR/osnova-vitest-cache, and three files indexed the fixture into one repository-relative folder, so test files that indexed the same fixture in parallel waited on one lock and failed withcache-lock-timeout, a different file each time. Each run now gets its own folder, each worker a subfolder, and the folder is removed when the run ends. Two concurrent runs failed 4 files each before; three concurrent runs pass after. pnpm testandpnpm perfbuild first, so no test can pass against a staledist/; the 28 CLI and MCP tests each get their own workspace and cache and can run alone;scripts/perf.mjsgenerates 642 files across TypeScript, Python and Go plus the 20-language fixture (16,657 symbols, 40,162 edges), gates peak resident memory instead ofheapUsed, fails if a coldgroundquery or a no-change refresh loads the edge section, and takes the best of five samples for the scan metric, since a single sample on a shared runner swung 6.2x on identical code while a real one-millisecond slowdown per path still fails the gate at 936 percent of budget. CI adds a job on the declared Node floor, 22.13.0.- The release workflow is two jobs.
verifyruns every gate with read-only permissions and packs the tarball;publishholds the write and provenance permissions, installs nothing, and publishes that tarball.npm install -g npm@latestbefore a trusted publish is gone, every action in every workflow is pinned to a commit SHA, and the checkout no longer persists its token. A step in the pull requestsettleworkflow fails a change that touchessrc/extract/,src/grammar/orsrc/index/scan.tswithout movingextractionVersion, because a missed bump served stale caches whose hashes all still verified;scripts/extraction-version-guard.mjs <base-ref>runs it by hand. - The eleven runtime validator lists that hand-copied TypeScript unions are derived from them, so a new union member is a compile error at the validator rather than an artifact the reader rejects as corrupt right after the writer produced it. The stored order of edge kinds is written down as explicit ordinals and pinned by a test, since that order is the on-disk encoding.
- Dropped
@types/emscriptenand@vitest/coverage-v8;node scripts/check-package.mjs --supply-chainchecks a recorded SHA-256 for each packaged grammar file rather than trusting the lockfile hash alone. - The GitHub Action runs the osnova version of the action ref you use, so
getdomovoi/osnova@v0.8.1runs osnova 0.8.1 on every run instead of whatever npm published last.version: latestfloats on purpose.scripts/settle-ci.shresolves the version and prints it underOSNOVA_PRINT_COMMAND=1. - A
NOTICEfile ships in the package. It records that the tree-sitter grammar binaries npm installs fromtree-sitter-wasmsare compiled from MIT-licensed grammar projects under a package that declares Unlicense, and that osnova does not redistribute them. - The README's type-checker oracle figures now say the version they were measured at, 0.8.0, as
docs/reference.mdalready did. - Every MCP tool declares its annotation hints:
readOnlyHint: true,destructiveHint: false,idempotentHint: true,openWorldHint: false. All ten read the index and write nothing, and clients and directories read these hints to decide what a tool may do. A test lists the tools over an in-memory transport and holds every one to those four values.
Added
osnova_groundandaskrank a definition above test code that repeats its words, unless the question mentions tests. A test file's text match counts half; an exact name keeps its priority. On 120 questions from a fixing-patch retrieval eval (50 pinned tasks), the first hit named a file the fix changed for 48% of questions instead of 42%, and a top-three hit for 67% instead of 59%; 17 questions ranked better and none worse.PRIVACY.mdstates what Osnova reads, writes and sends, with the two carve-outs, and is linked from the README andSECURITY.md.osnova doctor --json.- MCP
osnova_outlinereads an absolute file path inside the workspace as the repository path it names. A not-found file or symbol error, and an absolute path outside the workspace, now name the workspace this server indexes. Session traces showed agents in another worktree repeating the same failing lookup up to four times. - Footing output ends with one closing line.
scope: no indexed results omitted at this scope and depthappears only when no definition, relationship, candidate test, lower-ranked candidate or requested symbol was dropped; the depth frontier and uncertain edges keep their counts in theomitted:line and bound the statement. Otherwise anext:line names one follow-up per kind of omission, such asosnova_warp <seed> full=truefor dropped relationships. The line is last, so output clipping removes it rather than leaving a clipped answer that claims to be whole. Session traces showed agents re-querying or grepping after an answer that did not say whether it was complete. - An
implementsedge kind. A writtenimplementsclause on a TypeScript, TSX or JavaScript class was recorded and reported asextends, which misstated the relation on the one product property that every edge carries its own basis. It is now its own kind, resolved by the same rule asextends(same-file, then imports, filtered to class, interface, struct and trait candidates), stored at wire position 5 so no existing edge renumbers, and counted by coverage asimplementsandimplementsResolved.
Performance
- The extraction visitors read
node.type, a getter that crosses into WASM, up to twelve times per node; each now reads it once. Reads fell from 13,952,666 to 3,488,867 on zod and from 26,060,792 to 7,699,521 on pyright, with byte-identical artifacts on all seven pinned corpora. Sequential build time fell from 1,785 ms to 1,520 ms on zod (minimum of 9 interleaved runs) and from 4,536 ms to 3,976 ms on pyright (minimum of 6), on a machine under load, so indicative rather than exact. ground --inbuilds search documents only for the files inside the scope while keeping the repository-wide statistics every score depends on exact, so a one-file query no longer costs a whole-repository scan; scores are unchanged.- The tokenizer resumes instead of restarting at the first non-ASCII character, and the innermost symbol of a matching line is found once per line rather than once per match.
osnova_threadand the boundedosnova_outlineno longer decode the edge section to rank by degree. The core now records each symbol's incoming and outgoing edge counts, so a cold text search or outline answers from the core alone;scripts/perf.mjsfails if either loads the edge section. The bounded outline also fits its budget in one pass instead of re-sorting and re-rendering the selection for every candidate.
Security
- The lockfile resolved two esbuild majors: vitest's vite used 0.28.2 and tsup's bundle-require used 0.27.7, which matches GHSA-g7r4-m6w7-qqqr (arbitrary file read through esbuild's dev server on Windows). A single override in
pnpm-workspace.yamlcollapses the split onto the patched line and removes the duplicated@esbuild/*platform packages with it. Development dependency only; nothing shipped changes.
Removed
- The unused changesets machinery: the
changesetscript, the@changesets/clidevelopment dependency and.changeset/config.json. Releases are the hand-written changelog plus the tag workflow, and nothing read a changeset.
0.8.1 (2026-09-22)
The extraction version moves to structural-9.28, so the first run after upgrading rebuilds the cache once.
Fixed
- A declaration on the line where its own scope opens (
items.map((n) => { const twin = ...; return twin(); }), or a one-line method in an object literal) was assigned the enclosing scope, so two such declarations of one name were never marked shadowed and every call to the name resolved to whichever record survived. Scope lookup now compares positions rather than lines: the scope of a declaration is the innermost function, class or file root that starts strictly before it. Found by a Kilo trial round in whichosnova_unreferencedlistedwrappedin a memoizer as unreferenced. unreferencedno longer lists a shadowed declaration. The resolver records no reference edge to a name the file declares in more than one scope, so such a symbol always looked unreferenced with no leads; it is now counted inshadowedNotListed, the text header saysN shadowed not listed, and the limitations gainshadowed-declarations-never-listed.
Added
integrations/claude-code/is a Claude Code plugin:.claude-plugin/plugin.json,.mcp.jsonandhooks/hooks.jsonbeside the existing skill, listed by.claude-plugin/marketplace.jsonat the repository root, so/plugin marketplace add getdomovoi/osnovaand/plugin install osnova@osnovainstall the MCP entry, the session, prompt and stop hooks and the skill together, each run asnpx -y @getdomovoi/osnovawith no global install.test/distribution-manifests.test.tsholds the plugin hooks to whatosnova setupwrites and pins every manifest version to the package version.assets/demo/warp.gif, embedded at the top of the README's "What you get back":osnova warpandosnova plumbon the pinned click checkout, recorded with the tape and ffmpeg command beside it.server.jsondescribes the server for the MCP registry (schema 2025-12-11) asio.github.getdomovoi/osnova, matched bymcpNameinpackage.json, and the test validates it against a vendored copy of that schema; the npm keywords addcode-graph,repo-map,code-intelligence,cursor,kiloandpi.
0.8.0 (2026-09-21)
The core format moves to 12, the edge section format to 11 and the extraction version to structural-9.27, so the first run after upgrading rebuilds the cache once.
Added
- A fifth edge kind,
routes, records framework route registrations for Express, NestJS, Flask and FastAPI: the HTTP verb and the literal path written at the registration site, tied to the handler. The edge is recorded only when four written facts hold, so a verb name alone never produces one: the receiver binds through the existing import and receiver bindings to an import from the framework package (.get(on aMaprecords nothing), the member is in that framework's verb set, the first positional argument is a plain string literal (else the edge carries nopath), and the handler is a bound name, a member the receiver hints identify, or the decorated definition. Mounts (app.use("/api", router),include_router(r, prefix=...),register_blueprint(bp, url_prefix=...),@Controller("cats")) are their ownANYedges and prefixes are never composed. An inline closure records the route with reasonroute-handler-inline, a call result withroute-handler-wrapped.OsnovaEdge.routecarries{ method, path? },osnova_groundranks a registration by its verb and path (GET /usersreturns the registration line with the handler as its symbol, and a NestJS method under a@Controllerprefix in the same file also answers the composedPOST /cats/:id; composition is query-time only and never crosses files; each file card carries its route sites in the core section, so a route query never loads the edge section and the coldgroundquery costs what it did before), warp prints aroute:detail line, coverage addsroutesandroutesResolved, and the edge section format is 11. Measured on three pinned checkouts (benchmarks/results/route-edges-2026-09-21.json): on the FastAPI template every one of the 23 verb edges matches the runtime route table and every runtime route is covered; the Flask checkout yields 137 edges, 113 resolved, and the NestJS checkout 431, 424 resolved, with 25-edge hand-verified samples of each at 25 of 25. On the census of six public checkouts that motivated the rule, 94 to 100 percent of registration sites write the path as a literal and 957.get/.setcalls on non-framework receivers in one checkout would have been false routes under a name rule. The extraction version isstructural-9.27. scripts/parse-profile.tssplits a full build on the pinned checkouts into its phases (scan, grammar load, read and hash, tree-sitter parse, adapter walk, symbol assembly, resolve, serialize, save), timed on one thread in steady state with the wall clock beside them, and refuses a checkout whose revision differs from its manifest. The first record,benchmarks/results/parse-profile-2026-09-21.json, answers the question the script was written for: the WASM parse is 17 to 39 percent of build time across the seven corpora and the TypeScript adapter walk is 35 to 57 percent, so native grammar bindings would not be the lever and were not priced.benchmarks/results/type-checker-oracle-2026-09-21.jsonrecords call edge precision and recall at 0.8.0 against a type checker on two pinned checkouts: every call site is sent to pyright 1.1.414 (click) or TypeScript 5.9.3 (zod) for the callee's declarations and each osnova edge is scored true or false against them. click: 2883 decided edges, 0 false, 90.4% of in-repo sites covered. zod: 21276 decided, 4 false (0.02%), 76.9% covered; the four false edges are listed by site with their resolution basis. The README quotes these numbers under "How much of the graph is exact" and the reference states the method and its limits.
Fixed
- A member chain through a field named like an
Object.prototypemember (this.constructor.name,this.toString) crashed resolution withCannot read properties of undefined (reading 'startsWith'), because the persisted field-type table is a plain object and the lookup read the prototype. The lookup now takes own properties only. Reproduced on a public NestJS checkout, where 0.6.2 and 0.7.0 fail to build the index at all.
Changed
- The MCP server instructions, the
osnova_groundtool description, the session hook line and the shipped Claude Code skill say that a verb and path such asGET /usersfinds a route registration and its handler, so an agent learns the query exists without reading the reference.osnova coveragetext printsextendsandroutescounts besidereferences; the JSON already carried them. - A call to a name the file declares in more than one scope no longer resolves. Osnova keeps one symbol record per qualified name, so when a file declares
const helperinside two callbacks, or a class of the same name inside several test blocks, no edge can name the declaration a given call site sees; the index used to emit an edge to whichever record survived and was wrong at every site but one. Extraction now records the scope of each declaration and marks a name the file declares in more than one of them, and the resolver refuses those calls with the newshadowed-declarationreason instead of guessing. A scope is the nearest enclosing function, class or file root, not any block, so declarations that share one scope are still one declaration and still resolve: overload signatures with their implementation, a value and a type of the same name (const Shapebesideinterface Shape), the branches of a conditional definition (if WIN: def f() ... else: def f() ...), and Python@overloadstubs. Scored against the TypeScript 5.9.3 checker on the pinned 702-file TypeScript monorepo, false call edges fall from 39 of 21,325 decided to 4 of 21,276, a false-edge rate of 0.183% to 0.019%, and all 35 removed edges are this one cause; covered call sites fall from 21,213 to 21,199, the 14 sites where the surviving record happened to be the one in scope. Scored against pyright on the pinned 160-file Python package the result is byte for byte identical: 2,883 true positives, no false positives and no refusals, because every duplicate name there is an@overloadgroup or a conditional definition. Refusals by corpus: 49 of 53,417 call sites on the TypeScript monorepo, 22 of 58,578 on pyright, 6 of 30,517 on humanizer, none on click, cobra, gson or ripgrep.OsnovaSymbolgains an optionalshadowedflag. This change alone moved the extraction version tostructural-9.26; the release ships core format 12, edge section format 11 andstructural-9.27, as stated at the top of this section.
0.7.0 (2026-09-21)
Added
- A Python decorator that is itself a call is recorded as a
callsedge to the decorator factory.@option("--n")above a definition evaluatesoptionat that line exactly as an ordinary call statement does, and the edge now says so: it runs from the enclosing scope of the decorated definition to the factory, sits at the line of the@, and binds through the same lexical, import and re-export bindings a call uses. This is syntax, not type inference. A dotted decorator such as@mod.option("--n")resolves only through a namespace binding or a receiver hint, so a factory reached through a local value with no declared owner keeps its edge unresolved withreceiver-unresolvedrecorded, and an unbound name keepsunbound-global; there is no name-heuristic fallback. A bare decorator (@plain) applies an existing value rather than calling one and stays areferencesedge, and calls written inside the decorator argument list are unchanged. The decorator allowlist that decides whether a decorated method keeps its declared calling shape is untouched: a decorator whose shape is unknown still makes the member kindunknown, because emitting a call edge to the factory says nothing about what the factory returns. On a 160-file Python package this moves 1105 edges fromreferencestocallsand lifts resolved calls from 2134 of 5488 to 2903 of 6593, a resolved share of 0.3888 to 0.4403; 756 of the 769 newly resolved calls bind through a re-export, 8 through a receiver hint, 3 lexically and 2 through a direct import. The 336 decorator calls that stay unresolved are honest: 333 are dotted decorators on a receiver with no declared owner and 3 are factories imported from an external test dependency. A 340-file TypeScript package is unchanged in every coverage counter, since this is the Python adapter only.
- A fourth edge kind,
extends, records declared heritage: the superclass or interface name written in the source. TypeScript, TSX and JavaScript record theextendsclause of a class declaration, theimplementsclause of a class declaration and theextendsclause of an interface declaration; Python records every entry of a class base list. This is pure syntax, not type inference: the base name binds through the same lexical and import bindings a call uses, candidates are filtered toclass,interface,structandtraitsymbols so a same-named function, constant or type alias is never the base, and an unbound or ambiguous name keeps its edge with the basis recorded (bound-symbol-missing,binding-blocked,unbound-global,ambiguous) with no name-heuristic fallback. The edge runs from the declaring class or interface to the base and sits at the line of the base name. Warp printsd1 extends <symbol>:<line> [<basis>]in the same row shape as calls and references, and reach, tests, unreferenced and settle count it as a use; coverage addsextendsandextendsResolvedbesidereferencesandreferencesResolved. On a 340-file TypeScript package this adds 688 extends edges, 586 of them resolved, and on a 160-file Python package 148 edges, 104 resolved. Import and reference counts are unchanged in both, and so is the call count; on the Python package 16 of its 5488 calls move from unresolved to resolved because Python heritage now includes subscripted bases (see Fixed), which lets the inherited-member walk reach a base such asParamType[_ValueT_co]:src/click/types.py#ParamType.failgoes from 9 to 21 indexed callers and from 20 to 8 unresolved same-name sites.src/v4/core/errors.ts#$ZodIssueBaseandsrc/v4/core/checks.ts#$ZodCheckDefreported no indexed relationships before and now report 12 and 17 incoming edges. Not covered: class expressions assigned to a variable, and Go, Rust, Java and C#, whose type names bind by unique name within the language family rather than by an import or lexical binding, which is the name heuristic this edge kind refuses. The generic tier records no heritage.
osnova_groundtakeslean: trueandosnova groundtakes--lean: an opt-in no-source shape for the question "where does this live". Each hit printsfile:line, the symbol kind, the qualified name and the definition's own line span, then the indexed signature on one line clipped at 200 code units; a hit with no definition keeps its single matching line. Nothing else changes: hit order, ranking, the eight-hit default,in,limit, thealso:fold and every omission count are the same, and the line span the dropped excerpt notice carried moves into the header.leanoverridesfull, and--leanalso applies to--scoped, where it prints the header and span without the excerpt. Measured warm over MCP at limit five: on a 160-file Python package 2,333 to 699 bytes, 635 to 191 tokens, 70 percent smaller; on a 702-file TypeScript monorepo whose matching definitions are one to three lines each, 1,096 to 986 bytes, 304 to 276 tokens, 9 percent. The same two queries through a fresh CLI: 577 to 175 tokens and 319 to 235. The saving is the source body, so it tracks the size of the matched definitions. The default shape is unchanged, because an agent that then has to read the file back pays more than the inlined excerpt saves.- TypeScript, JavaScript and Python
referencesedges now cover augmented assignment right-hand sides (??=,||=,&&=,+=and the other compound operators) and the&&/andbranches, under the same binding rule as every other value position; on a 333-file TypeScript package this adds 6 resolved references, all through??=. osnova tests <symbol...>andosnova_testswithsymbolsprint the two evidence tiers under separate headings so a file that only imports the symbol's file cannot be read as a test of the symbol. The header and each symbol line carry separate counts (N test files with a resolved edge; M import the file only), resolved-edge files come first underresolved edge (calls or references the symbol):, import-only files follow underimports the file only (no indexed call or reference to the symbol):, and an empty resolved tier is stated on its own line before the import-only block. The result set is unchanged. Additive:includeImportOnlyon the MCP tool andtestsFor(default true) and--no-import-onlyon the CLI drop the import-only tier;TestsForResult.includeImportOnlyechoes the setting.- TypeScript and JavaScript imports resolve through tsconfig
compilerOptions.pathsandbaseUrl: the nearesttsconfig.jsonorjsconfig.jsonabove the importing file is read (comments and trailing commas allowed), relativeextendschains are followed, an exact key wins, then the*pattern with the longest literal prefix, and an entry's targets are probed in declaration order with the usual extension andindexrules. Two patterns with the same prefix length leave the import unresolved with the new reasonimport-target-ambiguous. Anextendsthat names a package stops the chain, since nonode_modulesis read. Config files are indexed text, so editing one re-resolves every edge on refresh. Bundler aliases (Vite, webpack) are not read. - Objective-C joins the generic tier:
.mand.mmfiles index@implementationclasses,@protocolinterfaces, method definitions, C functions, structs, enums and typedefs, and record message sends and C calls. A message send is named by the first selector segment ([self formatName:x with:y]callsformatName), which is also how a method definition is named, so same-file sends resolve by name like every other generic-tier call.@interfaceblocks and their method declarations are declarations, not definitions, so an interface paired with its implementation in one file does not produce duplicate symbols; the header side of a class stays with the C grammar, because.hstill maps to C. Objective-C shares the C language family for name resolution. The grammar count inosnova doctoris 21. osnova update-checkasks the npm registry whether a newer version of osnova is published. It runs only when a person types it: there is no automatic notifier, no schedule and no check on startup, because a tool that reads a private source tree should not report its version and address to a registry without being asked. The command sends one GET toregistry.npmjs.orgwith no body and no query string, reports no repository data, and times out after 5 seconds. A failure prints a message instead of throwing. Exit code 1 marks a stale version so a script can act on it, and--jsongives machine readable output. The two README lines that claimed no network access now name this one command, so the statement stays true.osnova_unreferencedandosnova unreferencedlist definitions with no resolved call or reference edge from outside their own body in a non-test file. Every row is a candidate, never proof that deletion is safe: it carries the leads a reader has to check by hand, the count of unresolved same-name call sites, the test-file sites, and the identifier mentions in non-test files outside the candidate's own span (nullwhen the non-test text exceeds 32M code units and the scan was skipped). Entry points are excluded by a fixed rule printed in the output: symbols namedmain, default exports,index.*files,package.jsonbinfiles read from the indexed text, test files and constructors. Exported definitions are entry points for external consumers and appear only withincludeExported. The default kinds arefunction,methodandclassbecause the index records edges to nothing else (on this repository constants were referenced 1 of 3595 times, interfaces 0 of 181, types 0 of 33);kinds,scopeandlimitnarrow the run. On this repository the tool lists 19 candidates of 955 symbols examined with 48 exported not listed; on a 1678-symbol TypeScript package it lists 24 with 728 exported not listed. Five of the 19 were checked by hand and all five are false candidates for two reasons the index cannot see: a function passed as a value receives no reference edge, and calls to a local arrow closure stay unresolved. Each of those rows carries at least one lead, which is why the leads are counted per row and ano leadsmarker separates the rows worth reading first. Library:unreferenced(index, { scope?, kinds?, limit?, includeExported? }). The MCP tool list is ten.osnova_testsandosnova testsmap symbols to the indexed test files that reference them, and a test file to the symbols it exercises. A test file counts for a symbol on one of two bases, printed per file: a resolved call or reference edge that is not a name heuristic, listed asfile:linesites with the edge kind, resolution method and enclosing test symbol, or an import of the symbol's file with no direct edge. A string that merely spells the name is not evidence.osnova tests --file <path>is the reverse: the non-test symbols one file reaches through resolved direct edges plus the non-test files it imports, with the count of unresolved edges not listed. The MCP tool takes{ symbols?, file?, limit? }, exactly one ofsymbolsorfile, bounded at 4,096 code units, and every response says that no indexed test is not proof of no test and that a listed test references the symbol without proving coverage. Library:testsFor,symbolsUnderTestandisTestFile.osnova settle --base-ref <ref>compares the working tree with any commit without a checkout: the commit tree is exported withgit archiveinto<cache>/<workspace>/base/<sha>/, indexed there once per commit, andgit diff <ref>supplies the changed spans, so the report is the same one--base-cacheprints. Repeated runs reuse the base index; at most two base trees are kept per workspace, oldest evicted first, and evicting a workspace also removes its base trees.osnova_settlegains the optionalbaseRefargument with the same behaviour. The command fails closed with a clear message whengitortaris missing, the ref is unknown, or the workspace is not a git repository;--base-cachekeeps working. Measured on this repository againstorigin/dev~3: first runbuiltin 1.96 s, second runreusedin 0.19 s, same report (130 symbol changes, 436 dependents); on a 160-file Python package againstHEAD~5: 0.90 s then 0.15 s (104 symbol changes, 706 dependents).scripts/settle-ci.shruns this native path instead of a detached checkout and no longer needs a clean tree.- A new git worktree seeds its first index from a sibling worktree's cache instead of building from zero. Worktrees of one repository form a cache family, identified by the resolved
git rev-parse --git-common-dirof a workspace that is the top level of a main or linked worktree, resolved once per build (never on the refresh path; no git means no family) and stored in afamily.jsonsidecar beside the artifact, never in the artifact. The cache key stays per worktree, so each worktree keeps its own cache, artifact and lock. On the first build of a workspace with a family and no cache,buildIndexandrefreshWorkspacecopytext.binandedges.jsonfrom the most recently used sibling whose receipts are intact (index.sha, matchingverification.jsonre-checked under the sibling's lock, core checksum, text and edge hashes), re-root the sibling core in memory and run the ordinary changed-file refresh; a contended sibling lock is skipped, never waited for.osnova buildprintsseeded from sibling worktree cache <root>: N of M files reused,onProgressgains aseedphase, andosnova doctoraddscache:family.OSNOVA_CACHE_SEED=0orWorkspaceOptions.seedFromSiblings: falsedisables it. Measured on this repository in a scratch worktree with one edited file: a cold build took 1223 ms; the seeded build reused 256 of 268 files and took 318 ms, anddiff -rof the seeded and cold cache directories reported only theaccesssidecar. - Unresolved imports of a dependency are labelled with the package and the version the lockfile pins, so a reader can tell "external dependency, expected" from "in-repo import the index failed to resolve". The
import-target-unresolvedreason is unchanged;EdgeResolutiongains an optionalexternalfield of the form<package>or<package>@<version>, and warp text printsreason: import-target-unresolved (external:vitest@4.1.5). Versions come from lockfiles alone (pnpm-lock.yaml with per-importer versions, package-lock.json v1 to v3, yarn.lock classic and berry, uv.lock, poetry.lock, Pipfile.lock, pinned requirements*.txt, go.mod require, go.sum, Cargo.lock); no node_modules, site-packages or registry is read. The nearest lockfile above the importing file wins, competing versions of one package yield no version, and in-repo imports stay unlabelled (relative paths,@/and#aliases, workspace packages, workspace Go modules, workspace crates, Python modules under a manifest root).osnova coveragesplits the reason into(external N, in-repo M), lists the top 10 packages, and addsexternalImportCallsandbyExternalto its JSON. Editing a lockfile or Python manifest re-resolves every edge on refresh. On a 340-file TypeScript package 19812 of 19852 unresolved import calls are external and the other 40 are one path alias; on a 160-file Python package all 735 are external. The yarn berry and Pipfile parsers are tested on inline fixtures only. - One
reach:line per symbol with exact counts from the index, never a score:reach: d1 callers 66 in 11 files (3 dirs); d2 +14 in 2 files; unresolved same-name 8; tests 13.d1counts incoming edges with distinct caller files and directories;d2counts incoming edges into the depth-1 callers, expanding a caller reached through two paths once and counting its file once, and stops after 2,000 edges withd2 >2000;unresolved same-nameis the unresolved evidence warp already lists;testsis the test file countosnova testsreports. Warp prints it after the symbol line in both the full and the budget-fit layout, footing prints it under each seed definition at depth 1 only, and map hotspot lines now readin N from F files. The line adds 93 to 95 code units to an unbounded warp payload and displaces at most one unresolved item inside the 2,048 budget. Additive shapes:CallersDetailedResult.reach,ContextDefinition.reach,HubEntry.inFilesand the exportedSymbolReachtypes. No ranking changes. osnova_footingseeds real definitions before one-line constants, type aliases and test-file symbols. Question hits are partitioned into preferred and fallback with no score change, and fallback fills only when fewer thanlimitpreferred hits exist; seeds are deduplicated by qualified name and omission counts stay exact. The optionalkindsargument restricts question seeds to the listed symbol kinds; an unknown kind is rejected. For "how does refreshWorkspace verify lock ownership" on this repository the eight seeds previously included four one-line testlocklocals and a one-linetaskKey; now six seeds all come fromsrc/, includingreleaseOwnedLock, the function that checks the owner before releasing. For "object schema parse unknown keys catchall" on a TypeScript package the payload moved from 9 definitions (5 one-liners, 2 in test files) with 1 relationship to 8 definitions (none one-line, none in test files) with 8 relationships and 3 candidate tests.- A function, method or class passed as a value is recorded as a
referencesedge in TypeScript, JavaScript and Python: call and constructor arguments, array and object elements, declaration and assignment right-hand sides, default parameters, return values, arrow bodies,??/||/oroperands, ternary branches, class-field initializers, and template and f-string substitutions. The identifier binds through the same lexical and import bindings a call uses; parameters, locals and unbound names produce nothing, and the resolver keeps the edge only when it reaches a function, method or class. Formatters printd1 references, and warp, reach, tests, settle and unreferenced count the edge as a use. Coverage gainsreferencesandreferencesResolvedas additive fields and a text suffix.xs.sort(comparePropertyKeys),(io.stdin ?? readStdin)()and.every(storedResult)previously showed no caller and listed the symbol inunreferenced; on this repositoryosnova unreferenceddropped from 19 candidates to 9. Not covered yet:this.memberand other property-access values, JSX tag names and attribute values,instanceof/&&operands, Python lambdas, other languages.
Changed
osnova ground,warp,footing,plumbandtestsexit 2 withosnova <cmd>: "<arg>" looks like a directory; pass the workspace with --workspace <path>when no--workspaceis given and a query or symbol positional names an existing directory and is absolute,.,.., or ends with a path separator, instead of indexing the current directory and searching for the path as text. With--workspacethe positionals are always the query, so a question that names a directory such ashow does src/query resolve namesis searched as text;thread(a regex pattern) andoutline(a file path) never apply the check, and a relative token without a trailing separator such assrc/queryis still a query.buildandcheckkeep their<root>positional.osnova settle --base-refandosnova_settlewithbaseRefseed dependents from the symbol-level changes only when a diff is available; a file edited in place whose non-blank diff lines all fall inside symbol spans no longer seeds every importer, and the report prints one linefiles changed: N; importers of changed files: M (not listed; module-level edits attribute to no symbol)whereMcounts the importers of such files that are not already listed. A changed file with a diff line outside every symbol (a module-level edit), an added, deleted or renamed file, or a changed file the diff does not mention still seeds its importers as before.ImpactResult.omitted.fileImportersis additive. On a 160-file Python package a method rename plus two body edits went from 640 dependents listed (16,384-code-unit clip reached) to 2 dependents at depth 1 withimporters of changed files: 16; the same-index path and the stop hook are unchanged.- The stop hook continues the turn at most once per diff per session, and only when the unchanged dependents outnumber
OSNOVA_HOOK_SETTLE_BLOCK_AT(default 1). It previously returneddecision: blockon every Stop event that found an uncommitted diff with indexed dependents, so each stop cost one extra full turn and the same diff fired again on every later stop although the text promised the notice fires once. On the first sighting of a diff Claude Code receiveshookSpecificOutput.additionalContext, Codex receivesdecision: "block"and Cursor receivesfollowup_message; below the gate or on a repeat, Claude Code and Codex receivesystemMessageonly, which costs no turn, and Cursor receives nothing.stop_hook_activestill guards loops. Diffs are keyed by the first 16 hex of their sha256, and hook state lives at<cacheDir>/hook-state/<sessionId>.jsonas{nudges:[], settled:[]}. The grep-nudge names moved into the same file; the previousos.tmpdir()/osnova-hook-nudgespath was a write outside the cache directory, against the read-only contract, and is gone. The state file is read-modify-write without a lock; Stop runs once per turn end so it cannot race, and two parallel grep nudges could at worst print one duplicate line, as before. osnova hook sessionprints one line by default:[osnova] Indexed: N files, M symbols; use the osnova_* MCP tools (osnova_footing first) before grep and file reads.The MCPinstructionsare the single canonical tool contract, and the SessionStart hook, theosnova setup --instructionsblock and SKILL.md now point at it instead of restating it; the contract (eight tools at the time) was previously stated three to four times per session. A hook cannot tell whether the client surfaces MCP instructions, so--full-contractrestates the whole contract for a harness without MCP; the OpenCode, Kilo and Pi plugins pass it, andosnova doctorreports an existing plugin install as differing untilosnova setup --apply --pluginis rerun.osnova setup --instructionswrites a pointer plus the two reading rules (no indexed callers is not proof of absence; an unresolved edge is a lead). Measured with every surface installed on one repository: SessionStart hook 990 to 121 chars, AGENTS block 999 to 387, SKILL.md 2429 to 1410, MCP instructions unchanged at 722; 5140 to 2640 chars in total. Tool names, argument shapes and tool descriptions are unchanged.- The prompt hook prints only the definitions the prompt named, each with its exact
file:line, followed by oneomitted: N definitions, M relationshipsline, where M counts every depth-1 edge left toosnova_footingandosnova_warp. It previously spent 62 percent of its 1,024-character budget on the seed's callee list in source order, which the agent sees again when it reads the function body, and filled the rest with related definitions the prompt never named. Callers are not sampled either: three source-order callers of a hub such asrefreshWorkspaceout of 56 would mostly be test modules. On this repository (265 files, 4,224 symbols) three prompts that name a symbol went from 1023, 1014 and 761 characters to 243, 252 and 315. osnova_settleno longer requiresdiffwhenbaseRefis given; the tool computes the diff with git in that case and usesdiffwhen both are present. WithoutbaseRefthe tool is unchanged.- A pooled full build (see Performance) roughly doubles peak resident memory: on a 340-file TypeScript package max RSS moved from 453 MiB to 841 MiB, and on a 160-file Python package from 194 MiB to 422 MiB. The pool is capped at 8 workers, which bounds it;
OSNOVA_EXTRACT_WORKERS=0restores the sequential build and its previous memory. - Warp prints each distinct list of same-name candidates once per callee name (
candidates for <name> (N, unverified): a, b, c) and groups the unresolved sites by name, depth and basis into onefile:l1,l2; file2:l3 [reason]row, so an unresolved row now has the same shape as a resolved one. Every unresolved site previously repeated its ownsame-name symbols (...)line, and a Python method with 389 unresolved same-name sites clipped--fullat the 16,384 cap with 107,351 code units omitted; the same query now prints all 402 sites in 4,342 characters. The bounded 2,048 layout applies its per-symbol line-number cap to the grouped rows and counts hidden sites in the existingcapped:notice, and a group that does not fit is omitted whole (the footer still reports it), where the old layout squeezed in one or two single-site rows. Footer counts still name unresolved evidence items, not rows;callersDetailed, the unresolved evidence array,nameMatchesand ranking are unchanged.
osnova settlewalks one level of dependents by default, the same depthosnova_settleand the stop hook already used. The CLI passed no depth when--depthwas absent, andimpactfalls back to an unbounded walk, so the same change reported the whole transitive dependent closure from the CLI and only the direct dependents from the MCP tool and the hook.--depth Nis unchanged.
Fixed
- A symlinked directory inside the workspace is skipped with a diagnostic instead of aborting the build. The scan recorded
symlink-not-indexedas a fatal error andscanFilesthrows the first error it collects, so one symlinked directory made the whole repository impossible to index; a repository that ships such a link could not be indexed at all. The link is still never followed and no file under it is indexed or watched, which is what the check was protecting.osnova buildprintsosnova build: skipped N symlinked director(y|ies); symlinks are not followed: <paths>to stderr, andScanResult.symlinkedDirectoriesis an additive sorted field. A symlinked ignore file is still a fatalignore-unreadableerror, because its contents decide what the scan excludes. .tmp-coverage-cache/is ignored. Three test files write that directory into the repository root, so it reappeared as an untracked directory after every test run and had to be removed by hand before each commit.- A Python subscripted base is recorded as that base.
class Choice(ParamType[_ValueT_co])parses the base as asubscriptnode, which the base-list walk skipped, so the class recorded no heritage at all and the inherited-member walk could not reachParamType. The base is now taken from the subscript's value. - A Python class with no base list no longer records itself as its own base.
heritagefell back to the class node when thesuperclassesfield was absent and found the class's own name identifier, so every bareclass Foo:carriedheritage: [{ kind: "local", name: "Foo" }]; the inherited-member walk then hit its own cycle guard and returned "unresolved" instead of "no bases" for members of such a class.
- Worktree cache seeding skips a sibling whose gzipped
index.jsonoredges.jsonis truncated or corrupt instead of failing the linked worktree's refresh withcache-read-failed. The sibling artifact is opportunistic, so every read, decompress, parse or verify failure inseedArtifactFromnow means build cold; the copiedtext.binandedges.jsonare removed when the post-copy hash check fails, so no half-seeded target remains. A workspace's own artifact still fails closed on corruption. - A parser is discarded after an extraction failure, so one unparseable file no longer empties every later file of the same language. A tree-sitter parser that throws mid-parse leaves its WASM instance in a state where every later parse on that instance throws too, and parsers are cached per language and shared across the whole build. On a repository with 13 shell scripts, all 13 reported
extraction-failedand the language indexed 0 symbols and 0 calls, although only 8 of them fail to parse on their own. The same repository now indexes 2 symbols and 123 calls, and the diagnostics name the 8 files that really fail instead of all 13. The underlying parse failure is a grammar defect rather than an osnova defect: a bashcasestatement throwsresolved is not a functionfrom inside the WASM parse call with tree-sitter-wasms 0.1.13 and web-tree-sitter 0.25.10. A sweep of the other 11 generic-tier languages found no other language affected. A file that fails to parse keeps its full text, so text search still reaches it; only its symbols and call edges are lost. - TypeScript variance annotations on type parameters (
out T,in T, TypeScript 4.7) no longer leave type parameters in a declaration's type hints. The pinned grammar does not know the keywords and recovers locally:out T extends ...becomes a stray error node beside a normal type parameter namedT, and<in T = never>becomes a type parameter namedinwithTinside the error child. Symbols and edges lose nothing to that partial parse (a 340-file TypeScript package produced a byte-identicaledges.jsonagainst a control copy with the keywords removed), but the binding extractor skipped any type parameter list that contained an error, so the parameters of those declarations were recorded as field-type hints such as{ kind: "local", name: "Shape" }, which the clean control never records. The extractor now walks a recovered list and reads the real name from the error child when the grammar named the parameterinorout. Thesyntax-errorsdiagnostic stays, because the files do contain constructs the grammar cannot parse. - Calls to a closure declared inside an object-literal method (
extract(tree) { const visit = ... }) resolve to the emitted nested symbol instead of ending asbound-symbol-missing: the binding collector named them under the method while the adapter emitted them on the enclosing owner. A declaration stays unresolved once the same scope assigns over its name (TypeScript writes; Python assignment, augmented assignment, for target, with alias, del, global, nonlocal), reported asbinding-blocked, andconst f = g(TypeScript) orf = g(Python) carries the declaration or import thatgnames at that point; parameters, globals, builtins, ambient declarations, reassigned names and Python defs declared later in the same body stay unresolved. On this repository resolved edges moved from 4756 to 4837,bound-symbol-missingfrom 38 to 0, andosnova unreferencedfrom 19 candidates to 13. - Calls inside decorator arguments are extracted in Python and TypeScript.
decorated_definition(Python) anddecorator(TypeScript) emitted only areferencesedge for the decorator name and never visited the decorator call's arguments, sotype=MyType()inside@click.argument(...)or a constructor inside@pytest.mark.parametrize(...)had no raw edge andwarpfound no indexed relationships. Resolution is unchanged: a callee bound through the scope chain or an import to a class becomes acallsedge to the class symbol itself, never to__init__orconstructor, and an unbound name staysunbound-global. Python attributes these calls to the scope enclosing the decorated definition and TypeScript to the decorated declaration's own frame, each matching where its decoratorreferencesedge already lives. On a 5,022-site Python corpus this added 466 call sites (2091/5488 resolved); a TypeScript corpus without decorators was unchanged. - Python methods under a shape-preserving decorator keep their declared member kind and resolve through receiver hints.
memberKindOfreturnedunknownfor any decorator other than@staticmethod,@classmethodand@property, and the resolver refuses a member call whose target kind is unknown because a decorator can turn a method into a property, a class method or any descriptor. A fixed allowlist now names decorators that provably return the function itself or a plain function wrapper:contextlib.contextmanager,contextlib.asynccontextmanager,functools.lru_cache,functools.cache,typing.final,typing.overrideandabc.abstractmethod, matched on the last name segment with or without call parentheses (@contextmanager,@contextlib.contextmanager,@lru_cache(maxsize=8)) and trusted only when the name binds to an import from the module that defines it: a bare name needsfrom contextlib import contextmanager, a dotted name needsimport contextliborimport contextlib as lib; a star import, a renamed import (as cm), a shadowed or unimported name and every other decorator still yieldunknown. Edges keep thereceiver-hintmethod and are never labelled type-proven. On a 5,488-site Python corpus this resolved 30 more calls (2088 to 2118) with none lost, includingwith formatter.section(...),with wrapper.extra_indent(...)andwith runner.isolation(...); a TypeScript corpus and this repository were byte-identical. TypeScript is untouched: its member kinds never depended on decorators, and the TypeScript corpus contains none. osnova settleattributes each added or removed diff line to the innermost indexed symbol containing it (latest start line, then start column, then narrowest span). It previously attributed a line to every symbol whose span contained it, so a one-line edit inside a method marked the enclosing class and any outer functions as changed and the dependents list became the fan-out of the whole class. Context lines never attribute; enclosing classes and outer functions change only when a line outside their children changes. On a three-line rename inside a Python CLI library, symbol changes went from 6 to 3, dependents from 58 to 1 and the payload from 5,842 characters (clipped at 4,096) to 745; thediff N context lines short, treated unchangednote and the impact API shape are unchanged.
Performance
- A no-change refresh in a fresh process no longer decodes the core body.
loadIndexandrefreshWorkspaceverifyindex.shaagainst the raw core bytes as before, then parse only the envelope (format, extraction version, root, text and edge identities) from the header prefix; the file and symbol tables are decoded and validated on the firstfiles,symbols,diagnosticsor edge access, and a checksum-valid core whose body fails validation reportscache-read-failedthere instead of at load. The generation of a loaded index is the verified checksum, the edge sidecar is hashed once instead of twice, and an unchanged refresh leavesverification.jsonuntouched. Every checksum stays on the load path and the artifact bytes are unchanged (diff -ragainst a cache written before the change reports onlyaccess). Measured in a fresh process on an unchanged 340-file TypeScript package with a warm cache:refreshWorkspacemedian 31.65 ms to 13.38 ms (n=15); of the remaining time the directory walk and stat of 340 files is 6.6 ms and the two section hashes 2.0 ms. - The full build pipeline is faster at every phase, with the structural artifact byte-identical before and after each change (
diff -rof the cache directories reports only theaccesssidecar). Each change was measured on one machine against the preceding baseline, on a 340-file TypeScript package (49364 edges) and a 160-file Python package. The artifact is serialized once per build instead of twice, and each edge is canonicalized once instead of four times:saveArtifact260.9 ms to 127.9 ms, in-processbuildIndex2802 ms to 2525 ms on the TypeScript package. Resolution looks symbols up by qualified name through one per-file map built once per pass instead of filtering every symbol of the file per member lookup: the resolve phase 833 ms to 138 ms and the build 2412 ms to 1681 ms on the TypeScript package (CLI wall 2457 ms to 1819 ms), withosnova coverage --jsonidentical before and after.childrenOfis memoised per tree and released with the tree, and reassignment tracking walks each file once instead of once per binding name: in-process build 2889 ms to 2141 ms on the TypeScript package and 773 ms to 320 ms on the Python package, with sampled peak heap 232 MiB to 156 MiB and 75 MiB to 69 MiB. Full builds of at least 32 files then extract onmin(availableParallelism - 1, 8)worker threads and merge results in path order: CLI wall 1.70 s to 0.75 s on the TypeScript package and 0.44 s to 0.28 s on the Python package (in-process extract phase 1025 ms to 318 ms), with workers terminating when extraction ends. A refresh keeps sequential extraction below 64 changed files because worker startup is paid on every call: measured withrefreshWorkspaceon a 702-file repository the pool lost at 32 and 48 changed files, broke even at 64 and won from 96 up (128 files: 470 ms against 625 ms).OSNOVA_EXTRACT_WORKERS=0disables the pool and=<n>fixes its size for both paths. After a pooled build the first sequential refresh in the same process loads the grammar on the main thread once, which shows in the syntheticpnpm perfincremental step as about 40 ms to about 55 ms; a process that loads its index from cache pays the same warm-up with or without the pool. - The CLI and the hooks no longer load the MCP SDK at startup. The bundle inlined the lazily imported MCP server into
dist/bin.jsanddist/cli.js, so both statically imported@modelcontextprotocol/sdkon every invocation; the SDK now lives in a shared chunk that onlydist/index.jsanddist/mcp.jsimport statically. Measured on one machine (Node 26.9.0, median of 5):await import('dist/bin.js')48.8 ms to 12.8 ms,osnova check .on a warm cache 124.4 ms to 85.2 ms wall. Runtime export keys of every entry are identical to the unsplit build,dist/bin.jsalone carries the shebang, andosnova mcpstill answersinitializeover stdio with no stderr output. - The library entry
dist/index.jsno longer loads the MCP SDK at import time.index.tsre-exportedcreateOsnovaMcpServerandrunMcpStdiostatically from the MCP server module, so everyimport("@getdomovoi/osnova")paid for@modelcontextprotocol/sdkand its schema library (36 ms alone in a fresh process) before any query ran. Both exports keep their names and signatures:runMcpStdionow imports the server module on first call, andcreateOsnovaMcpServerloads the siblingmcp.jsentry synchronously throughcreateRequire(Node 22.13+require(esm), sharing the module cache with the already loaded chunks). Measured in a fresh Node child, 10 samples each, median: coldimport("dist/index.js")50.9 ms to 14.3 ms; the same import undernode --import tsx97.7 ms to 46.8 ms;dist/cli.jsunchanged at 13.3 ms.osnova checkno-change wall on a 340-file TypeScript package is unchanged (108 ms to 105 ms, 5 runs, noise) because the CLI never imported the MCP chunk. Two builds of that packagediff -rto only theaccesssidecar. The package smoke now imports the root entry under a resolve hook that fails if any@modelcontextprotocolmodule loads, and callscreateOsnovaMcpServerfrom the root entry. - Text search rows are clipped to about 120 code units centred on their matches, with an ellipsis at each clipped end and never a cut inside a match, and matches that share a file and line collapse into one row listing their columns (
file:line:29,46: text). One 5,406-character documentation line previously took 35 percent of a payload, and every extra match on a line repeated the line. Header counts and omission notices still count matches;findTextDetailedstill returns the full line text andFindTextMatchgains alengthfield. Measured with the built CLI:maxCodeUnitson this repository 15,223 to 8,710 chars (105 to 96 rows);catchallon a TypeScript package 15,519 to 13,466 chars (182 to 167 rows). - Warp groups edges by caller symbol (callee for
direction: out) and hoists the file, basis and sharedviahops once per group, listing call sites as a line list:d1 calls src/cli/hook.ts#runHook:160,172,187,202 [import-binding]. One row per edge with a repeatedvialine under each previously let a hub spend the whole 2,048-code-unit MCP budget on a few dozen edges.viaand receiver hint lines are hoisted only when every edge in the group shares them and otherwise print per site list; groups sort by depth, file, qualified name, basis and first site, and the bounded footer counts edges, not groups (omitted: 70 of 258 confirmed edges). The unresolved evidence section is unchanged. Uncapped characters and edges shown inside the MCP budget:refreshWorkspace(56 edges) 6724 to 2318 chars, 25 to 56 shown;childrenOf(267 edges) 23311 to 4850 chars, 18 to 72 shown; a 258-edge constructor on a TypeScript package 31136 to 2686 chars, 11 to 188 shown.callersDetailedis unchanged. osnova_settleprints the first 16 hex of each dependent's receipt, matching the generation digits, and maps each uncertainty note to a short phrase; every fact in the line stays and unknown tokens print verbatim. Seven dependents previously cost 511 chars of 64-hex hashes, and theuncertainty:line was 363 chars on every call, 66 percent of a zero-change payload. Measured over MCP on a 7-dependent diff: dependent lines 789 to 453 chars, uncertainty line 363 to 317, whole payload 1416 to 1034; a zero-change diff on a TypeScript package 553 to 507.impact,uncertainty.notesand the stored 64-hex hashes are unchanged, and the stop hook prints no receipts.osnova_groundandosnova groundfold hits that are nested locals of another hit in the same result into one trailing line on the parent block (also: .canonicalRoot L74, .task L87) instead of repeating the parent's body under each.askandaskDetailedreturn the same hits in the same order, folded children still occupylimitslots, a nested hit whose parent is not shown prints in full, and children fold into the outermost ancestor present. Measured over MCP at the default limit of 8:refreshWorkspace2607 to 1281 chars,boundText2449 to 1834,collectBindings3446 to 1670; a query on a TypeScript package with no shown parent is unchanged at 1609.
Breaking
- The extraction version becomes
structural-9.25, so every existing cache rebuilds once on the next load. Two changes in this release alter extraction: the newextendskind, and Python decorator factories, which now extract ascallsrather thanreferences. The artifact format stays 10 and the edge tuple shape is unchanged; the newextendskind is appended to the edge-kind table, so the existing indices forcalls,referencesandimportskeep their values. The rebuild is the ordinary extraction-version path: the stale artifact is ignored and the index is built again, with no error surfaced to the caller.
- The CLI
warpcommand uses the same 2,048-code-unit bounded layout asosnova_warp; it was unbounded before.--fullon the CLI and the additivefull: trueargument onosnova_warpprint every site with no cap and no summary; that output is still subject to the existing 16,384-code-unit presentation cap and its clipping notice. When the grouped text fits the budget the output is byte-identical to before. On overflow the layout is summary-first: asummary:line with sites and files per directory (directories whose files are all tests last), avia (all):line when every re-exporting group shares the hop, groups ordered by depth, non-test before test, then file and name, each site list capped at the largest count of line numbers that lets every group fit (,+N more), remaining groups folded into one<file>: +N symbols, M edgesline per file, and acapped:line stating the cap, hidden sites and folded edges. The footer still counts edges exactly. Inside the MCP budgetchildrenOf(267 edges) went from 71 of 267 edges represented to all 267 (11 sites printed, 16 hidden, 240 folded), and a 258-edge constructor on a TypeScript package from 184 of 258 to all 258 (170 sites printed, 88 hidden at cap 45);fulloutput for those symbols is 4980 and 2804 chars. - Artifact format 10. The interned edge evidence table gains the
externalfield described under Added, so every cache built with format 9 rebuilds once on the next load.
0.6.3 (2026-09-18)
Fixed
- A Rust
self.fieldwhose declared type is not a struct parameter is a field owner again, soself.globs[i].is_only_dir()and a closure overself.globs.iter()resolve through the holder's element tables. 0.6.2 took the impl-argument path for everyself.fieldand lost ten ripgrep sites while gaining twenty-eight, which the coverage total showed as +18.
Added
- A call on a construction expression takes the constructed type as its receiver:
new GsonBuilder().create()andnew TypeToken<T>() {}.getType()in Java,new Server().Start()in C#,new(T).M()and&T{}.M()in Go,T {}.m()in Rust, and every hop of a chain that starts there. gson gains 830 resolved call sites (32.7% to 36.2%, 51.2% to 56.7% excluding externals) with no site lost; Humanizer gains 6; the other corpora are unchanged. Extraction versionstructural-9.19. - Rust paths through a facade crate resolve:
pub extern crate grep_printer as printer;(orpub use grep_printer as printer;) in a crate root letsgrep::printer::ColorSpecscontinue from the aliased workspace crate. A field or parameter typed by a full path (colors: grep::printer::ColorSpecs,std::path::PathBuf) binds through that path, so a standard-library path type now counts as external instead of an unidentified receiver. - A
useinside an inline Rust module binds the names that module uses:mod tests { use grep_regex::RegexMatcher; }no longer losesRegexMatcher::new(..)to a same-named type elsewhere, anduse super::Xthere names the file itself. ripgrep gains 178 resolved call sites (41.0% to 42.3%, 58.1% to 65.4% excluding externals) with no site lost; the other corpora are unchanged. - A Rust tuple variant (
Kind::Io(err)) is indexed as a static member of its enum, so the constructor call resolves and a chain on it continues. A plain call (Ok(x),Some(x),drop(x)) never takes an impl method or a variant by name alone; the prelude owns those names. - Rust module paths in calls resolve through the module:
flags::parse::lookup()besidemod flags;,super::render()from a child module,self::parse::lookup(),grep::cli::stdout()through a facade,use crate::hyperlink::{self, X}binding the module name, and a crate whose[[bin]]or[lib]pathkeeps its root outsidesrc. A path call the crate does not define (std::io::stdout(),hir::Class::Bytes(cls)) is external now instead of a same-named function matched by luck. ripgrep: 42.3% to 45.7% resolved, 65.4% to 71.6% excluding externals; per edge, 559 gained, 27 moved to the right definition, 89 dropped a wrong or lucky match (16 of those were right: a local alias of a method,let (or, and) = (GramQuery::or, GramQuery::and)). Extraction versionstructural-9.21. - A call rooted at a global object the file never binds (
console.log(x),Object.keys(x),JSON.stringify(x),Math.floor(n),Object.prototype.hasOwnProperty.call(..)) counts as external, beside plain calls to unbound names, instead of reading as a receiver the syntax could not identify. A file that binds the name itself, by import or by its own class, still wins. 2388 call sites move across the three JavaScript and TypeScript corpora with no edge gained, lost or retargeted: zod 63.9% to 67.7% excluding externals, pyright 73.2% to 74.4%, Humanizer 47.6% to 55.5% on its JavaScript. Extraction versionstructural-9.22. - A module constant declared as an alias of a method (
const stringType = ZodString.create) names a callable, not a value. The declaration records the method it holds, and a call on the constant's result now takes that method's return type, so the chain continues. The alias resolves against the file that declares it, not the calling file. A constant whose aliased name is a field, or names no member, keeps no alias. 1096 call sites gained on zod with none lost or retargeted, 39.8% of its TypeScript resolved against 37.7%, and 71.4% excluding externals against 67.7%. The other six corpora are unchanged. Extraction versionstructural-9.23. scripts/resolution-levers.mjs: samples the locally unresolved call edges of one or more corpora, asks a System One model which missing piece of information would resolve each one, and ranks the levers by estimated call sites. Review aid only.scripts/resolution-diff.mjslists every call edge whose resolution changed between two osnova refs on one corpus, the per-edge check behind the fix above; withTYPESAFE_API_KEYset it also asks a System One model which target each disputed call invokes, as a review aid only.
Changed
osnova setup --apply --hooks --client codexsays in its notice that Codex skips new hooks until they are trusted in/hooks; the README and reference say the same.osnova setup --applynow prints each written change's notice under its line, as--previewalready did. Codex records trust per hook hash, so the three osnova entries run only after that step, and osnova cannot trust them on the user's behalf.
0.6.2 (2026-09-18)
Added
osnova hook tool, an opt-in Claude CodePostToolUsehook onGrep|Bash(osnova setup --apply --hooks --nudge,osnova hook install-preview --nudge): when the agent greps for one identifier or method name that names an indexed definition with resolved callers, it adds one line with the count of resolved call sites and files and theosnova_warpcall that lists them, once per name per session, and nothing otherwise. Measured headless it fired as designed and did not change the model's tool use, so it stays out of the default hook set; the README states the numbers.
- A call on a type parameter with a trait or interface bound resolves to the bound's method: Rust
impl<M: Matcher> Core<M>andfn f<T: Runner>(t: T)or awhere T: Runnerclause, plusimpl Runnerparameters; Java<T extends Runner>; Go[T Runner]; C#where T : IRunner. Marker bounds (Clone,Cloneable,class,any) name nothing. A Rustself.fieldwhose declared type is a struct parameter takes the impl block's argument in that position. Rust trait method signatures now carry a member kind and return type. The ripgrepLineTerminator.as_bytetruth set reaches 26 of 26. - Rust
if let Some(x) = e,while let Some(x) = eand aSome(x) =>orOk(x) =>match arm bindxto whatewraps: the inner type of anOptionorResultparameter or local, or the unwrapped return of a call. A Rust path whose head names another crate of the same Cargo workspace (grep_matcher::LineTerminator, by the crate's[package] name) resolves against that crate'ssrc, and apub useat a crate root is recorded as a re-export the resolver follows, so a type a crate re-exports from a submodule reaches its definition (ripgrep 29.6% to 40.8% of call sites, 39.7% to 50.7% excluding externals).
osnova doctorreports each installed plugin or skill file (plugin:opencode,plugin:kilo,plugin:pi,skill:claude-code): ok when it matches the file this osnova ships, a warning naming theosnova setup --applycommand that refreshes it when it differs.
Fixed
osnova hook prompt --client codexandhook session --client codexprint{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit"|"SessionStart","additionalContext":..}}, the object Codex validates. The bare{"additionalContext"}shipped in 0.6.0 made Codex reporthook returned invalid user prompt submit JSON outputon every prompt. The stop hook's{"decision":"block","reason"}was already the shape Codex accepts, and Codex sendsstop_hook_active, so the settle notice fires once per stop as on Claude Code.
Changed
- The session hook on a repository with no cache starts the background build and waits up to three seconds for it (
sessionWaitMs), so a small repository answers its first prompt with starting points instead of a notice that the build is running. - A receiver annotated with a language builtin (
string,number,Array,Map,Promise,str,list, and the rest) counts as external even when a same-named function exists in the repository (zod'sstring()factory made everystring-typed receiverreceiver-unresolved); a class or interface of that name still resolves. Resolved edges unchanged; zod 63.6% to 63.8% and ripgrep 57.4% to 58.0% excluding externals. - The stop hook's reason groups dependents by file, at most six symbols per file with the rest counted, sorted by how many a file holds, and when the budget runs out it says how many files and dependents were left out and that
osnova_settlelists them all, instead of clipping mid-line with a query notice. osnova coveragecounts two more shapes as external, since neither can ever resolve to an indexed definition: a call on a literal or on a local assigned from one without an annotation (Pythonstr,list,dict,set,tuple; TypeScriptstring,Array,RegExp), and in Go, Rust, Java and C# a plain name that no indexed file of the language defines (new IllegalArgumentException(..),len), which used to count asno-matching-symbol. Resolved edges are identical before and after on every corpus (checked edge by edge on zod and pyright). Excluding externals: click 54.9% to 57.2%, cobra 77.8% to 88.9%, gson 37.9% to 51.2%, humanizer 40.8% to 50.1%, pyright 71.8% to 72.4%, ripgrep 51.4% to 57.4%, zod 63.1% to 63.6%.- In Go, Rust, Java and C# a reassignment no longer unbinds a receiver: a name's static type is fixed for its lifetime in those languages, so
s = nil; s.Start()andtype = TypeToken.get(..); type.getRawType()resolve as their declared types (cobraCommand.Roottruth set 28 to 30 of 30, gsonTypeToken.getRawType26 to 27 of 27). A closure inside a method sees the method's receiver (self.as_byte()inside amap_orclosure). A Rustuseinside an inlinemod testsbinds in that module only, so itsuse super::Xno longer shadows the file's own import or definition ofX, and a brace-rooteduse { a::B, c::D }binds each item. Extraction version moves tostructural-9.18; caches built by 0.6.1 are rebuilt. - The Kilo plugin and the Pi extension were run end to end (
kilo runandpi -pin this repository withOSNOVA_BINpointing at a logging wrapper): each callsosnova hook sessionat start andosnova hook prompton the message, and the model answered normally. The Codex hooks file is written but has not been run live. - The default cache keeps 32 workspaces before evicting the least recently used, up from 8, under the same 256 MB byte cap. Eight was low for a machine with a dozen repositories and cold builds followed every eviction.
- A cache lock acquisition polls again when creating the lock directory or reading its owner file fails with a transient code (
EPERM,EBUSY,EACCES,ENOTEMPTY,ENOENT), which Windows reports for the moment another process is renaming or removing an abandoned lock. It used to fail the whole operation withcache-lock-failed, which is the CI flake seen on the workspace-refresh recovery test. - The test suite indexes into a temporary cache directory (
OSNOVA_CACHE_DIRset invitest.config.ts) instead of the user's default cache, where its temporary workspaces filled the workspace cap and evicted real repositories, which is why the session hook kept reporting a missing cache on this checkout. - The prompt hook seeds only from names the prompt spells as code (a backticked token, or an identifier with an inner capital, underscore or digit), matched exactly against indexed definitions, instead of a ranked text search over the whole prompt. A prose prompt that contains a word such as
loadorwithinno longer prints an unrelated definition of that name.
0.6.1 (2026-09-18)
Added
osnova setup --apply --skillcopies the shipped Claude Code skill (integrations/claude-code/skills/osnova/SKILL.md) to~/.claude/skills/osnova/SKILL.md. The skill states the order of work (osnova_footingfirst,osnova_warpfor callers,osnova_plumbon any claimed list of call sites,osnova_settlebefore finishing) and the rules the trials taught (do not re-read a file the tool already quoted; no indexed callers is not proof of absence). Claude Code loads it only when the task matches its description: measured headless, never when the prompt names the MCP server, first in three of four runs when the prompt does not, at 14 percent more cost and one more perfect answer. A differing file is a conflict and is never overwritten.benchmarks/exactness/exactness-v1.jsongains four hand-verified call-site sets on ripgrep (Searcher.line_terminator,LineTerminator.as_byte) and gson (JsonReader.beginObject,TypeToken.getRawType), so the grep-versus-graph table covers Go, Rust and Java as well as Python and TypeScript;benchmarks/results/grep-vs-graph-2026-09-18.jsonrecords all nine on the released 0.6.0 code, including the two sets where the text search beats the graph on recall.
0.6.0 (2026-09-18)
Added
- A function or method with no return annotation whose every own return is
new Foo()(TypeScript and JavaScript),Foo()(Python) orthisrecords that result asreturns; anasyncone records it asunwrapped. Returns that disagree, a barereturn, a generator, or a return inside a nested function leave it unknown.const service = createAnalyzer(); service.setOptions(..)resolves whencreateAnalyzerends inreturn new AnalyzerService(..)(pyright +100 sites).
osnova hook prompt,osnova hook sessionandosnova hook stop: Claude Code hooks that read the payload on stdin.promptprints starting points for the prompt (definitions and relationships fromfooting, callable and holder kinds only, under 1,024 code units);sessionprints the tool contract and the index size;stopdiffs the worktree againstHEADand, once per stop, returns ablockdecision whose reason lists the indexed dependents of the changed symbols (under 1,536 code units), so the agent checks them before it finishes (OSNOVA_HOOK_SETTLE=offdisables it). A repository with no cache is indexed in the background by the session hook; the prompt and stop hooks answer only from an existing cache. Nothing is printed on a slash command, a prompt under twelve characters, or any failure, and every hook exits 0.osnova hook install-previewprints the settings snippet. The CLI wrapper now carries a caller-supplied stdin reader through to commands.osnova setup --applywrites what--previewshows: the MCP entry for any client,--hooksfor the three Claude Code hook groups in~/.claude/settings.json(added only when no group already runs thatosnova hook <event>; other keys and the file's indent are kept), and--instructions <file>for a tool-contract block appended once between<!-- osnova:start -->and<!-- osnova:end -->markers. Every changed file is backed up first as<file>.bak-osnova-<stamp>, a second run reportsunchangedand writes nothing, and one conflict stops the whole apply before any write.- The MCP server sends the tool contract as
instructionson initialize, so every client that honours MCP instructions carries it in the system prompt without a hook. osnova hook --client codexanswers withadditionalContextJSON and--client cursoranswers the stop hook with afollowup_message;osnova setup --apply --hooks --client codex|cursorwrites~/.codex/hooks.json(session, prompt, stop) and~/.cursor/hooks.json(stop only, since Cursor's prompt hook cannot add context).osnova setup --apply --plugin --client opencode|kilo|picopies the shippedintegrations/opencode/osnova.jsplugin orintegrations/pi/osnova.tsextension into the client's plugin directory; both shell out toosnova hookfor the contract and starting points (OSNOVA_BINoverrides the executable) and are never overwritten once present.osnova doctorreads~/.claude/settings.jsonand~/.claude.json, runs--versionon every osnova hook and MCP command they configure, and warns when one reports another version than the doctor itself (client:hook,client:mcpchecks).- Hooks and
osnova mcpwithout--workspaceresolve the workspace to the git top level of the starting directory, so a client started in a subdirectory indexes the repository.
Changed
- Artifact extraction version moves to
structural-9.17, so caches built by 0.5.0 are rebuilt with the PythonNoneunion handling and the constructor-literal return inference. - Measured hook latency on the pinned pyright checkout (7,653 files): the prompt hook answers in about 1.5 s from a warm cache (most of it verifying file hashes), the session hook in 0.4 s, the stop hook on a clean tree in 0.2 s, and the first build takes 6 s in the background. No shared warm process yet; the numbers did not call for one.
- Python
X | None,None | X,Optional[X],t.Optional[X]andUnion[X, None]nameXin parameter, return, field and collection annotations, as TypeScript already stripsnullandundefined; a union of two or more real types names no receiver. Overloads that differ only by| Nonenow agree, soget_current_context()in click binds its result and thectx.invokesites indecorators.pyresolve (click 38.2% to 38.5%). eslintignores.claude/**, so the publish gate runs unaided next to agent-installed helpers, andbinpoints atdist/bin.jswithout the leading./that npm normalized with a warning.
0.5.0 (2026-09-18)
Fixed
- A receiver chain nested deeper than eight owners (field and element chains such as
state.workspace.service.clone().getConfigOptions().executionEnvironments[0].extraPaths[0].toString()in pyright) was written toedges.jsonbut rejected by the cache validator on the next load, so every query in a fresh process failed withcache-read-failedwhile the process that built the index still answered. Extraction now caps owner nesting at twelve (the resolver follows six hops) and the validator accepts sixteen. Artifact extraction version moves tostructural-9.16so affected caches rebuild.
Added
osnova_warpandwarpno longer repeat a relationship's ownfile:lineassource file:lineoninresults (everyinsite is its own source);outresults keep the suffix because the site and the target differ. About 40 code units per relationship inside the unchanged 2,048 budget.scripts/coverage-corpora.mjsloads every corpus back from the cache it just wrote, decodes the edges and re-measures coverage on the reloaded index, so a binding shape the serializer accepts but the validator rejects fails the measurement instead of a fresh MCP process. The record carriescacheRoundTripper corpus. On the code before #42 the pyright corpus fails this check withcache-read-failed.osnova mcp --watch: a recursive file watcher marks the index stale on change and refreshes after a short debounce, so a query reuses the last verified index instead of hashing the working tree first. A verification older than 30 seconds, a change seen since it, or an unavailable watcher falls back to the per-query refresh. Changes under ignored directories such asnode_modulesand the cache directory are skipped.- A composite GitHub Action (
action.yml, backed byscripts/settle-ci.sh) that indexes the pull request base and head at the same path, runsosnova settle, writes the dependents of the changed symbols to the job summary and optionally posts them as a comment. The repository runs it on its own pull requests through.github/workflows/settle.yml. - The README shows
plumbchecking a claimed caller list on the pinned click checkout, with the verdict semantics spelled out. benchmarks/exactness/exactness-v1.jsonpins five hand-verified call-site sets on public checkouts, andscripts/exactness.mjscompares the first regex a person would type against the resolved call graph on each; the README carries the measured table andbenchmarks/results/grep-vs-graph-2026-09-17.jsonthe record.- Unresolved call edges in
osnova_warpandwarpcarrynameMatches: the indexed functions, methods and classes in the same language family that share the call's name, capped at five with the total, printed assame-name symbols (N, unverified): ....plumbprints the same count and list onname-onlyverdicts. A same-name list is a reading list, never a resolution.
Changed
- A receiver chain that ends on a type no indexed file declares (
s.names[0].trim()onstring[],map.get(k).trim(),label().trim()on(): string, a Rust&stroru32parameter, a Go[]stringelement) now classifies asunbound-global, and one that ends on a type behind an unresolved import asimport-target-unresolved, so both leave the excluding-externals denominator instead of counting asreceiver-unresolved. A chain that ends on a type parameter of an enclosing declaration stays unresolved (a parser-recovered type parameter list is ignored). TypeScript primitive annotations (string,number,boolean,symbol,bigint) bind as builtin receivers;anyandunknownstay unknown. RustBox<T>,Rc<T>,Arc<T>,&Tanddyn Traitbind the pointee, so a call through a boxed trait object resolves to the trait method. Gonew(pkg.T)constructs apkg.Tthrough the import. - Collections carry their contents: class and interface symbols record
elementTypes(Foo[],Array<Foo>,Set<Foo>,Iterable<Foo>,list[Foo],Sequence[Foo]) andvalueTypes(Map<K, Foo>,Record<K, Foo>,dict[K, Foo],Mapping[K, Foo]) per field, and functions recordelementsandvaluesfor a returned collection.for (const x of xs),for x in xs, comprehensions,xs.forEach((x) => ..),xs.map/filter/some/every/find/flatMap((x) => ..),xs[i],map.get(k),map.forEach((v, k) => ..),map.values()andxs.filter(..).slice(..)pass-throughs bind the element or value; a local that aliases a member chain (const list = store.items) or an indexed element takes that owner. A collection bound by an alias, a destructured loop variable, a Map iterated directly withfor..of, and builtin functions such assorted(xs)stay unresolved. Go, Rust, Java and C# record the same tables from[]T,map[K]V,Vec<T>,HashMap<K, V>,&[T],List<T>,Map<K, V>,T[],Dictionary<K, V>,IEnumerable<T>and friends;for _, x := range xs(the second range variable), Rustfor x in &xsandxs.iter(), Java enhancedforandxs.forEach(x -> ..)orxs.stream().filter(x -> ..), C#foreachandxs.ForEach(c => ..)or LINQWhere,SelectandAnylambdas,xs[i],m[k],list.get(i),map.get(k)andHashMap::get(k).unwrap()bind the element or value the same way. Artifact extraction version moves tostructural-9.15. - Functions and methods carry
unwrapped: the value type inside aPromise<T>orPromiseLike<T>return annotation (TypeScript), an async function's declared result (Python), or the first type argument of aResultorOptionreturn type (Rust, includingio::Result<T>).await f(),const x = await f(),f()?,let x = f()?;,f().unwrap()andf().expect(..)then resolve calls on the inner value;f().m()without the unwrap still does not. Artifact extraction version moves tostructural-9.13. import * as ns from "./x"; export { ns }(andexport { ns as default }) is recorded as a namespace re-export, soimport { z } from "zod"; z.string()resolves through the barrel, and a call on the result of a namespace member (ns.make().m()) follows that export's declared return type. A type position (return annotation, heritage clause, field or parameter annotation) now names the interface or type when a const shares its name, sofunction number(): ZodNumberbinds to theZodNumberinterface next toconst ZodNumber. Overload declarations reached through an export map must agree on the return type, as same-file overloads already had to. On the pinned zod checkout this resolves 3874 more call sites (30.4% to 37.6%).- Field chains resolve: class, interface and struct symbols carry
fieldTypes(the declared type of each typed field as a local or import binding), and a member call on a field of an identified receiver (this.pool.conn.send(),param.field.m(),make().field.m(), Gos.pool.Conn.Hit(), Rustself.rdr.fill(), Java and C#this.pool.conn.hit()) follows the holder's field type, across files and through the single-base heritage walk. On the pinned checkouts this resolves 964 more call sites in pyright and 250 more in ripgrep. Artifact extraction version moves tostructural-9.12. - A generic instantiation or an array names its base type as the receiver (
Wrapper<Foo>is aWrapper,Foo[]is anArray,list[Foo]is alist,Vec<T>is aVec) in every language with receiver hints, so user generics resolve and builtin containers count asunbound-global; a type name no indexed file declares is external for TypeScript and Python too. Go methods on generic pointer receivers are indexed. Artifact extraction version moves tostructural-9.11. - Go functions and methods with a multi-value result list carry
returnTuple, anda, b := f()binds each name to its position, socmd, err := c.Traverse(args)followed bycmd.Root()resolves. Artifact extraction version moves tostructural-9.10. super.m()in TypeScript, Java and C# (base.m()) andsuper().m()in Python resolve to the single declared base of the enclosing class through the heritage walk; more than one base stays unresolved. Java and C# classes record heritage. Artifact extraction version moves tostructural-9.9.- Go, Rust, Java and C# member calls carry receiver hints: typed parameters, typed locals, constructor literals, declared return types (including chains), struct and class fields,
this,self, the Go method receiver, static access through a type name, and unqualified calls inside a Java or C# class. Methods carrymemberKind(Go instance; Rust static or instance byself; Java and C# by thestaticmodifier) andreturns. Go package imports resolve throughgo.modto the package directory with package-wide export lookup; Rustcrate::,super::andself::paths resolve against the nearestCargo.toml; Java imports resolve toa/b/Name.java; a type declared exactly once in the language family resolves without an import, and a type no indexed file declares counts asunbound-global. A member call whose receiver is unknown is nowreceiver-unresolvedinstead of a name-only match, so resolved counts fall where the old name heuristic guessed. Artifact extraction version moves tostructural-9.8. - Python class-body annotations (
conn: Conn,other: mod.Conn = make()) and@propertymethods with a return annotation type the field, soself.conn.send()andself.link.send()resolve when the field is written at most once. Artifact extraction version moves tostructural-9.8. - Bare import specifiers resolve to workspace packages: a
package.jsonnameplus itsexportsmap (every condition is tried, source files first;*patterns are expanded) or itsmodule,mainandtypesfields, withsrc/indexandsrc/<subpath>as fallbacks. Python absolute imports resolve through every directory that holds apyproject.toml,setup.pyorsetup.cfgand through that directory'ssrclayout. Two packages with the same name stay unresolved.node:builtins and packages outside the repository stayimport-target-unresolved. - A file named
package.jsonis scanned even when a repository ignore rule matches it, since a manifest is needed to map the package name; configured output and dependency directories such asnode_modulesanddistare still skipped.
0.4.0 (2026-09-17)
Added
osnova_plumband the CLI commandplumb: check a claimed list ofpath:linecall sites for a symbol against the index. Verdicts per site are confirmed, name-only, no-call or not-indexed, plus the resolved dependents the list left out.osnova coverageandresolutionCoverage: call-site resolution coverage per language, by method and by reason, with the share among call sites not blocked by an unresolved import shown beside the plain share.scripts/coverage-corpora.mjsrecords it on the pinned checkouts; the README carries the measured numbers.
Changed
- Call resolution follows
export * as namenamespace re-exports, soname.member(...)through a barrel resolves to the declaring symbol. - Python parameters annotated with a class name (
ctx: Context,ctx: mod.Context) act as instance receivers, soctx.method()resolves to that class's method. Unions,Optional, string annotations and reassigned parameters stay unbound. Artifact extraction version moves tostructural-9.8; older caches rebuild. - TypeScript type annotations on parameters, class fields, constructor parameter properties and
constorletlocals act as instance receivers, and interface method signatures and function-typed property signatures are indexed as members, soreader.read()resolves whenreader: Reader. - Members are found through declared inheritance:
extendsclauses on classes and interfaces (TypeScript) and base classes (Python) are followed for up to eight hops when the receiver's own class lacks the member.implementsclauses are not followed. The walk stays unresolved when a base cannot be identified, when two base chains supply different members, when the chain cycles, or when the class declares a non-method field of that name. Symbols carryheritageandfieldslists. Type-only imports andreadonlyconstructor parameter properties supply receivers; static fields, fields written more than once, and fields assigned only inside a nested function do not. - A field assigned exactly once in the constructor from a constructor call (
this.client = new Client(),self.client = Client()) acts as a receiver forthis.client.method()andself.client.method(). - TypeScript
namespaceandmoduleblocks are indexed asmodulesymbols with their members under them (Uri.createis afunctionunderUri), soUri.create()and a call through a namespace merged with a class or interface resolve. A namespace function is reachable through the namespace name only, never through an instance. Nested namespaces resolve when the outer name is declared in the same file. - Declared return types act as receivers:
make().hit(),const x = make(); x.hit(),this.build().hit()and chains such asbuilder().trim().make().hit()resolve when each callee's return annotation names an indexed class or interface (TypeScript: Fooand: this, Python-> Fooand-> Self). Symbols carryreturns. Unions, generics such asPromise<Foo>, string annotations, unannotated callees and reassigned locals stay unbound. A call on a value produced by an import the index cannot resolve now counts asimport-target-unresolvedrather thanreceiver-unresolved, and a call to a name with no binding in the file (a builtin or ambient global) counts asunbound-global;osnova coveragereports both counts and a share that excludes both (resolvedShareExcludingExternal). osnova_groundandosnova_footinginline whole definitions of 40 lines or fewer; the footing budget is 4096 code units; the response prefix is shorter.
Fixed
- Cache lock recovery: a transient failure while removing the recovery marker could leave a dead lock unrecoverable until timeout, and Windows could refuse the rename while another waiter held a handle. Recovery now yields and retries, and the marker is always removed.
- The clean-install smoke retries temporary directory cleanup on Windows.
- Tool count, budgets and heritage wording in the README and reference match the shipped behavior; CLI
plumbshares the 4,096 code-unit budget.
0.3.0 (2026-09-17)
Added
osnova --version.osnova setup --preview --client <name>: a unified diff against the client's real global config (Claude Code, Codex, OpenCode, Kilo, Cursor, Pi) that adds the oneosnovaentry and nothing else. Read-only. Reports unchanged or conflict when an entry exists.SECURITY.md, issue templates and a private security report link.
Changed
osnova_warpacceptsClass.methodwithout the file prefix when it names one symbol; several matches come back as an ambiguous candidate list as before. Errors fromwarpandoutlinenow namethreadandgroundinstead of a retired tool.osnova_settleaccepts a diff whose hunks are short by the same number of old and new lines, which can only be dropped context, and recordsdiff-short-by-N-context-lines-treated-as-unchangedin its notes. A hunk short on one side only is still rejected, now with the hunk line and the missing counts.osnova_settleon a large repository dropped from about 2 s to under 50 ms;osnova_footingon a warm index from about 0.4 s to 0.37 s. Both walk edges in artifact order instead of sorting by a canonical JSON key.- Search keeps plain sentence words out of the exact-identifier tier, so a prose question no longer promotes tiny symbols named after common words. Words still enter that tier when they look like identifiers, are quoted, or are the whole query. Recall and reciprocal rank are unchanged on every benchmark corpus.
osnova_footingseeds from definition hits only and overfetches so prose files never crowd out code.
0.2.0
First public release.
Added
- MCP stdio server with seven read-only tools:
osnova_ground(keyword search),osnova_thread(text search),osnova_outline(one file's signatures),osnova_warp(call graph),osnova_groundwork(repository map),osnova_footing(task context) andosnova_settle(change impact for a unified diff). - CLI with the same seven commands plus
build,check,doctorandmcp.osnova mcpdefaults to the current directory so one global client entry serves every repository. - Deterministic index: incremental refresh produces the same bytes as a full rebuild. Artifact format 9 with a verified structural core, lazily loaded edges and lazily read source text.
- Deep adapters for TypeScript, JavaScript, Python, Go, Rust, Java and C#, with lexical import bindings, re-export chains and receiver hints for TypeScript, JavaScript and Python. Generic definition and call extraction for C, C++, Ruby, PHP, Kotlin, Swift, Scala, Dart, Elixir, OCaml, Zig and Bash.
- Every response carries an index generation, exact omission counts and a one-line foundation state when the index is partial.
- Library API:
buildIndex,loadIndex,refreshWorkspace,ask,askDetailed,findText,findTextDetailed,skeleton,callers,callersDetailed,map,renderMapCard,scopedAsk,taskContext,impact,indexHealth,doctor, optional LSP enrichment andrunMcpStdio. - Reproducible benchmark harness with frozen corpora and recorded results, including rejected experiments.
Breaking
- Pre-release tool names (
osnova_ask,osnova_find_text,osnova_skeleton,osnova_callers,osnova_map) and CLI commands (ask,scoped-ask,grep,skeleton,callers,map,context,impact) are not accepted. Nothing shipped under them. setup --previewand thepreviewSetupAPI are removed. Per-client configuration previews return in a later release.
This page is built from CHANGELOG.md in the repository.