---
title: "Tools"
url: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.api.mcp.repocontext/tools.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/docs/lattice.api.mcp.repocontext/tools.md"
package: "Orleans.Lattice.Api.Mcp.RepoContext"
status: "unreleased"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
bundle: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.api.mcp.repocontext/llms-full.txt"
---
# Tools

Part of the [Api.Mcp.RepoContext documentation](README.md).

The module contributes the `repocontext_*` MCP tools in two host-selected shapes. In the default **single-repository** mode it offers the read-only tools, offered to any caller whose data read-or-write permission unlocks the repository-context group, plus the mutating tools, contributed only when the host calls `AddRepoContextTools(enableWrites: true)`. In **workspace** mode - what the bundled container runs - the read-only `repocontext_list_repos` is added and the mutating `repocontext_bootstrap` is replaced by `repocontext_add_repo`, `repocontext_remove_repo`, and `repocontext_reset_index`, so the client manages many repositories under one mounted root and can repair a wedged code index without destroying the memory attached to that repository. Every tool, in either mode, clears the same fail-closed authorization gate at both advertisement and invocation.

In either mode the **onboarding** tool - `repocontext_bootstrap` in single-repository mode, `repocontext_add_repo` in workspace mode - takes the working tree to walk from the wire, so it additionally requires a configured workspace root. Pass `workspaceRoot` to `AddRepoContextTools`: the onboarding tools (and the read-only `repocontext_changed`, which also takes a caller-supplied path) are advertised only when a root is supplied there, and each re-checks at invocation that the effective path guard is enforcing, refusing otherwise, because an unbounded guard would let any caller with a write grant have the server index an arbitrary directory on the host.

## Read-only tools

Always contributed to a caller whose effective permissions include a data-plane operation that unlocks the repository-context group, regardless of the `enableWrites` flag. The one exception is `repocontext_changed`, which takes a caller-supplied path and is therefore contributed only when a workspace root is configured (see its row).

