<!-- contamination-scan: skip
     This guide uses literal example paths (tsconfig.json, pyproject.toml,
     `src/components/...`, etc.) as pedagogical examples of what
     `baldart configure` autodetects. They are not project leak. -->
# LSP Symbol-Search Layer

A complete operator + agent reference for the LSP retrieval tier introduced in
**v3.10.0**. Read this when:

- you are upgrading a consumer repo from pre-v3.10 and want to know what
  changed and how to roll the new feature out,
- you are an AI agent asked to extend, debug, or modify the layer,
- a contributor wants to add a new language adapter or change the fallback
  rules.

For the *runtime* protocol (LSP → Grep → Git, query-type
discrimination, budget) read [`framework/agents/code-search-protocol.md`](../agents/code-search-protocol.md).
This document covers the *plumbing*: how the layer is installed, configured,
verified, and wired into agents/skills.

---

## 1. Why this exists

Pre-v3.10, every BALDART code-exploration flow ran:

> canonical-doc routing → `git log` → **Grep** → read files.

Grep collapses semantically distinct symbols that share a name. A search for
`handleSubmit` in a medium repo dumps every form handler, every CLI utility,
every test helper into the model's context — Claude then burns turns opening
files to figure out which one is relevant. The signal is buried in the
textual noise.

LSP — Language Server Protocol — answers "give me every reference to *this*
symbol" by walking the type graph the compiler/interpreter already has. A
function named `handleSubmit` declared in `src/forms/Login.tsx` returns
exactly the callsites that resolve to *that* declaration; the CLI utility's
unrelated `handleSubmit` is filtered out before any file is read.

