Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 binariesgrove (CLI + the always-structural MCP server) and grove-explore (its own MCP server, the LLM-delegating locator). You can reach the engine four ways:

SurfaceWhat it isStart here
CLIgrove <verb> — the seven tools at your shell, human tables or --jsonCLI & the seven tools
MCP: structuralgrove serve — the same seven tools to a coding agent over stdioMCP: standard server
MCP: exploregrove-explore serve — a single delegated explore locator backed by a local LLM, its own server identity, composable alongside grove serveMCP: explore mode
Librarygrove-cst on crates.io — use grove_core::ops in your own RustUse 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.
  • Setupgrove init wires 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.

BinaryWhat it is
groveThe CLI (grove <verb>) and the 7-tool structural MCP server (grove serve). This is what most projects want.
grove-exploreThe 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.

PhaseCommandWhat it does
Readgrove 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
Findgrove 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
Readgrove source <id> or grove source <file> <name>full source of one symbol — no whole-file read
Verifygrove check <file>ERROR / MISSING nodes (exit 1 if any) — post-edit syntax check
Tracegrove callers <name> [-d <dir>]call sites of a symbol, each with its enclosing function
Mapgrove map <dir> [--kind K] [--name SUB] [--name-contains]directory dependency graph: definitions + outgoing references, no source bodies
Tracegrove 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 -n convention).
  • Every result carries a stable symbol-id (<lang>:<relpath>#<name>@<line>, line 1-based) usable across turns — pass it from outline/symbols/map into source, callers, definition.
  • --name matches exactly (case-insensitive) by default so --name batch returns batch, not testCreateBatch. Use --name-contains for fuzzy substring exploration. (issue #37)

Detail tiers (outline)

--detail controls field density so big files stay token-cheap:

  • 0 — terse: kind · name · parent · line
  • 1 — default: adds id · col · signature
  • 2 — full: adds byte offsets (for read slicing 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 fileoutline (skeleton) → source <id> (the one symbol’s body).
  • Where is X defined across the reposymbols <dir> --name Xsource <id>.
  • Who calls Xcallers <name> -d <dir>.
  • How does this directory connectmap <dir> (definitions + outgoing references, one call, no bodies) — replaces many symbols+source round-trips.
  • After an editcheck <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:

--asRegisters under groveTools
mcp (default)grove servemcp__grove__* — the 7 structural tools
mcp-llmgrove-explore servemcp__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 initializetools/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-explore is a separate binary with its own MCP server; --as mcp-llm registers it instead of the structural grove serve, not alongside it (see ADR 0005). The .grove/config.json config format and the explore tool contract are covered by semantic versioning as of 0.3.0. The standard --as mcp|skill|both targets and the always-structural 7-tool grove serve are 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 the grove key, running grove-explore serve (any stale two-server-era grove-explore key is cleaned up on init).
  • CLAUDE.md — a locator-framed steering block: ask mcp__grove__explore broad 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

FilePurposeTarget
.mcp.jsonregisters grove serve for MCP-aware harnessesavailability
CLAUDE.mda marked steering block routing the agent to groveadoption
grove.lockpinned 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:

  1. GROVE_REGISTRY env var — explicit override (CI, tests, air-gapped).
  2. <project>/.grove/grammars/ — project-vendored grammars (commit them for hermetic / offline builds), found by walking up from the cwd.
  3. OS user cache — the default shared store:
    • Linux: ~/.cache/grove/grammars (honors $XDG_CACHE_HOME)
    • macOS: ~/Library/Caches/grove/grammars
    • Windows: %LOCALAPPDATA%\grove/grammars
  4. 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, so callers (enclosing function), definition from a position, and parent grouping all work.
  • Minimal profile (12 languages) — core tools (outline, symbols, source, check, map) work; callers/definition degrade.
  • css/html/json/regex have no upstream tags.scm — they still check but 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 (grove and grove-core were taken by unrelated crates); the library you use is grove_core, and the CLI binary is grove (published as grove-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:

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 / mapsource / 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 for init/doctor. grove serve is unconditionally the 7-tool structural surface regardless of modemode no longer selects a serve surface. mode: mcp-llm means “init registers both grove and grove-explore in the harness (.mcp.json, CLAUDE.md, AGENTS.md)”; grove-explore is its own MCP server binary with its own always-on explore surface, composable alongside grove serve.
  • explore — present when mode is mcp-llm; read by grove-explore:
    • providerollama or llamacpp (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), or strict (grove-first).
    • allowed_tools — the tools the inner explorer may invoke.
    • tap — record per-session traces under .grove/traces/ (browse with grove-explore tap).
    • trace_retain — how many trace sessions to keep (0 = keep all).

grove config / grove tap still work as deprecated forwarding shims to grove-explore config / grove-explore tap — they print a one-line note and forward to the sibling binary. Use the grove-explore spelling directly.

The legacy .grove/explore.json is migrated forward to config.json on first load; grove doctor flags 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

grovean LSP server
What it modelsthe parse tree (syntax)a semantic index (types, scopes, imports)
go-to-defname-based, or the identifier at a position (tree-sitter) — not type/receiver resolvedtype- and scope-resolved
find referencesstructural (tags) + whole-word textual fallback, by namescope- and type-aware
hover / completionyes
rename / refactoryes
diagnosticssyntax only (check → ERROR / MISSING nodes)type errors, lints, semantic warnings
cross-file type graphyes (the core of a language server)
ProtocolMCP (grove’s own JSON-RPC tool schema)LSP (textDocument/definition, hover, references, …)
Consumercoding agentsIDEs / editors
Languagesone engine, all 27 grammars load at runtime from a WASM registryone server per language, usually a heavy language-specific toolchain
Indexparses 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::edit is ahead.
  • callers / definition are name-based — no receiver-type or local-scope resolution (the tags locals query is a Tier-3 item).
  • 12 languages ship a minimal profile (core tools only); css/html/json/regex have no upstream tags.scm (they still check). See Languages & grammars.
  • No map-over-repo / scope-aware grep yet — repo-orient (map) and a structural grep are 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::opsengine (+ 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.definition resolution”), §8 (tool→API mapping), Roadmap Tier 3 (“scope-aware callers/definition via the tags locals query”)

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:

  1. No scope awareness. A local variable or parameter named parse resolves to the module-level function parse. Shadowing is invisible. callers can even count a local binding as a call site (today mitigated only by a lossy textual fallback).
  2. 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.scm artifact to each registry dir, alongside tags.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-process Loaded cache) and gains resolve_local_at(root, row, col, …): find the identifier under the cursor, then walk enclosing @local.scope ranges innermost→outermost for a @local.definition of the same name contained in that scope. Innermost match wins (correct shadowing). Returns a Symbol for the binding, or None.
  • definition_at tries 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 imports block to the manifest profile (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_package for Python, relative_path for JS/TS, use_path for Rust). A grammar without it keeps today’s behavior.
  • definition_at decision tree becomes:
    1. local scope resolution (Step 1) — 1 file parsed;
    2. 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;
    3. 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 typingfoo.bar() → which bar? Needs foo’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 .tsg inside 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

  • definition returns one correct binding for locals and imported symbols in the common case; callers precision 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.scm for rust/python/javascript, definition_at wiring, unit + integration tests).
  • Step 2: implemented (engine extract_imports + imports.scm, ops resolve_import_at / import_candidate_paths with dotted_package and relative_path strategies, import_resolution manifest field, definition_at decision 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-language locals.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 argsCLAUDE.md blockAGENTS.md block.grove/explore.json
mcp["serve"]MCP steeringuntoucheduntouched
skilluntouchedskill steeringuntoucheduntouched
both["serve"]MCP steeringuntoucheduntouched
mcp-llm["serve","--explore"]explore steeringexplore steering (written)created via TUI
grammarsuntoucheduntoucheduntoucheduntouched

The mode itself is never persistedTarget 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 serve picks its surface from ExploreConfig::config_path(root).exists() (mcp.rs:49) — the mere presence of .grove/explore.json, not the .mcp.json args. In explore surface, tools/list returns only explore; 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 init is stateless: it does not read the prior mode, it only overwrites the subset of files its new --as targets.

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:

  1. mcp-llm → mcp (or both). init rewrites .mcp.json to ["serve"] and CLAUDE.md to standard MCP steering (naming outline/symbols/…), but .grove/explore.json survives, so serve still boots the explore surface exposing only explore. CLAUDE.md now 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.
  2. Orphaned AGENTS.md. Only mcp-llm ever writes AGENTS.md; no other mode rewrites or removes its block. Every transition out of mcp-llm strands a stale explore-mode AGENTS.md, misdirecting any AGENTS.md-reading harness.
  3. skill / grammars never rewrite .mcp.json, so a prior --explore registration 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
  }
}
  • mode records the init Target verbatim (mcp | skill | both | mcp-llm | grammars). It is written by grove init on every run.
  • explore is today’s ExploreConfig, present only when relevant. Its inner mode field (steering level: standard/balanced/strict) is renamed to steering to remove the config.mode / config.explore.mode ambiguity — 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. serve no longer selects a surface at all — grove serve is unconditionally the 7-tool structural surface, and there is nothing left for a mode to select. The explore surface moved to its own grove-explore serve binary.

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-explore server — there is no inert/greyed state to render. Any badge should reflect whether grove-explore is registered in the harness, not the declared mode. The TUI itself moved to grove-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 from config.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 until grove 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 --as remains 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 → steering rename is optional. The only remaining collision is a human reading the JSON (config.mode vs config.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.
  • grammars mode still touches no project-owned files. Writing .grove/config.json is consistent with the grammars contract because .grove/ is grove’s own directory, not a host file like CLAUDE.md / .mcp.json.

Alternatives considered

  • Keep mode emergent; special-case the cleanup in init. Rejected: without a persisted mode, init cannot 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.json holding only { mode }, leave explore.json separate. 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 serve authoritative on the .mcp.json --explore arg instead of a file. Fixes the runtime half but leaves init with no persisted prior mode to reconcile, and .mcp.json is a host-owned file grove shouldn’t treat as its own state store. Rejected.
  • Let the config TUI change mode + call reconcile_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 by grove 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 the mode → files mapping, so a mode switch converges every file from a single code path.
  • serve, the agent’s steering, and init’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 (new GroveConfig wrapper, mode enum, migration), mcp.rs (surface check), init.rs (extract reconcile_harness), and config_tui/ (mode badge + inert-state rendering).
  • Migration code must carry the legacy explore.json path indefinitely (or until a deprecation window closes).
  • reconcile_harness must be careful to only strip grove-authored blocks (sentinel-delimited) and grove’s own .mcp.json entry, 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 → B mode switch leaves .mcp.json, CLAUDE.md, AGENTS.md, and the serve surface 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:

  1. The toolset captures stdout only (run_capture returns stdout on success), so only the println! bodies must be reproduced — not the eprintln! summary lines, which the toolset never saw.
  2. core is already clap-free by design (see core/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.rs replaces its inline println! loops with print!("{}", render::outline(&syms)) etc. It keeps clap parsing, the --json branch, and its eprintln! summaries — those stay CLI concerns. No output change.
  • core::explore::toolset parses the verb + documented flags from the command string (a small hand-rolled parser — core stays clap-free), calls the matching ops function, and renders with core::render. The per-call subprocess (grove_binary + run_capture for the Grove verb) is removed. run_capture remains for the rg/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

  • core does not gain clap. The toolset hand-parses the six verbs’ small flag set; the Cli/Cmd clap structs stay in cli. This preserves the clap-free core.
  • The CLI’s --json output and stderr summaries stay in cli. core::render is human-text only; JSON is serde on the typed ops results, already shared.
  • No behaviour or format change. render reproduces 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 Cmd dispatch into core and have both faces share it. Rejected: pulls clap into core, 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 core to cli’s dispatch, and there is no clean in-process handle to it from core.

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; cli retains 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: core now owns human text rendering for these verbs, which previously lived entirely in cli.

Status of implementation

  • Accepted; implemented in the 0.3.0 prep branch (Phase 0): core::render + in-process toolset dispatch, with a parity test asserting render output 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: serve no longer selects a surface from the declared mode, determine_surface is 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:

PieceWhereSizeWhat it actually is
Inner explorer enginecore/src/explore/ (11 modules + embedded prompt)~3,900 linesLLM 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 verbcli/src/config_tui/, cli/src/trace_tui/, cli/src/tap.rs~2,950 linesFull-screen ratatui apps for managing the explore backend and browsing its traces
Radiating couplingconfig.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:

  1. grove-cst is 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.rs even re-implements Glob/Grep/Read with Claude-style schemas, tools with no relation to grove’s domain.

  2. The explore product’s lifecycle already lives elsewhere. Its research is in grove-explore-model, its weights are on HuggingFace/ollama fronted by grove-models, its eval bed is the fastcontext sidebench in is-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 is include_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.

  3. The either/or Surface is 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 to explore, use map/source directly for precision work) is unreachable.

  4. 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 stale explore.json booted the explore surface against the declared mode. The silent health-probe fallback is a standing latent instance of the same class: the client’s CLAUDE.md steering says “use mcp__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.

  5. 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-hf reference 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-explore bin target serving only the explore tool 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 recoverable isError.
  • grove serve always serves the 7-tool structural surface. Delete Surface, determine_surface, the --explore/--standard flags, and the health fallback; mcp.rs loses all explore:: imports.
  • Amendment to ADR 0002 §1: the declared mode no longer selects a serve surface (there is nothing left to select). mode remains the harness-reconciliation key for init/doctor: mcp-llm now means “register both servers in .mcp.json and write the delegation steering.” This keeps ADR 0002’s single-source-of-truth and reconcile_harness design intact — only the runtime consumer of mode disappears.
  • TUI surfaces. Both TUIs are pure explore-product UIs — config_tui imports only ExploreConfig/Provider/Steering, engine discovery, and the /models fetch; trace_tui/tap only the trace helpers and the tap flag; none reference ops/engine/registry — so they move wholesale: grove configgrove-explore config, grove tapgrove-explore tap, with the old grove verbs retained for one release as forwarding shims that print the new spelling. ratatui/crossterm leave the grove binary entirely (they are its only TUI dependencies). User-facing strings are swept in the same change: tap’s “restart grove 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 when mode != mcp-llm becomes moot — with no mode-switched surface, the explore config is always live for the explore server; any badge should reflect whether the grove-explore server is registered, not the mode.
  • Config ownership (interim). Through stages 1–2, config_tui keeps its read-modify-write of .grove/config.json — a grove-owned file — preserving mode and harnesses and 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-llm remains 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: init no longer contains the TUI, so it shells out to grove-explore config. In the interim single-repo form both binaries ship together, so the sibling is normally present; when it is not on PATH, init must degrade gracefully — register the server anyway and print “run grove-explore config to finish setup” rather than failing the init.
  • doctor’s explore check group moves behind the same boundary: the checks run when the harness registers the grove-explore server, not when mode == mcp-llm implies 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 stays grove-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::LEGAL is unchanged; mcp-llm changes 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) on grove-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 explore as 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 mv of a crate when the trigger fires.
  • grove-explore shells out to the grove binary (or its MCP server) instead of linking grove-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 in Cargo.lock.

Consequences

Positive

  • mcp.rs shrinks to the 7-tool server: no Surface, 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-llm writes exactly that.
  • grove-cst is 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.json registrations to write, reconcile, and doctor-check where there was one; reconcile_harness’s mode→files matrix grows a column.
  • A deprecation window: serve --explore/--standard flags, the grove config/grove tap spellings, and the old single-registration mcp-llm projects 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 grove itself drops — ratatui/crossterm move to grove-explore — but total install size for both-server users is unchanged).
  • Cross-crate versioning discipline: grove-explore pins grove-cst exactly (as cli does today); a structural-surface change now touches two crates’ release notes.
  • Until stage 3, grove-explore remains a guest writer in grove-owned .grove/config.json (the TUI’s read-modify-write of the explore section), and grove init --as mcp-llm depends on the grove-explore binary 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 as grove-explore-core); the grove-explore binary with its own MCP server identity and a hard startup health gate (config load → explore deser → health_probe, each process::exit(1) before the serve loop — never a fallback); mcp.rs’s Surface/determine_surface/health-fallback machinery deleted, grove serve unconditionally structural; reconcile_harness’s both-servers column (mcp-llm registers grove + grove-explore) plus forward migration; config/trace TUIs and tap moved to grove-explore with grove config/grove tap retained as deprecated forwarding shims; grove init --as mcp-llm’s PATH-aware shell-out to grove-explore config with graceful degrade when the sibling binary is absent; doctor’s explore checks re-keyed on grove-explore registration 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 → B mode 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-llm means “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:

  1. 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.

  2. The dereference argument does not hold. The composition was justified by explore returning bare location lines with no way to read them cheaply. But a Claude Code-class outer harness has Read with offset/limit. Given appcommon.js#configureSessionStore@228 it reads a window at line 228. mcp__grove__source knows 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.

  3. The capability is already present twice. The inner harness carries nine tools — Glob, Grep, Read, and six mcp__grove__* — in-process, since grove-explore links grove-cst directly. The outer harness independently has its own Grep/Glob/Read. Registering grove’s structural tools alongside explore adds a third path to capability already reachable two other ways.

  4. The flag name stopped matching its behaviour. Pre-0.4.0 mcp-llm meant “switch grove serve into 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:

