Reference

Changelog

Release history for contextlake.

All notable changes to contextlake will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]#

[9.2.0] - 2026-09-07#

Added#

Usage: 140 calls (140 timed)  /home/you/.contextlake/kb/mcp-usage.jsonl

KEY       CALLS  ERR  THR  DENY    P50    P95
k_4f2a91    120    0    0     0   75ms  142ms
k_9c01de     20   20  500     0  423ms  843ms

Refused requests (never reached a tool)
  throttled       500
  unknown          30
  identity_unset   12
  total           545

A row has six fields and there is nowhere to put a seventh: the minute, the key id, the tool name, the outcome, the tool time in whole milliseconds, and how many events the row stands for. No query text, no symbol, no repository, no file path, no client address, and nothing about the credential a refused caller presented. The recorder takes keyword arguments only, with no free-text parameter and no **kwargs, so that is structural rather than a sanitiser somebody has to remember to run.

Counts, not lines, for traffic the server never admitted. The eight refusal outcomes and the identity fault are counted into one row per key, tool and minute; 500 refused requests are one line reading 500. An unauthenticated flood would otherwise evict every real row inside a minute. Calls from an issued key keep one row each, because a percentile needs the individual values.

A refused call is recorded too. The row is written in the tool wrapper's outer finally, so a call refused by the tool grant, a call the rate limiter never admitted and a call that raised are all in the file. Percentiles are nearest-rank; a refusal above the concurrency slot carries no duration and prints - rather than 0ms.

Rows buffer in memory and are written every ten seconds and on shutdown, so a tool call does no disk I/O. The file grows to 22,000 rows and is then trimmed back to the newest 20,000, so the rewrite happens once per 2,000 rows instead of once per append. A line the reader cannot score, from a truncated write or a newer contextlake, is skipped and counted, and kb keys usage says how many rather than quietly reporting a short total.

Three things it deliberately does not measure, each stated on the surface that prints it: one ask counts once, as ask, since it reaches its eight siblings below the wrapper; tools/list and the handshake cross no wrapper, so CALLS counts tool calls and never HTTP requests; and kb://stats resource reads are not recorded.

Off with --no-usage or [serve] usage = false. [serve] usage_max_lines and usage_flush_seconds tune it, read only from a config you named, the same gate [serve] keys_file and the quota defaults go through.

stdio is unchanged, byte for byte. It builds no recorder, reads no ContextVar and does not load the usage module at all.

HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 20

{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"rate limit exceeded for this key: 3/min. retry in 20s"}}

Two buckets per key, filled lazily from two floats each. --rate and --burst bound requests; --cost-budget bounds tool TIME, as a duration per period (30s/min), and each call is charged how long its body ran. A duration rather than a count because a count misprices ask by 8x: it is one request and eight tool bodies, since ask reaches its siblings below the wrapper that could have counted them.

The refusal is at the gate, before the request reaches any tool. So it costs one header parse rather than a worker thread, and it covers tools/list and the kb://stats resource, which cross no tool wrapper at all. A caller with no valid key gets 401 and is never counted against a quota: identity resolves first, which is what keeps the bucket map keyed by ids this server minted rather than by anything a caller can forge.

Values are validated now. kb keys create --rate 60 is refused at the flag, naming the string, so a typo cannot be minted onto a key that then reads as limited. The same parser runs over every stored value when a key file is loaded for serving: a bad value exits 1 before the socket binds, and a bad value introduced by a live edit is rejected with one warning while the previous keyring keeps serving.

none on any axis means no limit there, and beats a server default. --burst needs --rate: on its own it is the capacity of a bucket that does not exist. The minimum burst is 4, because an MCP client spends three requests on the handshake before its first tool call.

Not persisted and not shared between processes: a restart refills every quota, and two server processes give each key twice its quota. On the sse transport the 429 message is lost and the session closes, which is a defect in that client, not in this server. docs/mcp-transports.md carries all three.

stdio is unchanged, byte for byte. It builds no limiter, opens no timer and does not load the rate-limit module at all.

A shared token is bounded by default_rate and has no per-credential opt-out, since it has no key record to write none on.

Enforced at three surfaces, because a gate on one is a gate the caller walks around by using another: the tool wrapper, tools/list, and the kb://stats resource, which answers the counts graph_stats answers and crosses no wrapper at all.

--tools takes comma-separated groups (graph, search, docs, stats, owners, semantic) plus all, read and none. read is every group except semantic. A group this server does not know is refused at create, so a typo cannot be minted onto a key that then reads as scoped. In a hand-edited key file the same value is denied rather than refused: it narrows the key and never widens it.

ask is refused unless every tool it routes to is granted. It calls eight siblings directly, below the wrapper that checks a grant, so a key granted ask and denied blast_radius would otherwise reach blast_radius through the impact route.

--owners real allows who_knows; pseudonymous and hidden refuse it, and refuse ask with it. There is no anonymiser on the network path, so a key that asked for pseudonyms gets no names rather than real ones.

--repos is deliberately not enforced. It cannot be decided from a call alone: a node id does not carry the repository it came from, and repo_dependencies, repo_flow and repo_event_flow take a required repo and return rows naming other repositories. Correct scoping needs a filter inside the store.

stdio is unchanged, byte for byte. It reads no key, no policy and no identity, and it loads no grant module.

Changed#

Each per-record --json document gains effective_rate, effective_burst, effective_cost_budget (strings or null) and limits_source, an object mapping each of the three axes to key, config or unset. No field is removed and none changes type.

Fixed#

[9.1.0] - 2026-09-06#

Added#

9.0.0 made those five refuse the flag at exit 2. That was the honest interim state: the release before it let them take --json, print their ordinary log lines and exit 0, so a script that asked for machine-readable output got prose and no error. Refusing was better than lying about it, and answering is better than refusing.

Standard output carries the document and nothing else, on every exit path. A failure is a document too, carrying "error" with a snake_case code, which is what kb query, kb owners, kb impact and kb eval already do.

Three fields exist because an exit code could not carry the answer:

create --json and rotate --json keep the key on stderr and report "key_shown_on": "stderr". --json > out.json would otherwise write a live credential into a file at the caller's umask. --print-key moves the key into the document's key field, and it already refuses a terminal.

Fixed#

If you ran that command on 9.0.0, look at kb keys list. An orphaned record is indistinguishable from a key you hold: same live state, same empty LAST USED, and no field on the record says whether the key ever reached anybody. Find the records matching the names you tried to create, and kb keys revoke them. Each retry of the failing command minted another one, so there may be more than one per name.

[9.0.0] - 2026-09-06#

Added#

kb serve refuses to start on several key-file states, and each refusal told the operator to run contextlake kb keys create <name>. That command did not exist. A server that refuses and names a way out the reader cannot take is worse than one that starts wrongly, because the operator has nothing to do next. This is that command.

The key is shown once. It goes to standard error at creation and never appears again, because the file stores a SHA-256 digest rather than the key. It is deliberately never sent through the logger: the console handler always writes to stdout, a --log-file run adds a 5 MB rotating file with three backups that outlives the process, and the redactor rewrites workspace paths and repo names, so it would scrub a key on neither. A lost key is rotated, never recovered, and rotate keeps the old key working for --overlap so the holder can swap without an outage.

Two other paths can carry the key out, and both are narrower than they look. --print-key writes the bare key to stdout for a pipe and refuses a terminal, where it would land in the scrollback instead of a secret store. --out FILE writes it at mode 0600, with the mode set at creation rather than chmod-ed afterwards, and with O_EXCL so an existing path is refused rather than overwritten.

check reads the key from standard input only. A key on a command line lands in shell history and shows in ps to every account on the machine. A terminal with nothing piped in is refused too, rather than waiting for end-of-file behind a blank screen: it does not prompt, because a typed key lands in the scrollback. It opens no socket and sends no request, which is what lets it answer when the server is the thing that is down, and it says so rather than implying it verified anything against a server.

No verb opens the store database. Every one of them runs on a machine that has never built an index, which is the machine an operator is on when a server has just refused to start. kb keys list is the first command they run.

Two refusals split by verb rather than collapsed into one rule. A key file carrying group or other bits, or sitting in a directory anyone can write to, is a policy fault: write verbs refuse, and list warns and prints the table anyway, because blocking the operator from seeing what exists is the wrong failure when list is how they diagnose the refusal they just hit. A file that cannot be read at all is not that: nothing was read, so every verb fails and names the path.

