baldart 3.32.0 → 3.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,60 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.33.1] - 2026-05-30
9
+
10
+ Fixes a **false-positive in the LSP layer's verify step** that let `/lsp-bootstrap` (and `baldart configure` / `doctor`) report a language server as `verified` while every real LSP call failed with `Executable not found in $PATH` — a dead layer masked by `codebase-architect`'s silent Grep fallback. Root cause was a **resolution mismatch**: verify used `npx --no-install typescript-language-server --version` (resolves from `node_modules/.bin`), but the consumer — Claude Code's LSP tool — spawns the server **by name from `$PATH`**. The npm-dev install (`npm install --save-dev`) put the binary in `node_modules/.bin`, which is *not* on `$PATH`, so verify passed and spawn failed. The npm-managed adapters now install **globally** so the binary lands on `$PATH`, and verify checks `$PATH` reachability the *same way the consumer spawns it*. **No new `baldart.config.yml` keys** — reachability is computed, not configured, so the schema-propagation rule does not apply.
11
+
12
+ ### Fixed — LSP verify no longer reports unreachable servers as `verified`
13
+
14
+ - **[src/utils/lsp-installer.js](src/utils/lsp-installer.js)**: `verifyServers()` rewritten around a 3-state taxonomy. It first probes `$PATH` reachability via `command -v <binary>` (new `_isOnPath` helper — mirrors the consumer's by-name spawn exactly), then runs the functional `verifyCommand()`. Results carry `status` ∈ `verified` (on `$PATH`, probe passed) / `installed-but-unreachable` (present in `node_modules` but not on `$PATH` — new `_isInstalledLocally` helper fingerprints the legacy devDep install; `fix` = global-install command) / `unreachable` / `not-installed` / `unknown-adapter`. Never throws.
15
+ - **[src/utils/lsp-adapters/typescript.js](src/utils/lsp-adapters/typescript.js)** + **[src/utils/lsp-adapters/python.js](src/utils/lsp-adapters/python.js)**: `installMode` `npm-dev` → `npm-global`; `installCommand()` → `npm install -g …`; `verifyCommand()` → `<binary> --version` (resolved from `$PATH`, mirroring the consumer) instead of `npx --no-install …`. System adapters (go/rust/ruby) were already PATH-correct and are unchanged.
16
+
17
+ ### Changed — `configure` records unreachable servers; `doctor` fix is now real
18
+
19
+ - **[src/commands/configure.js](src/commands/configure.js)**: **every** known-adapter result that isn't cleanly `verified` is now recorded in `lsp.installed_servers` (like system-mode pending installs) instead of being dropped by the old `filter(v => v.ok)` — otherwise re-runs reinstall blindly and `doctor` never sees the mismatch. This covers both `installed-but-unreachable` and the npm-global happy-path trap where `npm install -g` exits 0 but npm's global bin dir isn't on the current `$PATH` (→ status `not-installed`, since `npx` can't see the global prefix either) — without this, the primary configure path would have silently reintroduced the dead layer. The fix command (+ `npm prefix -g` PATH hint) is surfaced inline for each.
20
+ - **[src/commands/doctor.js](src/commands/doctor.js)**: the `lsp-fix` remediation now reinstalls globally (a real fix that puts the binary on `$PATH`), where before it re-ran `npm install --save-dev` — a no-op for the reachability problem. The `why` text explains the `$PATH` contract, and a post-reinstall re-verify surfaces the `npm prefix -g` PATH escape when a server stays unreachable (so a misconfigured global bin dir doesn't trap the user in a reinstall loop).
21
+
22
+ ### Docs
23
+
24
+ - **[framework/.claude/skills/lsp-bootstrap/SKILL.md](framework/.claude/skills/lsp-bootstrap/SKILL.md)**: Output Contract redefines `verified` as `$PATH`-reachable, adds the `installed-but-unreachable` status, and documents the **cold-start caveat** (first query may under-report references until indexing completes).
25
+ - **[framework/agents/code-search-protocol.md](framework/agents/code-search-protocol.md)**: new fallback rule for cold-start under-reporting — re-issue the first query before trusting a suspiciously-low reference count; never declare a helper unused off one cold find-references.
26
+ - **[framework/docs/LSP-LAYER.md](framework/docs/LSP-LAYER.md)** + **[framework/docs/PROJECT-CONFIGURATION.md](framework/docs/PROJECT-CONFIGURATION.md)**: adapter-contract table, install-mode descriptions, `verifyServers` contract, and troubleshooting §6.3/§6.4 updated for global install + `$PATH` reachability + the PATH-mismatch cause.
27
+
28
+ ## [3.33.0] - 2026-05-30
29
+
30
+ Closes a **structural silent-partial-install** failure mode (observed on the `mayo` consumer, 3.28→3.31, still live in 3.32.0; same class as the historic v3.27.1 bug): `add` was **non-atomic** — the hook registration ran *after* optional blocking prompts (git aliases, configure). In a non-TTY context (Claude's Bash tool never has a TTY on stdin) or on any interruption, those `inquirer` prompts throw/hang, so `.framework/` + `state.json` landed on the new version while `Hooks.registerAll` never ran — a half-install that `baldart status` still reported as "Valid". The fix makes install atomic (correctness-critical mutations run **before** every optional prompt), adds a **post-flight hook assert** that refuses to declare success with any active hook missing, and finally exposes `add --yes` so agents/CI can install unattended. Two additive, non-breaking follow-ups ship alongside. **No new `baldart.config.yml` keys** — `--yes` is a CLI flag, so the schema-propagation rule does not apply.
31
+
32
+ ### Fixed — `add` is now atomic + never declares a half-install successful
33
+
34
+ - **[src/commands/add.js](src/commands/add.js)**: `Hooks.registerAll` is **reordered ahead of** the optional git-aliases and configure prompts. Hook registration determines install correctness and must never sit behind a blocking prompt — if `add` is interrupted at an optional prompt, the hooks are already in place. The drift callback now passes `{ autoYes: nonInteractive }` (parity with `update.js`), so a drifted hook in non-TTY no longer throws.
35
+ - **[src/utils/hooks.js](src/utils/hooks.js)**: new exported `verifyAll(cwd)` — a pure read over `getStatus` returning `{ ok, malformed, missing, path }`. It iterates only the **active** `HOOK_REGISTRY` (commented-out entries like `capture-detect` excluded for free). A malformed `settings.json` counts as a failure (registration was skipped).
36
+ - **[src/commands/add.js](src/commands/add.js)**: **post-flight assert** before the success summary — re-reads `settings.json`, attempts a single best-effort re-registration if anything is missing, then exits non-zero ("Install is NOT complete") if a hook is still absent. Covers `update --reset` too, since that path delegates to `add(undefined, { yes: true })`.
37
+
38
+ ### Added — `add --yes` (non-interactive install for CI / agents)
39
+
40
+ - **[bin/baldart.js](bin/baldart.js)**: registers `-y, --yes` on the `add` subcommand (the underlying `add(repo, options)` already honored `options.yes`, it was just unreachable from the CLI). With `--yes`, `add` auto-confirms optional prompts and passes `{ nonInteractive: true }` to `configure` so its wizard writes autodetected values without blocking.
41
+ - **[src/commands/add.js](src/commands/add.js)**: `add --yes` on a repo that **already has** `.framework/` now fails fast with a clear pointer to `npx baldart update --reset --yes --i-know` instead of hitting the deliberately-interactive destructive "Remove and reinstall?" prompt (which would throw under non-TTY and surface as a generic failure). Clean installs only; reinstall goes through `--reset`.
42
+
43
+ ### Added — divergence classifier flags overlay-covered edits (`--reset` is safe)
44
+
45
+ - **[src/utils/git.js](src/utils/git.js)**: `classifyDivergence` now tags each `custom-overlay-able` commit with `overlayCovered` — true when **every** framework file it touches already has a matching overlay in `.baldart/overlays/`. New pure static `overlayRelForFrameworkFile(path)` mirrors the `overlay.js` path conventions (unit-tested).
46
+ - **[src/commands/update.js](src/commands/update.js)**: in the overlay-able divergence branch, when all commits are `overlayCovered` the UI/JSON now recommend `--reset --yes --i-know` as the **safe** resolution (intent already preserved in the overlays) and note that `scaffold-overlays` would create redundant skeletons. New `overlay_already_covered` field in the `--json` output.
47
+
48
+ ### Added — deterministic `overlay drift` via base-sha backfill
49
+
50
+ - **[src/utils/overlay-merger.js](src/utils/overlay-merger.js)**: new `ensureBaseFileShaInOverlay(overlayPath, baseSha)` — **additive-only, idempotent, fail-safe**: stamps `base_file_sha` into an overlay's frontmatter when absent (never overwrites, never fabricates frontmatter). Called from the merger in **[src/utils/symlinks.js](src/utils/symlinks.js)** `_generateOverlayedFile` at `add`/`update` time, so `baldart overlay drift` becomes deterministic for **every** overlay — not just freshly-scaffolded ones (hand-written / pre-v3.19.0 overlays were "unknown" to drift).
51
+
52
+ ### Tests
53
+
54
+ - **[scripts/test-add-postflight.sh](scripts/test-add-postflight.sh)**: new — encodes the post-flight **invariant** (a `settings.json` missing an active hook → `verifyAll().ok === false`; the dropped hook in the test is literally `baldart-overlay-telemetry`, the mayo failure). Fails on pre-v3.33.0 code (no `verifyAll`).
55
+ - **[scripts/test-overlay-backfill.sh](scripts/test-overlay-backfill.sh)**: new — backfill is additive, idempotent, never overwrites an existing sha, never fabricates frontmatter, fail-safe.
56
+ - **[src/utils/__tests__/classify-divergence.test.js](src/utils/__tests__/classify-divergence.test.js)**: extended with overlay-path-mapping fixtures (38 assertions, all green).
57
+
58
+ ### Changed — `/baldart-update` skill
59
+
60
+ - **[framework/.claude/skills/baldart-update/SKILL.md](framework/.claude/skills/baldart-update/SKILL.md)**: post-flight report (step 4) documents the new CLI hook assert in `add`/`--reset`; `last_verified` v3.32.0 → v3.33.0 (`hook_registry_entries`/`update_js_prompts` unchanged).
61
+
8
62
  ## [3.32.0] - 2026-05-30
9
63
 
10
64
  Makes the CLI **agent-native at the output layer**: `npx baldart version --json` and `npx baldart update --json --yes` now emit a single **machine-readable JSON object** on stdout (schemas `baldart.version/1` / `baldart.update/1`), with every human line routed to stderr. This closes the last fragile seam in the autonomous-update path — the `/baldart-update` skill previously had to **regex-scrape human-readable prose** (the v3.26.0 wording change had already forced a dual-format tolerant parser in Step 0). An agent now reads a field instead of matching a sentence. Borrowed from the [CLI-Anything](https://github.com/HKUDS/CLI-Anything) philosophy (`--json` on every command) — but BALDART is already a headless CLI, so this is a thin, hand-rolled output layer, not a generated wrapper. **No new `baldart.config.yml` keys** — `--json` is a CLI flag, not a config key, so the schema-propagation rule does not apply.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.32.0
1
+ 3.33.1
package/bin/baldart.js CHANGED
@@ -65,6 +65,7 @@ program
65
65
  .command('add [repo]')
66
66
  .description('Install the framework in your project')
67
67
  .option('-b, --branch <branch>', 'Branch to use', 'main')
68
+ .option('-y, --yes', 'Non-interactive install (CI / agents): auto-confirm all optional prompts and skip the interactive configure wizard. Refuses to reinstall over an existing .framework/ — use `update --reset` for that.')
68
69
  .action(async (repo, options) => {
69
70
  const addCommand = require('../src/commands/add');
70
71
  await addCommand(repo || 'antbald/BALDART', options);
@@ -15,7 +15,7 @@ this skill needs an update is left to a human reviewer.
15
15
  update_js_prompts: 6
16
16
  hook_registry_entries: 4
17
17
  config_template_top_level_keys: features,git,identity,lsp,paths,stack,tools,version
18
- last_verified: v3.32.0
18
+ last_verified: v3.33.0
19
19
  -->
20
20
 
21
21
  <!--
@@ -498,7 +498,15 @@ Report to the user, in order:
498
498
  3. **CLI self-upgrade**: if stdout mentions "Auto-relaunching via npx
499
499
  baldart@…", surface it once so the user knows the CLI bumped too.
500
500
  4. **Hooks reconciled**: any hook backfilled (parse "Auto-registered N
501
- missing hook(s)" or per-line "Registered hook" from stdout).
501
+ missing hook(s)" or per-line "Registered hook" from stdout). Since v3.33.0
502
+ the CLI also runs a **post-flight hook assert** at the end of `add` (and
503
+ therefore of `update --reset`, which delegates to `add`): it re-reads
504
+ `.claude/settings.json` and refuses to declare success — exiting non-zero
505
+ with "Install is NOT complete" — if any active `HOOK_REGISTRY` entry is
506
+ missing. So a clean `--reset` can no longer leave a half-install that
507
+ reports "Valid" (the mayo 3.28→3.31 / v3.27.1 failure class). If you see
508
+ that error, the repo is in an inconsistent state: run `npx baldart doctor`
509
+ to repair, do NOT report success.
502
510
  5. **Agent layout migration (v3.27.0+)**: parse stdout for lines matching
503
511
  `agent generated (overlay applied): .claude/agents/<name>.md → ../../.baldart/generated/agents/<name>.md`.
504
512
  Pre-v3.27.0 overlay-merged agents (`coder`, `ui-expert`, `code-reviewer`,
@@ -3,8 +3,8 @@ name: lsp-bootstrap
3
3
  description: >
4
4
  Install, verify, and document the LSP symbol-search layer for this project.
5
5
  Detects languages (TypeScript, Python, Go, Rust, Ruby), installs the matching
6
- language servers (npm devDep where possible, system command otherwise),
7
- verifies binaries are reachable, and writes lsp.installed_servers to
6
+ language servers (global npm install where possible, system command otherwise),
7
+ verifies binaries are reachable on $PATH, and writes lsp.installed_servers to
8
8
  baldart.config.yml. Use when the user says /lsp-bootstrap, "install LSP",
9
9
  "set up symbol search", or after enabling features.has_lsp_layer for the
10
10
  first time. Idempotent — re-running on an already-configured project
@@ -32,15 +32,22 @@ context on textual collisions; with it, references resolve to the same symbol
32
32
  present (markers: `tsconfig.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`,
33
33
  `Gemfile`, plus heuristic fallbacks).
34
34
  3. For each detected language:
35
- - **npm-dev** adapters (TypeScript, Python via pyright): runs `npm install
36
- --save-dev <pkg>` with user confirmation.
35
+ - **npm-global** adapters (TypeScript, Python via pyright): runs `npm install
36
+ -g <pkg>` with user confirmation. The install is **global** on purpose —
37
+ the consumer (Claude Code's LSP tool) spawns the server by name from
38
+ `$PATH`, and a project-local devDep under `node_modules/.bin/` is not on
39
+ `$PATH`, so it would never be reachable.
37
40
  - **system** adapters (Go gopls, Rust rust-analyzer, Ruby ruby-lsp): prints
38
41
  the install command for the user to run in a separate terminal. Does NOT
39
42
  execute system-level installs automatically.
40
- 4. Verifies each binary is reachable (`<binary> --version`).
41
- 5. Updates `lsp.installed_servers` in `baldart.config.yml` with the verified
42
- set. System-mode servers are recorded even if not yet reachable (the user
43
- may have a pending install) `baldart doctor` will flag the mismatch.
43
+ 4. Verifies each binary is **reachable on `$PATH` the same way the consumer
44
+ spawns it** (`command -v <binary>`), then runs `<binary> --version` for the
45
+ functional check. A binary present only in `node_modules/.bin` (legacy
46
+ devDep) is reported as `installed-but-unreachable`, NOT `verified`.
47
+ 5. Updates `lsp.installed_servers` in `baldart.config.yml`. Both `verified` and
48
+ `installed-but-unreachable` servers are recorded (the latter so re-runs don't
49
+ reinstall blindly and `baldart doctor` keeps flagging the mismatch);
50
+ system-mode pending installs are recorded too.
44
51
  6. Prints a summary table and points the user to
45
52
  `framework/agents/code-search-protocol.md` for runtime behavior.
46
53
 
@@ -66,8 +73,11 @@ context on textual collisions; with it, references resolve to the same symbol
66
73
  4. **Install.** Run the appropriate `installCommand()`. Capture failures into a
67
74
  "Skipped" bucket — never abort the whole skill on one failure.
68
75
 
69
- 5. **Verify.** For each installed server, call its `verifyCommand()`. Mark the
70
- results.
76
+ 5. **Verify.** For each installed server, the installer first checks `$PATH`
77
+ reachability (`command -v <binary>` — exactly how the consumer spawns it),
78
+ then runs `verifyCommand()`. Mark each result `verified`,
79
+ `installed-but-unreachable` (present in node_modules but not on `$PATH` —
80
+ print the global-install fix), or `not-installed`.
71
81
 
72
82
  6. **Persist.** Update `baldart.config.yml`:
73
83
  ```yaml
@@ -86,19 +96,25 @@ A single short status block — never a multi-page report. Format:
86
96
 
87
97
  ```
88
98
  LSP bootstrap:
89
- ✓ typescript-language-server (npm devDep, verified)
90
- pyright (npm devDep, verified)
99
+ ✓ typescript-language-server (npm -g, on $PATH, verified)
100
+ pyright — installed but not on $PATH; run `npm install -g pyright`, then `baldart doctor`
91
101
  ⚠ gopls — run `go install golang.org/x/tools/gopls@latest`, then `baldart doctor`
92
102
  · ruby — not detected (no Gemfile)
93
103
  Config updated: baldart.config.yml lsp.installed_servers = [typescript, python, go]
94
104
  Next: codebase-architect will now prefer LSP find-references over Grep for symbols.
105
+ Note: the first LSP query after a cold start may under-report references until the
106
+ workspace finishes indexing — re-run the query once warm if a count looks low.
95
107
  ```
96
108
 
109
+ `verified` is reserved for binaries that resolve on `$PATH` (the consumer's
110
+ spawn path). Never report `verified` for a binary that only `npx --no-install`
111
+ can find — that is precisely the false positive this skill must not emit.
112
+
97
113
  ## Notes
98
114
 
99
115
  - This skill never edits source code or installs binaries that aren't language
100
- servers — its only side effect is `npm install --save-dev <lsp-package>` and a
101
- YAML write to `baldart.config.yml`.
116
+ servers — its only side effect is `npm install -g <lsp-package>` (global, so
117
+ the binary is reachable on `$PATH`) and a YAML write to `baldart.config.yml`.
102
118
  - For Claude Code users: in addition to the binaries, you may need to load a
103
119
  code-intelligence plugin (`/plugin` in Claude Code). That step is documented
104
120
  in the printed output when applicable, but the skill does NOT manage Claude
@@ -75,6 +75,14 @@ Before picking a tier, classify the query:
75
75
  a path excluded by `tsconfig.json`).
76
76
  - LSP times out (>8s) → fall back to Grep for this query, log the symptom in
77
77
  the agent's reasoning so the user can re-run doctor.
78
+ - **Cold start under-reporting**: the first LSP query in a session can return
79
+ an incomplete set (e.g. find-references returning 1 hit where the symbol has
80
+ 6) until the language server finishes indexing the workspace. If a reference
81
+ count looks suspiciously low for a symbol you expect to be widely used, treat
82
+ the first result as a warm-up: re-issue the same query once before trusting
83
+ it, or cross-check with a single Grep. Do not act on a cold-start count as if
84
+ it were exhaustive (e.g. don't declare a helper "unused / safe to delete"
85
+ off one cold find-references).
78
86
 
79
87
  ## Examples
80
88
 
@@ -38,8 +38,9 @@ function named `handleSubmit` declared in `src/forms/Login.tsx` returns
38
38
  exactly the callsites that resolve to *that* declaration; the CLI utility's
39
39
  unrelated `handleSubmit` is filtered out before any file is read.
40
40
 
41
- The layer is **opt-in** (some consumers don't want devDep churn, some target
42
- languages we don't have an adapter for yet) and **degrades gracefully** (when
41
+ The layer is **opt-in** (some consumers don't want a global npm install for a
42
+ language server, some target languages we don't have an adapter for yet) and
43
+ **degrades gracefully** (when
43
44
  LSP is unavailable, agents silently fall back to the legacy RAG → Grep flow,
44
45
  so existing behavior is preserved).
45
46
 
@@ -133,8 +134,13 @@ configure prompt (the user can still say no). The prompt copy is
133
134
 
134
135
  When the user confirms, for each detected language:
135
136
 
136
- - **npm-dev mode** (TS, Python via pyright): `npm install --save-dev <pkg>`
137
- runs in-process. Success added to `lsp.installed_servers`.
137
+ - **npm-global mode** (TS, Python via pyright): `npm install -g <pkg>` runs
138
+ in-process. The install is **global** — the consumer (Claude Code's LSP
139
+ tool) spawns the server by name from `$PATH`, so a project-local devDep in
140
+ `node_modules/.bin/` would never be reachable (this was the pre-v3.33.1
141
+ dead-layer bug). Reachable on `$PATH` → recorded as `verified`; present but
142
+ not on `$PATH` → recorded as `installed-but-unreachable` (with the
143
+ global-install fix printed).
138
144
  - **system mode** (Go, Rust, Ruby): the install command is *printed*, never
139
145
  executed. The server name is recorded as pending in `installed_servers`;
140
146
  `baldart doctor` re-verifies on the next run and flags the mismatch if the
@@ -180,8 +186,10 @@ npx baldart # smart doctor (no-arg, default since v3.2.0)
180
186
  ```
181
187
 
182
188
  `doctor.js` reads `config.lsp.installed_servers`. When
183
- `lsp.auto_verify !== false`, it executes each adapter's `verifyCommand()`
184
- (e.g. `npx --no-install typescript-language-server --version`). Output:
189
+ `lsp.auto_verify !== false`, the installer first checks `$PATH` reachability
190
+ (`command -v <binary>` — exactly how the consumer spawns the server), then runs
191
+ each adapter's `verifyCommand()` (e.g. `typescript-language-server --version`)
192
+ for the functional check. Output:
185
193
 
186
194
  ```
187
195
  · LSP layer 2 server(s) verified (typescript, python)
@@ -268,10 +276,10 @@ Every adapter is a class exporting:
268
276
  | `name` | string getter | Stable id used in `installed_servers` (e.g. `"typescript"`)|
269
277
  | `label` | string getter | Human-readable for prompts/diagnostics |
270
278
  | `binary` | string getter | Executable name expected on PATH or in node_modules |
271
- | `installMode` | `"npm-dev"` / `"system"` | How the installer treats this language |
272
- | `npmPackage` | string (npm-dev only) | What goes after `npm install --save-dev` |
279
+ | `installMode` | `"npm-global"` / `"system"` | How the installer treats this language |
280
+ | `npmPackage` | string (npm-global only) | What goes after `npm install -g` |
273
281
  | `installCommand()` | string | The exact shell command (printed or executed) |
274
- | `verifyCommand()` | string | A no-side-effect probe (e.g. `--version`) |
282
+ | `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>` |
275
283
  | `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 |
276
284
  | `static detect(cwd)` | boolean | Pure: does the language live in this repo? |
277
285
 
@@ -289,7 +297,13 @@ to the REGISTRY in `lsp-adapters/index.js`.
289
297
  - `recommend(langs?)` → entries with install metadata for UI consumption
290
298
  - `installServers(names, { interactive=true, dryRun=false })` →
291
299
  `{ installed, skipped, manual }` — returns rather than throws
292
- - `verifyServers(names)` → `[{ name, ok, output|error }]` — never throws
300
+ - `verifyServers(names)` → `[{ name, ok, status, output?, error?, fix? }]` —
301
+ never throws. `status` ∈ `verified` (on `$PATH`, probe passed) /
302
+ `installed-but-unreachable` (in node_modules but not on `$PATH`; `fix` =
303
+ global-install command) / `unreachable` (on `$PATH` but probe errored) /
304
+ `not-installed` / `unknown-adapter`. Reachability is `command -v <binary>`,
305
+ mirroring how the consumer spawns the server — never `npx --no-install`,
306
+ which resolves from node_modules and produces a false positive.
293
307
 
294
308
  **Native Claude Code plugin layer (since v3.14.0):**
295
309
 
@@ -396,20 +410,34 @@ The autodetector found no supported language markers. Either:
396
410
 
397
411
  ### 6.3 "doctor says my server is broken but it works in my editor"
398
412
 
399
- `verifyCommand()` executes from the consumer cwd with a hard 8s timeout. If
400
- the language server takes longer than that to print `--version` (rare, but
401
- seen on first-run cold caches), the verify fails. Re-run `baldart doctor`
402
- once warm. If still failing, run the exact `verifyCommand()` string in your
403
- shell the actual error will surface.
404
-
405
- ### 6.4 "I want to enable the layer but not have devDeps in package.json"
406
-
407
- For TypeScript / Python you can override the adapter's `installMode` by
408
- writing an overlay at `.baldart/overlays/agents/codebase-architect.md`
409
- documenting your alternative install path (e.g. system pyright via uv), and
410
- then manually setting `lsp.installed_servers: [python]` in the config. The
411
- runtime layer doesn't care *how* the binary got there, only that it's
412
- reachable. `baldart doctor` will continue to verify it.
413
+ Two common causes:
414
+
415
+ 1. **PATH-resolution mismatch (the usual culprit).** Your editor resolves the
416
+ language server through `node_modules/.bin`, `npx`, or its own bundled copy;
417
+ the Claude Code LSP tool spawns it **by name from `$PATH`**. A binary
418
+ installed as a project-local devDep is visible to the editor but reports
419
+ `installed-but-unreachable` here, because it isn't on `$PATH`. Fix: install
420
+ it globally (`npm install -g <pkg>`) — or, if you installed globally and it's
421
+ still unreachable, confirm npm's global bin dir (`npm prefix -g`/`npm bin -g`)
422
+ is on your `$PATH`. This was the pre-v3.33.1 false-positive bug: verify used
423
+ `npx --no-install` (node_modules resolution) and passed while the consumer's
424
+ `$PATH` spawn failed.
425
+ 2. **Cold-start timeout.** `verifyCommand()` executes from the consumer cwd with
426
+ a hard 8s timeout. If the server takes longer than that to print `--version`
427
+ (rare, seen on first-run cold caches), verify fails. Re-run `baldart doctor`
428
+ once warm. If still failing, run the exact `command -v <binary>` and
429
+ `verifyCommand()` strings in your shell — the actual error will surface.
430
+
431
+ ### 6.4 "I don't want a global npm install for the language server"
432
+
433
+ The npm-global default exists because the consumer spawns the server from
434
+ `$PATH`. If you'd rather manage the binary yourself (system pyright via uv, a
435
+ Volta/asdf shim, a Nix profile, whatever), do it your way — just make sure the
436
+ binary ends up on `$PATH`. Then write an overlay at
437
+ `.baldart/overlays/agents/codebase-architect.md` documenting your install path
438
+ and manually set `lsp.installed_servers: [python]` in the config. The runtime
439
+ layer doesn't care *how* the binary got onto `$PATH`, only that it's reachable
440
+ there. `baldart doctor` will continue to verify it via `command -v`.
413
441
 
414
442
  ---
415
443
 
@@ -216,11 +216,11 @@ Populated by `baldart configure` and the `/lsp-bootstrap` skill when `features.h
216
216
  | Key | Meaning |
217
217
  |---|---|
218
218
  | `lsp.installed_servers` | List of language IDs (`typescript`, `python`, `go`, `rust`, `ruby`, …) whose server BALDART has recorded for this project. Identifiers map 1:1 to adapter files under `src/utils/lsp-adapters/`. |
219
- | `lsp.auto_verify` | When `true` (default), `baldart doctor` re-verifies the binaries are reachable on every run. Disable on CI if the verify step is too noisy. |
219
+ | `lsp.auto_verify` | When `true` (default), `baldart doctor` re-verifies the binaries are reachable **on `$PATH`** (via `command -v`, mirroring how the Claude Code LSP tool spawns them) on every run. Disable on CI if the verify step is too noisy. |
220
220
 
221
221
  **Install modes per adapter:**
222
222
 
223
- - `typescript`, `python` — installed as npm devDeps (`npm install --save-dev typescript-language-server typescript` / `pyright`). Project-local, version-pinned.
223
+ - `typescript`, `python` — installed globally via npm (`npm install -g typescript-language-server typescript` / `pyright`). Global on purpose: the consumer spawns the server by name from `$PATH`, so a project-local devDep under `node_modules/.bin/` would never be reachable. A binary present only in `node_modules` is reported `installed-but-unreachable`, not `verified`.
224
224
  - `go`, `rust`, `ruby` — installed via system tools (`go install …`, `rustup component add …`, `gem install …`). BALDART prints the command; the user runs it.
225
225
 
226
226
  **Fallback contract.** When the LSP layer is enabled but a server is missing or broken, agents silently fall back to Grep — code search never fails because of LSP issues. `baldart doctor` surfaces the mismatch and offers reinstall.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.32.0",
3
+ "version": "3.33.1",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -44,6 +44,15 @@ async function add(repo, options) {
44
44
  const exists = await git.frameworkExists();
45
45
  if (exists) {
46
46
  UI.warning('Framework already installed!');
47
+ // A non-interactive `add --yes` must never block on (or auto-confirm) the
48
+ // destructive "Remove and reinstall?" prompt. Reinstalling is an explicit
49
+ // user decision routed through `update --reset` (which rm -rf's
50
+ // `.framework/` itself, so this branch is never reached on that path).
51
+ // Fail fast with a clear pointer instead of a generic catch below.
52
+ if (nonInteractive) {
53
+ UI.error('.framework/ already present — a non-interactive reinstall must go through `npx baldart update --reset --yes --i-know`.');
54
+ process.exit(1);
55
+ }
47
56
  const shouldReinstall = await UI.confirm(
48
57
  'Remove and reinstall? (You will lose customizations)',
49
58
  false
@@ -163,6 +172,35 @@ async function add(repo, options) {
163
172
  UI.newline();
164
173
  symlinks.copyCustomizableFiles();
165
174
 
175
+ // BALDART hooks (multi-hook registry since v3.18.0)
176
+ // Auto-register every hook in src/utils/hooks.js HOOK_REGISTRY into
177
+ // .claude/settings.json. Drift on an existing entry triggers an
178
+ // interactive prompt (Keep / Replace / Show diff), or 'keep' in --yes mode.
179
+ //
180
+ // This runs BEFORE the optional prompts below (git aliases, configure) —
181
+ // hook registration determines the correctness of the install and must
182
+ // never sit behind a blocking prompt. If `add` is interrupted at an
183
+ // optional prompt (non-TTY, Ctrl-C), the hooks are already in place.
184
+ // (Reordered in v3.33.0 to close the silent partial-install gap.)
185
+ try {
186
+ const res = await Hooks.registerAll(process.cwd(), {
187
+ onDrift: Hooks.createDriftPrompt(UI, { autoYes: nonInteractive }),
188
+ });
189
+ if (res.malformed) {
190
+ UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
191
+ } else {
192
+ for (const r of res.results) {
193
+ if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json`);
194
+ else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
195
+ else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
196
+ else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
197
+ else if (r.status === 'already') UI.info(`Hook \`${r.id}\` already registered.`);
198
+ }
199
+ }
200
+ } catch (err) {
201
+ UI.warning(`Hook registration skipped: ${err.message}`);
202
+ }
203
+
166
204
  // Configure Git aliases
167
205
  UI.section('Configuring Git Aliases');
168
206
 
@@ -201,29 +239,6 @@ async function add(repo, options) {
201
239
  }
202
240
  }
203
241
 
204
- // BALDART hooks (multi-hook registry since v3.18.0)
205
- // Auto-register every hook in src/utils/hooks.js HOOK_REGISTRY into
206
- // .claude/settings.json. Drift on an existing entry triggers an
207
- // interactive prompt (Keep / Replace / Show diff).
208
- try {
209
- const res = await Hooks.registerAll(process.cwd(), {
210
- onDrift: Hooks.createDriftPrompt(UI),
211
- });
212
- if (res.malformed) {
213
- UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
214
- } else {
215
- for (const r of res.results) {
216
- if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json`);
217
- else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
218
- else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
219
- else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
220
- else if (r.status === 'already') UI.info(`Hook \`${r.id}\` already registered.`);
221
- }
222
- }
223
- } catch (err) {
224
- UI.warning(`Hook registration skipped: ${err.message}`);
225
- }
226
-
227
242
  // Routines wizard (since v2.1.0)
228
243
  try {
229
244
  const routinesCmd = require('./routines');
@@ -245,7 +260,9 @@ async function add(repo, options) {
245
260
  if (runConfigure) {
246
261
  try {
247
262
  const configureCmd = require('./configure');
248
- await configureCmd();
263
+ // In --yes mode, configure must write autodetected values without
264
+ // prompting — otherwise its inquirer prompts block in non-TTY.
265
+ await configureCmd(nonInteractive ? { nonInteractive: true } : undefined);
249
266
  } catch (err) {
250
267
  UI.warning(`Configure step failed: ${err.message}`);
251
268
  UI.info('Run `npx baldart configure` later to complete setup.');
@@ -254,6 +271,31 @@ async function add(repo, options) {
254
271
  UI.info('Skipped. Run `npx baldart configure` before invoking skills.');
255
272
  }
256
273
 
274
+ // Post-flight assert — never declare success with missing hooks.
275
+ // Re-reads .claude/settings.json and confirms every active hook in
276
+ // HOOK_REGISTRY is present. Guards against the silent partial-install
277
+ // class (v3.27.1 / mayo 3.28→3.31). Covers `update --reset` too, since
278
+ // that path delegates to add(undefined, { yes: true }).
279
+ let verify = Hooks.verifyAll(process.cwd());
280
+ if (!verify.ok && !verify.malformed) {
281
+ // A single best-effort re-registration before failing hard.
282
+ try {
283
+ await Hooks.registerAll(process.cwd(), {
284
+ onDrift: Hooks.createDriftPrompt(UI, { autoYes: nonInteractive }),
285
+ });
286
+ } catch (_) { /* fall through to the assert below */ }
287
+ verify = Hooks.verifyAll(process.cwd());
288
+ }
289
+ if (!verify.ok) {
290
+ UI.error(
291
+ verify.malformed
292
+ ? '.claude/settings.json is malformed — cannot verify hooks. Install is NOT complete.'
293
+ : `Hooks missing after install: ${verify.missing.join(', ')}. Install is NOT complete.`
294
+ );
295
+ UI.info('Run `npx baldart doctor` to repair, then re-check `npx baldart status`.');
296
+ process.exit(1);
297
+ }
298
+
257
299
  // Success summary
258
300
  UI.header('✓ INSTALLATION COMPLETE');
259
301
 
@@ -706,7 +706,26 @@ async function interactivePrompts(merged, detected) {
706
706
  }
707
707
  const verify = installer.verifyServers(installed.map(i => i.name));
708
708
  const verified = verify.filter(v => v.ok).map(v => v.name);
709
- const newSet = new Set([...(merged.lsp.installed_servers || []), ...verified, ...manual.map(m => m.name)]);
709
+ // Every known-adapter result that isn't cleanly `verified` is STILL
710
+ // recorded (like system-mode pending installs): dropping it would make
711
+ // every re-run reinstall and `baldart doctor` would never see the
712
+ // mismatch. This covers `installed-but-unreachable` AND the npm-global
713
+ // happy-path trap where `npm install -g` exited 0 but npm's global bin
714
+ // dir isn't on the current `$PATH` (→ status `not-installed`, since npx
715
+ // can't see the global prefix either). Surface the fix in both cases.
716
+ const needsAttention = verify.filter(v => !v.ok && v.status !== 'unknown-adapter');
717
+ for (const u of needsAttention) {
718
+ UI.warning(`${u.name}: not reachable on $PATH (status: ${u.status}) — Claude Code's LSP tool can't spawn it.`);
719
+ if (u.fix) {
720
+ UI.code(u.fix, 'Run this, then ensure npm\'s global bin dir (`npm prefix -g` → /bin) is on $PATH; re-run `baldart doctor` to verify.');
721
+ }
722
+ }
723
+ const newSet = new Set([
724
+ ...(merged.lsp.installed_servers || []),
725
+ ...verified,
726
+ ...needsAttention.map(u => u.name),
727
+ ...manual.map(m => m.name)
728
+ ]);
710
729
  merged.lsp.installed_servers = Array.from(newSet);
711
730
  if (skipped.length) {
712
731
  UI.warning(`Skipped: ${skipped.map(s => `${s.name} (${s.reason})`).join(', ')}`);
@@ -534,14 +534,23 @@ function planActions(state) {
534
534
  actions.push({
535
535
  key: 'lsp-fix',
536
536
  label: `Reinstall ${state.lspBroken.length} broken LSP server(s)`,
537
- why: `LSP layer is enabled but these language servers are not reachable: ${state.lspBroken.join(', ')}. Symbol search will silently fall back to Grep until they are reinstalled.`,
538
- autoOk: false, // touches dev-deps and is the kind of action a user may
539
- // have made deliberate (switching from npm pyright to system pyright);
537
+ why: `LSP layer is enabled but these language servers are not reachable on $PATH: ${state.lspBroken.join(', ')}. The Claude Code LSP tool spawns each server by name from $PATH, so a binary that is missing OR only present in node_modules/.bin (a legacy devDep install) makes symbol search silently fall back to Grep. Reinstalling runs the global install so the binary lands on $PATH.`,
538
+ autoOk: false, // runs `npm install -g` and is the kind of action a user
539
+ // may have made deliberate (switching from npm pyright to system pyright);
540
540
  // keep the outer prompt + the per-server confirm both alive.
541
541
  run: async () => {
542
542
  const installer = new LspInstaller(state.cwd);
543
543
  const { installed, manual, skipped } = await installer.installServers(state.lspBroken);
544
- if (installed.length) UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
544
+ if (installed.length) {
545
+ UI.success(`Reinstalled: ${installed.map(i => i.name).join(', ')}`);
546
+ // If a reinstall keeps coming back broken, npm's global bin dir is
547
+ // likely not on $PATH — surface the escape so the user doesn't loop.
548
+ const recheck = installer.verifyServers(installed.map(i => i.name));
549
+ if (recheck.some(r => !r.ok)) {
550
+ UI.warning('Some servers are still unreachable after reinstall.');
551
+ UI.info('Check that npm\'s global bin dir is on $PATH: `npm prefix -g` → add its `/bin` to PATH.');
552
+ }
553
+ }
545
554
  for (const m of manual) {
546
555
  UI.warning(`System-level reinstall required for ${m.label}:`);
547
556
  console.log(` ${m.command}`);
@@ -592,6 +592,19 @@ async function update(options = {}) {
592
592
  UI.info(` • ${c.sha.slice(0, 7)} — ${c.subject}`);
593
593
  }
594
594
 
595
+ // If every overlay-able commit only touches files that ALREADY have a
596
+ // matching overlay in `.baldart/overlays/`, the divergent edit's intent
597
+ // is already preserved — `--reset` is safe and `scaffold-overlays`
598
+ // would only create redundant skeletons. Surface that so the user/agent
599
+ // picks the cheap, correct resolution. (v3.33.0)
600
+ const allCovered = overlayCommits.length > 0
601
+ && overlayCommits.every((c) => c.overlayCovered === true);
602
+ if (allCovered) {
603
+ UI.newline();
604
+ UI.success('These edits are already preserved in `.baldart/overlays/` — `--reset` is safe (your overlays are re-applied); scaffolding new overlays would be redundant.');
605
+ UI.info('Fast path: `npx baldart update --reset --yes --i-know`.');
606
+ }
607
+
595
608
  // Non-interactive resolution (agents / CI) via --on-divergence.
596
609
  if (divStrategy === 'scaffold-overlays') {
597
610
  UI.newline();
@@ -604,7 +617,7 @@ async function update(options = {}) {
604
617
  'Alternatively, push the edits upstream with `/baldart-push` if they are generic improvements.',
605
618
  ], 'cyan');
606
619
  emitUpdateJson({ ok: false, action: 'divergence-scaffolded',
607
- divergence_class: status.divergenceClass,
620
+ divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
608
621
  installed_before: status.installedVersion, remote_version: status.remoteVersion,
609
622
  reason: 'Scaffolded overlay skeleton(s); fill them in, then re-run.',
610
623
  next_command: 'npx baldart update --reset --yes --i-know' }, 0);
@@ -624,15 +637,23 @@ async function update(options = {}) {
624
637
  UI.error('Custom edits present — refusing to resolve them unattended under --yes.');
625
638
  UI.info('Re-run with one of:');
626
639
  UI.list([
627
- '--on-divergence scaffold-overlays → auto-create overlay skeleton(s), then stop so you can fill them',
640
+ allCovered
641
+ ? '--reset --yes --i-know → SAFE here: these edits are already in `.baldart/overlays/` (recommended)'
642
+ : '--on-divergence scaffold-overlays → auto-create overlay skeleton(s), then stop so you can fill them',
628
643
  '--on-divergence pull → keep the commits and merge the update (non-destructive)',
629
- '--reset --yes --i-know → discard .framework/ edits and reinstall clean (destructive)',
644
+ allCovered
645
+ ? '--on-divergence scaffold-overlays → would create redundant skeletons (edits already covered)'
646
+ : '--reset --yes --i-know → discard .framework/ edits and reinstall clean (destructive)',
630
647
  ], 'cyan');
631
648
  emitUpdateJson({ ok: false, action: 'divergence-refused',
632
- divergence_class: status.divergenceClass,
649
+ divergence_class: status.divergenceClass, overlay_already_covered: allCovered,
633
650
  installed_before: status.installedVersion, remote_version: status.remoteVersion,
634
- reason: 'Custom edits present — refusing to resolve them unattended under --yes.',
635
- next_command: 'npx baldart update --json --yes --on-divergence scaffold-overlays' }, 1);
651
+ reason: allCovered
652
+ ? 'Custom edits present but already preserved in .baldart/overlays/ `--reset --yes --i-know` is the safe resolution.'
653
+ : 'Custom edits present — refusing to resolve them unattended under --yes.',
654
+ next_command: allCovered
655
+ ? 'npx baldart update --reset --yes --i-know'
656
+ : 'npx baldart update --json --yes --on-divergence scaffold-overlays' }, 1);
636
657
  } else {
637
658
  const choice = await UI.select('How would you like to proceed?', [
638
659
  { name: 'Scaffold overlay skeleton(s) now (recommended — then fill them in & re-run)', value: 'overlay' },
@@ -149,6 +149,45 @@ const PATH_FIXTURES = [
149
149
  },
150
150
  ];
151
151
 
152
+ // overlayRelForFrameworkFile(p) — pure mapping of a touched .framework/ payload
153
+ // file to the consumer overlay path that would preserve a customization of it.
154
+ // Mirrors src/commands/overlay.js conventions (overlayPath). null when no
155
+ // overlay maps to the file. Used by overlayCoversTouched() to flag commits
156
+ // whose intent is already preserved (→ `--reset` safe, scaffold redundant).
157
+ const path = require('path');
158
+ const OVERLAY_REL_FIXTURES = [
159
+ {
160
+ name: 'skill SKILL.md → flat overlay',
161
+ file: '.framework/framework/.claude/skills/ui-design/SKILL.md',
162
+ expected: path.join('.baldart', 'overlays', 'ui-design.md'),
163
+ },
164
+ {
165
+ name: 'skill nested script → same flat overlay',
166
+ file: '.framework/framework/.claude/skills/ui-design/scripts/foo.sh',
167
+ expected: path.join('.baldart', 'overlays', 'ui-design.md'),
168
+ },
169
+ {
170
+ name: 'agent .md → agents/ overlay',
171
+ file: '.framework/framework/.claude/agents/ui-expert.md',
172
+ expected: path.join('.baldart', 'overlays', 'agents', 'ui-expert.md'),
173
+ },
174
+ {
175
+ name: 'command .md → commands/ overlay',
176
+ file: '.framework/framework/.claude/commands/check.md',
177
+ expected: path.join('.baldart', 'overlays', 'commands', 'check.md'),
178
+ },
179
+ {
180
+ name: 'src file → null (no overlay maps)',
181
+ file: '.framework/src/utils/git.js',
182
+ expected: null,
183
+ },
184
+ {
185
+ name: 'nested agent path → null (agents are flat .md)',
186
+ file: '.framework/framework/.claude/agents/sub/x.md',
187
+ expected: null,
188
+ },
189
+ ];
190
+
152
191
  let pass = 0, fail = 0;
153
192
 
154
193
  console.log('─── Subject classifier ───');
@@ -187,5 +226,48 @@ for (const f of AGGREGATE_FIXTURES) {
187
226
  }
188
227
  }
189
228
 
229
+ console.log('\n─── Overlay path mapping ───');
230
+ for (const f of OVERLAY_REL_FIXTURES) {
231
+ const actual = GitUtils.overlayRelForFrameworkFile(f.file);
232
+ if (actual === f.expected) {
233
+ pass++;
234
+ console.log(` ✓ ${f.name} → ${actual}`);
235
+ } else {
236
+ fail++;
237
+ console.log(` ✗ ${f.name} → ${actual} (expected ${f.expected})`);
238
+ }
239
+ }
240
+
241
+ // overlayCoversTouched(touched) — fs-backed: true only when EVERY touched file
242
+ // maps to an EXISTING overlay. This is the `overlayCovered` field that flows
243
+ // (via classifyDivergence → getUpdateStatus.divergenceCommits, a direct
244
+ // pass-through) into update.js's "reset is safe" branch. Lock the fs semantics.
245
+ console.log('\n─── overlayCoversTouched (fs-backed) ───');
246
+ {
247
+ const fsMod = require('fs');
248
+ const os = require('os');
249
+ const tmp = fsMod.mkdtempSync(path.join(os.tmpdir(), 'baldart-ovl-'));
250
+ fsMod.mkdirSync(path.join(tmp, '.baldart', 'overlays', 'agents'), { recursive: true });
251
+ fsMod.writeFileSync(path.join(tmp, '.baldart', 'overlays', 'agents', 'coder.md'), '---\n---\n');
252
+ const g2 = new GitUtils(tmp);
253
+ const cases = [
254
+ { name: 'touched file has an existing overlay → covered',
255
+ touched: ['.framework/framework/.claude/agents/coder.md'], expected: true },
256
+ { name: 'touched file has NO overlay → not covered',
257
+ touched: ['.framework/framework/.claude/agents/ui-expert.md'], expected: false },
258
+ { name: 'one covered + one uncovered → not covered (every)',
259
+ touched: ['.framework/framework/.claude/agents/coder.md',
260
+ '.framework/framework/.claude/agents/ui-expert.md'], expected: false },
261
+ { name: 'empty touched → not covered',
262
+ touched: [], expected: false },
263
+ ];
264
+ for (const c of cases) {
265
+ const actual = g2.overlayCoversTouched(c.touched);
266
+ if (actual === c.expected) { pass++; console.log(` ✓ ${c.name} → ${actual}`); }
267
+ else { fail++; console.log(` ✗ ${c.name} → ${actual} (expected ${c.expected})`); }
268
+ }
269
+ fsMod.rmSync(tmp, { recursive: true, force: true });
270
+ }
271
+
190
272
  console.log(`\nResult: ${pass} pass, ${fail} fail`);
191
273
  process.exit(fail > 0 ? 1 : 0);
package/src/utils/git.js CHANGED
@@ -256,6 +256,47 @@ class GitUtils {
256
256
  return allOverlayable ? 'custom-overlay-able' : 'custom-other';
257
257
  }
258
258
 
259
+ // Maps a touched `.framework/` payload file to the consumer overlay path that
260
+ // would preserve a customization of it, or null when no overlay maps to it.
261
+ // Mirrors the conventions in src/commands/overlay.js (overlayPath):
262
+ // .framework/framework/.claude/skills/<name>/... → .baldart/overlays/<name>.md
263
+ // .framework/framework/.claude/agents/<name>.md → .baldart/overlays/agents/<name>.md
264
+ // .framework/framework/.claude/commands/<name>.md → .baldart/overlays/commands/<name>.md
265
+ // Pure (no fs) so it stays unit-testable.
266
+ static overlayRelForFrameworkFile(p) {
267
+ const prefix = '.framework/framework/.claude/';
268
+ if (!p.startsWith(prefix)) return null;
269
+ const rest = p.slice(prefix.length);
270
+ const slash = rest.indexOf('/');
271
+ if (slash < 0) return null;
272
+ const dir = rest.slice(0, slash);
273
+ if (dir === 'skills') {
274
+ const name = rest.slice(slash + 1).split('/')[0];
275
+ if (!name) return null;
276
+ return path.join('.baldart', 'overlays', `${name}.md`);
277
+ }
278
+ if (dir === 'agents' || dir === 'commands') {
279
+ const file = rest.slice(slash + 1);
280
+ if (!file || file.includes('/')) return null; // flat <name>.md files only
281
+ const name = file.replace(/\.md$/, '');
282
+ if (!name) return null;
283
+ return path.join('.baldart', 'overlays', dir, `${name}.md`);
284
+ }
285
+ return null;
286
+ }
287
+
288
+ // True when EVERY overlay-able file touched by the commit already has an
289
+ // existing overlay in `.baldart/overlays/` — i.e. the divergent edit's intent
290
+ // is already preserved, so `--reset` is safe and `scaffold-overlays` would
291
+ // only create redundant skeletons.
292
+ overlayCoversTouched(touched) {
293
+ if (!touched || !touched.length) return false;
294
+ return touched.every((p) => {
295
+ const rel = GitUtils.overlayRelForFrameworkFile(p);
296
+ return rel ? fs.existsSync(path.join(this.cwd, rel)) : false;
297
+ });
298
+ }
299
+
259
300
  async classifyDivergence() {
260
301
  // Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
261
302
  const commits = [];
@@ -297,7 +338,10 @@ class GitUtils {
297
338
  } catch (_) { /* leave empty → custom-other */ }
298
339
  category = this.classifyCommitByPaths(touched);
299
340
  }
300
- commits.push({ sha, subject, category, parents, touched: touched || [] });
341
+ const overlayCovered = category === 'custom-overlay-able'
342
+ ? this.overlayCoversTouched(touched)
343
+ : false;
344
+ commits.push({ sha, subject, category, parents, touched: touched || [], overlayCovered });
301
345
  }
302
346
  } catch (_) {
303
347
  return { class: 'unknown', commits: [] };
@@ -384,6 +384,38 @@ function unregisterAll(cwd = process.cwd()) {
384
384
  return { malformed: false, path: settingsPath, results };
385
385
  }
386
386
 
387
+ // ---------------------------------------------------------------------------
388
+ // Verify — post-flight assertion that every active hook is registered
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Confirms that every active hook in HOOK_REGISTRY is present in
393
+ * `.claude/settings.json`. Pure read (delegates to `getStatus`). Designed as
394
+ * the post-flight gate for `add` / `update --reset`: the install must never
395
+ * declare success while a hook is missing (the silent partial-install class,
396
+ * v3.27.1 / mayo 3.28→3.31). Commented-out registry entries (e.g.
397
+ * capture-detect) are excluded for free — `getStatus` only iterates the array.
398
+ *
399
+ * A malformed settings.json counts as a failure: registration was skipped, so
400
+ * the hooks are not verifiably present.
401
+ *
402
+ * @param {string} [cwd]
403
+ * @returns {{ ok: boolean, malformed: boolean, missing: string[], path: string }}
404
+ */
405
+ function verifyAll(cwd = process.cwd()) {
406
+ const status = getStatus(cwd);
407
+ if (status.malformed) {
408
+ return {
409
+ ok: false,
410
+ malformed: true,
411
+ missing: HOOK_REGISTRY.map((d) => d.id),
412
+ path: status.path,
413
+ };
414
+ }
415
+ const missing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
416
+ return { ok: missing.length === 0, malformed: false, missing, path: status.path };
417
+ }
418
+
387
419
  // ---------------------------------------------------------------------------
388
420
  // Drift prompt helper (interactive Keep / Replace / Show diff)
389
421
  // ---------------------------------------------------------------------------
@@ -434,6 +466,7 @@ module.exports = {
434
466
  HOOK_REGISTRY,
435
467
  SETTINGS_FILE,
436
468
  getStatus,
469
+ verifyAll,
437
470
  registerAll,
438
471
  unregisterAll,
439
472
  createDriftPrompt,
@@ -4,10 +4,12 @@ const path = require('path');
4
4
  /**
5
5
  * Python LSP adapter — Pyright.
6
6
  *
7
- * Pyright ships as both an npm package and a pip package. We default to the
8
- * npm devDep route so installs stay project-local and version-pinned without
9
- * touching the user's Python env. Users who already drive Python via pip/uv
10
- * can override by removing pyright from devDeps and installing system-wide.
7
+ * Pyright ships as both an npm package and a pip package. We default to a
8
+ * global npm install so the `pyright` binary lands on `$PATH` — the consumer
9
+ * (Claude Code's LSP tool) spawns it by name from `$PATH`, so a project-local
10
+ * devDep under `node_modules/.bin/` (the pre-v3.33.1 behaviour) was never
11
+ * reachable and the layer silently fell back to Grep. Users who already drive
12
+ * Python via pip/uv can override with an overlay and a system-managed pyright.
11
13
  *
12
14
  * Detection signal: pyproject.toml, setup.py, setup.cfg, requirements.txt,
13
15
  * Pipfile, or a top-level .py source tree.
@@ -18,11 +20,13 @@ class PythonAdapter {
18
20
  get name() { return 'python'; }
19
21
  get label() { return 'Python (Pyright)'; }
20
22
  get binary() { return 'pyright'; }
21
- get installMode() { return 'npm-dev'; }
23
+ get installMode() { return 'npm-global'; }
22
24
  get npmPackage() { return 'pyright'; }
23
25
 
24
- installCommand() { return 'npm install --save-dev pyright'; }
25
- verifyCommand() { return 'npx --no-install pyright --version'; }
26
+ installCommand() { return 'npm install -g pyright'; }
27
+ // Functional probe; reachability on $PATH is checked by the installer via
28
+ // `command -v` (mirrors how the consumer spawns the binary).
29
+ verifyCommand() { return 'pyright --version'; }
26
30
  claudePluginId() { return 'pyright-lsp@claude-plugins-official'; }
27
31
 
28
32
  static detect(cwd = process.cwd()) {
@@ -5,8 +5,13 @@ const path = require('path');
5
5
  * TypeScript / JavaScript LSP adapter.
6
6
  *
7
7
  * Language server: `typescript-language-server` (Node, npm).
8
- * Install mode: project-local devDepkeeps the binary inside the consumer
9
- * repo's node_modules and avoids polluting the user's global env.
8
+ * Install mode: global npm install — the binary MUST land on `$PATH` because
9
+ * the consumer (Claude Code's LSP tool) spawns it by name from `$PATH`, not
10
+ * via `npx`/node_modules resolution. A project-local devDep (the pre-v3.33.1
11
+ * behaviour) installed into `node_modules/.bin/` which is NOT on `$PATH`, so
12
+ * every LSP spawn failed with `Executable not found in $PATH` while the
13
+ * `npx --no-install` verify falsely reported success — a dead layer masked by
14
+ * the silent Grep fallback.
10
15
  *
11
16
  * Detection signal: presence of `tsconfig.json`, `jsconfig.json`, or a
12
17
  * `package.json` referencing typescript in deps/devDeps.
@@ -18,19 +23,29 @@ class TypeScriptAdapter {
18
23
  get label() { return 'TypeScript / JavaScript'; }
19
24
  get binary() { return 'typescript-language-server'; }
20
25
 
21
- /** 'npm-dev' = npm devDependency, 'system' = printed install instructions. */
22
- get installMode() { return 'npm-dev'; }
26
+ /**
27
+ * 'npm-global' = installed globally via npm so it lands on `$PATH`.
28
+ * 'system' = printed install instructions (non-npm toolchains).
29
+ * Treated identically to system mode by the installer except that npm-global
30
+ * adapters are safe to auto-execute (no sudo, no toolchain assumptions).
31
+ */
32
+ get installMode() { return 'npm-global'; }
23
33
 
24
- /** npm package to install when mode='npm-dev'. */
34
+ /** npm package(s) to install when mode='npm-global'. */
25
35
  get npmPackage() { return 'typescript-language-server typescript'; }
26
36
 
27
37
  /** Shell command the user (or installer) should run. */
28
38
  installCommand() {
29
- return 'npm install --save-dev typescript-language-server typescript';
39
+ return 'npm install -g typescript-language-server typescript';
30
40
  }
31
41
 
32
- /** Verify the binary is reachable (no execution side effects). */
33
- verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
42
+ /**
43
+ * Functional probe: invokes the binary the SAME way the consumer spawns it —
44
+ * by name from `$PATH`. Reachability on `$PATH` is the actual contract the
45
+ * consumer depends on; `LspInstaller.verifyServers()` checks it explicitly
46
+ * via `command -v` and only runs this probe once the binary resolves.
47
+ */
48
+ verifyCommand() { return 'typescript-language-server --version'; }
34
49
 
35
50
  /**
36
51
  * Claude Code code-intelligence plugin id, if any. Null = no companion plugin.
@@ -63,7 +63,7 @@ class LspInstaller {
63
63
  /**
64
64
  * Build a recommendation list from detected languages. Each entry carries
65
65
  * the install command and the install mode so the UI can decide whether
66
- * to run it automatically (npm-dev) or just print it (system).
66
+ * to run it automatically (npm-global) or just print it (system).
67
67
  */
68
68
  recommend(langs = this.detectLanguages()) {
69
69
  return langs.map(name => {
@@ -82,7 +82,8 @@ class LspInstaller {
82
82
 
83
83
  /**
84
84
  * Install one or more language servers. Returns `{ installed, skipped, manual }`.
85
- * - npm-dev adapters: executed in-process (npm install --save-dev …).
85
+ * - npm-global adapters: executed in-process (npm install -g …) so the
86
+ * binary lands on `$PATH` where the consumer can spawn it.
86
87
  * - system adapters: never executed automatically; surfaced under `manual`
87
88
  * so the caller can print the command for the user to run.
88
89
  *
@@ -335,25 +336,96 @@ class LspInstaller {
335
336
  }
336
337
 
337
338
  /**
338
- * Verify each named server's binary is reachable. Returns
339
- * `[{ name, ok, output | error }]`. Never throws.
339
+ * Is `binary` resolvable on `$PATH`? This mirrors EXACTLY how the consumer
340
+ * (Claude Code's LSP tool) spawns the language server — by name, from
341
+ * `$PATH`. Using `npx --no-install` here instead would resolve from
342
+ * `node_modules/.bin/` and produce a false positive: the verify passes while
343
+ * every real LSP spawn fails with `Executable not found in $PATH`. That
344
+ * resolution mismatch was the root cause of the pre-v3.33.1 dead-layer bug.
345
+ */
346
+ _isOnPath(binary) {
347
+ try {
348
+ execSync(`command -v ${binary}`, {
349
+ cwd: this.cwd,
350
+ stdio: ['ignore', 'pipe', 'pipe'],
351
+ timeout: 5000,
352
+ shell: '/bin/sh'
353
+ });
354
+ return true;
355
+ } catch { return false; }
356
+ }
357
+
358
+ /**
359
+ * For npm-managed adapters: is the binary installed project-locally (in
360
+ * `node_modules/.bin/`) even though it isn't on `$PATH`? This is the precise
361
+ * fingerprint of the legacy `npm install --save-dev` install — present, but
362
+ * unreachable by the consumer. System adapters have no local notion → false.
363
+ */
364
+ _isInstalledLocally(adapter) {
365
+ if (adapter.installMode === 'system') return false;
366
+ try {
367
+ execSync(`npx --no-install ${adapter.binary} --version`, {
368
+ cwd: this.cwd,
369
+ stdio: ['ignore', 'pipe', 'pipe'],
370
+ timeout: 8000
371
+ });
372
+ return true;
373
+ } catch { return false; }
374
+ }
375
+
376
+ /**
377
+ * Verify each named server is reachable the way the consumer spawns it.
378
+ * Returns `[{ name, ok, status, output? , error?, fix? }]`. Never throws.
379
+ *
380
+ * `status` is one of:
381
+ * - `verified` — on `$PATH` AND the functional probe passed.
382
+ * - `installed-but-unreachable` — installed locally (node_modules) but not
383
+ * on `$PATH`; the consumer cannot spawn it. `fix` carries the command
384
+ * that puts it on `$PATH`. Treated as broken (`ok:false`) — same honesty
385
+ * bar already applied to system adapters whose binary isn't installed.
386
+ * - `unreachable` — on `$PATH` but the probe errored (corrupt
387
+ * install / wrong flag). Rare.
388
+ * - `not-installed` — neither on `$PATH` nor locally installed.
389
+ * - `unknown-adapter` — name not in the registry.
340
390
  */
341
391
  verifyServers(serverNames) {
342
392
  return serverNames.map(name => {
343
393
  let a;
344
394
  try { a = getAdapter(name, this.cwd); }
345
- catch (err) { return { name, ok: false, error: err.message }; }
395
+ catch (err) { return { name, ok: false, status: 'unknown-adapter', error: err.message }; }
346
396
 
347
- try {
348
- const out = execSync(a.verifyCommand(), {
349
- cwd: this.cwd,
350
- stdio: ['ignore', 'pipe', 'pipe'],
351
- timeout: 8000
352
- }).toString().trim();
353
- return { name, ok: true, output: out };
354
- } catch (err) {
355
- return { name, ok: false, error: (err.message || '').split('\n')[0] };
397
+ if (this._isOnPath(a.binary)) {
398
+ try {
399
+ const out = execSync(a.verifyCommand(), {
400
+ cwd: this.cwd,
401
+ stdio: ['ignore', 'pipe', 'pipe'],
402
+ timeout: 8000
403
+ }).toString().trim();
404
+ return { name, ok: true, status: 'verified', output: out };
405
+ } catch (err) {
406
+ return {
407
+ name, ok: false, status: 'unreachable',
408
+ error: (err.message || '').split('\n')[0],
409
+ fix: a.installCommand()
410
+ };
411
+ }
412
+ }
413
+
414
+ // Not on $PATH. Distinguish "installed locally but unreachable" (the
415
+ // documented false-positive scenario) from "not installed at all".
416
+ if (this._isInstalledLocally(a)) {
417
+ return {
418
+ name, ok: false, status: 'installed-but-unreachable',
419
+ error: `${a.binary} is installed locally but not on $PATH; the Claude Code LSP tool spawns it by name from $PATH`,
420
+ fix: a.installCommand()
421
+ };
356
422
  }
423
+
424
+ return {
425
+ name, ok: false, status: 'not-installed',
426
+ error: `${a.binary} not found on $PATH`,
427
+ fix: a.installCommand()
428
+ };
357
429
  });
358
430
  }
359
431
  }
@@ -1,4 +1,5 @@
1
1
  const crypto = require('crypto');
2
+ const fs = require('fs');
2
3
  const yaml = require('js-yaml');
3
4
 
4
5
  /**
@@ -41,6 +42,45 @@ function computeBaseFileSha(content) {
41
42
  return shortSha(content);
42
43
  }
43
44
 
45
+ /**
46
+ * Idempotently backfill `base_file_sha` into an overlay file's frontmatter so
47
+ * `baldart overlay drift` is deterministic for EVERY overlay — not only those
48
+ * freshly scaffolded by `overlay scaffold` (which already writes it). Overlays
49
+ * authored by hand or by pre-v3.19.0 versions are otherwise "unknown" to drift
50
+ * (overlay.js drift § no base_file_sha). The merger knows the base file, so it
51
+ * stamps the sha here at `add`/`update` time.
52
+ *
53
+ * Contract: ADDITIVE-ONLY. Never overwrites an existing `base_file_sha`. Only
54
+ * touches a file that already has a leading YAML frontmatter block — never
55
+ * fabricates frontmatter on a file that lacks it. Fail-safe: any I/O error is
56
+ * swallowed (returns `changed:false`) so a backfill can never block a merge.
57
+ *
58
+ * @param {string} overlayPath absolute path to `.baldart/overlays/<…>.md`
59
+ * @param {string} baseSha 12-char hex from computeBaseFileSha(baseContent)
60
+ * @returns {{ changed: boolean, reason: string }}
61
+ */
62
+ function ensureBaseFileShaInOverlay(overlayPath, baseSha) {
63
+ if (!baseSha) return { changed: false, reason: 'no-base-sha' };
64
+ let content;
65
+ try { content = fs.readFileSync(overlayPath, 'utf8'); }
66
+ catch (_) { return { changed: false, reason: 'unreadable' }; }
67
+ // Leading frontmatter block only (optionally after a BOM / blank lines).
68
+ const m = content.match(/^(\uFEFF?\s*)---\n([\s\S]*?)\n---(\r?\n|$)/);
69
+ if (!m) return { changed: false, reason: 'no-frontmatter' };
70
+ const fmBody = m[2];
71
+ if (/^\s*base_file_sha\s*:/m.test(fmBody)) {
72
+ return { changed: false, reason: 'already-present' };
73
+ }
74
+ // Quote the value — a 12-char hex can be all digits, which YAML would
75
+ // otherwise parse as a number (matches overlay.js buildFrontmatter).
76
+ const newFmBody = `${fmBody.replace(/\n+$/, '')}\nbase_file_sha: "${baseSha}"`;
77
+ const replaced = `${m[1]}---\n${newFmBody}\n---${m[3]}`;
78
+ const updated = content.slice(0, m.index) + replaced + content.slice(m.index + m[0].length);
79
+ try { fs.writeFileSync(overlayPath, updated, 'utf8'); }
80
+ catch (_) { return { changed: false, reason: 'unwritable' }; }
81
+ return { changed: true, reason: 'backfilled' };
82
+ }
83
+
44
84
  function buildMarker({ kind, name, baseVersion, baseSha, overlaySha }) {
45
85
  const baseShaField = baseSha ? ` base_sha=${baseSha}` : '';
46
86
  return `${MARKER_PREFIX} kind=${kind} name=${name} base_version=${baseVersion}${baseShaField} overlay_sha=${overlaySha}
@@ -239,5 +279,6 @@ module.exports = {
239
279
  parseMarkdown,
240
280
  shortSha,
241
281
  computeBaseFileSha,
282
+ ensureBaseFileShaInOverlay,
242
283
  MARKER_PREFIX
243
284
  };
@@ -484,8 +484,17 @@ class SymlinkUtils {
484
484
  }
485
485
 
486
486
  _generateOverlayedFile({ kind, name, fwAbsolute, overlayAbs, linkPath, lstat, frameworkVersion, aggregate, adapter, targetRel }) {
487
- const { mergeOverlay, readMarker, isGeneratedFile } = require('./overlay-merger');
487
+ const { mergeOverlay, isGeneratedFile, computeBaseFileSha, ensureBaseFileShaInOverlay } = require('./overlay-merger');
488
488
  const baseContent = fs.readFileSync(fwAbsolute, 'utf8');
489
+ // Backfill base_file_sha into the overlay frontmatter so `overlay drift` is
490
+ // deterministic for EVERY overlay, not just freshly-scaffolded ones
491
+ // (additive-only, never overwrites). Done BEFORE reading overlayContent so
492
+ // the marker's overlay_sha already reflects it — no one-time regen churn.
493
+ // (v3.33.0)
494
+ try {
495
+ const bf = ensureBaseFileShaInOverlay(overlayAbs, computeBaseFileSha(baseContent));
496
+ if (bf.changed) UI.info(`[${adapter.label}] ${kind} ${name}: backfilled base_file_sha into overlay frontmatter.`);
497
+ } catch (_) { /* non-fatal — never block a merge on backfill */ }
489
498
  const overlayContent = fs.readFileSync(overlayAbs, 'utf8');
490
499
  const merged = mergeOverlay({ kind, name, baseContent, overlayContent, frameworkVersion });
491
500