| Tool | What it does |
|---|---|
| `repocontext_health` | With `repoId`, reports the passive per-repository verdict and component evidence described under [Repository readiness](#repository-readiness). Without `repoId`, preserves the following host-only behavior: reports whether the repository-context surface is registered and reachable for the authenticated caller (`available`), and whether retrieval can actually serve (`retrievalReady`, `retrievalPhase`), alongside the facade `group` (always `repocontext`) and a human-readable `status` line that names any degradation. Returns success only when the caller cleared the authorization gate, so an agent can confirm the surface is wired end to end before using the other tools. `available` reports **reachability only** and stays true on a degraded host, because capture, recall, and scan keep working there; read `retrievalReady` for whether searches are trustworthy. A `retrievalPhase` of `building` means the vector plane is not serving and searches are answered by degraded keyword recall, so treat results as incomplete; `keyword_only` is an intended deployment with no embedding provider bound and IS ready. `serving` (semantic retrieval is serving) and `nothing_registered` (no repository is registered yet, so there is nothing to serve) are ready too. With `repoId`, the same tags describe the requested repository, and a `nothing_registered` verdict there - a measured empty vector corpus together with a completed zero-file ingest - reports `retrievalReady: false` instead (see [Repository readiness](#repository-readiness)). A `retrievalPhase` of `saturated_unavailable` means the vector plane is not serving and is **not expected to start at the present capacity**, because an admission gate refused its open past a declared bound (issue #3286); it is distinct from `building` precisely so a plane that will never arm is distinguishable from one still arming, and it clears by itself once admission recovers. |
| `repocontext_recall` | Fetches a single record by its full key - a structural node, a symbol, or a memory entry - and returns its flattened fields, tags, links, and remaining life. For a memory entry it also evaluates **link staleness**: every live structural link is checked against its target's present state, and one whose digest has drifted, whose target has no live record at all, or for which no digest was ever captured is reported through `stale` and `staleLinks`. The subset pointing at nothing is also named in `danglingLinks` - always a subset, never a partition - because the two states have opposite remedies: drift asks the caller to re-read the file, a dangling link asks it to wait for the target to be indexed. A key with no live entry returns `exists=false`, so an absent or expired entry is distinguishable from an empty one. |
| `repocontext_scan` | Walks an ordered range under a scope (all files, packages, or symbols; all memory; or the memory under one topic) and returns one page at a time with an opaque continuation token. Expired and tombstoned entries are never returned. As a bulk read it does not evaluate staleness, so `stale`/`staleLinks`/`danglingLinks` come back null ("not evaluated"), mirroring the expiry convention; `repocontext_recall` a key for its authoritative staleness. |
| `repocontext_list_topics` | Enumerates the distinct memory topics for a repository, each with its live entry count, so an agent can discover what notes and decisions exist before recalling them. |
| `repocontext_search` | Finds the records most relevant to a natural-language query, ranked best-first and hydrated from the store of record. Runs a semantic (nearest-neighbour) search when an embedding provider and vectors are available - answered from the approximate index by default, or by the complete-recall exact scan when the host sets `LATTICE_REPOCONTEXT_SEMANTIC_RETRIEVAL=exact`; otherwise degrades to a deterministic keyword/structural scan that ranks file content as well as names with Okapi BM25. The result's `mode` reports which path answered (`semantic`, `keyword`, or `empty`), and `retrievalPath` names the guarantee or keyword cause that served it (see [Semantic search](semantic-search.md#which-path-answered-retrievalpath)). Every hit also carries a machine-readable, deterministic **`reasons`** list explaining why it ranked - a semantic hit lists `semantic`, the matched chunk kind (`chunk:file`, `chunk:symbol`, or `chunk:memory`), and `symbol:<fqName>` for a symbol or `topic:<topic>` for a memory entry; a keyword hit lists whichever projected fields the query terms hit (`path-name-match`, `symbol:<fqName>`, `tag:<tag>`, `topic-match`, `content-match`, `key-match`). |
| `repocontext_outline` | Returns the structural skeleton of one indexed file without reading its body: each declared symbol (kind, signature, and 1-based start/end line span) ordered by position, plus the token cost of reading the whole file under the configured tokenizer profile. The cheapest way to grasp a file's shape and decide whether a full read is worth the tokens. The token count is null only when the file was never content-processed; a path with no stored file node returns `exists=false`. A pure read over stored records that never touches the workspace on disk. |
| `repocontext_changed` | Reports how the current workspace has drifted from the stored index - the files added, updated, and removed - by comparing each file's content digest against the indexed digest, without invoking git, so it works in any checkout. It also lists the indexed files that depend on the changed ones (the reverse-reference impact set), so an agent sees the blast radius of a set of edits before re-indexing. The walk is rooted at the repository's indexed root and reuses the filters it was ingested with, so the report always compares the same path space the index was built in; `path` is a scope - pass the repository root for the whole tree, or a directory inside it to restrict the report to that subtree - and a path outside the indexed root is refused rather than compared. Unchanged files are settled by a stat against the stored size and ingest anchor instead of being re-read, so a whole-repository report stays cheap. The workspace is walked only through the fail-closed workspace boundary; a path resolving outside the mounted workspace is refused, and so is a repository with no persisted onboarding request, since there is then no indexed root to contain the caller's path against. Because the path is caller-supplied, the tool is contributed only when a workspace root is configured - under an inert guard it is withheld exactly as the onboarding tools are. |
| `repocontext_related` | Resolves the structural neighbourhood of one file so an agent can navigate the code graph without full-file reads: the type-names the file references (outbound imports), the indexed symbols that reference the file's declarations (inbound dependents, resolved to their declaring files), and the test types that cover it (from the `{Name}Tests`/`{Name}Test` convention). Dependents and tests are maintained incrementally from a reverse cross-reference projection, so the lookup is a bounded read. Edges are keyed by simple (unqualified) type-name, a syntactic approximation. A path with no stored file node returns `exists=false`. |
| `repocontext_context` | Returns a ranked, explained bundle of source for a natural-language task, packed under a **hard token ceiling** in a single call - collapsing the search -> recall -> read loop into one round trip that can never overrun the context budget. It packs each resolved file at a detail level (`paths`, `outline`, or `slices`; `auto` picks the richest level that yields a non-empty bundle). Every entry carries its match `reasons`, its exact BPE `tokenCount`, and the whole-file `fullReadTokenCount`; `responseTokens` (the estimated cost of the response as delivered - content plus JSON envelope, multiplied by a conservative dual-emission factor of 3.5 because the SDK serializes every result twice and the second copy is escaped JSON text) never exceeds `budgetTokens` (an empty bundle reports 0), while `totalTokens` reports the narrower BPE sum of the packed source alone. A unit is a descriptor, not a second copy of the text: an entry's `content` is the newline-join of its units, so source ships exactly once. When even the cheapest entry does not fit it **fails closed** (`entries` empty, `retryBudgetTokens` reports a budget guaranteed to admit one entry). **Reuse economics**: each delivered unit carries a stable opaque `receipt` and each entry a per-version `contentHash`; hand receipts back in `seen`, assert whole-file possession as `known` (`path@hash`), or pass a `session` to persist that bookkeeping across calls. A whole-file claim is honoured only for a version actually delivered as a complete body, so partial evidence can never be promoted to whole-file possession. Suppressed content is acknowledged in `reused` and is never charged against `top` or the budget. |
| `repocontext_stats` | Reports an aggregate summary of the surface's own usage over a bounded recent window, so a team can see whether it actually reduces context cost. Returns only summed token figures: calls answered (`calls`), the response tokens spent (`responseTokens` - each bundle's own conservative estimate of what its response cost, not a BPE count), the whole-file read tokens conservatively replaced (`readsReplacedTokens` - credited only for delivered whole-file-equivalent content, never for discovery, partial detail, or content the caller already held), the net tokens saved (`netSavedTokens` - `readsReplacedTokens` minus `responseTokens`, so it is negative when the responses cost more than the reads they replaced), and the window length (`windowSeconds` - the last hour, so it reads 3600). Only `repocontext_context` calls are recorded, and the window is kept in memory on the host that answers. It carries no body, query, path, or repository identity. |
| `repocontext_index_status` | Reports the progress of a repository's indexing job: its lifecycle `status` (`None`, `Running`, `Completed`, or `Failed`), the current `phase` (`Pending`, `Walking`, `Reconciling`, `Applying`, `Vectorising`, or `Done` for a build, and `Resetting` while a `repocontext_reset_index` sweep runs), the running file and chunk counters, the `symbolsEmbedded` and `filesContentProjected` counters, the `treesSwept` and `entriesDeleted` counters a reset advances, the `attempt` count, timing, any failure reason, and - while the run is `Running` - a live `pacing` reading (see [Adaptive pacing](#adaptive-pacing)). A repository that was never onboarded reports `status=None` without erroring. Because onboarding runs asynchronously, a caller polls this tool to follow a `repocontext_bootstrap` or `repocontext_add_repo` pass to completion. |
| `repocontext_neighbors` | Walks the typed knowledge-linking edges out of a memory entry and returns the adjacent entries, hydrated from the store, as a bounded breadth-first traversal - optionally restricted to one `relation`, up to a `depth` of hops, and stopping once `maxNodes` neighbours are collected (`truncated` reports that the cap stopped the walk with at least one further linked record unvisited). A seed key with no live entry returns `exists=false`; a dangling edge whose target has no live value is still returned with its own `exists=false`, so a broken link is observable. Each walked memory entry has its **link staleness** evaluated (`stale`/`staleLinks`/`danglingLinks`), as `repocontext_recall` does. Use it to explore the curated concept graph an agent has captured across sessions. |
| `repocontext_claim_status` | Reports the claim recorded on a memory record - whether a claim is live (`claimed`), its fencing token (`fencingToken`), the highest released token (`releasedFencingToken`), and the `owner` and `region` that took it - alongside the live state of the distributed lock that grants it (`lockName`, `isHeld`, `lockFencingToken`, `leaseExpiresAtUtc`, and the `queueDepth` of agents waiting behind it). A key with no live record reports `exists=false`, so an absent record is distinguishable from an unclaimed one. The two halves can disagree, and the disagreement is informative: after a lease lapses, `claimed` stays `true` with the last `fencingToken` while `isHeld` reads `false` and `lockFencingToken` reads `0`, until the next claim or a release; a record that was never claimed reports `claimed=false` and no `fencingToken`. Use it to see who holds an item and how deep the queue is before deciding to wait. The answer is a point-in-time observation and never an entitlement: **`authoritative` is always false**, because a claim can lapse or be superseded between this read and the caller's next write. Only `repocontext_claim` grants the right to write, and only the fencing check on the write path enforces it - never gate a write on this tool's answer. |