The scope flags are recorded and enforced by nothing, and every surface says so. --tools, --repos, --owners, --rate, --burst and --cost-budget are written onto the key and rendered back by create, list, show and check. No code reads them. A key created with --tools none --repos nothing-matches/* was presented to a live kb serve --transport http --keys-only server and tools/list answered with all 23 registered tools, one of which then ran and returned a result.

So the values print with (recorded, not enforced) beside them and three lines saying what that means, show --json and list --json carry "policy_enforced": false, and an unset axis reads unset rather than none, which read as a denial for the key a bare create makes. Telling an operator their key is scoped when it is not is worse than not offering the flags, because they hand the key out on that reading.

Scope is per tool, not per repository once it does work: a key allowed a tool will read every indexed repo through it. --rate and --cost-budget are stored as typed and validated by nobody, because their parser ships with the rate limiter. The LAST USED column reads never until the usage file it reads from exists.

build_http_app now resolves a Principal per request and the tool wrapper reads it, so two callers holding two different credentials are two different identities inside a tool body rather than one anonymous caller. Until now there was no place to put that fact, which is why access control, rate limiting and usage accounting could not be built: each would have had to invent its own notion of who was asking.

The wrapper's whole try/finally/except structure lands here, once, deliberately. Four planned stories each need to add a line to that twelve-line function, and when they were specified separately their orderings contradicted each other. Landing the frame first turns each of them into an insertion at a named anchor instead of a restructure, so whoever lands second does not have to unpick whoever landed first.

It fails closed. Whether identity is required is a build-time decision made by build_http_app, never inferred from whether an identity happens to be present. A stdio run does not read the value at all, and on a network run a missing identity refuses the call rather than answering it unscoped. Those two states used to be the same value, which meant a server whose identity plumbing broke would have answered every request as if unauthenticated access were intended, with every test still passing.

What it does not do. It detects a MISSING identity. It cannot tell a WRONG one, and on the SSE transport the plausible failure is substitution rather than absence. Closing that needs a per-connection token compared at the boundary, which is specified and not built. ask calls its sibling tools directly, so those legs never cross the wrapper and an access check placed there will not cover them; the anchor comment says so. And the run outcome the wrapper records has no reader until the usage recorder lands.

stdio, the default, is unchanged: same tool output, no identity lookup, no new file, config field or dependency for anyone who never serves over the network.

Changed#

The run now prints, per repo, the terms tried, the documents returned and the edges attached to code, and closes with a line that puts every targeted repo in one of five buckets: enriched, nothing returned, returned but unattached, failed, skipped. The five add up to the number of repos the run planned to touch, on every exit path.

"Returned but unattached" is a state, not a failure. The matcher is whole-word with a three-character floor, so a ticket that discusses a repo in prose without naming a symbol correctly attaches to nothing. That run still prints .

A repo whose store or shard write fails is now counted and reported instead of aborting the whole run. A run where every repo failed that way exits 1.

API change: run_enrich_repo returns an EnrichCounts(terms, documents, edges) triple instead of the document count alone.

What this loses: doctor no longer reports a broken path or an unreachable endpoint on a disabled source. Nothing reads that source, so the round trip bought nothing, but the line used to be there. Re-enable the source to have it dialled again.

acme/auth-service   ->  acme/station-registry
acme/catalog-api    ->  acme/forecast-api    (rebuilt, not renamed; see below)
acme/payments-api   ->  acme/sensor-ingest   (call edges redirected; see below)
acme/web-ui         ->  acme/console-ui
acme/notifications  ->  acme/alerts
acme/shared-lib     ->  acme/shared-lib      (unchanged)
demo/app            ->  demo/app             (id unchanged; its two symbols changed)

acme/forecast-api is a rebuild. The old repo was a four-layer controller / service / repository / validator chain in C#. It is now a scheduled model run in Go: RunCycle -> ForecastRunner -> GridSampler -> ModelGrid, with no controller, no repository and no validator. acme/sensor-ingest keeps its nodes and redirects its call edges so readings flow one way: SensorGateway -> Ingest -> ReadingProcessor, plus Backfill -> ReadingProcessor. Nothing downstream calls back into the gateway, and the two triggers converge on one processor instead of forming another straight line.

What moved and what did not, measured on the fixture. The acme org, the repo count (7), the node total (44), the edge total (36) and all 11 cross-repo edges are the same, edge for edge, under the mapping above. Two counts did move: languages went from 4 to 5 (Go added, C# down from 10 nodes to 5), and node kinds from 7 to 8 (struct added, class 12 to 11, method 3 to 2).

Every symbol inside those repos changed with the ids, so a golden-query file, an MCP call or a dashboard bookmark naming an old one returns nothing until it is updated. The new node and edge lists are in src/contextlake/kb/dashboard/fixtures/sample-dashboard.json. Nothing in the product changed. This is demo data and the docs that quote it.

This changes where your requests go. If you run an OpenAI-compatible server on port 11434 and reach it with provider = "openai" and no base_url line, that traffic stayed on your machine before the upgrade. After it, embedding requests go to https://api.openai.com/v1/embeddings and your indexed code leaves the machine. One line keeps it where it was:

[embeddings]
provider = "openai"
base_url = "http://127.0.0.1:11434"

Nothing changes for provider = "ollama", "builtin" or "auto", or for any config that already writes a base_url line.

Fixed#

✓ MCP server on http://127.0.0.1:8765/mcp  (Ctrl-C to stop)
  --keys-only refused: no key file with a live key was found ...

and exited 1 with nothing listening. The banner sat above the block that reads the key file, so all five key-file refusals printed second, and an operator reading their terminal top to bottom saw a running server. A code comment on that block claimed the opposite, that every refusal ran before any banner.

The two banner lines are printed last now, right before the server call, with nothing between them that can return. A start that refuses prints no banner at all. The refusals covered: a key file that cannot be trusted, --keys-only with $CONTEXTLAKE_MCP_TOKEN set, a file this reader cannot account for, a file whose keys have all expired or that holds no records, and --keys-only with no live key anywhere. The --host refusal already ran before the banner and is unchanged. Output on a start that goes ahead is unchanged.

repo_brief now carries a second ranked list, top_embeddable_symbols, filtered to the embeddable kinds first and capped after, one row per distinct name. The term builder reads that. On the same 48 repos all 29 with enough symbols now reach 10 terms, and the total rises from 191 terms to 321.

Your terms will change, not only grow. The count per repo rises or holds, and no repo ends with fewer, but the ranking that fills the list is different, so the SET moves: 20 of the 32 repos that HELD a symbol term lose at least one they used to get, and 41 of the 143 previous symbol terms are gone. A term drops out when a higher-degree definition displaces it. (The denominator is 32, not the 48 measured: the other 16 held no symbol term to lose, so counting them would understate the churn among repos this can affect.)

field and endpoint names can vanish from a repo's terms entirely. There is no per-kind floor, so the ranking alone decides. On those 48 repos, field reached no term in any of the 4 repos holding field nodes, including one where 103 of its 178 embeddable nodes are fields; endpoint reached none in 12 of the 15 repos holding one. Widening the budget barely helps: re-measured at 25 terms a repo instead of 10, all 4 field repos stay empty, and endpoint recovers in at most 2, leaving 10 of 15 still empty. So a bigger budget is not the fix for either kind, and for field it changes nothing at all. This is deliberate. A repo gets 9 symbol names by default, across 19 embeddable kinds, so a floor could reserve at most one slot each, and a per-kind floor is half of what starved the old path. The full reasoning sits beside the ranking in wiki/generate.py.

top_symbols is unchanged, and so is every wiki page: its all-node candidate set is deliberate, the new list stays out of the grounded_count/coverage_total ratio and out of hubs/dispatchers, and no wiki, dashboard or MCP surface reads it. The 16 repos in that measured set whose symbols were never extracted still get one term; that is indexer coverage, and this change does not claim it.

Security#

On top of the per-key refusal, a discovered file may not aim a tier that carries a credential. When the provider that wins the merge for [llm] or [embeddings] is openai or anthropic and that value came from a discovered file, the tier is off for that run, rather than falling back to built-in defaults nobody chose.

This breaks an honest project-local block that names openai or anthropic in a .contextlake.kb.toml. Three things clear it: delete those keys from that file and set them in ~/.contextlake/kb.toml; pass --config PATH naming that file; or, for [llm] only, pass --llm PROVIDER to kb wiki, kb docs or bootstrap. Adding the block to ~/.contextlake/kb.toml while the discovered file keeps its own provider line does not clear it, because the discovered file is merged last and its provider still wins. Set CONTEXTLAKE_NO_LOCAL_CONFIG=1 to skip ancestor discovery entirely.

[8.13.0] - 2026-09-02#

Changed#

Admitting everything is still wrong, for a sharper reason than the old note gave. The uncapped cost is one or two pathological names per repository: 2,864 sites naming a symbol with 1,432 definitions produced 4.1M edges by themselves, 92% of that repository's uncapped total.

PARSER_VERSION moves to 12, so existing stores re-index rather than keeping edges built under the old cap.

Added#

Wheel zoom got more responsive alongside them: wheelSensitivity was 0.2, so reaching a readable scale took a dozen notches and the canvas read as stuck. It is now 1.

Fixed#

[8.12.0] - 2026-09-02#

Added#

The control appears only for a repo that has a generated wiki. The prefix match is anchored on a path segment, so src cannot claim srcutil/helper.py.

There is no jump to a heading, and that is a limit of the data rather than an omission. Generated pages carry page-level headings only (Overview, Setup & Run, Architecture, Dependencies, Gotchas), the model is told to omit any it has nothing to say for, and nothing in a page is about a single symbol. A computed anchor would land silently at the top of the page.

Fixed#

[8.11.0] - 2026-08-31#

Fixed#

Added#

Documentation#

It also covers the one case the anchoring does not reach. There is no escape character, so odd*name matches a repo literally named odd*name and matches oddXname as well, and cannot select the first on its own. A one-character set does: --repos "odd[*]name". * and ? are legal in a path on Linux and macOS, not on Windows.

Every claim on that page is now pinned by tests/test_repos_pattern_syntax.py.

[8.10.1] - 2026-08-31#

Fixed#

pickle.dumps succeeded on the exception throughout. Only the parent's unpickle failed, which is why nothing caught it. The same 17-repository cluster that broke the pool twice now completes at --workers 4 with 0 failures, the over-budget repository reported as a skip, and all four workers exiting 0.

[8.10.0] - 2026-08-31#

Upgrading#

This release re-indexes every repository on your next kb index. PARSER_VERSION moves from 10 to 11 because .config and four sibling extensions now carry settings into the graph, and no commit moves when an extractor starts reading a file type it previously ignored. Without the bump an existing store would report every repository "unchanged" and never gain a single setting.

What that costs, measured on a 660-repository fleet:

Known issues#

kb index --workspace can break its worker pool at --workers above 1, failing every still-pending repository with A process in the process pool was terminated abruptly. On a 656-repository run this failed 640 of them in about a minute. --workers 1 is unaffected.

Fixed in 8.10.1, which describes the cause. It is RepoTooLarge, the exception this release added, failing to unpickle in the pool's manager thread. No worker runs out of memory and the max_repo_memory estimate is not involved. The wording in this section before 8.10.1 attributed the break to that estimate and to a dying worker, and both were wrong.

Added#

[8.9.0] - 2026-08-30#

Added#

Fixed#

[8.8.0] - 2026-08-27#

Added#

contextlake schedule interval 6h run -- kb wiki --force

Ten schedule_* config keys, an honest split between what systemd and cron can each do, and a container refusal for state that will not survive a restart. Discarding measured history (--purge, reset --history) renames the history file to a .discarded sidecar instead of deleting it, so a mistaken discard is recoverable. See Scheduling runs.

branch_map = team/api=develop, legacy-*=maintenance

Changed#

[8.7.0] - 2026-08-25#

Four defects, none of which the unit suite could see. Three were found by running the CLI against itself and against a live GitLab group. The fourth was found by reading a CI line that had been red for eight runs while every local suite passed, because the MCP SDK is floored without a cap and a new release changed how tool errors surface.

Two of the four are commands that reported success while doing nothing. One withheld the reason for a refusal it was right to make. One is a documentation example that does not run.

PARSER_VERSION, SCHEMA_VERSION and EMBED_CONTENT_VERSION are untouched, so nothing re-parses and nothing re-embeds. One stored value does change: see Migration.

Fixed#

Migration#

Automatic. No action required. A store written by an earlier version holds directory-name ids for anything indexed with --source. Re-indexing the same checkout re-files that row under its canonical id and clears the stale one, the same migration --workspace has always run, scoped to the single path being indexed. The rename is logged, for example oldname -> gitlab.example.com/acme/widgets.

The consequence worth knowing: a repository indexed before this release is invisible to kb connect. Re-index it once and the connectors will see it.

Added#

[8.6.1] - 2026-08-24#

A documentation-structure release. No behaviour changed, no schema moved, EMBED_CONTENT_VERSION untouched, so nothing re-embeds.

Fixed#

Changed#

Nine pages were candidates. Five cleared the bar: the off-type block had to be contiguous, at least 100 lines, and at least 40% of the page. The published set is now 36 pages.

Internal#

[8.6.0] - 2026-08-24#

Three ways to read what the graph already knew. Presentation-layer throughout: no schema change, no parser change, EMBED_CONTENT_VERSION untouched, so nothing re-embeds.

Added#

Ranked by name, and counted by reference site rather than by edge, because both matter at fleet scale. The parser emits one edge per candidate, so edge counts overstate the work by the average candidate count; on a large store a third of a million ambiguous edges were a fifth as many actual reference sites. Grouping by name concentrates it further, so a single disambiguation can cover thousands of sites. A flat list of the raw rows would not be a review surface.

Read-only by design: it reports and cites, it never repairs. Resolving a name is a judgement about intent, and the parser has already said it cannot make it.

[8.5.0] - 2026-08-23#

The graph page becomes something you drive. Every change is presentation-layer: no schema change, no parser change, and EMBED_CONTENT_VERSION is untouched, so nothing re-embeds.

Added#

Changed#

Fixed#

[8.4.0] - 2026-08-23#

Additive throughout. No re-embed, no schema change, no breaking surface: every new capability arrives behind an optional extra or a file you do not have to write.

Added#

That property is the whole reason this exists in this shape. The obvious way to read an image is to send it to a vision model, and that would have made ingestion the first path to leave local-first. The offline claim is asserted rather than stated: the live test blocks socket.getaddrinfo and socket.create_connection before calling the engine.

An image the engine reads no words in -- a logo, an icon, a photograph -- is reported and stored as nothing, never as an empty document that would look like knowledge. OCR'd documents carry ocr = true, because OCR misreads and a reader should not have to infer that from a file extension.

The work is bounded rather than the file. max_bytes gates every other type because size predicts how much text a document contributes; for a video it predicts resolution and length, so a 1 MB cap would reject every real recording while admitting nothing. Capped instead: one frame every 5 seconds, at most 60. Repeated on-screen lines are said once, since a slide holds still across many samples.

Three outcomes are stated rather than implied: no transcriber installed (the video is still read from its frames, and the document says transcribed = false), no audio track at all (a screen recording with no microphone is ordinary, not a failed transcription), and nothing readable either way (reported, stored as nothing).

Fixed#

Changed#

[8.3.0] - 2026-08-22#

⚠ Re-embed: your stored document vectors are rebuilt once#

EMBED_CONTENT_VERSION moves 4 -> 5. Both halves of the staleness rule apply at once: the text a vector is built from changed, and every stored key changed shape. kb embed notices the mismatch and re-embeds everything once; kb ingest rewrites document vectors and sweeps the partition first, so an old whole-document vector cannot linger beside the new chunks. Nothing is required of you beyond the next run costing more than usual.

Changed#

So the question was never "which chunking strategy", it was "chunking versus none". Measured with kb eval on 29 real documents (424 KB, mean 14.6 KB) and 53 queries selected by position rather than by hand:

one vector per document chunked, ~1200 chars delta
hit rate 71.7% 94.3% +22.6pp
MRR 45.2% 80.4% +35.2pp
tokens/query 276 291 +15

13 queries fixed, 1 lost. The control holds: queries were stratified by depth before indexing, the unchunked arm is already worse deep in a document (62.5% against 79.3%) which is the dilution the hypothesis predicts, and chunking helps depth (+29.2pp) more than the surface (+17.2pp). The obvious confound -- each chunk repeats the document title, which helps retrieval by itself -- was ruled out on evidence rather than waved away: all 13 queries chunking fixed share zero words with their document's title.

What this does not establish, because a number without its limits is worse than no number: one embedder (model2vec, which averages; a transformer would truncate instead, plausibly worse for a 14 KB document, unmeasured), verbatim-sentence queries so the absolute figures are optimistic and only the delta is sound, one single-topic corpus, and a chunk size that was never tuned. The full write-up is in docs/semantic-search.md.

The splitter packs whole paragraphs rather than cutting at a fixed width, and never drops a short trailing chunk -- the last chunk of a document is usually its conclusion. Defaults are 1200 characters with 200 of overlap, which are the values that were measured.

[8.2.0] - 2026-08-20#

Added#

It is a separate tool rather than a third kind, because the fleet page has no repository: it is one file for the whole store, so the tool takes no repo argument rather than accepting one it would have to ignore.

Three states, not two. stale=true with a fingerprint means the store moved. stale=true with no fingerprint means the page predates stamping and nothing is known about whether it is current. A caller that cannot see the store cannot tell those apart unless told, so it is told. The parser version is in the key because a page can go stale without a single commit moving.

This also catches something a per-repo commit stamp cannot express at all: a new repository joining the store makes the page wrong without any existing member changing, because its populations count a fleet that grew.

Fixed#

Changed#

What needed deciding was not the pin but the miss. A release branch usually exists in a handful of repositories out of hundreds, so a repository without the requested branch is its own outcome, counted separately and listed in the summary, rather than an ordinary switch to something else:

✓ Branch switch complete: 4 switched, 0 already, 0 skipped, 0 empty, 0 dry-run, 396 unpinned, 0 errors
  396 repo(s) have no branch 'release/24.1'; each stayed on its most active branch

Folding that into "switched" would leave several hundred repositories reading as though they had done what was asked. The outcome is not an error either, so --branch on a real fleet still exits 0.

The name is matched exactly: --branch release/24 does not select release/24.1. A prefix test on an identity question is a hole this project has closed three times already.

Fixed#

[8.1.0] - 2026-08-20#

Added#

Two node kinds. schema_element for a global xs:element, which is the name a message or a document root actually carries and therefore the name a person searches for. schema_type for a global complexType, simpleType, group, attributeGroup or attribute, with which one recorded as an attribute rather than as five kinds.

They are not a reuse of struct and typedef, and that is the load-bearing decision here rather than a naming preference. Reference resolution is by name across the whole repository, narrowed only by target kind, so sharing a kind with C++ would let type="Address" resolve onto an unrelated struct Address -- confidently, with nothing reporting that a guess had been made.

.xsd is also matched ahead of, and never falls through to, the XML config scanner: name is one of that scanner's key attributes, so a schema sent there would file every component as a settings key.

This mints no new kinds, and the contrast with the schema work above is deliberate rather than inconsistent. Schema references resolve on name alone, so they needed kinds of their own. Calls and variable reads are filtered by language family first, and xsl is its own family, so an xsl:template named format cannot reach a Python format. The isolation was already there; the test asserting it is what proves so.

<xsl:import>/<xsl:include> and XPath calls to an xsl:function are not extracted, said here rather than left to be discovered. The first would need an edge to a file node that may not exist; the second would need an XPath parser.

So the file is read twice. The C parse sees every EXEC SQL statement blanked, preserving length and every newline so the line numbers it cites stay real, and blanking only the statements so the host variables declared between the declare-section markers survive as the ordinary C they are. The dataflow pass sees the file intact, because which tables it reads and writes is what an EXEC SQL statement is there to say.

That pass already normalises table names through the same recipe kb/sql.py gives its table nodes, so an EXEC SQL SELECT ... FROM CUSTOMERS and a CREATE TABLE dbo.[Customers] in another file land on one node -- with no second copy of that rule existing anywhere to drift out of sync.

.pc follows C for language filtering rather than carrying a flag of its own.

Changed#

[8.0.0] - 2026-08-20#

The compatibility promise starts binding with this release, and this release deliberately breaks nothing. A major bump permits breaking changes; announcing "stability begins here" in a release that breaks things would read exactly as badly as it sounds. Nothing you wrote against 7.x stops working. One action is worth taking: this release moves PARSER_VERSION, so kb index rebuilds any repository holding PL/SQL or the newly routed shell suffixes, and those repositories carry less than they should until it does.

README.md states what counts as breaking on four surfaces: CLI verbs and flags, store layout, MCP tool contracts, and config keys. A change is breaking when it would stop something you wrote from working, so adding a flag is not, and tightening a flag's validation is not either. PARSER_VERSION is deliberately outside that list: bumping it stops nothing working, it means a repository indexed by an older parser carries less than the current one would extract, so kb index rebuilds it.

The milestone this closes#

Eight gates defined what "complete" meant, written so each could be checked rather than asserted. Seven are closed, with the evidence committed rather than described. The eighth is parser stability, and this release reopens it itself: it closed on PARSER_VERSION "8" holding across eight releases, and the PL/SQL and shell work below moves it to "9". It re-closes once two releases hold at "9". Saying "all eight" here would have frozen a false sentence into an immutable release, so it says seven and names the eighth.

Closed:

Added#

Packages, package bodies, functions, types and triggers are extracted now, both redefinition spellings are accepted, and a trigger records the table it fires on. The ON search is bounded to the trigger's own statement, because ON introduces a join everywhere else in SQL and an unbounded search hands a truncated trigger the next statement's table.

Changed#

The decision was taken and written down before the code, because this reopens the parser stability gate that closed earlier the same day on "8" holding across eight releases. That is recorded in the gate status as a reopening, not quietly left as closed.

Fixed#

[7.32.0] - 2026-08-19#

Fixed#

That mattered because the diagram bar compares the count the command announced against the count rendered in the file, and reports a disagreement as the product drawing a different graph than it described. An overcount there is a false accusation waiting for the first repository whose code contains an arrow. Measured on a four-line fixture: three counted where there are two. The pattern is anchored on the edge line now, and the bar still passes on the real tree.

Third time this scanner rule has fired in this project and third time it named something real underneath.

[7.31.0] - 2026-08-18#

Added#

One happy path is not a clean room, so it also covers the shapes that have broken before: a second index over an unchanged tree rebuilds nothing, an --offline run completes with every proxy variable poisoned so any outbound call would fail loudly, and a repository with no manifest indexes to a graph rather than an error.

Getting to 10 of 10 took three runs and every failure was the harness, not the product: it passed init --local where the command's own message named --no-mirror; it installed [kb], which deliberately carries no embedder, and read the honest "unavailable" as a missing output; and it looked for the wiki and docs in a directory it had invented while all three sat under the store_dir that init had written. The same error three ways, which is measuring against an assumed layout instead of the one the product writes. The runner now reads that path out of the config init produced.

A review then found six checks that were wrong while the harness reported 10 of 10. init failing was converted into a pass. "Re-index is quiet" read zero for both runs, because the regex matched a line the command never prints and the fallback matched the first run's own "0 unchanged". The offline check ran a command with no network path at all. "Vector search" accepted the full-text fallback that kb query degrades to. The no-manifest check only proved a second repository row existed, which the previously indexed tree had already made true. And a run where nothing executed passed, with a test blessing it.

All six are fixed and the gate re-run: 10 of 10, with an offline check that now requires --offline mirror fetch to be refused by the guard rather than by config validation arriving first. Every one of those six is the defect class this series exists to remove, which a harness is not exempt from -- and a harness that grades a gate is the worst place for it to hide.

[7.30.0] - 2026-08-18#

Added#

The pinned commit in the first version of this file did not exist in that repository. It was written from memory while the network was down, so nothing could resolve it. It is now a release tag, resolved from the remote.

Changed#

Loosening a bar because it failed is how a gate stops meaning anything, so this is recorded in the bar itself, in the check, and in a test rather than quietly applied. The reason it is defensible: the bar's stated purpose is catching a "semantic" search that is really substring matching, and the query shares no word with the symbol or its docstring, so a substring matcher returns it nowhere at all. Appearing in the ranked results already proves retrieval by meaning. First place is a claim about ranking quality against every other symbol in the tree, which is a different question from whether the output is derived from the source. The measured rank is kept in the evidence either way, so a regression in that quality stays visible.

[7.29.0] - 2026-08-18#

Fixed#

Each now says which it is, and each names the move that fixes it: pick a real kind, run kb docs, or correct the id. The comment in that branch already claimed the kind was "named, not silently coerced" -- it was named in a field nothing distinguished.

[7.28.0] - 2026-08-18#

Added#

It says plainly that the promise takes effect at 1.0 and that every break before then is named in the changelog. Writing it as though it already bound would have been contradicted by this project's own history two releases ago, when two MCP response shapes changed in a minor -- and a promise the changelog contradicts is worse than no section at all.

PARSER_VERSION is deliberately outside the list, with the reason stated: bumping it stops nothing working, it means an older-parser repository carries less than the current parser would extract, so kb index rebuilds it. That is why doctor reports a stale shard as an advisory rather than a fault, and a test now checks the README's claim against what doctor actually does -- a promise about behaviour in another file is exactly the kind that starts lying quietly.

[7.27.0] - 2026-08-18#

Added#

The bars are written before the test and each names a failure it would catch: a graph whose totals move while the new symbol's edges dangle; a reference that lists a symbol without its real call sites; design notes written from a template rather than read out of the manifest; a diagram whose printed summary describes a different graph than the one it drew; a wiki page regenerated with a stale commit stamp; a "semantic" search that is really substring matching.

The deciding half is separated from the I/O so it can be tested without a network, and every assertion is break-tested to confirm it fails for its own reason. Three states, never two: a bar that could not be tested counts against the run, because the question is whether it was proven and an untested bar has not been.

This does not close that gate on its own. A harness that has never run is not evidence, and the result file is written by a live run.

[7.26.0] - 2026-08-18#

Fixed#

get_neighbors was the cleanest case: byte-for-byte identical output -- {"edges": [], "total": 0, "truncated": false} -- for a real node with genuinely zero edges and for a node id that was never indexed. It now names an id the graph does not hold, and separately says when a relation/direction filter is what emptied the list.

find_definition says whether a name is absent from the graph entirely or is defined and was excluded by a kind/repo filter. The reasoning that had left it bare -- "no definition with this name is what an empty result already means" -- was wrong in the case it did not consider: with a filter, that empty result reads as "X is not defined" when the truth is "X is defined, and you asked for the wrong kind".

search_code says whether the query's terms are in the index at all, or whether a filter excluded everything, and now reports total and truncated like every sibling.

Changed#

Done now, deliberately, because the MCP tool contract carries no stability promise yet -- that promise is one of the remaining gates for 1.0, and the right time to make a response shape consistent is before it is promised rather than after.

[7.25.0] - 2026-08-18#

Added#

That gap is not theoretical. The publish step carries skip-existing: true so a re-run is idempotent, and the cost of that is an earlier upload under the same version number being silently kept. Nothing downstream compared bytes, so a wheel that never came from the tagged commit could have served that version indefinitely with every other gate green.

The build job records the wheel's sha256; a new verify-published job downloads what the index serves, compares the digest, installs it into a clean environment, and confirms the tag packages that version. scripts/verify-published-release.py runs the same checks by hand for any past release.

It reports three states and never two: a check that could not RUN is unverifiable and exits non-zero, because the one thing a verifier must never do is let "I could not look" read as "I looked and it was fine" -- which is precisely the defect class this release series has spent its time removing from the product's own commands.

Review of that first cut found six more, and two would have made the gate worse than useless:

[7.24.0] - 2026-08-18#

Fixed#

Four commands printed a fault and then a success word about the same run. Each was found by a probe of a surface nobody had exercised, and each has the same shape: the summary was written from a variable the reporting did not feed.

Three checks then had to become the advisory they were already documented as being, since a printed ✗ now counts: per-source reachability, glab on PATH ("advisory, not critical" said the comment beside a red mark), and the optional sqlite-vec ANN index. Without that the first cut of this change would have failed doctor on a stock install and in CI, which installs neither -- the fix reproducing the defect class it fixes, caught by review.

The stale-shard check is advisory too, and that resolves a disagreement rather than picking a side of it. A parser bump makes every existing shard stale, so failing on it would redden every user's CI on upgrade, which this project had already decided twice. The wrong part was the red ✗ with "OK" printed underneath. A ⚠ says the same thing without contradicting the summary. A regression test now runs doctor without the optional tooling and requires exit 0, so the remaining hand-read call sites have a gate.

[7.23.0] - 2026-08-18#

Fixed#

Every prose file is now discovered by glob, with CHANGELOG.md the single deliberate exclusion because a count under an old version heading is TRUE of that version. On its first run the widened gate immediately found two stale claims on a page no list had ever included: a language count two years out of date, and beside it a tool count in a wording no pattern recognised. Both are corrected, and the wording is now covered.

A reviewer then found the third: the glob took .md only, and the site GENERATOR (site/build_docs.py) holds the published page subtitles, meta descriptions, OpenGraph text and JSON-LD as Python string literals. "across 14 languages" was sitting LIVE on the published page against a build of 27, in the one file a markdown glob can never reach. Corrected, and the generator is now scanned -- the generator rather than its output, so there stays one authority.

Two more holes in the same gate: the tool-count check required five claims to match in total, so rewording the one sentence a pattern covered dropped the total to nine, still passed, and left that claim unchecked -- every pattern must now match something. And the test guarding the +2 offsets asserted only that the two embedding tools were ABSENT without embeddings, which says nothing about how many conditional tools exist; it now builds a server with an embedder and asserts the difference is exactly those two.

--repos also meant two different things inside that one command: --site matched over repositories with parsed nodes, --c4 over repositories-table rows, so the same spelling selected one and not the other. Both now decide "does this pattern name anything" from the union of both populations, while each still generates from its own source, so the widening cannot make a run produce less.

[7.22.0] - 2026-08-18#

Fixed#

The degrade path was not missing. It was there and correct for the case where embeddings are DISABLED. What nobody had covered is the case contextlake init actually creates: [embeddings] enabled = true with no vectors until kb embed runs, so the embedder builds successfully and reports that the model loaded, which is a different fact from anything having been embedded. The search now says which of the two it hit, names the remedy, and shows the fts results.

Four surfaces read the vector store, not one, and all four had it. The MCP semantic_search and hybrid_search tools now carry the reason in the result's note; the MCP ask tool routes to full-text and says which search actually ran; the dashboard's search panel returns lexical results with the reason attached. An agent on the other end of an empty result with no note reports back that the codebase has no such concept, which is the worst version of this bug because nobody sees the store.

kb eval --retriever semantic|hybrid REFUSES instead of degrading, and the difference is deliberate: a query still has a useful answer to give from keywords, while an eval's entire output is the score of the retriever that was asked for. It used to report P@k=0 R@k=0 hit-rate=0 and exit 0, which is what --json gates CI on, so an unpopulated index read as a total retrieval regression.

The check is an existence probe, not a count. It runs before every semantic query, and the question is "is there anything at all", which the first row answers; an exact COUNT(*) over the ANN backend's virtual table carries no cheapness guarantee, on precisely the large stores where per-query work is felt.

[7.21.0] - 2026-08-18#

Fixed#

Both now raise, the CLI reports bad_golden_set and exits 1, and the message for the string case names the correction (Write ["Calculator"].) because the reader's next move is to edit the file. A file with no queries key says what the shape is, where it used to surface a TypeError about list indices.

Reported by an external review as "kb eval always reports 0.0, the feature is non-functional". That was not true -- the documented form scores hit_rate: 1.0, verified -- but the complaint underneath it was: nothing told the reviewer that nine attempted spellings were being rejected rather than scored, so a working feature looked broken.

An empty "queries": [] scored n: 0 with every metric at 0.0 and exit 0, which cannot be told apart from a set that ran and retrieved nothing. A non-string inside expected (0, null, a nested list) can never equal a retrieved id or name, so it was a guaranteed miss dressed as a measurement. An empty query string gives the full-text layer no terms, and no terms retrieves nothing, scored as a retrieval failure. A query entry that is not an object raised a bare TypeError naming neither the file nor which entry.

The last one runs the other way and is the dangerous one: a falsy kind or repo (false, [], {}, "") is dropped by the store's if kind: filter test, so the query ran UNFILTERED while appearing to carry a filter -- and could therefore score a HIT on a node the filter would have excluded. All five now raise, and each message says which value is wrong and what it would otherwise have measured.

Changed#

[7.20.0] - 2026-08-18#

Fixed#

A one-day-old regression with one root cause: the structural stage took args and never read it. The local-first default is what hid it, because with no LLM configured the command returns right after that stage, so the correctly-filtered code further down was never reached. Found independently by two reviewers, one with a control run proving the leak affects a MATCHING id too, not only a missing one.

Discovery now reports what it could not read, the summary names it, and the exit code is non-zero. Deliberately narrow: a vendored tree and a duplicate checkout are also skipped and both are correct decisions taken on purpose, so folding them in would turn clean runs red and teach a reader to ignore the count.

The predicate is also renamed to say what it checks. It was _is_sentinel_repo, which is the name of the narrower (-prefix contract that kb.model owns and kb forget depends on, so widening it under that name would have quietly changed a shared word's meaning.

It is built from the EDGES now, so publishes and depends_on cannot collapse into one claim, and the brief carries three separate facts because they answer three different questions: requires (what a user needs to run it, with each constraint as written), dev_requires (what a contributor additionally needs), and publishes (what this repository offers others). packages survives for the MCP get_repo_brief and dashboard contracts and is now simply true: the names this repository depends on.

Two scoping rules, each measured against a real tree rather than reasoned about:

Added#

Each pattern now carries its OWN expected value rather than sharing one permissive set: the first fix allowed "unconditional minus one" everywhere, which let a stale "21 tools are registered" read as legitimate. All eight claims were confirmed to fail individually.

[7.19.0] - 2026-08-18#

Added#

Written as a test rather than a script because a script gets run once and then rots. Adding a language, a node kind or an MCP tool now fails a test that names the doc line to update.

It exists because the claims were caught drifting: docs/explained.md said "21 tools are registered unconditionally... bring it to 23", and counting from a built server gave 22 with a tool added that morning -- so 21 had been right only momentarily and 23 dated from an earlier era. Reading a number tells you nothing about whether it is true.

The rule it enforces: a number in the docs must have exactly one authority in the code, and the test file names it. A claim with no authority cannot be checked and should not be a number. All five checks were confirmed to fail against a corrupted doc before being trusted.

Changed#

VERBS deliberately includes source, which is dispatched lazily to keep tomlkit off every other command's import path. The eager handler dict alone under-reports by exactly one and would look complete, which is the same shape as every other count this release is about.

[7.18.0] - 2026-08-18#

Added#

This is the one generated document no per-repo page can produce. The graph keeps package nodes global, keyed by ecosystem and name rather than by repo, so two repositories depending on the same package point at the same node. Disagreement is invisible from inside either one: a service pinning >=2.5,<4 and another leaving the same package unpinned each look entirely reasonable on their own page.

Every population is a count of distinct repositories, and manifests are counted separately. Measured on a real four-repository fleet before the renderer was written, one package had 11 dependency edges across 2 repositories, because one of them declares it in eleven manifests: its own plus ten bundled examples. Counting edges would have printed "11 repositories" onto a four-repository fleet. That is absurd at four and perfectly plausible at forty, which is why the two numbers now sit in adjacent columns and are never substituted.

The page names which shared packages are pinned inconsistently and then explicitly declines to recommend anything: a repository may pin tightly because it met a real incompatibility, and nothing in a graph can tell a deliberate split from a drifted one. Agreement is stated too rather than left to inference, because silence reads as "not checked".

Absence is split into its three different causes, because one heading over all of them made a repository with a dev-only manifest indistinguishable from a broken store entry. A repository can be missing from the tables because it declares only development or opt-in dependencies (a manifest was read; nothing in it runs), because it declares nothing this reads, or because its shard could not be loaded at all -- in which case the page knows nothing about it either way and says so, rather than reporting it as declaring nothing. All three are named, not counted, since a count invites the reader to guess which.

The shared-package denominator names the filter it was drawn from. "3 of 15 packages" would silently redefine packages as runtime packages: a fleet with 15 runtime and 200 development packages reads as a 15-package fleet, and nothing else on the page contradicts it. It now reads "3 of 15 packages required at runtime", and states how many appear only as development or opt-in dependencies.

It is written only for a full run. kb docs <repo> skips it and says why, because "3 of 15 packages are shared" is a claim about the whole store and a reader has no way to tell a scoped page from a complete one. Only runtime and peer dependencies reach it; a dev dependency disagreeing across the fleet is a lesser finding that would bury the one that matters. The command's summary line names the fleet page when it writes one, since a summary listing two outputs while three were written under-reports the work.

[7.17.0] - 2026-08-18#

Added#

The commit stamp had to come first, and it is the reason this is one release rather than two. A generated page is a claim about source code at a moment. Strip the moment and a page describing code that changed months ago is indistinguishable from a current one, while reading exactly as authoritative. The wiki has carried its commit since it was built, which is how get_wiki reports stale; the API reference and design notes carried nothing. Serving them without a stamp would have shipped a surface that cannot say whether it is current.

Every page now carries the fact twice, for the two kinds of reader: a visible sentence (Generated from \repo` at commit `abc123`.) and a comment marker (`). The marker is authoritative; the sentence is what gets reworded.

An absent commit is recorded as unknown, not omitted. Omitting the field makes the marker unparseable and sends a consumer down the same path as a page that was never stamped; a present unknown says the thing that is true, which is the difference between a caller defaulting to fresh and defaulting to stale. stale is therefore true in four distinct cases: the commits differ, the page has no stamp, the stamp says unknown, or the repo has no indexed head. Not knowing and being out of date are the same risk to whoever asked.

An unrecognised kind reports found=false rather than being coerced to the default, so a caller asking for something that does not exist learns that instead of receiving the API reference and believing it asked correctly.

[7.16.0] - 2026-08-18#

Added#

### ADR-001: Depend on `blinker` at `>=1.9.0`

**Status:** proposed, never ratified.

**Decision.** `pyproject.toml:24` declares `blinker` with the constraint `>=1.9.0`,
required at runtime.

**Context.** *Nobody wrote this down. The repository records the choice and not the
reason, so what was weighed against it is not recoverable from the code.*

That absent Context is the point. A real decision record states what was chosen, what was rejected and why; a graph supplies only the first, so the entry says so instead of inventing the rest.

Only recorded evidence is numbered. A constant read in many places stays a plain table row, because on a measured public tree three of the seven constants that cleared the evidence bar were typing constructs, and "ADR-005: T is a repository-wide type variable" is exactly the invention this page exists to avoid. And only the repository's own runtime commitments are numbered: a dev dependency is a contributor's convenience, an optional extra is opt-in, and a nested project's dependencies are that project's decisions. Every one of those stays recorded in the tables, so the narrower scope costs no coverage.

Entries are ordered by name, because nothing in the graph ranks one dependency above another, and the page states that the numbers are positions in a generated file rather than stable identifiers: adding a dependency renumbers everything after it, so each heading names its package to give a reader something stable to cite. The count is bounded, with the total and the remainder both stated, since one measured application repository declares 112 runtime dependencies in a single manifest.

[7.15.0] - 2026-08-18#

Added#

The honest scope is narrower than "design document" suggests, and the page opens by saying so. A graph holds no decision records: it never sees what was rejected or why. It holds two kinds of evidence, kept apart because they are not equally strong. A manifest dependency is recorded (somebody wrote blinker>=1.9.0 on purpose, so the package, its constraint and its line are facts). A constant read in many places is inferred evidence that a value is load-bearing, and no evidence at all that anybody decided anything.

So the page states counts and refuses to explain them, the rule the wiki's gotchas prompt already carries. Measured on a mature public library before any of it was written: seven constants clear a defensible evidence rule and about four point at something a human would call a decision, the rest being typing constructs. Nothing in the graph can tell those apart, and a generated sentence calling a type variable a core architectural decision is worse than no sentence.

Three properties keep it honest, each present because its absence produced a real wrong answer: coverage is always stated as N of M, since filters drop candidates silently and a short list with no denominator reads as "there is little here"; an ambiguous reading is never counted, because a name with several definitions has each use attributed to all of them, so one name defined three times carried an identical 41 sites on each and summing reports 123 uses of 41; and an empty list names what was read, since "declares no dependencies" and "declares them in a file not yet read" otherwise render identically. Dependencies get one table per manifest, the repository's own first, so a bundled example that depends on this project does not read as a dependency of it.

The page carries a machine-readable marker as well as the prose, because whoever reads the file receives bytes rather than a rendered page, and a status stated only in a paragraph is a sentence a summariser can drop: <!-- contextlake:document=design status=proposed-never-ratified evidence=derived-from-code -->

Each edge now carries attrs["constraint"] (the remainder of the spec as written, so >=1.9.0, ^4.17.1, [redis]>=5.0 or a whole environment marker survive; nothing is parsed or interpreted, and the key is absent rather than empty when the manifest pinned nothing), attrs["group"] (runtime, dev, peer or optional:<extra>), and a real declaring line. All four ecosystems: pyproject, package.json, csproj and pom.xml, each mapped onto the same group vocabulary so a consumer does not need to know which ecosystem it is reading.

Four defects fell out of doing this, three of them the same shape: a wrong answer that looks like a right one.

Each is covered by a test confirmed to fail against the previous behaviour.

This bumps PARSER_VERSION and therefore re-indexes every store, closely after the previous bump, which is not ideal for anyone who just re-indexed. The alternative is worse: a manifest that has not changed since the last index would keep the thinner edges forever, and no commit-keyed check would ever say so.

Fixed#

This supersedes the 7.14.0 entry below, which described the same helper as fixed after two passes. It was better, not finished. The scanner re-fired on the corrected line and named a real case, as it had the previous two times: <SCRIPT> first, then plain lowercase </script >, which measuring found and no alert had ever named, now the attribute-carrying form. Four new parametrised cases cover end tags with attributes and with embedded tabs and newlines, and each one was confirmed to leak under the previous pattern before the fix landed. Neither helper is a sanitizer or guards a security boundary: one keeps accessibility assertions from matching a string quoted inside a script, the other keeps script bodies out of the docs-site search index.

[7.14.0] - 2026-08-17#

Added#

Every constant carries attrs["declaration"], the declaration as written, collapsed to one line and capped: MAX_RETRY = 3, #define TIMEOUT 30. It is called a declaration and not a value because nothing has been parsed out of it. Each read becomes a uses edge from the file to the constant citing its own line, stored once per occurrence like calls, so "where is this read" is exhaustive rather than one edge with an arbitrary line attached. uses joins impact's default relations, so contextlake kb impact MAX_RETRY now answers.

This bumps PARSER_VERSION and therefore re-indexes every store. Nothing about it is visible to a commit-keyed check, which is exactly what that version exists to signal.

What is deliberately not a use: the declaration itself, a write (TOTAL += 1, global TOTAL), an import, and an attribute, since cfg.MAX_RETRY reads an attribute of cfg. A bare name is also never matched against a class field: a data member is reached as self.x or this->x, so a bare x is a local. That distinction was not theoretical. Allowing fields attributed 588 reads of a loop counter to a class member of the same name on one public C++ tree, confidently and wrongly rather than flagged as ambiguous, and removing them cut that tree's shard from 24.8 MB to 14.5 MB. Where a name has several definitions the edge is marked ambiguous rather than pointed at a guess, so anything counting uses can filter on confidence.

Cost, measured on two public trees: read edges came to +11% of all edges on a small Python package and +63% on a macro-heavy C++ one, where one test-assertion macro is read from 22 different files. Shard bytes stay reproducible: two independent indexes of one tree are byte-identical with the new stream, checked before and after.

Fixed#

Raised by CodeQL as py/bad-tag-filter for missing <SCRIPT>. Measuring it found a second miss the alert did not mention: plain lowercase </script >. So re.I alone would not have closed it, and the new test proves both halves independently, one case per spelling.

The shipped package does not share this defect. It defends that boundary by escaping <, > and & where data enters the page, rather than by pattern-matching for dangerous tags, which is the approach that fails. The same two weaknesses did exist in the docs-site search indexer, where the input is this repository's own rendered Markdown and every index field is escaped again before display; fixed there too, as correctness rather than as a vulnerability.

[7.13.0] - 2026-08-17#

Added#

Five things it will not do, each because a draft did and rendering against public trees showed it. It does not call a row count a caller count, so a symbol called twelve times from one place reports twelve sites and one caller. It does not name a file as a caller when a call carries no enclosing definition; it says so, and leaves it out of the count. It does not drop a caller that is not itself documented, because a test function calls things: the rule reads the kind registry's own container group rather than the documented-kind set, which had discarded 270 real callers on one C++ tree and reported a symbol with twelve call sites as having none. It does not claim to be ordered by call count while grouping by filename. And where the cap fell inside a tie, so that which symbols were dropped came down to their filenames, the page says so rather than implying a ranking.

Symbols carry their recorded scope, so a header-heavy C++ library does not produce several identical headings in a row; where the graph recorded no scope, the bare name is shown rather than a guessed one.

Fixed#

The check itself moved to kb/paths.py and is shared with the dashboard, which already had one. That version also catches ValueError, which resolve() raises rather than OSError for a path carrying an embedded NUL byte, so the copy written for the documentation generator would have ended a run with an uncaught exception on such a path.

[7.12.1] - 2026-08-17#

Fixed#

"Installation and usage" reported nothing on a repository with a Makefile. setup_signals is a flat list of filenames; the renderer unpacked it as a three-tuple, which is a shard-only helper's internal return rather than what the brief carries. Worse, the unpacking was written "defensively" with type checks, so it read the first FILENAME as if it were the list, failed the check, and rendered an empty section over data it was holding. Defensive unpacking that degrades silently is worse than a shape assumption that fails loudly. The test fixture had the same wrong shape, so both were wrong in the same direction and neither revealed the other.

The public surface listed files. A file reached that table only because it is a node with a degree, which is a fact about the graph's shape rather than about the repository's API.

An entry point could appear twice. Section 4 excluded entry-point kinds from one of its two passes, so a make target with an incoming edge arrived through the other and was listed under both "Entry points" and "The public surface".

A column labelled "Callers" did not count callers. The rank behind it is in-degree over every relation, so a symbol its own file contains already counted one. It reads "Incoming references" now: the vaguer word is true, and the precise one was not.

The install section also quotes the README excerpt when there is one, which the brief was already carrying and nothing displayed.

[7.12.0] - 2026-08-17#

Added#

The new structural page is built entirely from the graph, the manifests and the checkout, and carries six sections: entry points and how to run it, architecture, ownership and activity, the public surface with caller counts, installation, and what the repository contains, including the repositories it depends on and that depend on it. That last pair is a cross-repository answer no single-repo tool can give. An empty section is omitted AND named at the end, because an absence that says nothing reads identically whether the repository has none of that thing or the extractor missed it.

Large repositories get one structural page per module as well.

Strict deliberately. Expect drafts to fail this, and expect to keep reading the structural page on some repositories even with a strong model configured.

Because that document carries the ownership section, contributor names now reach whatever provider is configured. It is the same page written to disk, so it already honours [kb] anonymize: pseudonyms under "always", real names under the default. A run against a non-local provider says so once before sending anything.

[7.11.0] - 2026-08-17#

Added#

Two producers, because the fact arrives two ways. In most languages the entry point is an ordinary definition that something else makes special, so the kind is REFINED from function/method rather than a second node added beside it, the same call the test kind already makes. Python's if __name__ == "__main__": is an if_statement and not a definition at all, and [project.scripts] / package.json bin name a command that may point into another file entirely, so each of those produces its own node.

Each language needs a SECOND condition, and that condition is the whole feature. Go's package must be main, because func main() in a helper package is an ordinary function that Go will not build as a command and that looks identical to the real thing. Java and C# require static. Rust, Kotlin, C and C++ require the top level of the file. Without them every helper called main anywhere in a repository is advertised as a way to run the project.

Covered: Go, Rust, C, C++, Kotlin, Java, C# (Main, the one language that capitalises it), Python, plus pyproject.toml console scripts and package.json bin. npm run targets are deliberately NOT read: those are build tasks rather than commands on your PATH, and treating test and lint as entry points would bury the real one.

This is a parser change, so every existing store is stale and re-indexes itself. kb index already refuses to skip a repository whose graph an older parser built, and says how many it is re-indexing and why. Nothing to do by hand.

Fixed#

Explicit rather than inferred, decided rather than defaulted. The intent was to turn it on automatically when the store holds repos the operator does not own, and the index cannot answer that: a repository record carries id, path, host, branch and commit, and no ownership. The most obvious substitute, whether a repo id sits inside the configured mirror group, inverts on the case that motivated the rule, since mirroring an organisation you contribute to but do not own puts every repo inside the group.

Three deliberate asymmetries, each because this setting guards people rather than preferences. --anonymize can only raise it and there is no --no-anonymize, so a standing "always" cannot be lost to a half-remembered flag in a shared shell. An unreadable value anonymises anyway, with a warning quoting the spelling, because a typo must not read as permission to show a name. And a .contextlake.kb.toml found by walking up from the current directory may turn it on but never off, since contextlake clones repositories into the workspace itself and a checkout could otherwise disable the operator's own setting.

Fixed#

The regression test drives real HTTP over every route that can carry that prose, and asserts each one DOES carry it with anonymising off, so a route that returns nothing cannot pass the not-present half by accident.

Added#

Make targets become make_target nodes: the names a person types at a shell and a CI job invokes, which is the shortest honest answer to what a project expects of itself. Make's own special targets (.PHONY, .SUFFIXES) are not extracted, because a symbol nobody wrote does not belong in a graph whose claim is that its contents came from source. Variables are not extracted either, stated rather than left to be discovered.

Included fragments (common.mk, rules.mak) take the extension route to the same grammar, so a build system split across both spellings is one language and not two.

Optional for a packaging reason and not a product one. tree-sitter-dockerfile ships two wheels and no source distribution, so making it a hard dependency would not index less on Windows, aarch64 Linux and musl: it would make pip install contextlake[kb] fail there outright. When it is absent the Dockerfiles are skipped, and the run says how many and names the extra that fixes it. That is deliberately a different sentence from the one about files with no parser, because those have different fixes and one of them points at a page that cannot help.

27 languages across 25 grammars.

[7.10.0] - 2026-08-16#

Added#

These do not go through the definition query, and cannot: in CSS the pseudo-class in a.nav:hover is the same class_name node as the real class in .nav, so a node type cannot tell them apart, and a query would invent a CSS class called hover on every hover rule.

Depth is now part of the documented claim rather than hidden behind one number: C, C++, JavaScript, TypeScript, TSX and Python also yield module-level variables and class fields; every other language yields definitions, imports and calls. docs/style-guide-reference.md holds the phrasing and a test derives both counts from the parser, so a page cannot drift from the code again.

Changed#

[7.9.0] - 2026-08-16#

Module-level variables and class fields are now extracted for JavaScript, TypeScript and Python. They were extracted for C and C++ only. The head-to-head benchmark in benchmarks/head-to-head/ is what put a number on that: on a small public JavaScript tree the comparator emitted 461 variable nodes where contextlake emitted none, and both tools had read the same 141 files.

⚠ Re-index: your existing graphs are rebuilt on the next index#

PARSER_VERSION moves to 5, so kb index re-indexes every repository whose recorded parser version differs even though its HEAD has not moved, and says so while it does. Nothing is required of you beyond running it. That mechanism exists because a previous parser bump left Python and TypeScript repositories stale indefinitely while every surface reported healthy.

Added#

Destructuring patterns and tuple targets are deliberately not emitted: they bind several names at once, and a node named after the whole pattern would be a symbol nobody wrote. Locals inside functions are not emitted either, which the tests assert as carefully as they assert the positives.

What this does not claim: more nodes is not automatically better. A const holding a require() alias is a weaker answer to "what is in this codebase" than a function is. The benchmark measures coverage, not precision.

Fixed#

[7.8.0] - 2026-08-16#

Search stopped burying the answer, and the gate that should have caught it can now see it. The two are one story: a symbol's own definition ranked 32nd of 153 on a live index, and the project's own retrieval harness scored the fix as changing nothing at all.

⚠ Behaviour change: search results come back in a different order#

kb query, and the MCP search_code tool that agents call, return the same matches ordered differently. No re-index is needed and no result is dropped, but anything that depended on the previous ordering, including "take the first result", will see different output. The reason is in Fixed below: the previous order buried a symbol's own definition under files that merely mention its name.

Changed#

Fixed#

FTS5's bare rank weights every indexed column equally and the default tokenizer splits on _, so test_context_meta in tests/test_context.py matched the term in name, qualified_name and file while the real Context matched twice. Longer, noisier rows won. Results are now ordered by exact name, then by name prefix, then by a weighted bm25, so a related ContextMeta also outranks a test file that merely mentions the word. Weighting alone was measured and is not sufficient: it moved the real definition from 4th to 3rd and no further.

[7.7.0] - 2026-08-16#

Turn on what was already built. Four capabilities were present in the code, complete and tested, and unreachable from the path a user actually walks. Nothing here is a new feature; each change connects a built thing to the command that should have been calling it.

A fifth item on the batch list, folding the wiki into the static site export, shipped no code: the whole-repo wiki was already carried there. See "Verified, no change needed" below.

Changed#

Added#

Fixed#

Verified, no change needed#

[7.6.0] - 2026-08-15#

There is no 7.5.0. The plan assigned 7.5.0 to the search and first-five-minutes batch and 7.6.0 to this one; the owner reordered them so the honesty work shipped first, and this release kept its planned number rather than moving. The search batch keeps its content and takes the next free number. Recorded here so a reader looking for 7.5.0 finds the reason instead of a hole.

Nothing reports success when it failed. Twelve fixes, one defect: a surface reporting a result as complete when it was partial or failed. It turned up independently in the sources, the ingest summary, the diagnostics, the redaction flags, the dashboard buttons and the parser, so it ships as one release under one convention:

An operation that could not observe its input says so in its summary line AND in its exit code. A count printed as an outcome is measured after the operation, not before.

⚠ Behaviour change: partial failures now exit non-zero#

Several paths that printed a warning and exited 0 now exit non-zero. This will break automation that was silently passing, which is the point, a content pipeline degrading one source at a time was previously invisible until the answers got worse. Every affected command names --exit-zero-on-partial in its own output, and that flag already exists as a global option, so a scheduled run needs one flag rather than a rollback.

Fixed#

[7.4.1] - 2026-08-15#

The command you were told to run, runs, and writes where you said it would. Six defects, every one of them a documented path that silently did the wrong thing while reporting success. Two of them wrote into a production knowledge store during the audit that found them.

Fixed#

Fixed#

Found by reading the output of a real site deploy, where it fired seven times over the bundled sample fleet, which is not a git repository at all. Had one of those been a genuine timeout it would have read identically.

The three causes are now distinct. A real timeout and an unrunnable git each say so in their own words; "not a repository" is silent, because it is the ordinary state of an indexed tree that was never a clone, it is already visible as an empty owners list, and warning per repository per request is what buried the other two. A pair of tests pins both directions, and they capture at the log seam rather than through caplog or capsys: logging_setup sets propagate = False, so caplog.text is always empty here, and a capsys assertion passes or fails on whether an earlier test installed a stream handler. Both were written and both were wrong before this landed.

[7.4.0] - 2026-08-13#

Added#

Every node an MCP tool returns carries citation_status, verified, stale, or unverifiable, plus a citation_note when it is not verified. The answer is still returned either way: the guard discloses, it never withholds a result. unverifiable is a real third state and not a polite verified: no local checkout, an unreadable file, or a repo carrying no index timestamp all mean nothing was checked.

Two stages, because either alone is useless. The gate is one stat() per distinct file in a response, against the repo's indexed_at; only files that really were written after indexing escalate to a confirming read, which is eval.verify_citations called rather than reimplemented, so "does this citation still hold" keeps one definition and gains a second caller. A gate alone would fire on everything (a git checkout moves mtimes on identical files); a confirmation alone would read a file per node.

Measured on 43 nodes across 10 real files: +1.7% on a full tool call when nothing changed, +28.6% in the worst case where every file was modified, about 1.5 tokens per node. Past 32 confirming reads in one request the rest are reported stale with modified_after_index rather than quietly passed, a budget nobody is told about reads as a clean bill of health for work that never ran.

blast_radius carries the fields too. It returns hits rather than nodes, so it bypasses the funnel every other verb goes through and would have been the one verb handing back a file and a line with nothing said about either, and an absent disclosure beside twenty present ones reads as "checked, fine". kb steer writes the three-value explanation into the generated agent skills as well, so an agent that reads only its steering files still knows what stale means.

The refusals are the point. A PDF that yields nothing is never ingested as an empty document, an empty node is indistinguishable from a real one in search results and in the wiki. Each of the four ways it can decline says which one by name: the extra is missing (reported once per run, not once per file), the file is over max_bytes, the PDF cannot be parsed, or it has no text layer at all, a scanned page is reported as having none rather than silently ingested blank, because contextlake does not OCR. Page numbers travel with the document as pages / pages_read / page_offsets: a PDF's page number is what a line number is to source, so flattening it away would lose the citation.

kb-pdf is deliberately not folded into kb-full, and pypdf rides in the dev extra as well: the tests skip themselves when it is absent, and a CI job that skips them is green without having exercised the feature at all. The floor is pypdf>=5.0 because the suite was run against 5.0.0, not because that is what happened to be installed while writing it.

It names the pitfalls the code itself warns about rather than generic advice, the headline one being a blanket except that turns a half-registered grammar into a single skip <file>: parse error line while the index still reports success, which would cost a first-time contributor an hour. And it says what not to touch: the golden-query fixture and PARSER_VERSION should not move for a pure addition, with the cost of doing so stated.

Fixed#

Both directions are now pinned by a test beside the kind-registry parity checks. The reverse direction is deliberately loose: c_sharp is kept as an alias of the real id csharp for older stores, so the test asserts the stray set is exactly the aliases chosen rather than banning strays, and fails when a third appears without an explanation.

The cache is deliberately store-wide, not per repo. A per-repo cache invalidates more precisely and would also let a stale count for one repo sit inside a single reported total beside a fresh one for another, a quietly-mixed number, which is the class of bug this codebase keeps having to fix.

It also refuses to cache a run it could not fully observe: if any repo's shard cannot be stat'd, the result is returned and not stored, because encoding "I could not see this" into a fingerprint is how a cache becomes confident about a store it never read.

What was not done is worth recording. Computing this from SQLite instead would be faster still, since the edges are already in a table, but the investigation behind this change observed a real store whose shards had been deleted while the edges table held zero rows, where a SQL anti-join answers "0 dangling edges" about a graph with no edges left. This changes when the walk happens, never what it measures.

There is now a test that resolves every in-repo link and every #anchor on it, including the absolute GitHub blob URLs README and QUICKSTART use so their links work on PyPI. External URLs are deliberately not fetched: a test that reaches the network fails for reasons unrelated to the change in front of you. It found two more dead anchors on its first run, changelog cross-references into the dashboard guide, whose numbered sections had shifted underneath them, and both are repointed.

The two headline numbers were re-measured on this tree rather than copied out of the audit, and the command count is a good example of why: the CLI exposes 21 kb keys but only 19 distinct commands, because impact/blast-radius and owners/who-knows are alias pairs sharing one parser. Counting keys gives 34 commands; counting parsers gives 32.

Where the code looked like the real defect rather than the doc, a stale --help string, a flag that is documented as global and is not, the documented behaviour was made to match what the code does today and the defect was recorded separately, rather than changing behaviour inside a docs pass. Eight such items are now on a list for the owner.

The shard layer gained a way to resolve and stat a shard without reading it, and the cached core now carries the last three things a hit still needed from the file: the head commit, the parser version, and the shard-derived half of the setup signals. A cache hit now touches the file's metadata and nothing else.

Measured on a 306.8 MB shard, counting real parses rather than trusting the clock: 7.00s cold with one parse, 0.03s warm with zero. The cold path is unchanged, because that parse is real work.

Two invariants were kept deliberately, and both had a reason already written into the code. The file is still observed exactly once per call, so a cache entry can never pair one observation's head with another's node_count, a test pins that. And the live-checkout reads still run every call: only the shard-derived half of the setup signals is cached, because freezing the live scan would reintroduce precisely the staleness that scan exists to catch.

[7.3.0] - 2026-08-13#

All 24 accessibility violations, and a favicon you can actually see. An audit of the dashboard and the graph viewer found 24 WCAG 2.2 AA failures; every one is fixed, with each contrast ratio computed from the real token values and then re-read from the rendered page. The graph canvas had an accessible name and no accessible content at all, and now has a parallel text view wired to the same handlers a mouse tap calls.

The favicon changed: below 64px the mark is the context-pebble rather than a picture of the mascot, because a character cannot survive 16 pixels however it is simplified. Pebble is unchanged at 180px and above. Browsers cache favicons hard, so a tab you already had open may keep showing the old one until you force a reload.

Nothing to re-index and nothing to re-embed.

Added#

Every span of repo-derived content now travels inside a delimited block carrying its source path and a content hash, with one rule stated once per prompt: everything inside is data to describe, never instructions to follow. contextlake's own labels and directives stay outside the blocks, and nothing the model is asked to do changed.

The delimiter is unspoofable structurally, not probabilistically. Content that forges a closing marker is escaped in a single pass, with a replacement containing no <, so it cannot reintroduce the marker or supply half of one; the digest is then taken over the emitted bytes. An emitted block provably carries exactly the two markers the wrapper wrote. The load-bearing test builds a README that forges a close marker and then speaks as the operator, and asserts the count is 2, the naive string-interpolation wrapper is constructed alongside it to show it yields 4. A property test extends the invariant to arbitrary strings.

kb steer now also installs a skill telling agents the same boundary, and SECURITY.md documents it. The cost is bounded and flat: one rule plus about 130 characters per block, at most 5 blocks , between +837 and +1,133 characters, which is +3.5% on the largest wiki prompt and +12.7% on the smallest.

Fixed#

--cl-line was carrying both decorative and load-bearing borders at one value, so it is split, with --cl-line-strong measured at 3.25-4.51:1 across every surface it appears on. Dark theme gained its own --cl-lake with a paired --cl-on-lake (5.13:1 fill, 5.79:1 label). And opacity is no longer used to encode state -- a dimmed row said "excluded" only to someone who could compare it with an undimmed one, which meant a low-vision reader could not tell whether the counts they were reading were complete. It is now strike-through (5.65/5.91:1) and a hatched fill (13.85/11.41:1).

Two fixes needed structure rather than attributes: lists that were styled to look like lists are now real <ul>/<li>, and the search results stopped nesting interactive controls inside each other. Keyboard behaviour was confirmed by key sequence: Enter lands focus on the opened panel's Close button, and the p shortcut no longer fires inside a <select> or a contenteditable.

Every ratio is computed from the real token values in both themes and then re-read from the rendered page with getComputedStyle, because a declared value and a composited value are not the same number. That distinction found two things the audit had not: one ring was failing in light theme too once measured against its own tint rather than the card behind it, and the obvious fix for another would have introduced a fresh 1.4.3 failure. 34 tests pin the tokens and roles.

A force-directed diagram cannot be made meaningful by adding attributes to a canvas, so it now has a parallel text view rendering the same visible nodes, kinds and per-node connections as real buttons, wired to the same handlers a mouse tap calls -- namespace drill-in and neighbour expansion work from the keyboard. role="application" is gone, and the canvas pans, zooms and fits with the arrow keys, +/- and 0. Verified from page load in the collapsed-namespace state, the case where a naive text view would list nothing usable.

The contrast work needed structure too: edges were drawn at 0.45 opacity, at which no hue can reach 3:1, so they are opaque now with a per-theme palette, and every relation clears 3.40:1 against the worst gradient stop of its theme. The node stroke became theme-aware, which closed a hole the audit had missed -- the old border was navy on navy at 1.03:1 in dark.

Five adjacent defects were fixed in passing and are named rather than absorbed: the focus ring (2.60 -> 5.02:1), a chip that was white on a pale hue (1.70:1), two link groups that only looked like buttons, the PNG export ground, and a bug the new palette itself introduced where legend swatches kept light hues on a dark first paint.

Both are regenerated from the registry, and both are now gated. The new check recomputes from the real generator into a temp directory and compares bytes, rather than restating a list that would itself drift, the same shape as the existing llms-full.txt sync test. Nothing was added to CI, because CI already runs the whole suite; a separate script is one more thing to forget.

Two details worth keeping. The generator now writes a trailing newline, because the committed files had one and it did not, so a byte gate would have failed on all four for a reason no reader could act on. And its generation calls moved behind a __main__ guard so a test can import it, point the output directory at a temp path and recompute, which also exposed a flaw in the first version of the guard: an import-and-diff check passed even when in-sync files were rewritten, so it now inspects the source with ast instead.

[7.2.1] - 2026-08-12#

A data-loss fix, and three silent-wrongness bugs. The one that matters: kb index could delete repositories it was never asked to look at. If you have ever pointed --workspace somewhere other than where your store was built, upgrade before your next index run.

No re-index and no re-embed are required by this release. If a repo of yours has been re-embedding on every run, that stops now, though a repo carrying one of the empty markers described below will embed once more to catch up, and then settle.

Added#

Fixed#

"" is reachable, and that was proved rather than assumed: kb index --source <shard>.json carries an imported shard's stamps verbatim into the repos row, where every other accessor returns '' while these two returned None. The consequence was silent: such a repo was fully re-embedded on every run for the life of the store, while the command reported success each time.

Fixed on the write side, None now deletes the row, so absence is the absence of a row , rather than by dropping the read-side collapse, because that collapse is load-bearing: an unknown-matches-unknown comparison is what stops a genuinely unstamped repo from looping, which is the same answer kb wiki already reached. get_embedded_parser_version's docstring claimed the opposite and would have led the next reader straight back into the regression; it now sets out all four quadrants of the comparison.

Point a run at a different --workspace than the store was built from and every non-canonically-named repo in it was destroyed, with nothing in that run able to restore it, and no error, because from the migration's point of view the job was done. Observed on a real store: two repos, their shards and their vectors, gone.

The fix is ordering plus scope. Discovery runs first, and the migration is told which checkouts this run will actually index; anything outside that set is left alone. A skipped repo also no longer marks the store "clean" in the process-lifetime cache, because that would make a later run which did include it skip the migration entirely, a fix planting the next silent bug. Five regression tests, three of which fail against the old behaviour.

The walk now reports whether git could be asked at all. An honest empty still falls back, that is what stops a dormant repository reporting no owners, while a timeout does not, and either outcome is cached against the commit. A timeout also now says so, instead of being indistinguishable from a repository whose history attributes nothing to anyone.

[7.2.0] - 2026-08-12#

Three ways to trust what you are told, and one way to check the claim on the tin. A cited file:line can now be verified to actually hold the symbol; a coding session is told up front whether the graph still describes today's code; and --offline refuses every non-loopback connection, so "local-first, no telemetry" is something you can test rather than something you have to believe.

Nothing to re-index and nothing to re-embed. kb steer gains one more generated file (.claude/settings.json), and existing hooks in it are preserved.

Added#

Loopback stays open on purpose: the MCP server, the dashboard, the graph viewer and a local Ollama all live there, and an offline mode that turned those off is one nobody would use. Verified with the network blocked: kb index, kb query, kb embed, semantic search and kb graph all work. Two limits found by testing rather than assumed: the bundled embedding model is fetched from Hugging Face on first use, so a cold cache plus --offline leaves semantic search unavailable (it degrades with a clear message, no crash), and the wiki's LLM tier is only as local as the provider it is pointed at.

The boundary is stated instead of glossed. This is an in-process guard, and git and glab are subprocesses with their own sockets, so mirror fetch|clone|update|branches| sync refuse up front under --offline (exit 2) rather than pretending to be covered. mirror verify and mirror status read the local workspace and stay available. bootstrap composes those same stages, and the first version of this let it walk straight into the forge: the socket guard did stop the enumeration, but only after ~26 seconds of retries, and it then blamed "a VPN/network drop" for a restriction the user had asked for. It now skips the mirror stage and builds the knowledge layer from what is already on disk, which is the same resumable state its network-failure path already produced.

Written adversarially and it paid immediately: the first version of the loopback test was host.startswith("127."), which reads as correct and accepts 127.example.com -- an ordinary remote hostname, straight through the guard. Its own test caught it. The address is now parsed, not prefix-matched. Same unanchored-string bug class as the --repos matching fix in 7.0.0.

The check is cheap by design (one git rev-parse per repo plus two indexed lookups, no parsing) and bounded by --budget seconds, with anything it did not reach reported as unchecked -- a cap nobody is told about reads as a clean bill of health for work that never happened. It reports moved heads, repos built by an older parser, and vectors built from an older text format. A repo whose clone is missing is reported and deliberately not counted as stale: re-indexing cannot fix it, and a session start that proposes work which changes nothing is one people learn to ignore.

--refresh starts kb index then kb steer detached, so a session opens immediately and the graph catches up behind it. Blocking a session start on a fleet re-index would be worse than the problem, and a re-index killed part-way is not a state this project has proved safe. Concurrency needed nothing new: write commands already take the cooperative store lock, so a second session's refresh refuses cleanly.

kb steer now also writes .claude/settings.json, adding a SessionStart hook that runs kb refresh --hook --refresh. Other hooks and settings in that file are preserved, and re-running steer replaces our entry rather than appending another copy -- hooks.SessionStart is a list, not a keyed dict, so appending would quietly run the hook twice, then three times. Claude Code is the only editor whose session-hook schema this was verified against and the only one claimed. CONTEXTLAKE_NO_SESSION_REFRESH=1 switches it off without editing the file.

Failures are named, not counted: file_missing (the graph outlived the file), line_out_of_range (the file shrank under a stale index), name_absent (the line is there, the symbol is not on it), no_citation (a symbol node with no file or line at all). A repository whose recorded clone is not on this machine is unverifiable and stays out of the rate, so a run without the mirror reports "nothing was checked" rather than a pass. Off by default: it does filesystem work per result and needs the checkout.

Measured on a large legacy C/C++ tree, all 48,556 citable nodes: 48,552 verified, 4 broken. Two things worth knowing came out of that run rather than out of the feature.

It found a bug in the checker, in the good sense. SQL table citations verified at 7.7%. Every failure had the name absent case-sensitively and present on exactly the cited line case-insensitively, because kb/sql.py casefolds DDL object names on purpose -- SQL identifiers are case-insensitive and foreign-key attribution matches on the normalised form. The citations were right; the comparison was wrong. It is now case-insensitive for languages whose identifiers are, keyed on the node's language rather than its kind, and emphatically not global: in C++ Draw and draw are different symbols.

And it contradicted its own design note. The two-line window either side of line_start exists because a definition's start line can precede its name (a C++ return type on its own line, a decorator above a def). Measured, widening from 0 to 2 reclaims one node in 3,000, and 5 reclaims nothing further. The comment now carries that number instead of the story. The four remaining failures all sit in one file with the name exactly 3 lines below the cited line, which a window of 3 would paper over -- left at 2 on purpose, because tuning a check until it passes is how a check becomes decoration.

[7.1.0] - 2026-08-12#

Grounding, and the cost of 7.0.0's bigger graphs. Three of these follow directly from 7.0.0 emitting five more kinds of C/C++ symbol: the new kinds are now reachable by semantic search, the wiki's per-kind floors are bounded so they cannot displace the ranking they exist to garnish, and the dashboard's owner panel no longer walks a whole repository's history on every request.

contextlake kb embed must run once after upgrading. Nothing else needs re-indexing: no ids change and PARSER_VERSION stays at 4.

Added#

It is now 30 queries over a synthetic multi-language fixture repo (examples/fixtures/eval-repo , C++ header and source, Python, SQL, XML config) that parses to 42 nodes across 13 kinds, including the five symbol kinds 7.0.0 added. A retrieval change that only helps functions can no longer hide.

Measured: hit-rate 0.80, precision@10 0.7241, recall@10 0.80, MRR 0.80. All six misses are the natural-language phrasings ("reject an implausible sensor value"); every exact-name query hits. FTS5 has no synonym matching, so those six are not a defect, they are the gap semantic search exists to close, which is why they are in the set.

Three properties worth stating, because each is a trap avoided rather than a feature:

Changed#

kb embed must run once after upgrading. EMBED_CONTENT_VERSION moves to 4, which marks stored vectors stale and triggers that automatically. The bump is the load-bearing part: widening the embeddable set does not make existing vectors wrong, it makes the store incomplete, and the incremental skip is keyed on commit and parser version, neither of which moves. Without it you would keep a store with no vectors for the new kinds while doctor reported a healthy row count.

The cost was measured, not assumed. On a large legacy tree, +180% vectors costs 5.25 percentage points of recall@10 for the kinds that were already embedded (273/400 probes to 252/400). Marginally: typedefs, enumerators and file-scope variables together cost 1.00pp and take 12,342 symbols from 0 to 74% findable; macros add 1.50pp for 16,347 symbols at 85%; data members add 2.75pp for 40,948 at 65%. Every step buys more than it costs in the unit that matters, whether the thing you searched for can be returned at all.

field is the heaviest by far (+105.9% vectors alone, and the least distinctive names, 54.8% unique, one name occurring 506 times). It is recorded in the kind registry as the first row to reconsider if that cost ever bites.

Fixed#

Two fixes, both measured. The walk is now bounded to 12 score half-lives (~5.9 years), which is where a commit's weight reaches 0.00024 and can no longer change a ranking, so the truncated history was pure cost. And the result is cached on HEAD, since owners only change when history does.

Repeat requests went from 34-35s to 4.2s. A repository whose newest commit predates the window falls back to the unbounded walk, because answering "no owners" for a dormant-but-real repo would be worse than the slowness being removed.

Two bugs were caught inside this fix and are worth knowing about. --since was first passed a float, and git log --since="2160.0 days ago" exits 0 and returns zero commits, it silently fails to parse, so the bound never matched, the fallback always ran, and the net effect was two full walks instead of one. And the cache key was first stored in a variable the aggregation loop rebinds, so it wrote under a contributor's email and never hit. Both left every existing test passing; there are now tests for the single-walk property and for the cache actually hitting.

Still slow and separately tracked: the first request remains ~30s on a repository this size, because a 253 MB shard costs a measured 2.84 GiB resident and so exceeds the shard cache's whole 2 GiB budget, it is correctly never cached, and 7.0.0's larger graphs are what pushed shards past that line.

Measured after 7.0.0 started emitting five more C/C++ symbol kinds: on a real 12-kind repository with a 15-row cap, the list kept only 47% of the pure degree-ranked top; constructed at 17 kinds, 1 of 15 rows was a genuine high-degree node.

Floors are now bounded to cap // 2 and spent only on kinds the honest ranking left out entirely, a kind already in the top rows needs no reservation. Same bounded-share rule visualize/payload.py has always used. The original purpose survives: a zero-degree kind present in the candidates still gets a slot, which a test pins.

Worth knowing where this bit: the cap is 15 until a repository exceeds ~22,500 nodes, so the harm concentrated in repos with many kinds relative to their size rather than in the largest ones. The floor selection had no test at all before this change.

[7.0.0] - 2026-08-12#

A correct graph, and one re-index to get it. This release fixes what the graph says, so every id changes and PARSER_VERSION moves to 4. Run contextlake kb index once after upgrading; it now notices on its own, and so do the wiki, the vectors and the cluster pages, which previously reported themselves fresh across a parser change.

The headline corrections: node ids no longer contain a file path or a line number, so a header and its .cpp finally describe one symbol; C++ internal linkage is honoured, so two files' static or anonymous-namespace symbols stop merging into one; calls edges are stored per call site rather than per pair; and five kinds of symbol that were never emitted at all -- data members, macros, typedefs, enum constants and file-scope variables -- now exist. On a large legacy C/C++ tree that is 62,066 nodes to 131,603.

Two breaking changes outside the graph: the built-in wiki LLM moved to openvino-genai (no compiler, no wheel index, and CVE-2025-69872 closes by removal), and --repos patterns are anchored, so a bare name no longer selects every repo that merely contains it.

Added#

Each is contained by the scope it really sits in: a data member by its class, an enumerator by its enum, a namespace-scoped variable by its namespace. Macros keep the file in their identity even in C/C++, where other symbols drop it, because the preprocessor runs before C++ scope exists and two headers defining the same macro name really are two macros.

Two traps, both measured rather than guessed, and both handled:

None of the five is embeddable yet. The measurement found the dilution risk is repetition rather than short names: only 46.3% of data-member names are unique in that tree and one occurs 516 times. Turning them on interacts with the per-kind embedding budget floors, so it stays a sequenced decision rather than a side effect.

The cap itself stays, and that is a measured decision rather than an unexamined default. On a large legacy tree 21.6% of resolvable call references sit above it, and the distribution has no knee: admitting them all costs about 3.6x the calls edges for 21.6% more references, because an ambiguous reference emits one edge per candidate. Raising the cap to 8 buys 3.0% more references for 17.4% more edges; raising it to 12 buys 10% for 85%. The line is silent when the count is zero, so it never becomes boilerplate.

All three now carry edge_file and edge_line. For a path, each hop cites the edge that makes it adjacent and the seed node carries nothing, because it was not reached by an edge and inventing provenance for it would be worse than leaving it empty.

These are a separate pair from the call_file/call_line added in 6.7.0, deliberately. A depends_on edge's provenance is a manifest declaration and an inherits edge's is a base-class mention; delivering either under a field named call_line would be a plausible-looking lie, and this project's defect history is made of those. Each verb populates only the pair whose name describes its relation, so no result carries both. call_file/call_line are unchanged, so nothing built against 6.7.0 breaks.

Also recorded in that audit and deliberately NOT changed: blast_radius (a hit several hops out has no single edge to cite, so the current output is coarse rather than wrong) and the repo-level flow verbs (their edges are aggregates rolled up from many, and one line is not a property of an aggregate).

Stated plainly because a partial extraction must not be mistaken for a complete one: the host-side launch kernel<<<grid, block>>>(...) is not C++ syntax and lands in a local ERROR region, so a kernel launch is missed as a call while an ordinary call in the same file resolves normally. tree-sitter degrades locally rather than failing the file, so everything else still extracts.

This one had a measurable cost while it was open: asked "who calls this", a comparator that reads .cu returned genuine callers that contextlake could not see.

Measured on a large legacy C++ tree: 181 .xml files that produced nothing now produce 12,991 settings. The element path is what makes them distinguishable, so the same key name under two sections stays two settings rather than colliding.

Two deliberate choices, both about not lying and not leaking:

Data-shaped XML does not flood the graph: a lookup file of thousands of identical rows collapses to its distinct element paths, so it contributes a schema and where to find it rather than a copy of the data. Per-file output is capped, and files over max_file_bytes were already skipped.

Indexing that tree costs 42.8s against a 39.4s baseline, so 8.6% for the whole config surface. The first working version cost 107% instead: line numbers were counted from the start of the file once per match, which is quadratic on a data-shaped file with thousands of leaf elements. Counting forward from the previous match is linear and produces byte-identical output. This is the third time this exact quadratic-scan shape has been found in this package, after pom.xml parsing and parse_hcl, so it is worth naming as a pattern rather than a one-off: any per-match count or index from position zero over the same buffer is the bug.

Changed#

A filter that silently selects more than you asked for is the expensive direction of this mistake, because you find out after a fleet-wide run you did not want. The old default made that the easy thing to type.

--repos-exact was the opt-in fix for it, and it is removed rather than kept as a no-op: it only ever reached five of the seven places that filter repos, so --repos-exact silently did nothing for kb index --workspace and for the metrics pass, and the same pattern scoped differently depending on which command you ran. There is now one rule and no flag to forget.

Nothing else changes: globs like team/* behave exactly as before, matching is still case-insensitive, and it still matches against both the group-qualified path and the local path.

The extra keeps its name. What changed is what it installs.

The old backend was the one dependency a plain pip install could not finish: upstream publishes no wheels to PyPI at all, so pip fell back to compiling llama.cpp and wanted cmake plus a compiler, and the project had to point pip at a per-accelerator index to avoid it. It also had no wheel for CPython 3.14 on any x86_64 platform, upstream ships exactly two cp314 wheels and both are linux_riscv64, which is what pinned the container base image to an older Python.

openvino-genai ships ordinary manylinux wheels for CPython 3.10 through 3.14. Its closure is openvino-tokenizers and openvino; it pulls neither torch nor transformers, verified by resolving the extra in a clean environment rather than by reading metadata.

The default model is OpenVINO/Qwen2.5-Coder-0.5B-Instruct-int4-ov: Apache-2.0, published pre-converted by the OpenVINO project, and 349 MB against the previous 491 MB. It is the same family and size class as the GGUF it replaces, deliberately, a dependency change is not the place to slip in a bigger model. Pre-converted matters: converting a checkpoint to OpenVINO IR yourself needs optimum-intel, which does pull torch.

The model_file config key is gone. It selected a GGUF quantisation, and an OpenVINO model is a directory rather than a file, so there is nothing for it to pick.

It applies to calls only. The same resolver serves inheritance and the config/SQL streams, and retaining every mention of a base class or every reference to a table is a different question that has not been asked. The choice lives in one shared constant that the parser and every degree consumer read, rather than a relation name copied into three files.

Degree now counts distinct pairs. This is the part worth reading if you maintain a consumer: ranking by raw row count answers "how many call sites" while the number is rendered beside a symbol as "N caller(s)", so a helper called fifty times from one place would present as fifty callers and outrank genuinely popular code in both the wiki's hub list and the node selection for a truncated diagram. Counting distinct pairs is also exactly the historical number, since there was one row per pair before this change -- verified on a large tree by running the new ranking against a pre-change graph and diffing: byte-identical.

Removing the de-duplication exposed a latent bug it had been hiding. References were sorted by line alone, so references sharing a line fell back on tree-sitter's capture order, which is not guaranteed. Only one of them used to survive, so the ambiguity was invisible; now they all do, and the sort is a total order. Shard output stays deterministic.

The reason is not consistency for its own sake. A vector row is keyed by node id, so a stale embedding store yields hits naming nodes the graph no longer holds. These two dropped those hits silently and had nowhere to say so, because a bare list has no field for a note -- the caller got a shorter, entirely plausible answer, and doctor reported a healthy row count throughout. They now disclose it: "N vector hit(s) named nodes that are not in the graph and were dropped ... this result is INCOMPLETE. Re-run kb embed." A healthy store adds no note, which a control test asserts, because a warning that always fires is the defect rather than the fix.

It is still prefixed everywhere the file genuinely forms part of the qualification: every language that puts one module per file (Python stays m.py::Foo.bar), and C/C++ static symbols, whose internal linkage is file-scoped by language rule. That is deliberately the same single rule that decides whether the file enters a node id, computed once and used by both, so the two can never disagree about what a symbol's identity is.

The old id was repo + path + qualified-name + line, which made two things impossible. The path meant a declaration and its out-of-line definition could never be the same symbol. The line meant editing anything above a symbol changed its id, so every edge, vector and wiki reference to it churned for no semantic reason.

Ids are now <readable-slug>_<8 hex digest>. The slug keeps them legible where people actually read them -- answers, dashboards, MCP arguments -- and the digest is what makes them correct, covering repo, language, kind, qualified name, signature, and the file for internal-linkage symbols only.

Three details that are not arbitrary:

A consequence worth knowing: two headers declaring the same S::T now produce one class node. That is accurate rather than lossy -- in well-formed C++ S::T names exactly one class, and two differing definitions of it are an ODR violation.

The lists are deliberately not merged: a colour map and an embeddable set answer different questions, and file legitimately has a colour while never being embeddable. Instead kb/kinds.py holds one row per kind carrying every property a consumer needs, and each list became a one-line comprehension at its original definition site, so no import moved. KindSpec has no field defaults, so a new kind cannot be added without answering every question once, in one diff.

This closes the drift the lists had accumulated: 16 of the 35 produced kinds had no colour, which is not cosmetic -- the graph page builds its kind filter by iterating the colour map rather than the graph, so those kinds (including table, view and resource, routinely hundreds of nodes per repo) had no legend button and could not be isolated or hidden at all. Also fixed: the glyph table had drifted to 15 entries against a 17-symbol sprite; impact's ranking set tested membership against a type kind no producer emits; the published vocabulary diagram documented 16 of 35 kinds while claiming it could never drift; and the MCP link-output comment documented a merge_request kind the git-forge connector has never emitted (it emits mr).

Which kinds are actually embedded is unchanged -- membership feeds the per-kind embedding budget floors, so widening it would evict existing vectors. config_key and test are recorded in the registry as eligible and deliberately deferred, with the reason, and a test now refuses any kind that is excluded without one.

Fixed#

kb index has always been parser-aware. Embeddings, the wiki page and cluster pages were keyed on the repo's commit alone, so a bump refreshed the graph while all three went on reporting themselves fresh. The vectors are the sharpest case, because a vector row is keyed by node id: stale rows name nodes the graph no longer holds, those hits are dropped at query time, and the caller gets a shorter, entirely plausible answer while doctor reports a healthy row count.

Each now records the parser that built what it describes, and asks two questions instead of one. A wiki page carries its stamp in the provenance footer, placed after the backticked commit so the four readers that parse at commit \…`` are undisturbed. An artefact with no stamp regenerates once and then settles -- except where the shard itself has no version, which nothing can conclude from, so that case keeps asking the commit-only question rather than rebuilding forever.

Upgrading from any earlier version requires a re-index (contextlake kb index --force, or simply kb index, which now notices). Without it, ids in your store match nothing this build produces, and no commit-based check would have told you.

Two files each writing namespace { int tally(int); } produced one node. The second file's definition simply vanished, its callers pointed at the first file's function, and a struct declared the same way took its data members down with it -- a member of the losing copy disappeared entirely. static free functions already kept their file, but anonymous-namespace symbols did not, and the file-scope variable path never checked linkage at all.

Resolution now honours it too. An internal-linkage symbol belongs to one translation unit, so a reference from a different file cannot mean it. This is a preference, not a requirement: where the only candidate is defined in a header (measured at roughly one in ten on a large legacy tree, since headers legitimately carry static definitions into their includers), the cross-file candidate is kept rather than dropped. Losing a real caller is the worse error.

Fixing identity alone would have been worse than fixing neither: it splits the symbol and then offers both copies to every caller as ambiguous candidates.

A second-order effect worth knowing, because it moves numbers in the opposite direction to the obvious one: a reference whose candidate set exceeds the ambiguity cap is discarded entirely, so removing unreachable candidates pushes some sets back under the cap. On a large legacy tree 461 references that previously produced no edge at all now resolve, and total calls edges rose even though 33,568 impossible candidates were rejected.

Also corrected: static inside a class declares a member with external linkage, and was being treated as internal. That kept a class's header declaration from matching its out-of-line definition -- the same header/source split fixed elsewhere in this release.

ask was the sharpest case, because its disclosure reports the question's unmatched terms rather than dropped results. So it affirmatively stated that everything you asked about was indexed while quietly discarding most of what the search actually found.

It now counts the unresolvable hits and says so: "2 vector hit(s) named nodes that are not in the graph and were dropped: the embedding store is stale relative to the index, so this answer is INCOMPLETE. Re-run kb embed." A healthy store produces no warning, which is asserted by a control test, because a warning that always fires is the defect rather than the fix.

EMBED_CONTENT_VERSION's contract is also widened. It existed to catch exactly this class of staleness but keyed only on the node-to-text mapping, and the node id is not part of that text -- which is precisely why the failure was invisible. It now documents that any change to how node ids are built must bump it, since it is the only signal that reaches kb embed's incremental path and re-embedding is the only repair.

It now filters. The subtlety is why this was not a one-line wire-up: the default was ["csharp", "typescript", "python"], and passing that through would have silently stopped indexing C, C++, Go, Java, JavaScript, Kotlin, PHP, Ruby, Rust, Scala and TSX for everyone who never set the key -- a far worse bug than the dead setting, and a graph that quietly loses most of a polyglot repo.

So the default is now None, meaning every supported language, which is exactly the behaviour every existing install already has. An explicit list finally restricts. languages = [] also means everything, because "I did not decide" is a much likelier reading than "index no code at all", and the alternative is a silently empty graph. The old three-language constant is gone, with a test guarding against its return.

Both symbols now exist, and a parity test compares the two files by regex (no browser needed) so a kind can never again be registered in one place and missing from the other. The test was verified to fail when a symbol is removed, rather than merely passing today.

Resolution now matches the whole qualifier. An exact hit on the full chain wins; otherwise the chain must be a suffix of the class's own, which accepts a qualifier written relative to an enclosing namespace (void Box::put() inside namespace NS resolves to NS::Box) while still rejecting Other::Box. A qualifier naming no known class, or one that stays genuinely ambiguous, attaches nothing: file-contained is the honest answer.

Measured on a large legacy C++ tree, like for like against the same counting unit: methods per class 5.66 to 5.79, classes carrying zero methods 310 down to 296. Indexing cost is unchanged at 1:08 against a 1:07 control, because both lookups are prebuilt in the single pass that already existed rather than scanned per method.

Nothing in the suite previously forbade a fabricated parent, which is why this survived review: the graph looked richer rather than wrong. There is now an explicit negative test for it.

A template segment now contributes its base name (Box<T> gives Box, which is what the class node is called, since the arguments belong to the specialisation rather than to the class's identity), so NS::Box<T>::put qualifies as NS.Box.put instead of NS.put.

More importantly, every unrecognised scope shape now falls through to its own text instead of disappearing. That matters more than the template case itself: a segment that is merely ugly still resolves or fails visibly, while a segment that is absent quietly changes which class a method belongs to. The next scope type nobody anticipated will not repeat this.

The case name now becomes the node's name, the suite becomes its qualifier, and the kind is test. So TEST(TimerSuite, HandlesMinutes) is a test named HandlesMinutes qualified TimerSuite.HandlesMinutes, and two suites that share a case name stay two distinct nodes.

Absence of a return type is deliberately not the discriminator on its own, because a constructor and a destructor have none either: the macro name is matched against a closed set first. That set was verified macro by macro against the grammar. Catch2's TEST_CASE("a name") is excluded because with a string-literal argument it does not parse as a definition at all, so there is no node to rename and it needs a different mechanism; listing it would have been an unsupported claim.

test is registered in the diagram colour map and the dashboard glyph vocabulary, so it does not render as a file icon or drop out of the kind filter.

A name lookup still found those nodes, which is exactly why this survived: "where is this setting defined" looked answered while nothing could reach the setting by traversal and no diagram of a file could show its contents. Each of these files now gets its file node and a contains edge to everything extracted from it, in one place in the dispatch so it cannot drift per extractor. A file that yielded nothing still gets no node, so the graph gains no empty shells.

Not extended to manifests on purpose: their nodes are cross-repo package nodes that several manifests legitimately share, and the relation that belongs between a manifest and a package is depends_on, which is already emitted. contains would assert the package lives in that file.

Linking a setting to the code that reads it is deliberately not attempted. A config_key named Timeout and a string literal "Timeout" in a source file are a plausible match, not a verified one, and minting that edge is the speculation this graph refuses to do.

[6.7.0] - 2026-08-11#

Added#

Measured on a large legacy C++ tree: 6 of 6 callers of a sampled symbol reported a call line different from their definition line, every one of them readable in the real file at the line given. On that tree the two lines were 3 to 33 lines apart, which is exactly the gap a reader was being asked to close by hand.

Fixed#

Changed#

[6.6.0] - 2026-08-10#

Added#

A ```mermaid fence in any page renders as a diagram, from a vendored copy so the site makes no external request, loaded only on the pages that have one. The same fence renders natively on github.com, so a diagram stays readable in the source tree and in a review.

Roles are carried by shape, not colour: a rectangle runs, a cylinder persists, a rounded box starts or ends, a diamond decides. That is what WCAG 1.4.1 asks for, and it is also the only thing that works, because mermaid renders a classDef as an inline style attribute carrying !important and no stylesheet can override one, so a colour written into a page could never follow the light or dark theme.

Changed#

The SBOM describes the built wheel's dependency closure, generated from a throwaway virtual environment holding that wheel with the kb-full extras and nothing else. That distinction is the whole point: running a generator over the release job's own environment would have produced a document listing ruff, pytest and twine and called it contextlake's SBOM. A canary asserts the result before it is published, so this cannot silently drift back into describing the build machine. The scope is stated rather than implied: kb-full is kb + kb-local + kb-vec, and excludes kb-fastembed and llm-local.

The binaries are attested in the job that uploads them, so the signed digest is the digest of the bytes you download. gh attestation verify <file> --repo sayak-sarkar/contextlake checks one, with no key material. The install docs say plainly what that does not cover: the launcher fetches its Python payload from PyPI on your machine at first run, after any signature, so signing cannot reach that half. Anyone who wants the payload covered should install from PyPI, where the wheel and sdist carry PEP 740 attestations.

Two could not be pinned by simply naming a version, and each says so where it sits. pypa/gh-action-pypi-publish was on the release/v1 branch, which upstream recommends so their fixes arrive unbidden; it is pinned anyway because it is the step holding the publish token, and its head was checked to be exactly v1.14.2 at pin time rather than assumed. dtolnay/rust-toolchain@stable is also a branch, and its comment gives a date instead of a version, because the pin fixes the action that selects a toolchain and not the compiler it goes on to install.

Every finding was read. 4672 were S101 ("assert used") in tests/, which is pytest's whole idiom, and the rest of tests/ is fixtures doing on purpose what the rules warn about, so the ruleset is switched off there: nothing in tests/ ships. Two rules are off in the package as well, S603 and S607, because they fire on every subprocess this tool exists to run and on resolving git and glab from PATH, which is the only portable choice. The remaining 34 sites were each read and now carry their own reason. All 34 turned out to be safe, and two of them are the code that warns you about binding to a wildcard address, which is what the rule flagging them is for. Every one of the 12 "possible SQL injection" findings interpolates a ? placeholder count or a fixed clause fragment, never a value.

S now sits in the ordinary lint select, so it runs in CI on every push and pull request, in the release build gate, and in a contributor's own ruff check. A new finding fails the build. The separate job is gone rather than kept alongside, since it would only re-report what the main gate already refuses.

Fixed#

[6.5.0] - 2026-08-07#

Changed#

This changes what gets indexed. kb index previously used the parser's 5 MiB, so a source file between 5,000,000 and 5,242,880 bytes was parsed and is now skipped, and its symbols leave the graph on the next index. The window is narrow and a source file that size is usually generated, but the change is real and it is not a bug fix.

Fixed#

They are swept by reachability rather than by ownership, which matters: deleting them per repository would take the packages the surviving repositories still import, and that is exactly the bug the stable sentinel was introduced to prevent. A shared node goes only once no edge anywhere still references it.

[6.4.0] - 2026-08-07#

kb index now refuses to bundle a directory of repositories instead of doing it silently. This is a deliberate behaviour change on a path that previously succeeded, and it is the fix for the most damaging quiet failure the tool had: pointing kb index at a folder containing git repositories bundled them all into one pseudo-repository, duplicating every symbol under a second identity. On one real store that reached 63% of all nodes. --bundle opts back in.

Added#

Changed#

It refuses rather than quietly switching to --workspace for you, because switching can lose data. --workspace indexes each nested repository and nothing outside one, so on a tree of your own loose sources that happens to carry a dependency with its own .git it would index the dependency and silently drop your sources -- strictly worse than the bundling it replaced, which at least captured them. So the shape is measured first, from how much indexable content lies outside the nested repositories, and the refusal prints what was found (how many working trees, at what depths, how much content outside them), which shape that indicates, the one command that fits it with the real path in it, and why --bundle exists. It exits non-zero.

Three shapes, three answers. Several repositories with effectively nothing of your own outside them is a workspace mirror, and the command is --workspace <dir>. One repository with nothing at all outside it means the directory is one level too high, and the command names that repository. Real content of yours outside the repositories is a project carrying a dependency, and that is bundled as before, now with a line saying so rather than in silence. A directory that is itself a git repository never reaches the diagnosis at all, however many checkouts it contains, so the ordinary kb index . is untouched.

Fixed#

In a real run it cost coverage a subtler way than that. The operator saw that . was wrong, reasonably inferred the fleet lived one level down, and ran --workspace ./repositories; the repository sitting above that subdirectory was then never indexed under its own identity at all, only inside the bundle. Advice that cannot be followed literally is not a cosmetic defect, because the reader has to guess, and a plausible guess was wrong.

The message now echoes the path as it was typed -- shell-quoted only when the path would not survive a shell -- so it stays . for a bare kb index run, where the short form is both correct and the command the reader will recognise as their own.

[6.3.0] - 2026-08-07#

Accessibility and security hardening. Six WCAG 2.2 AA failures fixed in the dashboard, each verified in a real browser rather than from source; the remaining findings from the security audit that 6.2.0 began; and kb index now sees nested repositories at any depth.

Added#

A second audit job now resolves the full shipped dependency set -- every kb extra plus llm-local and release -- and audits that. It resolves without building anything, so the original flakiness argument does not apply, and it refuses to report a clean result unless the resolved set demonstrably contains the extras it is meant to cover: a resolution that silently returned nothing used to look identical to a clean scan, and now fails loudly instead. The release extra was added to the existing job for the same reason, since it compiles nothing.

Consequence for anyone auditing this project: "is the dependency tree free of known vulnerabilities" is now a question CI can answer for the profiles that ship, rather than only for a subset of them. That check currently surfaces one advisory in a transitive dependency of llm-local with no fixed upstream release; it is listed explicitly in the workflow as known-unresolved with its disposition still open, so the job's pass/fail signal reports newly appearing advisories. - Security response headers on every local HTTP server. kb dashboard --serve, kb graph --serve and the served static site now send a Content-Security-Policy, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer on every response. The policy is stated once in the shared server base, so all three servers get it and a future server inherits it.

This is defence in depth, not a fix on its own: the policy's job is to contain a page that has already gone wrong. default-src 'none' with connect-src 'self' means a script running on the dashboard's origin cannot send anything to another host -- no fetch, no beacon, no form post -- which is the step that would turn a page-render into data leaving your machine. frame-src/frame-ancestors stay 'self' so the dashboard's architecture panel keeps working, img-src allows the data: URIs the node glyphs use, and the jsDelivr origin is permitted for scripts because kb graph --serve --cdn loads cytoscape from there. Inline scripts and styles are allowed, since the pages inline their own assets by design.

Fixed#

This mattered because a source URL is not necessarily one you chose. contextlake discovers .contextlake.kb.toml by walking up from the current directory, and it clones repositories into your workspace itself, so a checkout could supply the config that supplies the URL -- no action needed beyond working in that directory. The existing workspace-trust gate covers config keys that reach a subprocess and deliberately leaves url alone as "an HTTP endpoint"; that is now true rather than assumed.

Ingest fetchers now open http and https only, and log a warning naming the refused scheme rather than skipping quietly. Configured http(s) sources are unaffected. Requests to private or link-local addresses are still permitted -- SECURITY.md now says so explicitly. If you need the discovered-config tier gone entirely, CONTEXTLAKE_NO_LOCAL_CONFIG=1 still does that.

[6.2.0] - 2026-08-07#

Contains security fixes. Two issues were reachable from ordinary content in a repository you index, so upgrading is recommended for anyone running kb dashboard, kb graph, or the Windows binary. See the first two entries under Fixed, including one manual step: regenerating graph HTML you saved or published.

Added#

Motivating case: running bare kb index in a directory that contains git repos rather than being one bundles everything underneath into a single pseudo-repo named after the directory. contextlake warns first and the warning prints the right command, but on one real store that pseudo-repo ended up holding 63% of all nodes, duplicating every mirrored repo under a second identity, with embed then spending 91% of its vectors on it. - --repos-exact for an exact repo id/path match. --repos has always matched a plain pattern as a substring of a repo's id or local path (documented, but easy to be surprised by): on a real fleet, --repos ledger selected the intended repo plus an unrelated one whose name merely contained "ledger". --repos-exact drops that substring leg while keeping glob patterns (frontend/*) working exactly as before, for anyone who wants --repos to mean "this repo, not also anything that happens to contain its name." The default is unchanged -- --repos alone still matches on substring, so nobody's existing script silently starts matching less.

Fixed#

Every payload entering a script context now goes through one shared escape (kb.security.json_for_script), which the static --site export already applied to its own snapshot and now shares rather than duplicating. Repository text is additionally escaped where it reaches an HTML attribute or element text: the kind and relationship legends, the page title, the wiki staleness badge, and the site index's repo links and headings. Hostile values are rendered verbatim as inert text, so graphs look exactly as before -- output for ordinary content is byte-for-byte unchanged.

Page templates are also filled in a single pass now. Previously each placeholder was substituted in turn over the whole document, so repository text that merely spelled a later placeholder (for example a symbol named __GLYPH__) had template markup inserted into the middle of the data after escaping had run -- corrupting the page in a way no amount of character escaping could prevent.

What to do: upgrade -- no configuration change is needed. Then regenerate any graph HTML you saved, shared or published: files written by an earlier version are static artifacts that still carry the unescaped payload, and upgrading cannot retroactively fix a file already on disk. That means anything produced by kb graph -o …, kb graph --c4 or kb dashboard --site. It matters most for a file you sent to someone else or put on a web server, and least for one you generated from code you wrote yourself. Pages served live by kb dashboard --serve and kb graph --serve are rendered per request, so they are fixed by the upgrade alone. - SECURITY: the dashboard's wiki route could be steered to read Markdown files outside the store on Windows (path traversal). The ?module= value and the repo id in the URL are both turned into a wiki filename, and only / was being replaced -- so a \-separated value walked out of the wiki directory and the file's contents came back rendered. It affected Windows hosts, including the shipped contextlake-windows-x86_64.exe; POSIX happened to be unaffected because \ is an ordinary filename character there. Reading was limited to files whose name ends .md, and required access to the dashboard, which binds to loopback by default.

Wiki filenames are now built with a character allowlist that folds every path separator, and -- independently of that -- the read path verifies the resolved file really sits inside the store's wiki directory before opening it, so the containment holds even if a future change to the naming rules reintroduces a separator. A blocked request reads as "no such page" rather than an error. Legitimate module page names are unchanged, including non-ASCII directory names, so no already-generated page is orphaned. - An MCP tool error is no longer returned as data. _parse_result ignored the isError flag on a tool result, so a failed call came back as its own error text. A caller that iterated the result then found a string, yielded nothing, and reported an empty answer. Live symptom: an Atlassian source reporting 0 site(s) reachable, which reads as a permissions problem on the account. An error result now raises, carrying the server's own text and the tool name. - The Atlassian source asks for the product OAuth scopes it needs. It spawned the mcp-remote bridge with no scope argument. The bridge resolves scope as an explicit value, else the server's advertised scopes_supported, else its own default, and the hosted Atlassian endpoint advertises none, so the request asked for openid email profile: enough to identify the person and nothing about Jira or Confluence. The token then genuinely saw no sites. contextlake now requests read-only product scopes plus offline_access (without which every run re-opens the browser), overridable per source with scopes. - A local model that runs out of time says so, and says which knob to turn. urllib raises a read timeout whose entire message is timed out, and callers print that, so a wiki run against a CPU-only Ollama reported three words per page: not the provider, not the model, not the budget it waited for, and not that the budget is adjustable. It now names all of them, points at timeout under [llm], and says that a local model with no GPU running a council of 3 per page will exceed 300s. timeout is also a declared config field now rather than reaching the client through extra="allow", where it worked but was invisible to the config docs. - kb wiki checks its backends before announcing work. With the llm-local extra absent, --llm builtin printed a reviewer-quality advisory and Generating wiki for 1 repo(s) with builtin (council of 3), and only then failed per repo: it advised on a council that could not convene and claimed work that never started. Both the generation and review clients are now checked first via an optional preflight hook, which for the built-in model is an import check that neither downloads the GGUF nor loads weights. - Atlassian site discovery tells its failure modes apart. A tool error, a renamed tool, a changed response shape and a genuinely empty site list all produced the same empty mapping and the same "no sites accessible to this token" line, which named the one cause that was not true. Discovery now tolerates the wrapped response shapes its sibling parsers already handled, raises on a payload that is not a site list, and reports the three outcomes separately. An empty list still means no sites. - Docs: the working llm-local install command is now unmissable wherever --llm builtin is offered. pip install "contextlake[kb-full,llm-local]" fails on a machine with no C/C++ compiler, building llama-cpp-python from source: upstream ships no PyPI wheels, so a plain install always compiles. The working form already existed at docs/install.md (contextlake doctor --fix llm-local, which attaches the prebuilt CPU wheel index), and the runtime failure in the built-in LLM client already named it too, but neither was reachable from the places a user actually goes to turn on the wiki's local model: docs/keep-fresh.md's bootstrap --llm builtin example, docs/generate-wiki.md's kb wiki --llm builtin example, and docs/dashboard.md's copy-paste Wiki-tab command all showed or named builtin with no pointer to the extra step it needs, and the command-reference tables in README.md and docs/cli-reference.md listed --llm builtin alongside ollama/openai/anthropic/cli with no hint that one of those five needs anything extra at all. Each now names contextlake doctor --fix llm-local (or links to the existing docs/install.md section that does) right next to the --llm builtin example it sits beside, and calls out that --llm ollama needs no compiler at all as the no-install alternative. docs/install.md, docs/model-providers.md, docs/troubleshooting.md and QUICKSTART.md's bootstrap walkthrough already covered this correctly and needed no change beyond one added clause in QUICKSTART.md naming ollama's no-compiler property explicitly. - kb index's "Workspace indexed" summary reports the workspace, not the whole store. It printed store.stats() -- a store-wide count over every repo the store has ever indexed, from any --workspace -- under a line labelled with this run's workspace. On a real fleet the line read "Workspace indexed: 21 repos" two lines after "Found 19 repositories under repositories", and the store itself held 39 distinct repo ids: three disagreeing denominators for what should have been one number. The summary now sums repo_counts() over exactly the repo list this run discovered under the named workspace -- the same list the "Found N repositories" line above it counts -- so the two lines can never disagree, and an unrelated repo indexed by an earlier run under a different workspace can no longer inflate this one's numbers. - HuggingFace Hub download progress bars no longer leak into kb connect/kb embed. hush_hf_hub() was already called before every local-model download, but its env vars and logger-level settings gate HF Hub's own logging and deprecation warnings -- never the separate tqdm progress-bar switch, so three bars still rendered per fetch, two of them showing no file name or percentage (only a byte count stuck at 0.00B). Progress bars are now hushed there too, unless --verbose was passed -- a verbose run still sees them, e.g. to confirm a large model is actually moving.

[6.1.0] - 2026-08-06#

Added#

Changed#

Fixed#

[6.0.0] - 2026-08-05#

This is a major release. Three things need action or awareness before you upgrade, and the first is the one that fails quietly.

Re-index every store. The parser version moved from 2 to 3, so every shard built by an earlier release is stale. A stale store does not error: it keeps answering, from a graph an old parser produced. Run contextlake kb index against each workspace after upgrading. contextlake doctor names the repositories that are out of date, which is the fastest way to see whether you still need to, but read its output rather than its exit code: shard staleness is reported and does not fail the command, the same way kb lint treats it.

shortest_path returns an object, not a list. Any MCP client reading the tool's result as a bare array of nodes must read nodes instead. The shape changed because the old one had nowhere to put a flag: an unknown source, an unknown destination, and two real nodes with no route between them all came back as the same empty list, and only the last is what an empty answer reads as. The new envelope carries nodes, found, hops and a gap naming which miss occurred.

Python 3.10 is the minimum. The mirror core previously claimed 3.9 while the knowledge layer needed 3.10. There is one floor now, and pip declines cleanly on an older interpreter.

Smaller behaviour changes worth knowing: values that were previously accepted and could not work are now refused (out-of-range --port and --tool-concurrency, and dashboard --serve --site), and semantic_search and hybrid_search now return nothing when a question has no anchor in the index rather than always returning k results.

Changed#

Fixed#

Reads now honour the scope of the invocation. A cache built with no filter is a superset, so --repos is applied straight off it with no refetch; a cache built with a different filter can neither confirm nor deny what this run asked for, so it is re-enumerated instead of answered from. status, which never enumerates, names the scope the cache does cover rather than presenting it as the group, and names the scope it is reporting whenever one is in force. status narrows both sides of its comparison, as verify already did: narrowing the project list and not the local tree would report every non-matching clone in a fully-synced workspace as an extra repository. clone's "already cloned locally" count follows the same scope. The "no local repositories matched" warning now fires only when there was something for the filter to match, so clone --repos <name> into an empty workspace, the feature's own happy path, stops telling you to check a pattern that is working. - ask's owners answer claimed a git-history ranking it had never run. who_knows returns an empty owner list early, before a single git command is issued, when the repo has no local clone path on record. The answer was labelled , ranked from git history. regardless, so "nobody owns this" and "no history was ever read" reached the caller as the same sentence with the same provenance claim attached. The line is now derived from whether the ranking actually happened, and an empty result says which of the two produced it. who_knows carries the reason itself, in a new ranking_gap field, so the MCP tool is as honest as the router that wraps it.

[5.1.1] - 2026-08-04#

Two defects found by manual CLI testing, both of which a developer machine hides.

Fixed#

The second half is why it went unnoticed: glab api ran without check=True, so a rejected call returned a non-zero code that nothing raised on. The circuit breaker never counted it, never opened, and the resulting empty list was indistinguishable from "no open merge requests", so a source whose every call was refused still printed ✓ Connect complete: 0 external link(s) stored. A refused call is now a failure the breaker sees and the log names.

The unit tests passed throughout, because their fixtures used a bare api/svc repo id, a form the system stopped emitting. They now derive the fixture from the function that produces it. - mirror update and mirror branches never authenticated. The token env was built inline by the clone path and nothing else could reach it, so every fetch ran unauthenticated. On a workstation an ambient git credential helper supplies the credential and hides this entirely. Where the token is the only credential, a container or a CI job, the first sync clones successfully and every later refresh fails with could not read Username. All three fetch sites funnel through one helper, which now carries the same header the clone path uses. - Removed 106 em-dashes from documentation prose and added a test that keeps them out. The house style has always been to avoid them, but the only thing enforcing it was de_emdash in site/build_docs.py, which rewrites them at render time. That made the built site look correct while the markdown source accumulated them, and they reached readers everywhere the site is not: the repository on GitHub, the project page on PyPI, and llms-full.txt. Fenced code blocks are exempt, since their bytes are meant to match what a terminal actually prints. - doctor printed a green ✓ config loads whether or not a config existed, so a machine with no configuration at all looked identical to one whose config loaded cleanly, and the paths it had searched were never shown. The mirror side already reported both properly; the two halves of the tool now agree. A missing config is a warning rather than a failure, since built-in defaults are legitimate, and it does not change doctor's exit code.

The underlying cause is that "loaded nothing" and "loaded a file that happens to be empty" produce an identical merged result. KbConfig now carries loaded_from and searched, recorded in the one function that knows the precedence chain rather than re-derived by each caller.

[5.1.0] - 2026-08-04#

Added#

The privilege boundary is the point of the design. Python packages install into the current interpreter via sys.executable -m pip. A system package is never installed silently: the exact command is printed and offered with a y/N at a real terminal, and nothing privileged runs without a TTY or under --skip-interactive, so a CI job or a scripted run can never trip a sudo prompt. An externally-managed environment (PEP 668) is reported with the venv/pipx fix rather than pip's raw error. - The local-LLM install now attaches the CPU wheel index automatically, so it no longer needs a C++ toolchain. llama-cpp-python publishes no wheels to PyPI at all: llama.cpp is built per hardware backend, and one namespace cannot hold the CPU, CUDA and Metal builds of a version, so upstream ships an index per accelerator (as PyTorch does). Verified end to end on a Python 3.14 machine with no cmake and no g++. - kb lint, the graph_health MCP tool and the dashboard health payload gain additive parser_stale and parser_stale_repos fields.

Changed#

Fixed#

One new failure mode, deliberately. A bind mount carries the host's ownership, and the container runs as uid 1000, so if your host account is not uid 1000 the write now fails with a permission error where it previously "succeeded" by writing into the container and losing the result. Pass -u "$(id -u):$(id -g)" to run as yourself. - An index left stale by an upgrade is no longer invisible. PARSER_VERSION moved to 2 in 5.0.0, but doctor's staleness check only examined C and C++ repositories, and the re-index decision compared the repository HEAD alone. A Python or TypeScript repository indexed by 4.0.0 therefore stayed stale indefinitely: index reported it unchanged, doctor reported OK, and every answer came from a graph built by the old parser while every surface said healthy. That is the confident-but-wrong failure this tool exists to prevent.

doctor now flags a stale shard in any language, and kb index rebuilds a parser-stale repository instead of skipping it, announcing why. The re-index is scoped to repositories whose parser version differs, so it is not a blanket --force and it settles after one pass. The store schema gains a parser_version column (version 3) via an additive migration that leaves existing rows intact; a repository indexed before the column existed falls back to reading the shard. - kb lint was silent about parser staleness while doctor graded it as a fault, so the two commands disagreed about the same store. lint now reports it as its own category rather than folding it into stale: a parser-stale graph is out of date, not broken, and folding it in would flip lint's exit code from 0 to 1 for every store the moment PARSER_VERSION moves, turning an upgrade into a red CI gate. The exit code, clean semantics and glyph are unchanged. - Opening a store written by a newer contextlake silently re-stamped it to the running schema version, discarding the newer build's claim about its own format. The stamp is now read before it is written, anything newer or unparsable is preserved, and the store is refused with both versions, the path and the remedy named. An older stamp still migrates forward. This protects builds carrying this change only: an older binary will still downgrade a store it does not understand. - Knowledge commands loaded their config twice per invocation, so a single mistyped key produced two identical warnings and read as two separate problems. Resolved once and memoised for the lifetime of one invocation. - The repository-list cache no longer defaults into /tmp. It now lives under ~/.cache/contextlake with 0700 permissions. The old default was world-readable in a predictable location, listed every repository the account can reach along with clone URLs, and was shared by every workspace on the machine, so per-directory configs were not actually isolated. .contextlake.ini.example shipped an active cache_dir = /tmp line, and the "no config found" error points users at that file, so the bad default propagated by being copied. - Mirror commands refuse to run when the configured group is missing or is still the your-gitlab-group placeholder, instead of exiting 0 after printing a plausible sync report against a group that does not exist. init already refused that exact placeholder, so the two halves of the tool now agree. - init --skip-interactive no longer appends a completion block to your shell rc. Editing ~/.zshrc is a side effect well outside what init implies, and a non-interactive run never asked. Use contextlake completion to opt in. - contextlake inti now suggests init rather than kb lint, and an unknown flag on a subcommand prints that subcommand's usage instead of the root parser's. - The generated knowledge config names an explicit local provider rather than auto, so what runs is visible in the file rather than resolved at call time. - The error raised the first time the built-in LLM is used now prints a command that actually works. It previously suggested a plain pip install, which compiles from source and fails on any machine without a toolchain, which is most machines that hit this message. - docker pull ghcr.io/sayak-sarkar/contextlake (no tag) returned the slim image. The slim build's tag metadata did not disable metadata-action's default latest=auto, so it claimed a bare latest alongside its own tags, and because slim is pushed after full it won. latest now belongs to the full image again. If you pulled latest at 5.0.0 and expected the built-in local model, re-pull: the image you have is the slim one. - A failed PyPI upload no longer takes the GitHub Release with it. github-release depended on publish succeeding, so on the 4.0.0 tag a duplicate-file failure skipped it and the wheel and sdist had to be attached by hand. Publishing is now idempotent (skip-existing), and the release job runs whenever the artifacts built, since a GitHub Release has value regardless of whether the index accepted the upload. - Corrected five documentation claims that were wrong at 5.0.0, each verified against the source. The README and QUICKSTART upgrade sections said the graph re-indexes incrementally and nothing needs migrating, when 5.0.0 in fact made every existing shard stale; both now send you to doctor and kb index --force. The .mcp.json and .vscode/mcp.json snippets in serve.md passed serve without the kb namespace, so copy-pasting either produced an unknown-command error. usage.md told you to copy .contextlake.ini, which does not exist (the template is .contextlake.ini.example). cli-reference.md and troubleshooting.md offered a C++ toolchain via doctor --fix that no code path reaches.

[5.0.0] - 2026-08-04#

This release closes a remote-code-execution path and two denial-of-service paths, all three reachable by indexing a repository you cloned. Upgrading is recommended for anyone running 4.0.0.

Migrating from 4.0.0

  1. Run contextlake kb index --force. The parser version moved to 2, so every existing shard is stale, and nothing detects that on its own: needs_reindex compares only the repo HEAD.
  2. Mirror commands now exit 1 when repositories failed. They previously always exited 0. If a script depends on the old behaviour, add --exit-zero-on-partial; if it already checks $?, it starts working as intended and may go red where it was silently failing.
  3. contextlake.py at the repo root is now run-contextlake.py. Only affects running the launcher from a clone; the installed contextlake command and the standalone binaries are unchanged.
  4. kb serve --transport http and sse now require a bearer token, and refuse a non-loopback --host without --allow-remote. stdio is unaffected and needs no token, which is the default and what every documented editor integration uses.

Security#

stdio is completely unaffected and needs no token. That is the default and what every documented editor integration uses, so most setups need no change.

The dashboard's "start MCP server" card spawns that same command with its stderr discarded, which would have thrown the token away and left the card advertising a server nobody could connect to. It now mints the token itself, passes it to the child, stores it in a 0600 pidfile, and shows it on the card. - Security: --llm-chat is now refused with a non-loopback --host, the same guard --allow-mutations already had. The per-launch token that gates the chat route is served inside /dashboard.js, so anyone who could reach the bind could read the token and drive the configured LLM provider at the operator's expense. Host-header pinning does not cover this: pinning is a browser control, and a plain curl -H 'Host: localhost:PORT' http://<lan-ip>:PORT/dashboard.js satisfies it and returns the token. - Security: the dashboard's POST /api/mcp/serve no longer accepts an arbitrary bind address. A caller-supplied host went into the MCP server unvalidated, so a token holder could publish the whole graph on 0.0.0.0 over a transport with no authentication. The host must now be loopback and the port unprivileged; anything else is a 400, including a wrong-typed JSON value, which previously raised and surfaced as a 500. - Security: the dashboard and graph servers now pin the Host header on GET as well as POST. Only POST checked it, so a page whose domain re-resolved to 127.0.0.1 (DNS rebinding) could read the entire code graph cross-origin: /api/overview, /api/repo/<id>, /api/search, /graph/*: file paths, symbol names, owner identities. Static assets are deliberately not exempt, because dashboard.js carries the per-process token and exempting it would hand a rebinding page the key to the mutating routes. One consequence worth knowing: a server bound with --host 0.0.0.0 and browsed via its LAN address now returns 403; use http://localhost:PORT or bind the address you intend to browse. The server prints a hint. - A config file found by directory search can no longer make contextlake execute a program. .contextlake.kb.toml is discovered by walking up from the current directory, so a repository you cloned could ship one setting [llm] provider = "cli" + command/args, handed straight to subprocess.run by the next kb wiki, kb enrich, or dashboard --llm-chat. The same hole existed in [[sources]], whose command/args/mcp_command spawn an MCP server over stdio. Those keys are now honoured only from ~/.contextlake/kb.toml or an explicit --config path; from a discovered file they are dropped with a warning naming the file and the key. Nothing else is distrusted: store_dir, languages, max_file_bytes, [embeddings], [[rules]], and non-cli LLM providers keep working from a project-local file exactly as before, so directory-scoped config is unaffected. Passing --config on that same file still honours it: naming the file is the explicit act the gate asks for.

Added#

Changed#

What counts as a failure is exactly what each stage already logged as an error, so no repo is reclassified. Skipped work (already up to date, protected branch, dry run) is never a failure; neither is a verify that reports repos missing or extra (only a cloned path with no .git, which is corruption). fetch fails on 0 projects only when no --repos/repo_filter is in play, since 0 matches for a narrow pattern is a legitimate answer.

Migrating: if a script or CI job relies on a mirror command always exiting 0, add --exit-zero-on-partial. If it already checks the exit status, it starts working as intended, expect jobs to go red that were silently failing before.

Fixed#

Action required: run contextlake kb index --force (add --workspace <dir> if you keep the store elsewhere). Existing shards are stale, and nothing will tell you so: needs_reindex compares only the repo HEAD and does not consider the parser version, and doctor's stale-parser check is deliberately scoped to C/C++. An unchanged Python or TypeScript repo will therefore be neither flagged nor rebuilt on its own.

Two limits worth knowing: shard bytes are reproducible on one machine, not across machines, since file order still comes from directory traversal, so do not compare shard hashes between CI runners. The regression guard is in-process. - Malformed query parameters return 400 with a JSON body instead of raising inside the handler thread and dumping a traceback with no response. Out-of-range values clamp rather than error, and internal failures return a generic 500 with the traceback going to the log, never to the client. Two further unguarded integer parses on client input (Content-Length, and the mutation port) were fixed at the same time.

[4.0.0] - 2026-08-04#

Migrating from v3.0.0: replace contextlake init --yes / -y with contextlake init --skip-interactive -- it is a rename, not a new option; the old flags no longer parse.

Changed#

Fixed#

[3.0.0] - 2026-08-03#

Added#

Changed#

Removed#

Fixed#

[2.67.0] - 2026-07-31#

Added#

[2.66.0] - 2026-07-31#

Added#

Fixed#

[2.65.0] - 2026-07-31#

Fixed#

Added#

[2.64.0] - 2026-07-30#

Added#

[2.63.0] - 2026-07-30#

Added#

[2.62.1] - 2026-07-30#

Fixed#

[2.62.0] - 2026-07-30#

Added#

[2.61.0] - 2026-07-30#

Added#

This is an additive-only pass: no command, flag, or alias was renamed, removed, or re-nested -- everything documented in docs/usage.md, the README, and the site keeps working exactly as before. Full proposal, rationale, and what was deliberately deferred (not done here): planning/specs/ spec-cli-simplification.md.

[2.60.8] - 2026-07-30#

Fixed#

Added#

[2.60.7] - 2026-07-30#

Fixed#

Added#

[2.60.6] - 2026-07-29#

Fixed#

[2.60.5] - 2026-07-29#

Fixed#

[2.60.4] - 2026-07-29#

Fixed#

Added#

[2.60.3] - 2026-07-29#

Added#

[2.60.2] - 2026-07-29#

Fixed#

[2.60.1] - 2026-07-29#

Fixed#

Documentation#

[2.60.0] - 2026-07-29#

Added#

[2.59.1] - 2026-07-29#

Changed#

Fixed#

[2.59.0] - 2026-07-29#

Added#

[2.58.3] - 2026-07-29#

Fixed#

[2.58.2] - 2026-07-29#

Added#

Fixed#

[2.58.1] - 2026-07-28#

Fixed#

[2.58.0] - 2026-07-27#

Changed#

[2.57.0] - 2026-07-27#

Added#

[2.56.0] - 2026-07-27#

Added#

Fixed#

[2.55.0] - 2026-07-27#

Added#

Fixed#

[2.54.0] - 2026-07-27#

Added#

[2.53.0] - 2026-07-26#

Added#

[2.52.0] - 2026-07-26#

Added#

Fixed#

[2.51.1] - 2026-07-26#

Fixed#

[2.51.0] - 2026-07-26#

Added#

Fixed#

[2.50.0] - 2026-07-26#

Added#

Fixed#

Changed#

[2.49.0] - 2026-07-26#

Removed#

[2.48.2] - 2026-07-26#

Fixed#

[2.48.1] - 2026-07-26#

Fixed#

[2.48.0] - 2026-07-26#

Added#

Fixed#

[2.47.0] - 2026-07-25#

BREAKING#

[2.46.0] - 2026-07-25#

Added#

Fixed#

[2.45.1] - 2026-07-25#

Fixed#

[2.45.0] - 2026-07-25#

Fixed#

Changed#

[2.44.0] - 2026-07-23#

Added#

Changed#

[2.43.0] - 2026-07-22#

Added#

Changed#

[2.42.0] - 2026-07-21#

Fixed#

Added#

[2.41.0] - 2026-07-21#

Added#

[2.40.0] - 2026-07-20#

Added#

[2.39.0] - 2026-07-20#

Added#

[2.38.0] - 2026-07-20#

Added#

[2.37.0] - 2026-07-08#

Added#

Fixed#

[2.36.0] - 2026-07-08#

Added#

Documentation#

[2.35.0] - 2026-07-08#

Added#

Changed#

Fixed#

Documentation#

[2.34.0] - 2026-07-07#

Added#

Changed#

Fixed#

Documentation#

[2.33.2] - 2026-07-06#

[2.33.1] - 2026-07-06#

Fixed#

Documentation#

[2.33.0] - 2026-07-06#

Added#

Changed#

Added#

Fixed#

[2.30.0] - 2026-07-06#

Added#

Added#

[2.28.0] - 2026-07-06#

Changed#

[2.27.0] - 2026-07-06#

Added#

[2.26.0] - 2026-07-06#

Added#

[2.25.0] - 2026-07-02#

Added#

[2.24.0] - 2026-07-02#

Added#

[2.23.0] - 2026-07-02#

Added#

Changed#

[2.22.0] - 2026-07-02#

Added#

Changed#

[2.21.0] - 2026-07-02#

The product-review hardening release: an end-to-end review as a brand-new pip install user surfaced the gaps between the advertised experience and the real one; this release closes them.

Fixed#

Added#

Changed#

[2.20.1] - 2026-07-01#

Fixed#

Added#

[2.20.0] - 2026-06-30#

Added#

Fixed#

Changed#

[2.19.2] - 2026-06-28#

Fixed#

[2.19.1] - 2026-06-28#

Fixed#

[2.19.0] - 2026-06-28#

Added#

[2.18.0] - 2026-06-28#

Added#

[2.17.0] - 2026-06-28#

Added#

[2.16.0] - 2026-06-28#

Added#

[2.15.0] - 2026-06-28#

Added#

[2.14.0] - 2026-06-28#

Added#

[2.13.0] - 2026-06-28#

Added#

[2.12.0] - 2026-06-28#

Added#

[2.11.0] - 2026-06-28#

Changed#

[2.10.0] - 2026-06-28#

Added#

Changed#

[2.9.1] - 2026-06-26#

Changed#

[2.9.0] - 2026-06-26#

Added#

Changed#

Fixed#

Security#

[2.8.0] - 2026-06-26#

Added#

[2.7.0] - 2026-06-26#

Added#

[2.6.0] - 2026-06-26#

Security#

Added#

[2.5.1] - 2026-06-26#

Fixed#

Changed#

[2.5.0] - 2026-06-26#

Added#

Changed#

Added#

[2.4.0] - 2026-06-25#

Added#

Changed#

[2.3.0] - 2026-06-24#

Added#

Changed#

Fixed#

[2.2.0] - 2026-06-23#

Added#

[2.1.6] - 2026-06-23#

Fixed#

Added#

Changed#

[2.1.5] - 2026-06-23#

Added#

[2.1.4] - 2026-06-22#

Changed#

[2.1.3] - 2026-06-22#

Changed#

[2.1.2] - 2026-06-22#

Added#

Changed#

[2.1.1] - 2026-06-22#

Added#

[2.1.0] - 2026-06-22#

Added#

Changed#

[2.0.1] - 2026-06-22#

Changed#

[2.0.0] - 2026-06-22#

Changed#

Note#

[1.18.1] - 2026-06-22#

Changed#

[1.18.0] - 2026-06-22#

Added#

[1.17.1] - 2026-06-22#

Changed#

[1.17.0] - 2026-06-22#

Changed#

[1.16.0] - 2026-06-22#

Added#

Changed#

[1.15.0] - 2026-06-22#

Added#

Changed#

[1.14.0] - 2026-06-22#

Added#

[1.13.0] - 2026-06-22#

Added#

[1.12.0] - 2026-06-22#

Added#

[1.11.0] - 2026-06-22#

Added#

[1.10.0] - 2026-06-21#

Added#

[1.9.1] - 2026-06-21#

Fixed#

Changed#

[1.9.0] - 2026-06-21#

Added#

[1.8.0] - 2026-06-21#

Added#

[1.7.0] - 2026-06-21#

Added#

[1.6.0] - 2026-06-21#

Added#

[1.5.0] - 2026-06-21#

Added#

Fixed#

[1.4.0] - 2026-06-21#

Adds an optional knowledge layer (gitlab_sync.kb, the [kb] extra, Python ≥ 3.10) that turns the mirrored repositories into a queryable knowledge graph served to AI agents over MCP. The core sync tool is unchanged and the extra is entirely opt-in. Everything is generic and config-driven, no private data lives in the package.

Added#

[1.3.0] - 2026-06-21#

This release stabilizes the core and makes the tool installable. It repairs several regressions introduced by the earlier modularization and fixes a critical configuration bug.

Fixed#

Added#

Changed#

[1.2.0] - 2026-06-16#

Added#

Changed#

[1.1.0] - 2026-05-24#

Added#

Changed#

[1.0.0] - 2026-05-10#

Added#

Next steps