ModeRegistersOuter agent
--as mcp (default)grovenavigates structurally itself; no model in the loop
--as mcp-llmgrove-exploredelegates 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; an explore call against a down provider returns an actionable isError that reaches the model in-band.
  • When the startup probe fails, the server’s initialize instructions lead with PROVIDER UNAVAILABLE and the endpoint. Without this the model learns the provider is down only by spending calls on it — observed in the wild costing two explore calls 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-llm means no grove tools at all. Previously the structural server survived and could answer the question anyway. This is accepted: mcp-llm is 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.
  • /mcp shows grove-explore connected when it cannot work. Mitigated by the instructions warning, which reaches the model even though the client’s connection state looks healthy.
  • Existing mcp-llm projects keep their two-server layout until re-inited. Nothing strips a registration that is not rewritten. Re-running grove init --as mcp-llm converges them.
  • Pre-split projects converge correctly. A .mcp.json carrying grove: ["serve", "--explore"] has that entry stripped rather than rewritten — its --explore arg is a hard error post-T03, so a rewritten entry would fail at launch.
  • --as mcp is 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 explore self-sufficient. Have grove-explore expose source (and perhaps map) beside explore, giving one server with locate-and-read. This is coherent and was the leading option until the Read(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-llm and write the mcp registration 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.