## Mutating tools

Contributed only under `enableWrites: true`. Each is annotated destructive and offered only to a caller who cleared the gate, whose grant includes a write-shaped data-plane operation (write, delete, range delete, CRDT apply, atomic write, or bulk load), and for whom the host opted writes in.

| Tool | What it does |
|---|---|
| `repocontext_bootstrap` | Onboards a codebase: walks the repository at `repoRoot`, records a structural node and content digest for every file under the `repoId` keyspace, and reconciles the scan against the stored records. Starts asynchronously off the request thread and returns the running job's acceptance snapshot at once (poll `repocontext_index_status` for the outcome), so a dropped client stream never aborts an index. Idempotent and resumable - re-running on an unchanged repository is a no-op, a changed repository updates only changed files and prunes deleted ones, and an interrupted run resumes without duplication. A repository configured with a git source is refused, because the mounted and git index sources are mutually exclusive. |
| `repocontext_remember` | Creates or updates a memory or decision entry under a topic, with an optional time-to-live. Omit `id` to create a new entry with a generated id; supply an existing `id` to merge into it in place with CRDT semantics. When no explicit `ttlSeconds` is given, a new entry inherits the repository's default memory TTL if one is configured, otherwise it is durable. |
| `repocontext_update` | Patches scalar fields and tags on an existing structural or memory record using CRDT-merge semantics: each field is a last-writer-wins register applied at a fresh logical tick, so concurrent updates converge instead of clobbering each other. Any remaining time-to-live is preserved. Fails if no record exists at the key. |
| `repocontext_forget` | Removes an entry. By default it hard-deletes immediately; set `lapse` to true to re-write it with a short time-to-live (`lapseSeconds`, default 60 seconds) so it lapses on its own, letting concurrent readers drain gracefully. On a memory entry whose value decodes, the lapse is written through the multi-value register and bound by its later-expiry rule: it lapses a durable entry, but it cannot shorten one whose existing expiry is later than the lapse window, and the result's `expiresAtUtc` reports the expiry actually in force (see [Memory and TTL](memory-and-ttl.md)). A lapse succeeds even when the stored value cannot be decoded - retiring a record never requires reading it, so a corrupt entry is recoverable without the hard delete that would destroy it - and reports that case as `undecodable`. |
| `repocontext_claim` | Claims a memory record for exclusive authorship and returns the fencing token every later write under that claim must present, so two agents draining one backlog cannot both believe they own an item. It introduces no locking scheme of its own: mutual exclusion, first-in-first-out fairness, the bounded lease, expiry reclaim, and the strictly increasing token all come from the cluster's [distributed lock](../lattice/distributed-lock.md), and this tool records the granted token on the record as a fencing high-water mark. Losing a race is reported, not thrown - `granted=false` with a `reason` of `contended`, `timeout`, or `missing`. Omit `maxWaitSeconds` to fail fast, which is what a work-stealing agent wants; supply it to queue. Omitting `leaseSeconds` requests the cluster's deliberately short default lease (`LatticeOptions.DefaultLockLeaseDuration`, 30 seconds unless the host overrides it), and every lease is clamped to `LatticeOptions.MaxLockLeaseDuration` (5 minutes by default; the bundled container raises it to 30 minutes through `LATTICE_MAX_LOCK_LEASE_SECONDS`), so honour the returned `leaseSeconds` and `leaseExpiresAtUtc` rather than the length asked for, and renew before it lapses. Memory records only, because the fencing check is enforced on the memory record's own write path. |
| `repocontext_renew_claim` | Extends the lease on a claim the caller already holds, without changing its fencing token, so a long-running agent keeps its claim alive rather than having it reclaimed mid-task. A renew presenting a token that no longer holds the lock returns `granted=false` with reason `superseded` rather than throwing: that is the **authoritative** signal that the holder's lease is gone. The lock reclaims a lapsed lease at the start of every renew, waiter or not, so every renew after a lapse reports `superseded` whether or not anyone has claimed the item since - and the next claim, by anyone, is granted a strictly higher token that fences this one out. The holder must stop writing and re-claim rather than continue. Always pass `leaseSeconds` explicitly: omitting it defers to the same deliberately short cluster default a claim uses, so a renew that omits it *shortens* a claim currently held for longer while still reporting `granted=true`, with the loss surfacing only on the next renew. A renew that shortens its lease sets `leaseShortened`, alongside `previousLeaseExpiresAtUtc`; a `null` there means the prior lease could not be read, never that nothing shrank. |
| `repocontext_release_claim` | Releases a claim, handing the record to the next waiter in the lock's queue and marking the record's claim as no longer live so unfenced writes are admitted again. It never lowers the fencing high-water mark, so a released token stays refused: write every result owed to the record **before** releasing. Idempotent and safe to retry, and never an error. The outcome is decided by the record's fence, not by the lock: a token that is still the record's high-water mark is released (`released=true`) even if its lease has lapsed, and repeating that release reports the same; a token below or above the high-water mark is a no-op reported as `released=false` with reason `stale`; and `released=false` carries reason `missing` for a record that does not exist, or `unclaimed` for one that was never claimed. |

## How a claim is enforced