The layer is **opt-in** (some consumers don't want a global npm install for a
language server, some target languages we don't have an adapter for yet) and
**degrades gracefully** (when
LSP is unavailable, agents silently fall back to the Grep flow,
so existing behavior is preserved).

---

## 2. The five moving parts

```
┌─────────────────────────────────────────────────────────────────────┐
│  baldart.config.yml                                                 │
│    features.has_lsp_layer: true                                     │
│    lsp.installed_servers: [typescript, python]                      │
│    lsp.auto_verify: true                                            │
│    tools.enabled: [claude]   ← gates native plugin install          │
└──────────────┬──────────────────────────────────────────────────────┘
               │ read by
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Plumbing layer (CLI, this repo)                                    │
│    src/utils/lsp-adapters/<lang>.js     ← per-language recipes      │
│    src/utils/lsp-installer.js           ← server + plugin installer │
│    src/commands/configure.js  (prompt + install on opt-in)          │
│    src/commands/doctor.js     (verify + lsp-fix + plugin-fix)       │
└──────────────┬──────────────────────────────────────────────────────┘
               │ writes installed_servers; runs `claude plugin install`
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Native Claude Code integration (since v3.14.0)                     │
│    Marketplace: anthropics/claude-plugins-official  (user scope)    │
│    Plugins:                                                         │
│      typescript-lsp@claude-plugins-official                         │
│      pyright-lsp@claude-plugins-official                            │
│      gopls-lsp@claude-plugins-official                              │
│      rust-analyzer-lsp@claude-plugins-official                      │
│      ruby-lsp@claude-plugins-official                               │
└──────────────┬──────────────────────────────────────────────────────┘
               │ surfaces LSP to Claude Code at runtime
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Protocol layer (framework/)                                        │
│    framework/agents/code-search-protocol.md                         │
│    framework/agents/index.md                       (routing line)   │
└──────────────┬──────────────────────────────────────────────────────┘
               │ referenced by
               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Consumers (framework/.claude/)                                     │
│    agents/codebase-architect.md          (Investigation Protocol)   │
│    agents/REGISTRY.md                    (capability declaration)   │
│    skills/context-primer/SKILL.md        (retrieval step 3)         │
│    skills/bug/SKILL.md                   (Phase 0 step 3)           │
│    skills/simplify/SKILL.md              (deduplication step 3)     │
│    skills/prd/SKILL.md, skills/new/SKILL.md  (transitive note)      │
│    skills/lsp-bootstrap/SKILL.md         (/lsp-bootstrap)           │
└─────────────────────────────────────────────────────────────────────┘
```

Each layer is independently testable and individually replaceable. The
adapters describe *how to install* the language server AND *which native
plugin* surfaces it to Claude. Pre-v3.14.0 the second half was a manual
step the user almost always skipped — opting into LSP put a binary on
disk that Claude never invoked. v3.14.0 closes that gap: the configure
flow installs both halves in one step, gated on `claude` being in
`tools.enabled` (Codex-only consumers are unaffected — the gate degrades
to a silent no-op).

---

## 3. End-to-end lifecycle

### 3.1 First install in a brand-new repo

```bash
npx baldart add                # subtree pull + symlink reconcile
                               # → triggers configure interactively
```

`configure` autodetects supported languages by probing for marker files:

| Language   | Marker(s)                                                | Adapter                          |
|------------|----------------------------------------------------------|----------------------------------|
| TypeScript | `tsconfig.json`, `jsconfig.json`, `package.json` w/ typescript | `lsp-adapters/typescript.js` |
| Python     | `pyproject.toml`, `setup.py`, `setup.cfg`, `requirements.txt`, `Pipfile`, top-level `*.py` | `lsp-adapters/python.js` |
| Go         | `go.mod`                                                 | `lsp-adapters/go.js`             |
| Rust       | `Cargo.toml`                                             | `lsp-adapters/rust.js`           |
| Ruby       | `Gemfile`, `.ruby-version`, `config.ru`                  | `lsp-adapters/ruby.js`           |

If any adapter fires, `features.has_lsp_layer` defaults to `true` in the
configure prompt (the user can still say no). The prompt copy is
*"Enable LSP symbol-search layer? (recommended for large codebases)"*.

When the user confirms, for each detected language:

- **npm-global mode** (TS, Python via pyright): `npm install -g <pkg>` runs
  in-process. The install is **global** — the consumer (Claude Code's LSP
  tool) spawns the server by name from `$PATH`, so a project-local devDep in
  `node_modules/.bin/` would never be reachable (this was the pre-v3.33.1
  dead-layer bug). Reachable on `$PATH` → recorded as `verified`; present but
  not on `$PATH` → recorded as `installed-but-unreachable` (with the
  global-install fix printed).
- **system mode** (Go, Rust, Ruby): the install command is *printed*, never
  executed. The server name is recorded as pending in `installed_servers`;
  `baldart doctor` re-verifies on the next run and flags the mismatch if the
  user hasn't run the printed command yet.

**Then (v3.14.0+) the native plugin install runs.** For every server in
`installed_servers` whose adapter declares a `claudePluginId()`, configure:

1. Verifies the `claude` CLI is on PATH (skipped silently if not — manual
   install commands are printed instead).
2. Idempotently registers the `claude-plugins-official` marketplace at user
   scope (`claude plugin marketplace add anthropics/claude-plugins-official
   --scope user` — no-op when already registered).
3. Reads `claude plugin list --json` to skip plugins already installed at
   any scope.
4. Runs `claude plugin install <id> --scope user` for each missing plugin
   (e.g. `typescript-lsp@claude-plugins-official`). User scope = a single
   install is reused by every project on the machine.

Gated on `claude` being in `tools.enabled` — Codex-only consumers see a
silent skip, not a failure.

### 3.2 Update from pre-v3.10

```bash
npx baldart update             # subtree pull
                               # → schema-drift detector runs
```

`update.js:407–435` diffs `template.features` vs `cur.features`. It finds
`has_lsp_layer` missing and prints:

> ⚠ New config keys in this version: has_lsp_layer.
> Run `baldart configure` now to populate them? (y/N)

The user re-enters the configure flow above. **No silent default** — the
always-ask contract from `framework/agents/project-context.md` § 3 owns this.

### 3.3 Routine doctor run

```bash
npx baldart                    # smart doctor (no-arg, default since v3.2.0)
```

`doctor.js` reads `config.lsp.installed_servers`. When
`lsp.auto_verify !== false`, the installer first checks `$PATH` reachability
(`command -v <binary>` — exactly how the consumer spawns the server), then runs
each adapter's `verifyCommand()` (e.g. `typescript-language-server --version`)
for the functional check. Output:

```
·  LSP layer        2 server(s) verified (typescript, python)
```

If any server fails verify, the planner emits an `lsp-fix` action with
`autoOk: false` (so it asks before touching anything) and reinstalls only
the broken ones — with per-server confirmation, preserving any deliberate
uninstall the user may have done.

### 3.4 Standalone bootstrap

```
/lsp-bootstrap                 # inside Claude Code, when the layer is enabled
```

The same install + verify flow exposed as a skill, useful when the user has
toggled `has_lsp_layer: true` manually in `baldart.config.yml` and now wants
to populate the layer without re-running the full configure prompt. The
skill refuses to run when the flag is `false` — opt-in is owned by configure.

### 3.5 Runtime behavior in a Claude Code session

When `codebase-architect` or one of the code-exploration skills (`bug`,
`prd`, `new`, `simplify`, `context-primer`) is invoked:

1. Read `baldart.config.yml`. If `features.has_lsp_layer !== true`, behave
   as the Grep → Git flow.
2. Otherwise: classify the query.
   - Identifier-shaped (function name, type name, class, exported symbol) →
     **LSP first** (`ToolSearch("LSP")` → `find-references` / `definition`).
   - Free text / phrase / comment / path / regex → **Grep first**, LSP is
     useless here.
   - Mixed (e.g. "refactor function X used in 5 places") → Grep to disambiguate
     the conceptual context, then LSP for the structural part.
3. Budget: max **3 LSP calls per task**. After 3 calls without convergence,
   escalate to direct file reads — don't ping-pong references.
4. If LSP tool isn't loaded / plugin missing / timeout > 8s / zero
   references for a symbol you know exists → **silent fallback to Grep**.
   The user does not see an error mid-task; `baldart doctor` is the
   self-heal channel.

The protocol document
[`framework/agents/code-search-protocol.md`](../agents/code-search-protocol.md)
is the authoritative runtime spec — this doc points at it rather than
duplicating its content.

---

## 4. Field reference

### 4.1 `baldart.config.yml`

```yaml
features:
  has_lsp_layer: true            # gate

lsp:
  installed_servers:             # list of adapter names
    - typescript
    - python
  auto_verify: true              # doctor re-runs verifyCommand on each call
```

Constraints:

- `has_lsp_layer` MUST be present and explicitly `true | false` once
  configure has run (always-ask contract).
- `installed_servers` is **only meaningful when the flag is `true`**.
  Configure resets it to `[]` if the flag flips to `false` (so the file
  doesn't lie about pending installs).
- `auto_verify: false` is intended for noisy CI — agents still respect the
  layer at runtime; only the doctor's verify probe is suppressed.
- Adapter names map 1:1 to filenames under `src/utils/lsp-adapters/`. An
  unknown name in `installed_servers` produces a soft warning, never a hard
  failure.

### 4.2 Adapter contract (`src/utils/lsp-adapters/<lang>.js`)

Every adapter is a class exporting:

| Member               | Type                | Purpose                                                    |
|----------------------|---------------------|------------------------------------------------------------|
| `name`               | string getter       | Stable id used in `installed_servers` (e.g. `"typescript"`)|
| `label`              | string getter       | Human-readable for prompts/diagnostics                     |
| `binary`             | string getter       | Executable name expected on PATH or in node_modules        |
| `installMode`        | `"npm-global"` / `"system"` | How the installer treats this language            |
| `npmPackage`         | string (npm-global only) | What goes after `npm install -g`                      |
| `installCommand()`   | string              | The exact shell command (printed or executed)              |
| `verifyCommand()`    | string              | Functional probe run by name from `$PATH` (e.g. `<binary> --version`); `$PATH` reachability is checked separately by the installer via `command -v <binary>` |
| `claudePluginId()`   | string \| null      | Native Claude Code plugin id (e.g. `typescript-lsp@claude-plugins-official`) — installed by the configure flow alongside the language server (v3.14.0+). `null` = no companion plugin |
| `static detect(cwd)` | boolean             | Pure: does the language live in this repo?                 |

The contract is enforced by convention, not by an interface check. When you
add a new adapter, mirror the shape of `typescript.js` exactly, then add it
to the REGISTRY in `lsp-adapters/index.js`.

### 4.3 Installer contract (`src/utils/lsp-installer.js`)

`LspInstaller` exposes:

**Language-server layer (since v3.10.0):**

- `detectLanguages()` → string[] — pure, no I/O beyond filesystem reads
- `recommend(langs?)` → entries with install metadata for UI consumption
- `installServers(names, { interactive=true, dryRun=false })` →
  `{ installed, skipped, manual }` — returns rather than throws
- `verifyServers(names)` → `[{ name, ok, status, output?, error?, fix? }]` —
  never throws. `status` ∈ `verified` (on `$PATH`, probe passed) /
  `installed-but-unreachable` (in node_modules but not on `$PATH`; `fix` =
  global-install command) / `unreachable` (on `$PATH` but probe errored) /
  `not-installed` / `unknown-adapter`. Reachability is `command -v <binary>`,
  mirroring how the consumer spawns the server — never `npx --no-install`,
  which resolves from node_modules and produces a false positive.

**Native Claude Code plugin layer (since v3.14.0):**

- `isClaudeCliAvailable()` → boolean — `claude --version` probe with 5s timeout
- `ensureClaudeMarketplace()` → `{ ok, alreadyPresent, error? }` — idempotently
  registers `anthropics/claude-plugins-official` at user scope; reads the
  existing marketplace list first so re-runs are no-ops
- `listInstalledClaudePlugins()` → `Set<string>` of `<name>@<marketplace>` ids
  pulled from `claude plugin list --json`; empty set on any failure (degrade
  open — we'd rather attempt a redundant install than incorrectly skip)
- `installClaudePlugins(names, { toolsEnabled=['claude'], scope='user', interactive=false })`
  → `{ installed, skipped, manual, marketplace, reason? }` — gated on
  `claude` being in `toolsEnabled` AND the CLI being available; per-plugin
  idempotency check; defaults to `--scope user` so a single install is reused
  across every project on the machine
- `verifyClaudePlugins(names)` → `[{ name, pluginId, installed }]` —
  consumed by `baldart doctor` to surface the half-installed state where the
  language-server binary is on PATH but the matching plugin is missing

All side-effecting methods are gated by `interactive` and a per-server
confirm; doctor passes `interactive: true` for the server layer and
`interactive: false` for the plugin layer (the plugin install is idempotent
and user-scope, so the outer doctor prompt + per-action `autoOk: true` is
sufficient).

---

## 5. Adding a new language

Worked example: adding **Java** support.

1. Pick the language server. For Java the de-facto choice is `jdtls`
   (Eclipse JDT Language Server), installed via system means.
2. Create `src/utils/lsp-adapters/java.js`:
   ```js
   const fs = require('fs');
   const path = require('path');

   class JavaAdapter {
     constructor(cwd = process.cwd()) { this.cwd = cwd; }
     get name() { return 'java'; }
     get label() { return 'Java (jdtls)'; }
     get binary() { return 'jdtls'; }
     get installMode() { return 'system'; }
     installCommand() { return 'brew install jdtls'; }
     verifyCommand() { return 'jdtls --version'; }
     claudePluginId() { return null; }
     static detect(cwd = process.cwd()) {
       return ['pom.xml', 'build.gradle', 'build.gradle.kts']
         .some(m => fs.existsSync(path.join(cwd, m)));
     }
   }

   module.exports = JavaAdapter;
   ```
3. Register in `src/utils/lsp-adapters/index.js`:
   ```js
   const JavaAdapter = require('./java');
   // ...
   const REGISTRY = {
     // ...
     java: JavaAdapter,
   };
   ```
4. Smoke test:
   ```bash
   node -e 'const r = require("./src/utils/lsp-adapters"); console.log(r.listAdapters())'
   ```
   Should now include `java`.
5. No change required in `configure.js`, `doctor.js`, the protocol module,
   or any agent/skill — they all iterate over the REGISTRY.
6. Update `CHANGELOG.md`, bump VERSION (PATCH if pure addition), document the
   adapter in this file's § 3.1 marker table.
7. Optional: add a per-language quirk paragraph to
   `framework/templates/overlays/agents/codebase-architect.lsp-example.md`
   if Java has gotchas worth flagging to consumers (e.g. classpath setup,
   multi-module Maven projects).

---

## 6. Troubleshooting

### 6.1 "Agents are still using Grep even though LSP is enabled"

Likely causes, in order of likelihood:

1. The query is free text, not a symbol. Re-read § 3.5 — Grep is correct
   here, not a regression.
2. The Claude Code LSP plugin isn't loaded in this session. Check inside
   Claude Code with `/plugin list`. Reload if necessary.
3. The language server binary isn't on PATH. Run `baldart doctor` — the
   LSP line will say `n/m broken (typescript, ...)` and propose `lsp-fix`.
4. `features.has_lsp_layer` is `false` or missing in `baldart.config.yml`.
   Run `baldart configure`.

### 6.2 "configure prompted me but didn't install anything"

The autodetector found no supported language markers. Either:

- You're in a polyglot repo where the supported language lives in a
  subdirectory configure didn't probe. Add the marker file at the repo root
  (e.g. a stub `tsconfig.json`) or extend the adapter's `detect()`.
- You're using a language we don't ship an adapter for. See § 5.

### 6.3 "doctor says my server is broken but it works in my editor"

Two common causes:

1. **PATH-resolution mismatch (the usual culprit).** Your editor resolves the
   language server through `node_modules/.bin`, `npx`, or its own bundled copy;
   the Claude Code LSP tool spawns it **by name from `$PATH`**. A binary
   installed as a project-local devDep is visible to the editor but reports
   `installed-but-unreachable` here, because it isn't on `$PATH`. Fix: install
   it globally (`npm install -g <pkg>`) — or, if you installed globally and it's
   still unreachable, confirm npm's global bin dir (`npm prefix -g`/`npm bin -g`)
   is on your `$PATH`. This was the pre-v3.33.1 false-positive bug: verify used
   `npx --no-install` (node_modules resolution) and passed while the consumer's
   `$PATH` spawn failed.
2. **Cold-start timeout.** `verifyCommand()` executes from the consumer cwd with
   a hard 8s timeout. If the server takes longer than that to print `--version`
   (rare, seen on first-run cold caches), verify fails. Re-run `baldart doctor`
   once warm. If still failing, run the exact `command -v <binary>` and
   `verifyCommand()` strings in your shell — the actual error will surface.

### 6.4 "I don't want a global npm install for the language server"

The npm-global default exists because the consumer spawns the server from
`$PATH`. If you'd rather manage the binary yourself (system pyright via uv, a
Volta/asdf shim, a Nix profile, whatever), do it your way — just make sure the
binary ends up on `$PATH`. Then write an overlay at
`.baldart/overlays/agents/codebase-architect.md` documenting your install path
and manually set `lsp.installed_servers: [python]` in the config. The runtime
layer doesn't care *how* the binary got onto `$PATH`, only that it's reachable
there. `baldart doctor` will continue to verify it via `command -v`.

---

## 7. Mental model for agents extending this layer

When you (an AI agent) are asked to modify the LSP layer, internalize these
invariants — they are the rules the existing code obeys, and breaking them
silently corrupts consumer installs:

1. **Opt-in is owned by configure.** No code path may flip
   `features.has_lsp_layer` from `false` to `true` (or vice versa) without
   going through `configure.js` or an explicit user-typed YAML edit. The
   `lsp-bootstrap` skill and the doctor `lsp-fix` action both *refuse* to
   change the gate; they only act when it's already on.
2. **`installed_servers` is descriptive, not normative.** It records what
   BALDART knows about; it does not enforce. Removing a name from the list
   doesn't uninstall the binary, and adding a name doesn't install one. The
   single source of truth for "is it usable" is the verify probe.
3. **Fallback to Grep is silent.** Never abort an agent's task because LSP
   failed. The user finds out about broken LSP through `baldart doctor`,
   not by getting an error mid-bug-investigation.
4. **The adapter REGISTRY is the only place to enumerate languages.**
   `configure.js`, `doctor.js`, the bootstrap skill, and the docs all
   iterate the registry. Hard-coding a language anywhere else is a bug.
5. **Schema-change propagation rule** (from memory
   `feedback_schema_change_propagation.md`): any new key under `lsp.*` or
   `features.has_lsp_*` must land in the template, the configure prompt,
   the update detector, the doctor diagnostic, the relevant skill(s), AND
   the CHANGELOG. If you only add the template, you have introduced silent
   drift between layers — the v3.9.0 pattern is the worked example to copy.
6. **No MCP server lives in this repo.** BALDART does not ship its own LSP
   bridge. The runtime tools come from Claude Code's plugin ecosystem (or
   a third-party MCP the user installs); BALDART's job is to make sure the
   binaries are present and to instruct agents how to use them.

If a feature request seems to break one of these invariants, push back and
propose an alternative that preserves them — they were chosen deliberately
in the design review (see the approved plan in
`/Users/antoniobaldassarre/.claude/plans/` from 2026-05-23).

---

## 8. See also

- [`framework/agents/code-search-protocol.md`](../agents/code-search-protocol.md) — runtime protocol
- [`framework/agents/project-context.md`](../agents/project-context.md) — always-ask contract
- [`framework/docs/PROJECT-CONFIGURATION.md`](PROJECT-CONFIGURATION.md) § 4.5 + § 4.6 — config schema
- [`framework/.claude/skills/lsp-bootstrap/SKILL.md`](../.claude/skills/lsp-bootstrap/SKILL.md) — invokable skill
- [`framework/templates/overlays/agents/codebase-architect.lsp-example.md`](../templates/overlays/agents/codebase-architect.lsp-example.md) — starter overlay
- [`CHANGELOG.md`](../../CHANGELOG.md) — v3.10.0 entry
