grove
Structural, byte-precise, token-cheap access to a codebase — for coding agents and the humans working alongside them.
Instead of reading whole files or grepping blind, grove uses tree-sitter to answer structural questions: what’s defined in a file, where a symbol lives, who calls it, how a directory connects. Every answer is one symbol’s worth of structure with a stable id you can pass to the next call. Grammars load at runtime from a hosted WASM registry, so a new language is a dropped-in directory — no recompile, no toolchain on the consumer.
One engine, four surfaces
grove is a library (grove-cst) over one structural engine, shipped as two
binaries — grove (CLI + the always-structural MCP server) and
grove-explore (its own MCP server, the LLM-delegating locator). You can
reach the engine four ways:
| Surface | What it is | Start here |
|---|---|---|
| CLI | grove <verb> — the seven tools at your shell, human tables or --json | CLI & the seven tools |
| MCP: structural | grove serve — the same seven tools to a coding agent over stdio | MCP: standard server |
| MCP: explore | grove-explore serve — a single delegated explore locator backed by a local LLM, its own server identity, composable alongside grove serve | MCP: explore mode |
| Library | grove-cst on crates.io — use grove_core::ops in your own Rust | Use grove as a library |
The CLI, MCP structural, and Library surfaces all call the same ops engine, so
a human at the shell, an agent over MCP, and your own program see identical
results. MCP structural and MCP explore are two separate servers a project can
register independently or together — not a mode switch on one server.
Get going
- Install — curl / Homebrew / npm / cargo, or build from source.
- Setup —
grove initwires a project for your agent (--as mcp | skill | both | mcp-llm). - CLI & tools — the seven-tool surface and its conventions.
- Languages & grammars — the WASM registry and how grammars load.
New to why this matters? The project VISION and README tell the longer story; the FAQ answers the common “is this an LSP?” questions.
Install
grove ships as two static binaries (Linux, macOS, Windows; x86_64 + aarch64 where applicable). No grammar is compiled in — they load at runtime from the hosted registry.
| Binary | What it is |
|---|---|
grove | The CLI (grove <verb>) and the 7-tool structural MCP server (grove serve). This is what most projects want. |
grove-explore | The optional LLM-backed code locator — its own MCP server (grove-explore serve), plus the config and tap TUIs. |
Every install channel below places both on your PATH from v0.4.1 onward.
grove-explore is inert until configured, so installing it costs you nothing if
you never opt in; see Setup for grove init --as mcp-llm.
curl | sh (Linux / macOS)
Detects your platform, verifies the sha256, installs to ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/Entelligentsia/grove/main/install.sh | sh
Honors HTTP_PROXY/HTTPS_PROXY/ALL_PROXY + NO_PROXY via curl/wget.
PowerShell (Windows)
Verifies the sha256, installs to $env:LOCALAPPDATA\grove\bin:
irm https://raw.githubusercontent.com/Entelligentsia/grove/main/install.ps1 | iex
Honors HTTPS_PROXY/HTTP_PROXY/ALL_PROXY + NO_PROXY from the environment
(set them before running the one-liner). For an explicit -Proxy, download the
script first — piping into iex doesn’t accept parameters:
iwr https://raw.githubusercontent.com/Entelligentsia/grove/main/install.ps1 -OutFile install.ps1
./install.ps1 -Proxy "http://proxy.corp:8080"
-InstallDir and -Version (or $env:GROVE_INSTALL_DIR / $env:GROVE_VERSION)
override the defaults, mirroring install.sh’s env vars. Only the
x86_64-pc-windows-msvc prebuilt is published today; other Windows archs fall
back to cargo install.
Homebrew (macOS / Linux)
brew install Entelligentsia/grove/grove
Tap: Entelligentsia/homebrew-grove.
npm
Provides both the grove and grove-explore commands via a downloaded,
checksum-verified prebuilt:
npm install -g @entelligentsia/grove
The postinstall download honors HTTP_PROXY/HTTPS_PROXY/ALL_PROXY +
NO_PROXY from the environment.
From source
The two binaries are separate crates, so installing from source is the one
channel where you choose. grove alone:
cargo install grove-cst-cli # installs `grove`
Add the locator if you want it:
cargo install grove-explore-bin # installs `grove-explore`
Or straight from git — the repo is a workspace, so name the package:
cargo install --git https://github.com/Entelligentsia/grove grove-cst-cli
Build from a checkout — --workspace builds both:
cargo build --release --workspace # first build compiles wasmtime (~30s), then incremental
# binaries at target/release/grove and target/release/grove-explore
As an agent skill (cross-harness)
The skill works across 70+ harnesses (Claude Code, Cursor, Codex, Cline, …) via the agent-skills tool:
npx skills add Entelligentsia/grove
The skill steers your agent to grove’s MCP tools when present, else the grove
CLI — and self-installs the binary on first use if it’s missing (npm i -g @entelligentsia/grove, then grove init --as skill to fetch grammars). So this
one command is enough to get grove working in a fresh repo. See
Setup for grove init --as mcp|skill|both.
Prebuilt binaries
Attached to each GitHub Release.
Each platform archive contains both grove and grove-explore alongside a
.sha256 sidecar. Archives predating v0.4.1 hold grove only; the installers
tolerate those and simply skip the second binary.
Next: Setup · Languages & grammars
Tools
grove’s surface is the agent loop in miniature — seven tools, each returning one
symbol’s worth of structure with a stable id. Add --json to any command for the
agent-facing structured shape.
| Phase | Command | What it does |
|---|---|---|
| Read | grove outline <file> [--kind K] [--detail 0|1|2] | compact definition skeleton: kind · name · parent · signature · id. Filter by kind / dial detail down for big files |
| Find | grove symbols <dir> [--kind K] [--name SUB] [--name-contains] [--refs] | repo-wide symbol search (gitignore-aware). --name is exact (case-insensitive); --name-contains (alias --name-substr) opts into substring |
| Read | grove source <id> or grove source <file> <name> | full source of one symbol — no whole-file read |
| Verify | grove check <file> | ERROR / MISSING nodes (exit 1 if any) — post-edit syntax check |
| Trace | grove callers <name> [-d <dir>] | call sites of a symbol, each with its enclosing function |
| Map | grove map <dir> [--kind K] [--name SUB] [--name-contains] | directory dependency graph: definitions + outgoing references, no source bodies |
| Trace | grove definition <name> [-d <dir>] or grove definition --at <file:line:col> | go-to-def, by name or from a usage position (line/col are 1-based). --at resolves in order: scope-aware local binding (shadowing a same-named global) → import edge to the target file (cross-file, for languages with an import-resolution strategy) → directory-wide name lookup |
Conventions
- Lines and columns are 1-based everywhere grove reports or accepts them
(the editor /
grep -nconvention). - Every result carries a stable
symbol-id(<lang>:<relpath>#<name>@<line>, line 1-based) usable across turns — pass it fromoutline/symbols/mapintosource,callers,definition. --namematches exactly (case-insensitive) by default so--name batchreturnsbatch, nottestCreateBatch. Use--name-containsfor fuzzy substring exploration. (issue #37)
Detail tiers (outline)
--detail controls field density so big files stay token-cheap:
0— terse: kind · name · parent · line1— default: adds id · col · signature2— full: adds byte offsets (forreadslicing between symbols)
Examples
grove languages
grove outline foo.py --kind class # python, loaded from wasm at runtime
grove outline src/engine.rs --kind function
grove source src/mcp.rs serve
grove callers extract -d src
grove map src --kind function # dependency graph, no source bodies
grove check src/registry.rs
grove symbols . --name batch # exact: just `batch`
grove symbols . --name batch --name-contains # substring: testCreateBatch, …
Example results
Real output, run against grove’s own Rust source. Add --json to any command
for the agent-facing structured shape.
outline — a file’s definition skeleton (kind · name · parent · line:col · signature):
$ grove outline core/src/render.rs
function outline 13:8 pub fn outline(syms: &[Symbol]) -> String {
function source 37:8 pub fn source(res: &SourceResult) -> String {
function map 72:8 pub fn map(maps: &[FileMap]) -> String {
module tests 95:5 mod tests {
method sym tests 99:8 fn sym(kind: &str, …) -> Symbol {
symbols — find a name across a directory; each hit carries a stable id:
$ grove symbols core/src --name outline
def function outline rust:core/src/ops.rs#outline@100
def function outline rust:core/src/render.rs#outline@13
source — one symbol’s full body, by id (or source <file> <name>):
$ grove source rust:core/src/render.rs#source@37
pub fn source(res: &SourceResult) -> String {
format!("{}\n", res.source)
}
callers — every reference to a name, with its enclosing function and
provenance (S = structural / tree-sitter, T = textual / grep):
$ grove callers outline -d core/src
core/src/ops.rs:909:20 tests::project_tiers_control_field_density [S] let syms = outline(&file, None).unwrap();
core/src/ops.rs:939:19 tests::outline_filters_by_kind_and_skips_references [S] let all = outline(&file, None).unwrap();
core/src/lib.rs:14:14 <top-level> [T] //! - [`ops::outline`] — the definitions in one file …
map — a directory’s definitions, each with its outgoing references after →
(no source bodies — one call replaces many symbols+source round-trips):
$ grove map core/src
core/src/config.rs
method from_name Mode 55 pub fn from_name(s: &str) … → Ok, bail
method try_from GroveConfig 111 fn try_from(raw: RawGroveConfig) … → Ok, bail
function migrate_from_legacy_explore 157 fn migrate_from_legacy_explore() … → Ok, Some, save, validate
definition — go-to-def by name (results can span files, so each leads with
file:line:col); --at file:line:col resolves from a specific usage site:
$ grove definition symbols -d core/src
function symbols core/src/ops.rs:153:8 pub fn symbols(
function symbols core/src/render.rs:26:8 pub fn symbols(syms: &[Symbol]) -> String {
check — ERROR / MISSING nodes after an edit (exit 1 if any; here, clean):
$ grove check core/src/render.rs
ok · no syntax errors · core/src/render.rs
When to use which
- One file →
outline(skeleton) →source <id>(the one symbol’s body). - Where is X defined across the repo →
symbols <dir> --name X→source <id>. - Who calls X →
callers <name> -d <dir>. - How does this directory connect →
map <dir>(definitions + outgoing references, one call, no bodies) — replaces manysymbols+sourceround-trips. - After an edit →
check <file>to confirm you didn’t break syntax.
The cross-harness skill encodes these chains as the agent’s default procedure.
Next: MCP server · Skill →
MCP server
grove serve runs an MCP server over stdio (newline-delimited JSON-RPC 2.0),
exposing the same seven tools for every registered language. Register
it with a coding agent and the agent gains structural sight.
Project-scoped registration for Claude Code lives in .mcp.json
(written by grove init):
{ "mcpServers": { "grove": { "command": "…/target/release/grove", "args": ["serve"] } } }
Availability vs. adoption
.mcp.json makes the tools available; a CLAUDE.md steering directive (see
VISION §6.4.1) is what gets the agent to actually use them
rather than defaulting to grep / whole-file reads. grove init --as mcp writes
both — see Setup.
Two surfaces, one at a time
grove isn’t the only MCP server in the family. grove-explore is a
separate binary with its own MCP server — a single explore tool that
delegates broad “where is X” questions to a local LLM and returns validated
file:line citations (see Setup — explore-mode). A project
registers one surface or the other, never both (see
ADR 0005) — both together would put eight
tools in front of the agent with no rule for choosing:
--as | Registers under grove | Tools |
|---|---|---|
mcp (default) | grove serve | mcp__grove__* — the 7 structural tools |
mcp-llm | grove-explore serve | mcp__grove__explore — one delegating locator |
Both surfaces register under the same grove key, so the locator’s tool is
mcp__grove__explore — the historical name, and one the outer agent reads as
“grove”, not as a binary. Switching modes swaps the entry;
grove init strips the surface you left. See Setup for the full
--as mcp-llm wiring.
Tool schemas
Every inputSchema is a plain {type: "object", properties, required?}. Tool
list is returned by initialize → tools/list. outline is tiered (--detail
0|1|2); symbols/map take name (exact, case-insensitive) plus a nameContains
boolean for substring matching.
Result & error model
Tool results are JSON inside an MCP text block. Tool-level failures come back as
isError: true with a message so the model can recover (e.g. missing required
arg, unknown language, no identifier at a position). Unknown method →
-32601 method not found; unknown tool / bad args → -32602 invalid params.
Same engine as the CLI
grove serve and grove <verb> call the same ops library, so a human at the
shell and an agent over MCP see identical bytes. The cross-harness skill
prefers the MCP tools when the host exposes them and falls back to the CLI
otherwise — equal partners over one engine.
Back: Tools · Setup · Roadmap & layout
Setup — grove init
One command makes a project one where the agent uses grove:
grove init # in your project root
It detects the project’s languages from the hosted catalog (so it sees a language even before its grammar is installed), auto-fetches the grammars the project needs, then writes three things (idempotently, preserving anything already there):
.mcp.json— registers the grove MCP server (availability — the tools exist).CLAUDE.md— a steering directive in a marked section (adoption — the agent reaches for grove instead of grep/whole-file reads; see VISION §6.4.1).grove.lock— pins the detected grammars’ version + wasm sha256.
grove init --dry-run detects without writing or fetching. Re-running only
updates grove’s own pieces. Offline, it falls back to detecting from grammars
already in the cache.
MCP, skill, or both — grove init --as
grove has one engine behind three faces: the CLI, the MCP server, and a
cross-harness skill. --as selects which integration init wires up
(grammar provisioning + grove.lock happens for every target):
grove init --as mcp # default — .mcp.json + CLAUDE.md + grove.lock
grove init --as skill # grammars + grove.lock only; install the skill separately
grove init --as both # MCP wiring and grammars, for skill + MCP side by side
grove init --as mcp-llm # locator only: grove-explore under the grove key + CLAUDE.md + AGENTS.md
Explore-mode — grove init --as mcp-llm
Opt-in, and exclusive with the structural surface.
grove-exploreis a separate binary with its own MCP server;--as mcp-llmregisters it instead of the structuralgrove serve, not alongside it (see ADR 0005). The.grove/config.jsonconfig format and theexploretool contract are covered by semantic versioning as of 0.3.0. The standard--as mcp|skill|bothtargets and the always-structural 7-toolgrove serveare unaffected either way.
--as mcp-llm registers the locator alone — the grove-explore binary,
under the .mcp.json key grove, exposing a single tool mcp__grove__explore
backed by a local LLM (configured via .grove/config.json’s explore
section). It replaces the structural surface rather than composing with it: a
project registers grove (structural) or the locator, never both (see
ADR 0005). The outer agent asks
mcp__grove__explore narrow where-is questions and reads a window at each
cited file:line itself. If the provider is unreachable, grove-explore serve
starts anyway and the explore tool returns an actionable error in-band —
there is no silent fallback to the structural tools.
The locator uses the grove key deliberately, so the tool is
mcp__grove__explore — the historical name and the one the outer agent should
see; the grove-explore binary is a packaging detail. It’s safe because the
surfaces are exclusive, so the key is never contended.
What it writes:
.mcp.json— one MCP server under thegrovekey, runninggrove-explore serve(any stale two-server-eragrove-explorekey is cleaned up on init).CLAUDE.md— a locator-framed steering block: askmcp__grove__explorebroad where-is questions, then read the cited lines.AGENTS.md— the same steering, harness-neutral, for non-Claude harnesses (Codex, Cline, etc.).
First-run TUI: on the first grove init --as mcp-llm, init shells out to
grove-explore config to collect the provider, base URL, and model (requires
an interactive terminal), saving them to .grove/config.json. Re-runs (when
the config already exists) work without a TTY. When the grove-explore binary
isn’t found on PATH, init degrades gracefully — it still registers the
server and prints the follow-up command (grove-explore config) instead of
failing the init.
grove init --as mcp-llm --dry-run # print planned writes without creating files
The skill is distributed through the agent-skills tool:
npx skills add Entelligentsia/grove
The skill prefers grove’s MCP tools when the host exposes them and falls back
to the grove CLI otherwise — so MCP and the skill are equal partners over the
same engine. On first CLI use it self-bootstraps: if grove isn’t on PATH it
installs the npm package globally, then runs grove init --as skill to fetch the
repo’s grammars.
What init writes
| File | Purpose | Target |
|---|---|---|
.mcp.json | registers grove serve for MCP-aware harnesses | availability |
CLAUDE.md | a marked steering block routing the agent to grove | adoption |
grove.lock | pinned grammars (version + wasm sha256) | reproducibility |
Re-running grove init updates only grove’s own pieces and never clobbers
content outside its marked sections.
Next: Languages & grammars · MCP server
Languages & grammars
A grammar is a registry/<lang>/ directory holding grammar.wasm + tags.scm +
manifest.json. They load at runtime — grove init fetches what your project
needs, or fetch explicitly:
grove languages # what's installed locally
grove fetch # install all grammars from the hosted registry
grove lock # write grove.lock pinning version + wasm sha256
The hosted registry (Entelligentsia/grove-registry)
carries all 27 official tree-sitter grammars (c, cpp, c#, go, java, js, ts, tsx,
python, ruby, rust, php, scala, ocaml, ql, and more); grove fetch installs them.
The grove repo itself ships a small 3-language dev stub. Adding a language is
dropping a registry/<lang>/ directory in — the binary doesn’t change, and that
includes the full surface: the manifest.json carries a profile (container /
function / identifier node kinds) that drives parent grouping, callers’
enclosing-function, and go-to-def, so nothing language-specific is compiled in.
// registry/<lang>/manifest.json
{ "name": "go", "version": "…", "extensions": ["go"],
"profile": {
"function_kinds": ["function_declaration", "method_declaration"],
"containers": [["type_declaration", "name"]],
"identifier_kinds": ["identifier", "field_identifier"]
} }
Build a grammar’s wasm with tree-sitter build --wasm (emits the dylink.0
module the native runtime needs).
Where grammars live
Grammars are a cache — reconstructible from the hosted registry and
content-addressed by grove.lock — so the standard home is the OS-native cache
location. grove resolves the registry root by precedence (first existing wins);
grove registry shows it:
GROVE_REGISTRYenv var — explicit override (CI, tests, air-gapped).<project>/.grove/grammars/— project-vendored grammars (commit them for hermetic / offline builds), found by walking up from the cwd.- OS user cache — the default shared store:
- Linux:
~/.cache/grove/grammars(honors$XDG_CACHE_HOME) - macOS:
~/Library/Caches/grove/grammars - Windows:
%LOCALAPPDATA%\grove/grammars
- Linux:
- dev source tree (
registry/next to this crate) — only in a checkout.
Layout under the root is <lang>/{grammar.wasm, tags.scm, manifest.json}.
Fetching grammars
grove fetch pulls grammars from the hosted registry into the OS cache:
grove fetch # all languages in the catalog
grove fetch python rust # just these
grove fetch python --force # re-download
grove owns the artifacts it serves. Rather than redirecting to upstream URLs
at fetch time, grove ingests each grammar (official release wasm where it exists,
else built from source), normalizes it into the shape grove needs — grammar.wasm
(native dylink.0) + tags.scm + a manifest.json carrying the node-kind
profile — and hosts those bytes content-addressed, recording provenance
(source.repo / source.rev) for auditability. This guarantees the three travel
as one co-versioned unit and that grove.lock always resolves.
The host is the Entelligentsia/grove-registry
repo, split for efficiency: the small text files (index.json, per-language
tags.scm + manifest.json) live in the repo (served via
raw.githubusercontent.com), and the heavy grammar.wasm binaries are GitHub
Release assets (GitHub’s CDN). The catalog’s release_base + per-file asset
fields tell fetch where each file lives. Every file’s sha256 is verified
against the catalog before it’s written (download-verify-then-write, atomically),
so a corrupted or tampered artifact is rejected. Override the host with
GROVE_REGISTRY_URL (self-host, fork, or a local mirror).
Profiles (why some languages do more)
A grammar’s manifest.json profile decides which tools work fully:
- Full profile (~15 languages) — declares
function_kinds/identifier_kinds, socallers(enclosing function),definitionfrom a position, andparentgrouping all work. - Minimal profile (12 languages) — core tools (
outline,symbols,source,check,map) work;callers/definitiondegrade. - css/html/json/regex have no upstream
tags.scm— they stillcheckbut yield no symbols.
Nothing language-specific is compiled into the binary; the profile is data.
Building the registry (maintainer)
grove ingest builds the registry from a curated spec (registry-sources.json):
for each grammar it pulls the official tree-sitter release wasm + the repo’s
tags.scm at a pinned rev, attaches grove’s curated profile/extensions, writes
registry/<lang>/, and regenerates the catalog.
grove ingest # all grammars in registry-sources.json
grove ingest python rust # just these
grove index registry # (re)build index.json with per-file hashes
The spec records identity + provenance + the grove-authored profile; the wasm and
tags come from upstream and the version/source are pinned. grove index then
emits the index.json catalog (per language: version, provenance, content hash of
every served file) — the publish step for registry CI.
Next: Tools · Registry repo →
Use grove as a library
The engine behind the CLI and the MCP server is a published Rust crate,
grove-cst (the library name is
grove_core). Depend on it to run the same structural queries in-process — no
subprocess, and the grammar cache stays warm across calls.
The crates.io id is
grove-cst(groveandgrove-corewere taken by unrelated crates); the library youuseisgrove_core, and the CLI binary isgrove(published asgrove-cst-cli).
Add it
cargo add grove-cst
The ops surface
Every structural query lives in grove_core::ops and works for any registered
language (grammars load from the registry as WASM at runtime):
use std::path::Path;
use grove_core::ops;
fn main() -> anyhow::Result<()> {
// Every definition under `src/`, gitignore-aware.
for s in ops::symbols(Path::new("src"), None, None, false, false)? {
println!("{} {} — {}:{}", s.kind, s.name, s.file, s.line);
}
// One symbol's full source, by id or by (file, name).
let body = ops::source("rust:src/main.rs#main@1", None)?;
println!("{}", body.source);
Ok(())
}
The core return types (Symbol, SourceResult, CallSite, FileMap, Defect)
are re-exported at the crate root. To print results the way the CLI does, use
grove_core::render — the same formatters both faces share (see
ADR 0003).
Provisioning grammars
A consumer that ships its own environment can provision the grammars a project
needs (fetch + write grove.lock) via grove_core::init::provision_project,
or resolve them explicitly through the grove_core::registry module. The
registry-resolution precedence is described in Languages & grammars.
API reference
The full, always-current API reference is published on docs.rs:
- https://docs.rs/grove-cst — every public item, generated from the source.
Reference
Formats and files grove reads and writes.
Symbol id
Every result across the whole surface carries a stable symbol id you can pass
from one tool to the next (outline / symbols / map → source / callers /
definition):
<lang>:<relpath>#<name>@<line>
lang— the grammar name (rust,python,typescript, …).relpath— the file path relative to the working directory.name— the symbol’s name.line— the 1-based line of the name.
Example: rust:core/src/ops.rs#symbols@153.
1-based lines & columns
Lines and columns are 1-based everywhere grove reports or accepts them (the
editor / grep -n convention) — including definition --at file:line:col. This
is a deliberate normalization; tree-sitter’s own points are 0-based.
.grove/config.json
The single, versioned source of truth for a project’s grove integration, written
by grove init and read by grove serve, grove-explore serve/config/tap,
and grove doctor (see
ADR 0002).
{
"version": 1,
"mode": "mcp-llm",
"explore": {
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5-coder:7b",
"steering": "standard",
"allowed_tools": ["grove", "rg", "grep", "find"],
"tap": false,
"trace_retain": 50
}
}
mode— the integration mode (mcp·skill·both·mcp-llm·grammars), the harness-reconciliation key forinit/doctor.grove serveis unconditionally the 7-tool structural surface regardless ofmode—modeno longer selects a serve surface.mode: mcp-llmmeans “initregisters bothgroveandgrove-explorein the harness (.mcp.json,CLAUDE.md,AGENTS.md)”;grove-exploreis its own MCP server binary with its own always-onexploresurface, composable alongsidegrove serve.explore— present whenmodeismcp-llm; read bygrove-explore:provider—ollamaorllamacpp(both speak the OpenAI-compatible wire protocol).base_url,model— the inference endpoint and model id.steering— the steering arm:standard(merit),balanced(plan-first), orstrict(grove-first).allowed_tools— the tools the inner explorer may invoke.tap— record per-session traces under.grove/traces/(browse withgrove-explore tap).trace_retain— how many trace sessions to keep (0= keep all).
grove config/grove tapstill work as deprecated forwarding shims togrove-explore config/grove-explore tap— they print a one-line note and forward to the sibling binary. Use thegrove-explorespelling directly.
The legacy
.grove/explore.jsonis migrated forward toconfig.jsonon first load;grove doctorflags it until you remove it.
grove.lock
Written by grove lock (and grove init), pinning the grammars a project needs
by version + wasm sha256, so a checkout resolves the same grammars every time.
See Languages & grammars.
FAQ
Is grove an LSP?
No. grove is not a Language Server. It doesn’t speak the LSP protocol, and it deliberately stops short of the semantic work a language server does.
grove is a syntactic, tree-sitter-powered structural-access layer for coding
agents — a “syntactic shell” — exposed as a CLI (grove <verb>) and an MCP tool
server (grove serve). It returns the exact bytes of one symbol, a file’s
definition skeleton, or a directory’s definition/reference graph — token-cheap,
with stable ids the agent passes between turns. It does not build a type graph,
resolve scopes, or infer anything.
The line grove won’t cross
grove’s non-goals are explicit: not a language server — no diagnostics, completion, or type inference; and not a refactoring engine — no rename/move/extract. grove locates; the agent edits. The moment you need semantics (what type is this, what does this resolve to, rename every binding), you’re in LSP territory, and grove hands off rather than half-does it.
How they differ
| grove | an LSP server | |
|---|---|---|
| What it models | the parse tree (syntax) | a semantic index (types, scopes, imports) |
| go-to-def | name-based, or the identifier at a position (tree-sitter) — not type/receiver resolved | type- and scope-resolved |
| find references | structural (tags) + whole-word textual fallback, by name | scope- and type-aware |
| hover / completion | — | yes |
| rename / refactor | — | yes |
| diagnostics | syntax only (check → ERROR / MISSING nodes) | type errors, lints, semantic warnings |
| cross-file type graph | — | yes (the core of a language server) |
| Protocol | MCP (grove’s own JSON-RPC tool schema) | LSP (textDocument/definition, hover, references, …) |
| Consumer | coding agents | IDEs / editors |
| Languages | one engine, all 27 grammars load at runtime from a WASM registry | one server per language, usually a heavy language-specific toolchain |
| Index | parses on demand (no persistent DB) | a persistent, incrementally-maintained semantic DB |
Complementary, not competitive
grove is the cheap, always-on, language-agnostic syntactic first move; an LSP is the authoritative, language-specific semantic authority. They sit at different layers:
- grove answers “where is this defined, what are its bytes, who calls it by name, how does this directory connect” — fast, in any language grove has a grammar for, with no toolchain on the consumer.
- an LSP answers “what’s the type of this, where does this really resolve, rename it everywhere it’s bound” — authoritatively, for one language, with a real build.
An agent can use grove for ~90% of navigation and reach for an LSP (if one’s running in the project) only when it truly needs type-resolved semantics. grove doesn’t aspire to replace an LSP; it aspires to make the syntactic 90% cheap enough that the agent rarely needs the heavier 10%.
Where grove would start to look LSP-ish (and why it’s deferred)
The few places grove edges toward semantics — a backward slice to trace a parameter’s shape, require/import resolution, constructor-shape extraction — are explicitly Bridge 1 work (issue #37 non-goals): grove’s first heuristic, not-faithful-to-the-parse-tree step, i.e. the first step toward re-implementing an LSP. It is deliberately out of scope for now; grove stays a syntactic shell and lets the model keep doing the semantics.
So: is grove an LSP?
No. It’s a tree-sitter-powered structural navigation tool for agents — the cheap syntactic layer beneath where an LSP’s semantic intelligence begins. It speaks MCP, not LSP; it parses, it doesn’t analyze; it locates, it doesn’t refactor.
Why are there two binaries?
grove is the structural layer: the CLI and a 7-tool MCP server that returns
exact bytes, definition skeletons, and reference graphs. It is deterministic,
fast, and involves no model. This is the one you want.
grove-explore is an optional locator that delegates “where is X?” to a small
local LLM and returns file:line citations. It needs an inference engine
configured before it does anything.
They were one binary through 0.3.x, behind a grove serve --explore flag. That
made the structural server carry LLM configuration, health probes, and failure
modes it had no use for — a project wanting plain structural access still paid
for machinery it never called. Splitting them means grove serve is now
unconditionally the 7-tool structural surface, with no mode resolution and no
inference path to fall back to, and the two register as composable MCP
servers: either, or both, never a mode switch on one server.
Register just grove with grove init --as mcp (the default). Add the locator
with grove init --as mcp-llm, which registers both. See
ADR 0004.
Back to README · VISION · Tools · Roadmap
Roadmap & repo layout
Not yet (roadmap)
- No staleness / incremental reparse — grove parses on demand; a file watcher
Tree::editis ahead.
callers/definitionare name-based — no receiver-type or local-scope resolution (the tagslocalsquery is a Tier-3 item).- 12 languages ship a minimal profile (core tools only); css/html/json/regex
have no upstream
tags.scm(they stillcheck). See Languages & grammars. - No
map-over-repo / scope-awaregrepyet — repo-orient (map) and a structuralgrepare next in the loop.
See VISION §9 Build order / roadmap for the full plan.
Repo layout
grove is a Cargo workspace with two member crates: grove-core (the
library — engine, registry, fetch, ingest) and grove (the cli/ binary —
CLI dispatch + MCP server, consuming grove-core).
Cargo.toml [workspace] — members = ["core", "cli"]
core/ grove-core — the reusable library crate (publishable to crates.io)
src/lib.rs curated public surface — re-exports ops + provision_project + return types
src/ops.rs the operations as a library — the shared engine both faces call
src/engine.rs wasm load + Query-based tags, source slicing, check, position resolution
(node-kind profiles are data — they come from each manifest, not code)
src/registry.rs grammar resolver, extension map, lockfile — the registry spine
src/fetch.rs `grove fetch` — download grammars from the hosted registry (GitHub/CDN)
src/ingest.rs `grove ingest` — build registry artifacts from official tree-sitter releases
src/init.rs `provision_project` — detect langs + provision grammars (clap-free)
cli/ grove — the binary crate (depends on grove-core by path)
src/main.rs CLI dispatch (clap) — six verbs + init/languages/lock/serve
src/init.rs `grove init [--as mcp|skill|both]` — harness glue over provision_project
src/mcp.rs MCP server — newline-delimited JSON-RPC over stdio
registry/<lang>/ grammar.wasm + tags.scm + manifest.json (the registry stub)
registry-sources.json curated specs (repo/rev/extensions/profile) ingest builds from
skills/grove/ SKILL.md — the cross-harness skill (npx skills add Entelligentsia/grove)
docs/ install / setup / languages / tools / mcp / roadmap (this site)
docs/assets/ grove_demo.cast + grove_demo.gif — the README demo
docs/assets/langs/ language logos (devicon SVGs) for the README language grid
Data flow: main / mcp (in cli/) → grove_core::ops → engine (+
registry for grammar resolution). Engine logic never lives in main or mcp —
they only format; ops returns typed Symbol / Defect / etc.
Back to README · VISION · CHANGELOG
ADR 0001 — Scope-aware and import-edge name resolution
- Status: Accepted
- Date: 2026-06-27
- Deciders: Boni Gopalan
- Supersedes: —
- Related: VISION.md §6.3 (
definition“via@local.scope/@local.definitionresolution”), §8 (tool→API mapping), Roadmap Tier 3 (“scope-awarecallers/definitionvia the tagslocalsquery”)
Context
Grove’s definition and callers resolve symbols lexically by name. Today
definition_at (go-to-def from a cursor) finds the identifier under the cursor
and then returns every symbol with that name in the directory:
#![allow(unused)]
fn main() {
// ops.rs — definition_at
let name = engine::identifier_at(...)?; // "parse"
let defs = definition(dir, &name)?; // every "parse" in the dir
}
This has two precision failures that an LLM-driven agent feels directly:
- No scope awareness. A local variable or parameter named
parseresolves to the module-level functionparse. Shadowing is invisible.callerscan even count a local binding as a call site (today mitigated only by a lossy textual fallback). - No cross-file awareness. A reference to an imported top-level symbol resolves to all same-named defs across the directory, not the one the import actually binds — and never to a symbol in a file the agent hasn’t opened.
The strategic question (see the conversation that motivated this ADR) was whether
to adopt stack-graphs (built on tree-sitter-graph) for LSP-grade,
build-tool-free name resolution. That path is real but heavy: per-language .tsg
rule sets are large, the upstream github/stack-graphs is archived
(2025-09-09), and it forces a persisted, invalidated graph index — abandoning
grove’s stateless, parse-on-demand model and its “drop a registry dir, no
recompile” wedge.
This ADR records the decision to first capture the high-value, low-risk subset of
that capability without an index and without per-language .tsg, using
machinery tree-sitter already provides and grove already ships partial support
for. It is explicitly the plan VISION promised, not a vision amendment.
Decision
Add resolution in two independent, separately-shippable steps. Both stay stateless (no persisted index, no watcher) and extend the registry-dir data model rather than the binary, consistent with the WASM-registry spine.
Step 1 — Scope-aware resolution via the locals query
- Add an optional
locals.scmartifact to each registry dir, alongsidetags.scm+manifest.json+grammar.wasm. It uses tree-sitter’s standard locals capture vocabulary:@local.scope,@local.definition,@local.reference. A grammar without one keeps today’s behavior exactly. - The engine compiles
locals.scm(lazily, in the per-processLoadedcache) and gainsresolve_local_at(root, row, col, …): find the identifier under the cursor, then walk enclosing@local.scoperanges innermost→outermost for a@local.definitionof the same name contained in that scope. Innermost match wins (correct shadowing). Returns aSymbolfor the binding, orNone. definition_attries local resolution first. A hit returns the single binding; a miss falls through to today’s directory-wide name lookup. Never worse than current behavior.
Scope: a single file, one parse. No new IO. Fully testable against the dev stub
(rust/python/javascript), each of which gains a minimal locals.scm.
Step 2 — Import-edge cross-file resolution
- Add an optional
importsblock to the manifestprofile(data, not code), describing the import statement node kinds, the fields holding the module path / imported names / alias, and a module-resolution strategy enum (dotted_packagefor Python,relative_pathfor JS/TS,use_pathfor Rust). A grammar without it keeps today’s behavior. definition_atdecision tree becomes:- local scope resolution (Step 1) — 1 file parsed;
- else, if the name is bound by an import in this file: resolve the module path
to a file (string transform + a couple of
exists()probes, no repo scan), parse that one target file, return its matching top-level def — 2 files parsed; - else, today’s directory-wide name lookup.
Resolution cost is bounded by import-chain depth, not repo size. No index, no invalidation. This is the increment that delivers the headline “cross-file go-to-def” for the common case (imported top-level symbols).
Scope boundary — what this deliberately does NOT do
These are the stack-graphs frontier and are out of scope by construction. When hit, resolution degrades to returning the candidate list (today’s behavior), never a confident wrong answer:
- Method/receiver typing —
foo.bar()→ whichbar? Needsfoo’s type. - Multi-hop re-export / barrel chains — Step 2 follows one import hop well; cyclic/transitive re-exports need partial-path stitching.
- Wildcard, dynamic, conditional imports; monkey-patching.
An LLM brain consuming grove is good at picking from a short candidate list with surrounding context, so candidate-list degradation is an acceptable floor.
Alternatives considered
- Adopt stack-graphs / tree-sitter-graph now. Rejected for the near term:
heavy per-language
.tsg, archived upstream, and a persisted index that breaks the stateless model and the broad-language wedge. Kept as a possible later “deep tier on a few languages” bet; Step 2’s measured hit-rate on the testbench becomes the go/no-go evidence for it. (LLM-authored.tsginside a test-driven oracle loop could lower its cost — a separate investigation.) - tree-sitter-graph for richer single-file graphs only. Doesn’t deliver cross-file linkage; rejected as a primary direction.
- Do nothing / keep candidate lists. Rejected: scope and import precision are cheap and high-value, and already promised in VISION.
Consequences
Positive
definitionreturns one correct binding for locals and imported symbols in the common case;callersprecision improves (locals no longer counted as calls).- Stateless model, trust story, and zero-recompile language onboarding preserved.
- Cross-file resolution arrives without an index — a genuinely cheap win.
- Generates the evidence to decide the larger stack-graphs question.
Negative / costs
- Two new optional registry artifacts/fields to author per language (
locals.scm,imports), but they degrade gracefully when absent. - Step 2 re-parses target files per query (bounded by import depth) — acceptable under grove’s existing stateless tradeoff.
- Not LSP-complete (see scope boundary); the tail still needs the agent’s help or a future stack-graphs tier.
Status of implementation
- Step 1: implemented (engine
resolve_local_at,locals.scmfor rust/python/javascript,definition_atwiring, unit + integration tests). - Step 2: implemented (engine
extract_imports+imports.scm, opsresolve_import_at/import_candidate_pathswithdotted_packageandrelative_pathstrategies,import_resolutionmanifest field,definition_atdecision tree local→import→dir-wide, unit + integration tests). Shipped for python/javascript; rust import resolution (use_path) is deferred — rust falls back to directory-wide lookup. Next: validate the cross-file hit-rate on grove-testbench before any stack-graphs decision; widen the per-languagelocals.scm/imports.scm(match arms, destructuring,import *, re-exports).
ADR 0002 — .grove/config.json: a declared project mode
- Status: Proposed
- Date: 2026-07-04
- Deciders: Boni Gopalan
- Supersedes: —
- Related:
cli/src/init.rs(Target,write_harness),cli/src/mcp.rs(determine_surface),core/src/explore/config.rs(ExploreConfig),cli/src/config_tui/, VISION §6.4.1 (availability ≠ adoption)
Context
grove init --as <mode> wires a project for one of five integration modes —
mcp, skill, both, mcp-llm, grammars. Each mode writes a different
subset of on-disk state:
| Mode | .mcp.json grove args | CLAUDE.md block | AGENTS.md block | .grove/explore.json |
|---|---|---|---|---|
mcp | ["serve"] | MCP steering | untouched | untouched |
skill | untouched | skill steering | untouched | untouched |
both | ["serve"] | MCP steering | untouched | untouched |
mcp-llm | ["serve","--explore"] | explore steering | explore steering (written) | created via TUI |
grammars | untouched | untouched | untouched | untouched |
The mode itself is never persisted — Target is an in-memory CLI enum. A
project’s “mode” is only inferred from the residue of which files a given run
happened to write, and the two consumers infer it from different,
un-synchronized signals:
grove servepicks its surface fromExploreConfig::config_path(root).exists()(mcp.rs:49) — the mere presence of.grove/explore.json, not the.mcp.jsonargs. In explore surface,tools/listreturns onlyexplore; the seven structural tools are hidden (unless the provider health-probe fails, which silently falls back to Standard).- The agent reads whichever steering block is in
CLAUDE.md/AGENTS.md. - A re-run of
initis stateless: it does not read the prior mode, it only overwrites the subset of files its new--astargets.
Because nothing removes .grove/explore.json (grepped: no remove_* call targets
it) and serve keys off its existence, mode is sticky and emergent, not
declared. This produces routing bugs on any A → B transition that touches
some files but not all:
mcp-llm → mcp(orboth).initrewrites.mcp.jsonto["serve"]andCLAUDE.mdto standard MCP steering (namingoutline/symbols/…), but.grove/explore.jsonsurvives, soservestill boots the explore surface exposing onlyexplore.CLAUDE.mdnow steers the agent to seven tools the live server does not expose. The only thing that “fixes” it is the accidental fallback when the local model happens to be down — behaviour that depends on whether a background LLM is running.- Orphaned
AGENTS.md. Onlymcp-llmever writesAGENTS.md; no other mode rewrites or removes its block. Every transition out ofmcp-llmstrands a stale explore-modeAGENTS.md, misdirecting anyAGENTS.md-reading harness. skill/grammarsnever rewrite.mcp.json, so a prior--exploreregistration persists under steering that no longer mentions explore.
The root cause is singular: there is no single source of truth for the mode,
so the transition logic can’t clean up and the runtime can’t agree with the
steering. .grove/ is already grove’s sovereign, project-local directory (it
holds grammars/, explore.json, traces/) — the natural home for a declared
mode.
Decision
Introduce .grove/config.json as the single declared source of truth for a
project’s grove integration, with the explore backend demoted to a section:
{
"version": 1,
"mode": "mcp-llm",
"explore": {
"provider": "ollama",
"base_url": "http://localhost:11434/v1",
"model": "qwen2.5-coder:7b",
"steering": "standard",
"allowed_tools": ["grove", "rg", "grep", "find"],
"tap": false,
"trace_retain": 50
}
}
moderecords the initTargetverbatim (mcp|skill|both|mcp-llm|grammars). It is written bygrove initon every run.exploreis today’sExploreConfig, present only when relevant. Its innermodefield (steering level: standard/balanced/strict) is renamed tosteeringto remove theconfig.mode/config.explore.modeambiguity — optional polish, not load-bearing (see Scope boundary).
Three independent changes, each keyed off the declared mode:
1 — serve reads the declared mode
Superseded by ADR 0004 §2.
serveno longer selects a surface at all —grove serveis unconditionally the 7-tool structural surface, and there is nothing left for a mode to select. The explore surface moved to its owngrove-explore servebinary.
determine_surface (mcp.rs) changes its trigger from
explore.json.exists() to config.mode == mcp-llm. The --explore / --standard
flags remain as runtime overrides. This one change ends the stickiness: after
init --as mcp sets mode: "mcp", serve boots Standard immediately.
2 — init reconciles all harness files on a mode switch
Extract a single function keyed off the declared mode:
#![allow(unused)]
fn main() {
fn reconcile_harness(root, old_mode: Option<Mode>, new_mode: Mode) -> Result<Vec<String>>
}
It writes, rewrites, or strips .mcp.json, CLAUDE.md, and AGENTS.md so the
on-disk harness matches new_mode exactly, cleaning up old_mode’s residue
(e.g. removing the AGENTS.md explore block when leaving mcp-llm, dropping the
--explore arg when leaving explore mode). init reads the prior mode from
config.json, calls reconcile_harness, then persists the new mode. This is
the one place that maps mode → on-disk harness state.
3 — the config TUI becomes display-consistent (read-only on mode)
Moot per ADR 0004 §2. With no surface switching on mode, the explore config is always live for the
grove-exploreserver — there is no inert/greyed state to render. Any badge should reflect whethergrove-exploreis registered in the harness, not the declaredmode. The TUI itself moved togrove-explore config.
The TUI does not gain a mode selector — changing mode stays the job of
grove init --as. It only gains read awareness so it never implies explore
settings drive a project that isn’t in explore mode:
- Shows the active
mode(read fromconfig.json) as a header/badge. - When
mode == mcp-llm: the explore section is live and editable, as today. - When
mode != mcp-llm: the explore fields render inert/greyed with a one-line note that they are dormant untilgrove init --as mcp-llm.
Because the TUI never mutates mode, it cannot become a second, uncoordinated
harness writer — the reason mode-editing-in-TUI was rejected (see Alternatives).
Migration
GroveConfig::load: read config.json if present; else if legacy
.grove/explore.json exists, load it, synthesize mode: mcp-llm, and rewrite
forward to config.json. Without this, every deployed mcp-llm project silently
drops to Standard on upgrade (its explore.json would no longer be the trigger).
Scope boundary — what this deliberately does NOT do
- No mode changes from the TUI.
grove init --asremains the only way to switch mode; the TUI is display-only on that axis. This keeps a single writer of harness files (init) and avoids a second divergence source. - The
explore.mode → steeringrename is optional. The only remaining collision is a human reading the JSON (config.modevsconfig.explore.mode); both are unambiguous by path. Worth doing for clarity, but it can ship separately from the mode work. - No new config surface beyond
mode. This ADR consolidates existing state; it does not add tunables. grammarsmode still touches no project-owned files. Writing.grove/config.jsonis consistent with thegrammarscontract because.grove/is grove’s own directory, not a host file likeCLAUDE.md/.mcp.json.
Alternatives considered
- Keep mode emergent; special-case the cleanup in
init. Rejected: without a persisted mode,initcannot know the prior mode, so it can only guess at cleanup from file residue — brittle and exactly the ambiguity that produced the bugs. - Thin
config.jsonholding only{ mode }, leaveexplore.jsonseparate. Smaller diff and no TUI churn, but keeps two files and defers “explore as a section.” Rejected as a half-measure — the point is one source of truth. - Make
serveauthoritative on the.mcp.json--explorearg instead of a file. Fixes the runtime half but leavesinitwith no persisted prior mode to reconcile, and.mcp.jsonis a host-owned file grove shouldn’t treat as its own state store. Rejected. - Let the
configTUI change mode + callreconcile_harness. Rejected as unnecessary complication: it makes the TUI a second harness writer and forces mode-selector UI. Switching mode is a rare, deliberate act well served bygrove init --as.
Consequences
Positive
- Both original bugs are fixed structurally: the sticky explore surface (bug 1)
and the orphaned
AGENTS.md(bug 2) both stem from emergent mode, which is now declared. - One function (
reconcile_harness) owns themode → filesmapping, so a mode switch converges every file from a single code path. serve, the agent’s steering, andinit’s transition logic all read the same signal..grove/gains a documented, versioned config — a natural extension point.
Negative / costs
- A real refactor spanning
config.rs(newGroveConfigwrapper,modeenum, migration),mcp.rs(surface check),init.rs(extractreconcile_harness), andconfig_tui/(mode badge + inert-state rendering). - Migration code must carry the legacy
explore.jsonpath indefinitely (or until a deprecation window closes). reconcile_harnessmust be careful to only strip grove-authored blocks (sentinel-delimited) and grove’s own.mcp.jsonentry, never host content.
Status of implementation
- Proposed — not yet implemented. Next: a plan/task decomposing the four
touch-points, with a transition-matrix test asserting that every
A → Bmode switch leaves.mcp.json,CLAUDE.md,AGENTS.md, and theservesurface mutually consistent.
ADR 0003 — Shared structural-verb rendering in core
- Status: Accepted
- Date: 2026-07-05
- Deciders: Boni Gopalan
- Supersedes: —
- Related:
core/src/explore/toolset.rs(the explore inner toolset),cli/src/main.rs(verb dispatch + formatting), the 2026-07-03 code-quality review (item 2), release 0.3.0 plan Phase 0.
Context
The explore inner toolset (mcp-llm mode) exposes a Grove tool that runs
read-only structural verbs (outline, symbols, source, callers,
definition, map) on behalf of the local LLM. Today it shells out to grove’s
own binary per call — grove_tool builds a CLI arg vector and
run_capture(grove_binary(), parts, root) spawns grove <verb> … and captures
stdout (toolset.rs:405-411, grove_binary() resolves current_exe()).
The code-quality review flagged this: it loses the in-process grammar cache and
pays a subprocess spawn + full reparse on every tool call — “acceptable while
experimental, but must be revisited before mcp-llm stabilizes.” The 0.3.0 release
graduates mcp-llm out of experimental, so this must land first.
The obstacle is an altitude mismatch. The toolset lives in core, but the human
text it needs to reproduce is produced by the CLI’s formatting, which lives
inline in cli/src/main.rs’s match arms (println! blocks). core cannot
depend on cli, so “just call the formatter” isn’t available — hence the
subprocess. Two facts make an in-process port clean:
- The toolset captures stdout only (
run_capturereturns stdout on success), so only theprintln!bodies must be reproduced — not theeprintln!summary lines, which the toolset never saw. coreis already clap-free by design (seecore/src/init.rs), and the six verbs take a small, stable, documented flag set — so parsing them without clap is tractable.
Decision
Move the human text rendering of the six read-only structural verbs from
cli/src/main.rs into a new core::render module, and have the explore
toolset call ops + core::render in-process instead of shelling out.
core::render
Pure typed→String formatters, one per verb, each returning exactly the bytes the
CLI’s println! block emits today (trailing newline per line):
#![allow(unused)]
fn main() {
pub fn outline(syms: &[Symbol]) -> String;
pub fn symbols(syms: &[Symbol]) -> String;
pub fn source(res: &SourceResult) -> String;
pub fn callers(sites: &[CallSite]) -> String;
pub fn definition(defs: &[Symbol]) -> String;
pub fn map(maps: &[FileMap]) -> String;
}
cli/src/main.rsreplaces its inlineprintln!loops withprint!("{}", render::outline(&syms))etc. It keeps clap parsing, the--jsonbranch, and itseprintln!summaries — those stay CLI concerns. No output change.core::explore::toolsetparses the verb + documented flags from thecommandstring (a small hand-rolled parser —corestays clap-free), calls the matchingopsfunction, and renders withcore::render. The per-call subprocess (grove_binary+run_capturefor the Grove verb) is removed.run_captureremains for therg/find-backed Grep/Glob tools.
The verb allow-list (ALLOWED_VERBS / RECON_VERBS) and the workspace-relative
path sandbox are unchanged — they gate the in-process path exactly as before.
Scope boundary — what this deliberately does NOT do
coredoes not gain clap. The toolset hand-parses the six verbs’ small flag set; theCli/Cmdclap structs stay incli. This preserves the clap-free core.- The CLI’s
--jsonoutput and stderr summaries stay incli.core::renderis human-text only; JSON isserdeon the typedopsresults, already shared. - No behaviour or format change.
renderreproduces the current CLI stdout byte-for-byte (a parity test enforces this). This is a refactor, not a redesign. - Only the six read-only verbs move.
check,init,doctor, registry verbs, etc. keep their CLI-side formatting; they are not part of the explore toolset.
Alternatives considered
- Keep the subprocess. Rejected: the review’s stabilization blocker; a spawn + reparse per tool call is the wrong cost for a graduated surface.
- Move clap
Cmddispatch intocoreand have both faces share it. Rejected: pulls clap intocore, breaking the clap-free-core principle for a larger surface than the six verbs need. - Capture the CLI’s stdout in-process via a buffer without extracting the
formatters. Rejected: still couples
coretocli’s dispatch, and there is no clean in-process handle to it fromcore.
Consequences
Positive
- Explore tool calls use the in-process grammar cache — no spawn, no reparse per
call. Removes the review’s one stabilization blocker for
mcp-llm. - The verb text format now has a single owner (
core::render) shared by both faces, so CLI and explore output cannot drift. - Keeps the “faces only format” rule honest: the shared formatting moves down to
core;cliretains only CLI-specific concerns (clap,--json, stderr).
Negative / costs
- A hand-rolled arg parser in the toolset must track the six verbs’ documented flags; a new CLI flag the toolset should honour must be added in two places. Low risk (the set is small and stable) and covered by tests.
- A small altitude shift:
corenow owns human text rendering for these verbs, which previously lived entirely incli.
Status of implementation
- Accepted; implemented in the 0.3.0 prep branch (Phase 0):
core::render+ in-process toolset dispatch, with a parity test assertingrenderoutput equals the prior CLI stdout for representative calls.
ADR 0004 — Split the explore delegate out of grove core into grove-explore
- Status: Accepted
- Date: 2026-07-20
- Deciders: Boni Gopalan
- Supersedes: — (amends ADR 0002 §1:
serveno longer selects a surface from the declared mode,determine_surfaceis deleted; and §3: the config TUI’s mode-gated inert rendering is moot once no surface switches on mode) - Related:
cli/src/mcp.rs(Surface,determine_surface),core/src/explore/(the entire subsystem),cli/src/config_tui/,cli/src/trace_tui/,cli/src/tap.rs, ADR 0002 (declared mode), VISION §6.4.1 (availability ≠ adoption),grove-explore-model(research repo),grove-models(model release facade),is-grep-enough/studies/fastcontext-sidebench/(eval bed)
Context
grove serve is bimodal. At startup, determine_surface (mcp.rs) resolves
the declared mode plus --explore/--standard overrides plus a provider
health probe into a Surface enum, and the server then presents either the
standard 7-tool structural surface or a single delegating explore tool
backed by a local LLM. One server name, two unrelated products behind it, and
never both at once.
The explore subsystem this switch guards is not small, and it is not structural code intelligence:
| Piece | Where | Size | What it actually is |
|---|---|---|---|
| Inner explorer engine | core/src/explore/ (11 modules + embedded prompt) | ~3,900 lines | LLM agent loop (agent.rs, a Rust port of the eval bench’s run_question), OpenAI chat wire model, HTTP client, health probe, local-engine discovery (/proc scanning), prompt steering, grounding/leak-retry, JSONL trace persistence |
Config + trace TUIs, tap verb | cli/src/config_tui/, cli/src/trace_tui/, cli/src/tap.rs | ~2,950 lines | Full-screen ratatui apps for managing the explore backend and browsing its traces |
| Radiating coupling | config.rs (Mode::McpLlm, legacy explore.json migration), doctor.rs (explore check group), init.rs (first-run TUI, --as mcp-llm), mcp.rs (mode resolution, health fallback) | — | Mode plumbing that exists only because two products share one server identity |
For scale: the structural engine proper (ops/engine/registry/fetch/
ingest/…) is ~6,400 lines. The explore layer plus its TUIs is roughly the
size of the product it is embedded in.
Five forces make this a problem rather than a footnote:
-
grove-cstis a published library with a false advertisement. Its crates.io description reads “structural code-intelligence library”; VISION.md describes the structural-access product and does not mention explore mode at all. Yet every library consumer compiles in an LLM orchestration harness, an HTTP-to-LLM client, embedded prompts, and trace persistence — none of it feature-gated.toolset.rseven re-implements Glob/Grep/Read with Claude-style schemas, tools with no relation to grove’s domain. -
The explore product’s lifecycle already lives elsewhere. Its research is in
grove-explore-model, its weights are on HuggingFace/ollama fronted bygrove-models, its eval bed is the fastcontext sidebench inis-grep-enough. Three of its four lifecycle artifacts are outside this repo; the runtime harness is the straggler — and it is the piece with the tightest release coupling. Every prompt revision (the system prompt isinclude_str!-embedded), harness-discipline fix, or default-model change requires the full grove release train: version bump, tag, 5-platform build, npm publish, brew formula regen. -
The either/or
Surfaceis a product limitation, not just a code smell. A project gets either the structural tools or the locator — never both. The arguably best configuration (delegate broad “where is X” sweeps toexplore, usemap/sourcedirectly for precision work) is unreachable. -
The seam is being fought, and it has already produced bugs. Bug-1 (GROVE-S03-T03, documented in
determine_surface’s own doc comment): a staleexplore.jsonbooted the explore surface against the declared mode. The silent health-probe fallback is a standing latent instance of the same class: the client’sCLAUDE.mdsteering says “usemcp__grove__explore” while the server quietly served the 7-tool surface because the local model was down. Every one of these is downstream of a single server identity whose surface depends on local config and the health of a background daemon. -
Counterweights, honestly stated. (a) The inner explorer calls grove ops as direct in-process Rust calls — no MCP hop — and the delegate’s economics (a light model making many cheap tool calls) depend on that staying true. (b) The runtime is currently frozen: the shipped harness is the
base-q4-v2-hfreference combination, and the active research thread (quantization floor) does not touch it. The release-cadence pain is therefore prospective, not acute — which argues for cutting the seam cheaply now, not for standing up a second release train today.
Decision
Separate the explore delegate into its own product surface — grove-explore
— with grove-cst as the shared library underneath, and make the two MCP
surfaces composable instead of exclusive. Executed in three stages; stages
1–2 now, stage 3 gated on an explicit trigger.
grove-cst (library) pure structural engine; zero LLM knowledge
├── grove (binary) CLI + 7-tool MCP server; no modes, no fallback
└── grove-explore (binary) the locator product: agent loop, prompts,
config/trace TUIs, tap; its OWN MCP server
identity; links grove-cst for in-process ops
Users compose in .mcp.json: structural only, explore only, or both. The name
grove-explore keeps family branding and aligns with the already-released
model line (grove-explore-base in grove-models).
Stage 1 — crate split (now)
Move core/src/explore/ into a new workspace crate (explore/, published as
grove-explore-core if/when publishing is warranted). grove-cst returns to
being what its description claims. The boundary is already clean — mod.rs
exports only run_explore[_reporting], config, and health types — so this is
mechanical. ExploreConfig moves with it; core/src/config.rs keeps
GroveConfig but holds the explore section as an opaque-to-core type owned by
the new crate (or re-exported from it), so grove-cst carries no LLM types.
Stage 2 — surface split (now)
- Add a
grove-explorebin target serving only theexploretool under its own MCP server name. An unhealthy provider at startup is a startup error with an actionable message — never a silent morph into a different product. Mid-session provider loss keeps today’s recoverableisError. grove servealways serves the 7-tool structural surface. DeleteSurface,determine_surface, the--explore/--standardflags, and the health fallback;mcp.rsloses allexplore::imports.- Amendment to ADR 0002 §1: the declared
modeno longer selects a serve surface (there is nothing left to select).moderemains the harness-reconciliation key forinit/doctor:mcp-llmnow means “register both servers in.mcp.jsonand write the delegation steering.” This keeps ADR 0002’s single-source-of-truth andreconcile_harnessdesign intact — only the runtime consumer ofmodedisappears. - TUI surfaces. Both TUIs are pure explore-product UIs —
config_tuiimports onlyExploreConfig/Provider/Steering, engine discovery, and the/modelsfetch;trace_tui/taponly the trace helpers and thetapflag; none referenceops/engine/registry— so they move wholesale:grove config→grove-explore config,grove tap→grove-explore tap, with the oldgroveverbs retained for one release as forwarding shims that print the new spelling. ratatui/crossterm leave thegrovebinary entirely (they are its only TUI dependencies). User-facing strings are swept in the same change:tap’s “restartgrove serve” hint becomes “restart the grove-explore server”, and its error path stops pointing at the ADR-0002-deprecated.grove/explore.json. ADR 0002 §3’s planned inert/greyed rendering whenmode != mcp-llmbecomes moot — with no mode-switched surface, the explore config is always live for the explore server; any badge should reflect whether thegrove-exploreserver is registered, not the mode. - Config ownership (interim). Through stages 1–2,
config_tuikeeps its read-modify-write of.grove/config.json— a grove-owned file — preservingmodeandharnessesand rewriting only the explore section, exactly as it does today. grove-explore is a deliberate guest writer in that file until stage 3 moves the config out. grove init --as mcp-llmremains the adoption funnel (VISION §6.4.1 applies to the new product too): it registers both servers and writes steering that names both surfaces and when to use each. The first-run config TUI is the one cross-binary seam:initno longer contains the TUI, so it shells out togrove-explore config. In the interim single-repo form both binaries ship together, so the sibling is normally present; when it is not on PATH,initmust degrade gracefully — register the server anyway and print “rungrove-explore configto finish setup” rather than failing the init.doctor’s explore check group moves behind the same boundary: the checks run when the harness registers thegrove-exploreserver, not whenmode == mcp-llmimplies a surface.
Stage 3 — repo/release split (gated)
Extract grove-explore to its own repository, versioning, and distribution
(sibling brew formula / npm package) when its release cadence actually
diverges from grove’s. Concrete triggers, either sufficient:
- the sid-fork model track graduates from research (its output contract —
symbol-id
<final_answer>— forces harness changes), or - prompt/harness iteration resumes at a cadence where coupled grove releases demonstrably delay explore improvements (more than one explore-only release forced onto the grove train in a quarter).
Config moves with the product at this stage (own file, with a migration read
of the .grove/config.json explore section). Until then — stages 1–2 —
the explore section stays where ADR 0002 put it, to avoid user-visible config
churn twice.
Scope boundary — what this deliberately does NOT do
- No inner-loop behavior change. The reference harness (flat v2 prompt, bare location lines, H1/H2 backstops, retry-on-leak) ships byte-identical. This is a boundary change, not a quality change; the sidebench numbers must not move.
- No renames of user-visible contracts. The tool is still
explore; the symbol-id and location-line formats are untouched; model branding staysgrove-explore-*. - Stage 3 is not scheduled. It is prepared (the crate boundary makes it mechanical) and gated on the triggers above — not started speculatively while the harness is frozen.
- No new tunables or modes.
Mode::LEGALis unchanged;mcp-llmchanges meaning (register-both) but no mode is added or removed in this ADR.
Alternatives considered
- Status quo — mode-switched single server. Rejected: it is the direct cause of the exclusivity limitation, the bug-1/health-fallback class, and the library-purity violation. Every mitigation (more doctor checks, more migration code) grows the mode plumbing that only exists to defend the shared identity.
- Cargo feature flag (
explore) ongrove-cst. Fixes library consumers only. Release-cadence coupling, surface exclusivity, and the bimodal server identity all survive. Rejected as a half-measure that spends the migration budget without buying the composition win. - Expose
exploreas an 8th tool on the standard surface. Solves exclusivity cheaply, and was the strongest rival. Rejected because the surface would then vary with the health of a background daemon (the same steering-vs-reality mismatch in new clothes), the LLM harness stays inside the published library, and the release trains stay coupled. It also welds the product identities together permanently — the opposite of preparing stage 3. - Immediate full repo split (skip the gate). Rejected as speculative
overhead: the runtime is frozen on the reference combination, so a second
repo, CI, and distribution channel would today ship zero-delta releases.
Stages 1–2 capture all current pain; stage 3 is one
git mvof a crate when the trigger fires. grove-exploreshells out to the grove binary (or its MCP server) instead of linkinggrove-cst. Rejected: it taxes the delegate’s core economics — many cheap in-process structural calls per question — with subprocess or protocol overhead, and adds a runtime version-skew surface between two binaries. Library linkage pins the version inCargo.lock.
Consequences
Positive
mcp.rsshrinks to the 7-tool server: noSurface, no mode resolution, no health fallback, no explore imports. The steering-vs-served-surface mismatch class is structurally gone — each server’s surface is a constant.- Structural tools and the locator compose. A session can register both and
use each for what it is best at;
init --as mcp-llmwrites exactly that. grove-cstis honest again: a structural-parsing library consumers can take without an LLM harness. The explore crate becomes independently testable against the sidbench contract.- The explore product gets an identity that matches its already-external lifecycle (research repo, models repo, eval bed), and stage 3 becomes a mechanical extraction rather than surgery.
- Provider-down is a visible startup failure of the explore server with the structural server unaffected — strictly better than today’s silent morph.
Negative / costs
- Two
.mcp.jsonregistrations to write, reconcile, and doctor-check where there was one;reconcile_harness’s mode→files matrix grows a column. - A deprecation window:
serve --explore/--standardflags, thegrove config/grove tapspellings, and the old single-registrationmcp-llmprojects all need one release of shims/migration plus clear errors after. - Interim single-repo form still ships the TUIs in the workspace (binary size
of
groveitself drops — ratatui/crossterm move togrove-explore— but total install size for both-server users is unchanged). - Cross-crate versioning discipline:
grove-explorepinsgrove-cstexactly (asclidoes today); a structural-surface change now touches two crates’ release notes. - Until stage 3,
grove-exploreremains a guest writer in grove-owned.grove/config.json(the TUI’s read-modify-write of the explore section), andgrove init --as mcp-llmdepends on thegrove-explorebinary being on PATH for its first-run TUI — both are accepted interim couplings with a defined end state, but each is a spot where the two products can skew if the degrade paths are not tested. - Docs restructure: README’s delegated-mode section,
docs/setup.md,docs/mcp.md, and the init-written steering templates all change to the two-server story.
Status of implementation
- Stages 1–2: implemented. GROVE-S04-T01 through T08 are committed:
crate split (
explore/, published asgrove-explore-core); thegrove-explorebinary with its own MCP server identity and a hard startup health gate (config load → explore deser → health_probe, eachprocess::exit(1)before the serve loop — never a fallback);mcp.rs’sSurface/determine_surface/health-fallback machinery deleted,grove serveunconditionally structural;reconcile_harness’s both-servers column (mcp-llmregistersgrove+grove-explore) plus forward migration; config/trace TUIs andtapmoved togrove-explorewithgrove config/grove tapretained as deprecated forwarding shims;grove init --as mcp-llm’s PATH-aware shell-out togrove-explore configwith graceful degrade when the sibling binary is absent;doctor’s explore checks re-keyed ongrove-exploreregistration rather than declared mode; packaging (release workflow, npm, Homebrew, install.sh) ships both binaries. - Gate test (a) — the full sidebench reference-number re-run on the split binary — is GROVE-S04-T10, a user-supervised rig, and remains open.
- Gate test (b) — the extended transition-matrix test, asserting every
A → Bmode switch leaves both server registrations, steering, and served surfaces mutually consistent — shipped with GROVE-S04-T04. - Stage 3 (repo/release split) remains not scheduled, gated on the triggers stated above (sid-fork model-track graduation, or explore-only release cadence divergence).
ADR 0005 — grove and grove-explore are mutually exclusive in the outer harness
- Status: Accepted
- Date: 2026-07-21
- Deciders: Boni Gopalan
- Supersedes: ADR 0004 §2 (
mcp-llmmeans “register both servers in.mcp.json”). The mode key, the reconciliation design, and every other part of ADR 0004 stand unchanged. - Related:
core/src/harness.rs(expected_mcp_args, the shared writer / verifier source of truth),cli/src/init.rs(registration + steering block),grove-explore/src/main.rs(startup gate, server instructions), ADR 0002 (declared mode), ADR 0004 (the crate + surface split)
Context
ADR 0004 split the explore delegate into its own binary and defined mcp-llm
as “register both servers.” The two surfaces were held to compose: ask the
locator where something is, then use the structural tools on the citations it
returns. grove init --as mcp-llm wrote both registrations and a steering block
teaching that three-step flow.
In practice the composition has costs that were not visible when ADR 0004 was written:
-
Routing ambiguity. The outer agent sees eight tools across two servers and must choose between them per question, then execute a multi-step protocol from prose instructions. Protocols expressed as prose degrade — the agent skips the follow-up, or skips the locator and navigates structurally itself, at which point the delegate’s cost-saving premise is gone.
-
The dereference argument does not hold. The composition was justified by
explorereturning bare location lines with no way to read them cheaply. But a Claude Code-class outer harness hasReadwithoffset/limit. Givenappcommon.js#configureSessionStore@228it reads a window at line 228.mcp__grove__sourceknows the symbol’s exact end line, which is a real but small advantage over guessing a window — not enough to justify a second server in the outer context. -
The capability is already present twice. The inner harness carries nine tools —
Glob,Grep,Read, and sixmcp__grove__*— in-process, sincegrove-explorelinksgrove-cstdirectly. The outer harness independently has its ownGrep/Glob/Read. Registering grove’s structural tools alongsideexploreadds a third path to capability already reachable two other ways. -
The flag name stopped matching its behaviour. Pre-0.4.0
mcp-llmmeant “switchgrove serveinto explore mode” — one server, and the name was accurate. ADR 0004 changed the meaning to “both” while keeping the spelling.
Decision
mcp-llm registers grove-explore alone. The structural grove entry is
absent in that mode, and is stripped on transition into it.
The --as modes become properly exclusive:
| Mode | Registers | Outer agent |
|---|---|---|
--as mcp (default) | grove | navigates structurally itself; no model in the loop |
--as mcp-llm | grove-explore | delegates location; dereferences citations with its own Read |
This is expressed in one place — harness::expected_mcp_args, which returns
None for McpLlm. That function is the shared source of truth for init
(writer) and doctor (verifier), so both agree by construction.
The mcp-llm steering block is rewritten to describe the locator alone. It
must not name mcp__grove__* tools: in this mode they are not in the
agent’s context, and steering toward absent tools is worse than silence.
Failure behaviour
A down provider does not degrade to the structural surface. That fallback was deleted in GROVE-S04-T03 and stays deleted. But the failure must be legible, which is a separate question from whether it is recoverable:
- Startup config load and explore-section deserialize remain hard
exit(1). Without a valid config there is no endpoint to name in an error, so there is nothing useful to say in-band. - The health probe no longer exits. Exiting killed the process before it
answered
initialize; the client rendered only a synthesized transport error (Failed to reconnect to grove-explore: -32000) and the agent never learned the tool existed. It degraded silently to grep. The probe now warns to stderr and serves anyway; anexplorecall against a down provider returns an actionableisErrorthat reaches the model in-band. - When the startup probe fails, the server’s
initializeinstructions lead withPROVIDER UNAVAILABLEand the endpoint. Without this the model learns the provider is down only by spending calls on it — observed in the wild costing twoexplorecalls before the agent concluded the backend was unreachable.
Serving with a down provider is not a fallback: the only tool is still
explore, and it reports that it cannot work. The agent receives an error, not
a substitute.
Consequences
- A down provider in
mcp-llmmeans no grove tools at all. Previously the structural server survived and could answer the question anyway. This is accepted:mcp-llmis opt-in and presumes an engine. Observed in the wild — an agent asked to find a login handler reported the provider was down, looked for the structural tools, found them unregistered, and used ripgrep. /mcpshowsgrove-exploreconnected when it cannot work. Mitigated by the instructions warning, which reaches the model even though the client’s connection state looks healthy.- Existing
mcp-llmprojects keep their two-server layout until re-inited. Nothing strips a registration that is not rewritten. Re-runninggrove init --as mcp-llmconverges them. - Pre-split projects converge correctly. A
.mcp.jsoncarryinggrove: ["serve", "--explore"]has that entry stripped rather than rewritten — its--explorearg is a hard error post-T03, so a rewritten entry would fail at launch. --as mcpis unchanged, and remains the default and the recommended surface for most projects.
Alternatives considered
- Keep both, weaken the steering. Register both but describe the structural tools as secondary. Retains the eight-tool routing surface and relies on prose to prevent the behaviour it describes — the failure mode this ADR exists to remove.
- Make
exploreself-sufficient. Havegrove-exploreexposesource(and perhapsmap) besideexplore, giving one server with locate-and-read. This is coherent and was the leading option until theRead(offset)argument made it unnecessary. It remains available if outer harnesses without offset reads become a target. - Degrade at init. Probe the provider during
grove init --as mcp-llmand write themcpregistration instead when unreachable. Rejected: it makes the registration depend on a transient condition at setup time, so an engine that is merely not started yet silently produces the wrong mode.
Notes
This ADR is a step toward ADR 0004 Stage 3 (repo/release split). Once the two products no longer appear in the same harness context, what remains to sever is packaging and config ownership. Stage 3’s stated triggers are release-cadence divergence; the scope argument — a local-LLM code reader is not an AST/CST tool and does not belong in grove’s product surface — is independent of those triggers and may justify Stage 3 on its own.
grove-explore’s in-process dependency on grove-cst is expected to survive
extraction: it becomes an ordinary crates.io dependency, the way any consumer
uses grove. It is not residual entanglement to be removed.