The claim tools would be advisory - a flag one agent could ignore - if nothing checked them. They are not. Once a memory record carries a claim, `repocontext_remember`, `repocontext_update`, and `repocontext_forget` all evaluate a fencing check on that record before applying anything, and the write is refused with `RepoContextClaimConflictException` when it fails. None of the three has a bypass or a per-tool opt-out, so a claim constrains **every** write those tools make to the record, including its `body`. The fence is per record, so it does not stand in the way of `repocontext_remove_repo`, which drops the repository's memory wholesale - claimed records included - without consulting any claim.

The check is deterministic, and a caller can reason about which outcome it will get. A refusal names its cause in `RepoContextClaimConflictException.Reason`, as the value shown in parentheses:

| Record state | Token presented | Outcome |
|---|---|---|
| Never claimed | any, or none | Accepted - an unclaimed record behaves exactly as it did before the claim surface existed |
| Claim live | none | Refused (`ClaimRequired`) |
| Claim released | none | Accepted - a released record readmits ordinary unfenced writes |
| Claimed (live or released) | below the record's high-water mark | Refused (`StaleToken`) - a superseded holder can never write, even after the claim is released |
| Claimed (live or released) | above the high-water mark, but not the token the lock currently holds | Refused (`UnissuedToken`) - a token ahead of the record's stamp is honoured only when the lock confirms it issued it, so a caller cannot write under an integer of its own choosing |
| Claim released | at or above the high-water mark | Refused (`ClaimReleased`) - re-claim first |
| Claim live | at or above the high-water mark, claim's region | Accepted |
| Claim live | at or above the high-water mark, another region | Refused (`ForeignRegion`) |

A claim is **live** from the moment it is stamped until it is released; the write path never reads the lease's expiry. A holder whose lease has lapsed therefore still passes the check under its token until another claim is granted: that claim stamps a strictly higher token, and from then on the lapsed holder's token is below the high-water mark and refused. Expiry reaches the write path through that higher token, not through a clock.

One path cannot read the record's stamp. A `repocontext_forget` of a memory record whose stored value cannot be decoded resolves the claim against the lock instead: it is admitted when the lock is not held or the presented token is the one the lock holds, and is otherwise refused as `ClaimRequired` (no token presented) or `StaleToken`, so a record whose stamp nobody can read can still be retired.

The high-water mark is stored as a register keyed by the token itself, so it is a maximum under CRDT merge: a concurrent replica carrying an older token cannot lower it, and a fenced-out holder stays fenced out after the record converges. That is what makes a claim safe across regions rather than only within one silo.

The claim surface exists to make an agent-operated backlog safe for several workers to drain at once. For the item schema, the worker protocol, and the reasoning behind them, see [The agent-operated backlog](backlog.md) and the walkthrough in [samples/AgentBacklog](../../samples/AgentBacklog/README.md).

## Workspace-mode tools

Contributed only when the host runs in **workspace** mode: a broad parent directory is mounted read-only and the client manages repositories under it. `repocontext_list_repos` needs only the data read-or-write permission that unlocks the group; `repocontext_add_repo`, `repocontext_remove_repo`, and `repocontext_reset_index` are mutating and, like the other mutating tools, require the host to have opted writes in. In this mode they stand in for the single-repository `repocontext_bootstrap`.

| Tool | What it does |
|---|---|
| `repocontext_list_repos` | Lists every repository currently registered in the store, each with its last-ingested marker (re-stamped by every completed indexing pass, including one that found nothing to change, so it reads as when the index was last verified current), recorded file count, and `embeddedVectorCount` (the number of sources - files, captured symbols, and embedded memory entries - that currently carry a live embedding), in ascending id order, so an agent can discover which repositories under the workspace are queryable before recalling, scanning, or searching. It enumerates committed, materialised structural records - a different source from the live counters `repocontext_index_status` reports - so a repository still in its first ingest can read `Running` there while it is absent here, and not yet answering scan or search, until its structural records materialise; treat `repocontext_index_status` as the authority for an onboarding still in progress. `embeddedVectorCount` is served from the last completed membership walk rather than measured on demand: the field is absent when no walk has completed yet (which is not the same answer as `0`), and `embeddedVectorCountPending` is `true` whenever a refresh is outstanding - expect that throughout an active ingest, and poll again once it settles for an exact figure. Each row also carries `indexedRoot`, the absolute path the repository was actually indexed from, so a caller can see that a repository id does NOT imply a root: an id defaults to the final segment of the indexed path but can be supplied explicitly to `repocontext_add_repo`, and in a git worktree the base repository is what is indexed, so the id will not match the directory the caller is sitting in. It is read from the durable index request, so it is absent (`null`) for a repository whose index was reset and not yet re-onboarded, joining the three nulls a reset leaves on the row (`lastIngested`, `fileCount`, and `indexedCommit`). `indexedCommit` is the commit SHA a git-sourced repository's index generation was built from; it is absent for a repository indexed from the mounted workspace, which has no commit anchor. The result also carries the row `count`. Read-only. |
| `repocontext_add_repo` | Registers a repository under the mounted workspace and starts indexing it. Takes `path` (which must resolve inside the workspace root - a `..` or symlink escape is rejected) and an optional `repoId`, derived from the final path segment when omitted, plus the same walk filters as `repocontext_bootstrap`. Starts asynchronously and returns the running job's snapshot; poll `repocontext_index_status`. Idempotent and resumable. A repository configured with a git source is refused, because the mounted and git index sources are mutually exclusive. |
| `repocontext_remove_repo` | Removes every record for a repository - structural nodes, symbols, the content and cross-reference projections, session bookkeeping, memory, and every vector tree including the approximate index and the coverage digest - and drops it from `repocontext_list_repos`, cancelling any in-flight run and tearing down its indexing grains. The working tree on disk is never touched, and removing an unknown repository is a no-op. Prefer `repocontext_reset_index` when the goal is repairing a wedged or stale index - it drops the code index and its vectors but preserves the durable memory records. |
| `repocontext_reset_index` | Drops a repository's code index and its derived planes (structural, symbol, content, cross-reference, session bookkeeping, and every vector tree) and leaves the durable agent-memory records for that repository intact. Use it to repair a wedged, stale, or corrupt code index without discarding notes, decisions, or gotchas: the repository stays registered and stays listed by `repocontext_list_repos`, reporting no ingest, no file count, and no indexed commit - which is exactly the state it is in, and which keeps the preserved memory discoverable rather than reachable only by an id the caller already knew. A subsequent `repocontext_add_repo` rebuilds the code index and its vectors from the working files and repopulates those fields, and the surviving memory records are re-embedded on the next indexing pass. The vector-membership `memkey-` markers are dropped together with the payloads, so surviving memory is not left flagged-but-unreachable by semantic search. Resetting an unknown repository is a no-op that does not register it. The result reports `repoId`, `entriesDeleted`, `elapsedMilliseconds`, the `treesSwept` it dropped (named, in sweep order), `memoryPreserved` (always `true`), and `censusCleared` (whether the repository root marker's index-derived fields were cleared). A lighter-consent operation than `repocontext_remove_repo`, which remains the verb for destroying memory too - and the only verb that drops a repository from the listing. The reset reports its own lifecycle through `repocontext_index_status` (`Running` in phase `Resetting` with advancing tree and entry counters, then `Completed` or `Failed`), and its sweep runs on a background task bound to the host lifetime rather than to the call: a caller that times out or loses its connection abandons only its wait, the reset keeps running under that caller's credential, and polling `repocontext_index_status` reports how it ended - so do not re-run it just because the response never arrived. Only a host restart interrupts it, leaving it `Running`/`Resetting` and never complete; re-running the reset, which is idempotent, finishes it. |

## Repository readiness

Call `repocontext_health` with `repoId` to answer the serving question in one call,
rather than correlating ingest completion, vector counts, logs and exposition.
Without it the existing host-only JSON payload is unchanged. With it,
`retrievalReady`, `retrievalPhase` and `status` describe the requested repository,
and `repository` carries `RepoContextRepositoryReadiness`:

- `repoId` names the repository the snapshot describes.
- `verdict` uses `RepoContextRetrievalReadinessPhase`: `Serving`, `Building`,
  `KeywordOnly`, `NothingRegistered`, or `SaturatedUnavailable`. The existing
  top-level `retrievalPhase` keeps its canonical snake-case tags.
- `reason` names the actual blocking gate or last observed query fault, not a
  generic degradation. It is also present for an intended keyword-only or empty
  repository. `authoritative` is always `false`.
- `ingest` retains the job's own counters, including files embedded versus scanned;
  when the job's metadata cannot be read it is null and `ingestReason` names why.
  `vectorCoverage` reports the existing embedded-source count and `pending` flag.
  These are different populations: embedded sources include symbols and memory.
  A missing count is unknown, never zero, and this call schedules no count refresh.
- `embeddingSpace` comes from provider configuration. `ann` contains this
  repository/space's local build phase, generation and counts; null means no local
  handle, not an empty index. `annCanServe` reads the same gate the handle's search
  uses. An unpartitioned, non-empty index serves exhaustively and is ready.
- `breakerOpen` and `breakerProbeDueIn` are non-consuming reads. A health call
  cannot claim the half-open probe reserved for a real query.
- `lastRetrievalPath` and `lastQueryAt` describe the latest query observation for
  this repository only. A proven repository retains the 30-second fault hold-down;
  its pending fault remains visible in `reason` during that grace period.
- `contentPhase`, `contentReason` and `contentObservedAt` report the latest bounded
  keyword content-tree scan: `Serving` when it was readable, `NothingRegistered`
  when it completed empty, and `Building` when it faulted or none was observed.
  They do not execute a scan and do not certify all content or hydration paths.

This is a passive snapshot on the answering server. It does not call the embedder
(including its health endpoint), run search/hydration, open an ANN handle, or
advance any build. External embedder availability and hydration are known only
through the last real query. With no serving ANN or previous successful query,
`Building` with `semantic_serving_not_yet_demonstrated` is honest: a first successful
real query demonstrates the eligible exact fallback. Ingest `Completed` alone
never promotes it. A measured empty vector corpus together with a completed
zero-file ingest reports `NothingRegistered`, with `retrievalReady: false` at
repository scope; unknown coverage does not establish emptiness. A keyword-only
configuration remains ready without requiring a vector plane.

The verdict is advisory, not an authorization or correctness gate. Keep reading
`retrievalPath` on each result for what that particular query actually did, and
`index_status` for ingest progress. Neither tool is replaced. No part of this
verdict is read from Prometheus or process-wide readiness/arming.

## Tool parameters

Every argument is passed by name. Required arguments are in **bold**; every other argument is optional, and the defaults and bounds below are the ones the handlers apply. An argument is a string unless noted.

| Tool | Parameters |
|---|---|
| `repocontext_health` | `repoId` - optional; omitted preserves the host-only payload, supplied returns passive repository readiness, and a blank value is refused. |
| `repocontext_stats`, `repocontext_list_repos` | None. |
| `repocontext_recall`, `repocontext_claim_status` | **`key`** - the full repository-context key (a memory-record key for `repocontext_claim_status`). |
| `repocontext_list_topics`, `repocontext_index_status`, `repocontext_remove_repo`, `repocontext_reset_index` | **`repoId`**. |
| `repocontext_scan` | **`repoId`**; **`scope`** - `Files`, `Packages`, `Symbols`, `Memory`, or `MemoryTopic`, matched case-insensitively; `topic` - required for `MemoryTopic`, otherwise ignored; `pathPrefix` - narrows a `Files` scan to a directory and is rejected for every other scope; `continuationToken` - the previous page's token; `pageSize` (integer) - at most 500, and 0 or less means the default of 100. |
| `repocontext_search` | **`repoId`**; **`query`**; `k` (integer) - the most hits to return, at most 100, and 0 or less means the default of 10. |
| `repocontext_outline`, `repocontext_related` | **`repoId`**; **`path`** - a repository-relative file path. |
| `repocontext_changed` | **`repoId`**; **`path`** - the repository root, or a directory inside it to scope the report. |
| `repocontext_context` | **`repoId`**; **`task`**; `top` (integer) - at most 50, and 0 or less means the default of 10; `responseBudgetTokens` (integer) - at most 200000, and 0 or less means the default of 8192; `detail` - `paths`, `outline`, `slices`, or `auto` (the default, and what any other value is treated as); `seen` (string array) - unit receipts already held; `known` (string array) - `path@hash` whole-file claims; `session` - a caller session id. |
| `repocontext_neighbors` | **`key`**; `relation` - restrict the walk to one relation; `depth` (integer) - at most 3, and 0 or less means the default of 1; `maxNodes` (integer) - at most 100, and 0 or less means the default of 50. |
| `repocontext_bootstrap` | **`repoRoot`**; **`repoId`**; `includeGlobs` and `excludeGlobs` (string arrays); `respectGitignore` (boolean) - default `true`; `excludeBinary` (boolean) - default `true`. See [Filtering the walk](#filtering-the-walk). |
| `repocontext_add_repo` | **`path`** - a repository under the workspace root; `repoId` - defaults to the final path segment; and the same four walk filters as `repocontext_bootstrap`. |
| `repocontext_remember` | **`repoId`**; **`topic`**; `id` - omit to create with a generated id; `kind` - `Decision`, `Note`, or `Memory`, applied only when the entry is created (default `Note`; any other value, including the internal `Unspecified`, is rejected); `title`; `body`; `author`; `provenance`; `tags` (string array); `addLinks` and `removeLinks` - maps from a relation name to target keys; `ttlSeconds` (integer) - positive when supplied; `fencingToken` (integer). |
| `repocontext_update` | **`key`**; `fields` - scalar patches keyed by field name (see below); `addTags` and `removeTags` (string arrays); `addLinks` and `removeLinks` - memory records only; `fencingToken` (integer) - memory records only. |
| `repocontext_forget` | **`key`**; `lapse` (boolean) - default `false`, a hard delete; `lapseSeconds` (integer) - the lapse window, default 60, positive when supplied and ignored for a hard delete; `fencingToken` (integer) - memory records only, refused on any other record. |
| `repocontext_claim` | **`key`** - a memory-record key; **`owner`** - the claiming agent's identity; `leaseSeconds` and `maxWaitSeconds` (integers) - positive when supplied; see the `repocontext_claim` row above for what omitting each means. |
| `repocontext_renew_claim` | **`key`**; **`fencingToken`** (integer); `leaseSeconds` (integer) - positive when supplied, and pass it explicitly. |
| `repocontext_release_claim` | **`key`**; **`fencingToken`** (integer). |

`repocontext_update` matches `fields` names case-insensitively and refuses any name outside the record family's own set: `displayName`, `defaultBranch`, and `lastIngested` on a repository root; `language`, `version`, and `lastIngested` on a package; `digest`, `language`, `sizeBytes`, and `lastIngested` on a file; `filePath`, `startLine`, `endLine`, `signature`, and `digest` on a symbol; and `title`, `body`, `author`, and `provenance` on a memory entry. No claim field is in any set, so a claim cannot be patched.

Every tool also accepts the optional `region` argument the MCP host adds to each group tool (see [Region targeting](../lattice.api.mcp/tools.md#region-targeting)). No peer region serves the repository-context group, so a `region` naming a peer is refused; omit it to use the current region.

A refused call reaches the caller as an MCP error result naming the problem; for a classified refusal the MCP host first sanitizes the message (control and line-separator characters are replaced) and caps its length, both in the text it returns and in its own Debug line. These refusals are classified as the caller's mistake - a missing, blank, unbindable, or undeclared argument, a malformed key, an unrecognised `kind` or `scope`, a `pathPrefix` outside a `Files` scan, a non-positive `ttlSeconds` or `lapseSeconds`, free text refused for leaked tool-call framing or a credential-bearing URL, a path outside the workspace or the repository's indexed root, and a target that does not exist - so they are counted on `orleans.lattice.api.mcp.tool.client_errors` by `tool` and `reason`, and the MCP host logs them at Debug without a stack rather than as server faults (see [Client errors are answered, not logged as faults](../lattice.api.mcp/tools.md#client-errors-are-answered-not-logged-as-faults)). This package's own per-call log line, under the `Orleans.Lattice.Api.Mcp.RepoContext.ToolInvocation` category, still records every call that reaches a tool and fails at Warning with its exception. An undeclared argument, a refused `region`, and a refusal by the registered `ILatticeApiMcpAuthorizer` are turned away before the tool runs and leave no such line. An access-gate denial raised inside the tool - the `LatticeAuthorizationDeniedException` the tree access gate throws when it refuses the caller's credential on a tree operation - does reach it, so it leaves that Warning line with its stack. The remaining refusals are still raised unclassified, so the host also logs them at Error: a fencing conflict, a non-positive claim duration, a claim tool pointed at a non-memory key or a fencing token presented against a non-memory record, an `update` against a record family that cannot be patched or with a field, link, or integer value its family does not accept, a `remember` or `update` link whose relation name is empty or whose target is not a well-formed key, an onboarding call refused because the repository is git-sourced or the workspace boundary is not enforcing, a `repocontext_reset_index` refused because a damaged code-index tree it would have to drop whole is shared with another repository, a `region` naming a peer, an authorizer refusal, and an access-gate denial, which is never classified as a client error.

## Asynchronous indexing lifecycle

Onboarding a repository (`repocontext_bootstrap`, or `repocontext_add_repo` in workspace mode) is a potentially long walk-digest-reconcile-vectorise pass, so it does not run on the client request. The tool records a durable job, hands the work to a background runner bound to the host lifetime (not to the client stream), and returns immediately with a `Running` snapshot. A client follows the pass by polling `repocontext_index_status` with the same `repoId` until `status` is `Completed` or `Failed`.

Because the run is decoupled from the request, a dropped MCP stream or client disconnect can no longer abort an index. Each job is anchored by an Orleans reminder: while a run is in flight the reminder beats as a single-flight heartbeat, and after a host restart it re-fires, reactivates the job, and re-enqueues the persisted request so the interrupted pass resumes from where it left off (the bootstrap pass is idempotent, so already-committed files are skipped by digest). The `attempt` counter on the status snapshot is a cumulative tally of index runs *started* for the repository - the initial onboarding plus every re-drive (each periodic reconcile that picks up edits and deletions, each gap back-fill, and each re-drive of a failed run; a reminder-driven resume after a restart continues the interrupted run and does not add one) - so it rises steadily on a healthy, actively-maintained repository and a high value is normal, not a sign of failure or interruption. A durable grain-storage provider and the Orleans reminder service must therefore be configured on the host; the bundled container image wires both.

### Adaptive pacing

While a run is `Running`, the `repocontext_index_status` snapshot carries a `pacing` object read live from the silo's indexing pacer (it is absent once the run completes or fails, and on a host with pacing switched off by `LATTICE_REPOCONTEXT_PACING=false` it reports `Disabled`). It explains why an embedding pass is going slower than the hardware could, so a deliberately slowed job reads as slowed rather than as stalled:

| Field | Meaning |
|---|---|
| `state` | `Idle` (no batch recently), `Pacing` (full rate), `Backoff` (a congestion signal raised the inter-batch delay), `Waiting` (a vector tree is saturated, bounded at 30 s), `Resting` (the rest between work slices), `Yielding` (a search or context call is in flight, bounded at 2 s), or `Disabled`. |
| `reason` | A human-readable cause for the current state, for example `an embedding batch failed`. |
| `batchDelayMilliseconds` | The current congestion-driven delay before each batch; `0` at the full rate. |
| `since` | When the pacer entered its current state; absent while `Idle`. |
| `foregroundRequests` | How many search or context calls are in flight on this silo right now. |

A `Backoff`, `Waiting`, `Resting`, or `Yielding` state with `filesEmbedded` or `symbolsEmbedded` still advancing between polls is a healthy, paced job. The pacer never skips or fails a batch, so a paced pass lands exactly what an unpaced one would, only spread over more wall-clock time. A `Pacing` reason ending `is treated as advisory` names a vector tree that is still `Throttled` but did not clear while indexing held its delay at the ceiling for 60 seconds, so the drain runs at the full rate past it until it clears or worsens (issue #3456). The variables that tune it are listed under [container configuration](container.md).

A run of failed vector store or membership writes does not by itself end an embedding pass. After three consecutive failures the pass consults the silo's WAL saturation signal, and defers its remaining batches to the next reconcile only when a vector tree reports `Saturated`; against a `Throttled` or `Healthy` tree it keeps attempting under the pacer's backoff, so each further batch tests whether the plane admits the write again (issue #2683). A host that registers no saturation signal falls back to deferring on the run alone.

## Staying fully indexed: the self-index grain

Onboarding a repository does more than complete once: a per-repository **self-index grain**, keyed by `repoId`, owns that repository's "reach and stay fully indexed" guarantee from onboarding until the repository is removed or its index is reset. The same onboarding call that starts the first pass (`repocontext_bootstrap`, or `repocontext_add_repo` in workspace mode) arms this grain - a repository configured with a git source is armed from that configuration when the host starts - and removing the repository (`repocontext_remove_repo`) or resetting its index (`repocontext_reset_index`) tears it down. Onboarding and self-heal recovery therefore funnel through exactly one path and cannot drift.

The grain runs a continuous, low-cost background scan of its own repository's structural file range and re-drives the idempotent index whenever it finds work - all without a client call:

- **It is cheap and bounded.** A grain-local timer drives the scan, which reads stable source identifiers only, never the embeddings themselves. Once the repository's per-page vector-coverage digest is built, one tick classifies every file: a *keys-only* walk of the structural file range, checked in memory against the digest's fixed set of rows, reads nothing from the membership tree and names every uncovered file (up to 4,096 per scan) rather than stopping at the first. Before the digest is built, or after a membership reset drops it, the grain falls back to one bounded page of that walk per tick, point-probing the page's files against the add-wins membership and stopping at the first file with no live embedding. Between full scans the grain idles behind a jittered cooldown, and the first tick after each activation is itself jittered, so a fleet of repositories reactivated together never all scan at the same instant.
- **It back-fills missing embeddings.** When the scan finds an unembedded file, the grain re-drives the whole-repository index. The embedding pass is an idempotent back-fill: it re-embeds every file the membership says is missing and skips the rest, so a single trigger closes all of that repository's gaps. This is what makes embedding presence self-healing - a file whose content digest is unchanged is structurally skipped, but the independent presence flags still catch a vector that was never written (for example because the embedder was unavailable at first onboarding) and closes the gap once it is back. The same periodic reconcile also carries the **symbol**, **content-projection**, and **cross-reference** back-fills, so a repository indexed before symbol extraction, the content projection, or the reverse cross-reference index existed heals its symbol, content, and reverse-reference trees on its own, with no client call (see [record-model.md](record-model.md#content-projection)).
- **It rescues a failed run.** At the start of each scan cycle the grain checks the job's status and re-drives a run that outright *failed*. A failure before any structural record was written leaves nothing for the file scan to detect, so this status check - not the gap scan - is what keeps a failed onboarding from being abandoned. Both re-drives are single-flight: they are a no-op while a run is already in flight.
- **It picks up on-disk edits and deletions.** On a longer cadence than the gap scan (a jittered interval of roughly fifteen minutes, or a git-sourced repository's own refresh interval), the grain re-drives the whole idempotent index so files edited or deleted on disk after onboarding are reconciled automatically, with no client call. The gap scan only finds *missing embeddings*; this periodic reconcile is what re-walks the tree to notice *changed* and *removed* content. Each reconcile is kept cheap by the walk's stat fast-path (below), so in steady state it reads almost nothing.
- **It audits the coverage digest.** On a long cadence (24 hours by default, plus jitter; see `LATTICE_COVERAGE_DIGEST_AUDIT_INTERVAL_SECONDS` under [container configuration](container.md)), the grain re-derives the digest from an authoritative scan of the whole membership, bounding how far the digest can drift from what it mirrors. It is the one whole-membership read on this path, so it is postponed - at most 12 times in a row - while the indexing pacer reports foreground search load or a congested vector plane.

The reconcile leans on a **stat fast-path** to stay cheap. Every file's record carries the wall component of the hybrid-logical clock stamped when it was last ingested (its ingest anchor), recovered directly from the digest register's order key, so no extra field is stored. During a reconcile walk a candidate whose size is unchanged and whose on-disk modification time is *strictly older* than that anchor is assumed unchanged: its stored digest and language are reused without reading or hashing the file. The strict comparison stays clear of the racy-clean window (a file touched in the same tick as its last ingest is re-read), and the content digest remains the sole source of truth - a real edit either changes the size or leaves the modification time at or after the anchor, both of which fall through to a full read. A file that is touched but byte-for-byte identical is re-hashed once, kept at its stored digest (so it is not re-embedded), and has its node rewritten to advance the anchor, so the fast-path skips it from then on.

The content digest itself is a non-cryptographic **XxHash128** fingerprint - change detection only, never a security boundary - which is roughly ten times cheaper to compute than SHA-256 and dominates the cost of a cold walk. Digests are self-describing and the switch is non-breaking: a modern digest is written `xx128:<hex>`, while a legacy bare 64-character hex digest is read back as an implicit SHA-256 value. A reconcile always recomputes a file's fingerprint under its *stored* digest's own algorithm, so a store written before the switch keeps reconciling correctly with no forced bulk re-hash; a file migrates to the cheaper algorithm only when its content genuinely changes.

The grain stays durable through a one-minute keep-alive reminder that keeps it activated and re-fires it after a host restart, at which point it re-arms its scan timer and resumes from the persisted checkpoint. This keep-alive is the repository's standing backstop, and **only removing the repository or resetting its index unregisters it**; a reset leaves the repository registered but unindexed until a subsequent `repocontext_add_repo` - or, for a git-sourced repository, the next host start - re-arms the grain. A job's own resume reminder is cleared when a run *fails* (so a deterministic logic failure does not retry forever inside the job), but the self-index grain's keep-alive survives that failure and is what re-drives the run - a failed onboarding is never silently abandoned.

## Removing a repository

Removing a repository (`repocontext_remove_repo`) tears down everything indexing owns for it: it cancels any in-flight run, unregisters the job's resume reminder and the self-index grain's keep-alive reminder, clears both grains' durable state, and disarms the repository's approximate-index build coordinator, so a removed repository never resumes, never scans, and never rebuilds an index again. `repocontext_reset_index` runs the same teardown before its sweep, except that a step that times out or meets an unavailable tree is recorded and skipped rather than aborting the reset (a removal stops at the first fault).

A reset's own sweep is tolerant in the same way, because it is the verb invoked when the index is already damaged. A code-index tree it cannot drain because a call timed out is skipped, and the sweep moves on to the next. A tree holding a leaf that cannot be activated is dropped whole when this repository is the only one registered - it then counts as swept but adds nothing to `entriesDeleted` - and the reset is refused, naming the tree, when another repository shares it. The structural tree is never dropped whole, so any fault there aborts the reset. A reset that skipped a tree or a teardown step ends `Failed`, with an error naming each one, rather than `Completed`; everything it did drain stays dropped.

## Filtering the walk

The onboarding tools (`repocontext_bootstrap` and `repocontext_add_repo`) take optional `includeGlobs` and `excludeGlobs` to narrow which files are ingested, a `respectGitignore` flag that defaults to `true`, and an `excludeBinary` flag that also defaults to `true`.

When `respectGitignore` is on, the walk honours the repository's own `.gitignore` files with a dependency-free, hierarchical matcher: rules layer from the repository root down, a deeper `.gitignore` overriding a shallower one and the last matching pattern within a file winning (including a `!` re-include). It covers the forms real repositories use - comments, blank lines, `!` negation, a leading or interior `/` to anchor a pattern to its `.gitignore` directory, a trailing `/` for a directory-only match, `[...]` character classes (ranges and `!`/`^` negation), the `*`, `?`, and `**` wildcards, and a `\` escape that makes the next character literal (so `\#*\#`, `\*`, and an escaped trailing space match as git matches them) - and prunes an ignored directory during descent rather than walking it, so a build output tree never enters the index and never costs a hash. The matcher does not read `.git/info/exclude` or the user's global excludes, and the container needs no `git` binary. Set `respectGitignore` to `false` to index every file the include/exclude globs allow, tracked or not. When globs and `.gitignore` are combined, a file must satisfy both to be ingested.

When `excludeBinary` is on, a file whose leading bytes look non-text - a `NUL` byte anywhere in the first 8,000 bytes, the same cheap, language- and extension-agnostic heuristic Git uses - is dropped before it is hashed, embedded, or indexed, so compiled artefacts, images, archives, and other blobs never enter the index. Because the walk already reads each surviving file's bytes to hash it, the sniff is essentially free and never reads more than a bounded prefix. Set `excludeBinary` to `false` to ingest binary files too.

## Discovery and gating

Tool advertisement and invocation both defer to the core permission-aware discovery filter and the fail-closed gate - the module registers exactly one tool group and adds no per-session state. A caller with no matching data-plane capability sees none of these tools; a caller whose permissions only allow reads sees the read-only tools (the always-on read-only tools, plus `repocontext_changed` when a workspace root is configured and `repocontext_list_repos` in workspace mode); the mutating tools appear only when the host enabled writes. See the [MCP server](../lattice.api.mcp/README.md) docs for the credential bridge and grant model.

A host that also passes `registerAsApp: true` to `AddRepoContextTools` additionally offers the always-on read-only tools - every read-only tool except `repocontext_changed` and `repocontext_list_repos` - under app-local names such as `repo-context_search`, once the `repo-context` app is installed and enabled; they run the same handlers and never replace the group tools. See [As an installable app](README.md#as-an-installable-app-opt-in).
