# Changelog

All notable changes to pi-lens will be documented in this file.

## [Unreleased]

### Added

### Changed

### Fixed

## [3.8.70] - 2026-07-14

### Added

- **Review-graph symbols now carry an owner-qualified display name, and `calls`/`references` edges gain two more resolution tiers** (refs #655 — second, still narrowly-scoped slice; `BehaviorFact` extraction, `SymbolMetadataVNext`, and per-language behavior adapters remain untouched) — phase 1 (#659) fixed symbol-node ID collisions but deliberately left qualified ownership (`ClassName.method`) and call-edge resolution at just `"exact"`/`"name-only"`. This slice adds both, reusing existing machinery rather than building parallel logic: (1) **Qualified names**: a new shared containment helper, `clients/symbol-containment.ts`'s `findOwnerName`, implements the SAME strict-range-containment/smallest-span algorithm `module-report.ts`'s outline nesting (`nestEntries`, #301) already uses, so a new `ReviewGraphNode.qualifiedName` field (e.g. `UserService.run`) is computed identically for tree-sitter-based languages (`builder.ts`'s `addTreeSitterFile`, over the file's own deduped symbol list) and jsts (`dispatch/facts/function-facts.ts`'s `functionFactProvider`, which now also collects class/interface ranges in the SAME already-parsed-tree walk and stamps each `FunctionSummary.owner`, since jsts graph nodes come from a different, lighter tree-sitter integration than module-report's own extractor — literally sharing one function call across the two wasn't possible, only the algorithm was factored out). `module-report.ts`'s `resolveUsedBy` now renders `node.qualifiedName ?? node.symbolName`, so two same-file, same-named methods on different classes are distinguishable in `usedBy`/`blastRadius` output without cross-referencing line numbers — and, as a side benefit, its caller-dedup key (previously bare name) no longer risks conflating two different classes' same-named callers. Cross-tool consistency was a hard requirement: a qualified name rendered here is guaranteed to be a valid `Class.method` input to `read_symbol`'s own resolver (`resolveQualifiedMatch`) because both derive from the same "smallest strictly-containing declaration" notion of ownership — verified by a new consistency test, not just asserted. (2) **Two new `resolution` tiers**, both computed only for jsts in this slice (the one ingestion path with import-specifier names and same-file type hints already cheaply available; other languages continue to fall back to `"exact"`/`"name-only"` — a deliberate, documented scope boundary, not an oversight): `"import"` narrows a bare-name callee to the specific in-project file its caller's own `import { x } from "./service.js"` names, via a new `importHintFile` edge-metadata hint threaded from `addJsTsFile` into `resolveDeferredSymbolEdges`, resolved when that ONE file has exactly one same-named symbol (falls through to the existing global-uniqueness `"exact"` check, then `"name-only"`, when it doesn't uniquely narrow). `"receiver-type"` resolves a `obj.method()` call directly to a specific class's method when the receiver's type is determinable from the SAME function's tree-sitter parse — a `const x = new ClassName()` assignment or a typed parameter — via two new, narrowly-scoped `function-facts.ts` collectors (`collectMemberCallSites`, `collectReceiverTypes`) covering only the clearest common shapes (chained/computed/`this.`-receiver calls, cross-function flow, and generics are conservatively left as the pre-existing "external" classification, never guessed). Both tiers fail closed: an owner+name pair that's ITSELF ambiguous (e.g. two same-named methods sharing one qualified name) resolves to a dedicated qualified-name placeholder tagged `"name-only"` rather than picking one of the 2+ candidates, and a genuinely undeterminable receiver type keeps today's plain external classification with no resolution tag at all — never a wrong `"exact"`/`"import"`/`"receiver-type"` claim. Fixed as a necessary side effect (not scope creep): `localImportToFile` never stripped a `.js`/`.jsx`/`.mjs`/`.cjs` extension from an import specifier before trying sibling extensions, so the extremely common TS-as-ESM pattern this very codebase uses everywhere (`import { x } from "./service.js"` pointing at a real `service.ts`) silently failed to resolve to a real file — a latent gap in the existing file→file `imports` edge that the new `"import"` tier depends on directly. Investigated and confirmed UNAFFECTED: `read_enclosing` (`clients/module-report.ts`'s `readEnclosing`) is purely positional (file + line, fresh tree-sitter extraction per call) and touches no symbol ID, resolution, or qualified-name machinery. New tests: `tests/clients/review-graph/qualified-name-resolution.test.ts` (qualified-name rendering for a same-file different-class collision, the read_symbol/module-report qualified-name-format consistency check, both new resolution tiers each with a positive resolving case and a case that correctly stays `"name-only"` rather than over-claiming); one pre-existing `module-report.test.ts` assertion updated from `"exact"` to `"import"` (a strictly more specific, and now correctly reported, tier for that scenario).
- **`lsp_diagnostics`/`lens_diagnostics` sweeps now warm up the primary server once before the per-file loop starts, instead of the first few files eating cold-spawn-adjacent timeouts** (closes #667) — live dogfooding on a 100-file `lsp_diagnostics` sweep found the first 5 files touched all hit the exact 1000ms per-file timeout with `serverCountReady:1`, while every file from the 6th on was clean and fast (~450-500ms). `serverCountReady:1` only proves the server process spawned and passed the LSP `initialize` handshake — it does NOT prove the server can usefully answer a diagnostics request yet; tsserver-style servers can still be loading/indexing the project internally for seconds after that, and neither sweep tool had any check for this before starting its per-file loop, so whichever files landed first paid that cost individually. New `LSPService.ensureWarmForSweep` (`clients/lsp/index.ts`) is the ONE shared fix both tools route through (they already share `groupFilesByPrimaryServer`/`runPerServerGroups` from #631): a real "has this server already answered a confirmed diagnostics touch this session" check (a new `demonstratedReady` key set, populated by `touchFile` only on a non-inconclusive diagnostics-mode result — strictly stronger than `isAlive()`/spawned/handshake-complete), not just a guessed delay. Cold → performs exactly one bounded warm-up `touchFile` round trip against one representative file from the group, with its own generous, one-time, env-tunable budget (`PI_LENS_LSP_WARMUP_TIMEOUT_MS`, default 20s) distinct from the per-file sweep budget — then the normal sweep proceeds from an already-demonstrated-ready server. Already-warm (from an earlier touch/sweep this session) → a no-op: no extra round trip, no added latency. Wired into `runWorkspaceDiagnostics` (the engine behind `lens_diagnostics mode=full`) per server group, ahead of the per-file pre-open/touch loop but AFTER the opt-in whole-group `workspace/diagnostic` pull fast path (which already gets its own generous per-server budget covering the whole group in one shot, so it doesn't need a separate warm-up); and into `tools/lsp-diagnostics.ts`'s `mapWithConcurrency` (the shared batch/directory-scan primitive), called once per server group before its own per-file loop. Does NOT change the existing per-file wait budgets or confirmed/unconfirmed contract (#242/#611/#634) — purely a pre-loop addition. New tests: `tests/clients/lsp/sweep-warmup.test.ts` covers the pure decision logic directly against `ensureWarmForSweep` (a cold server performs exactly one warm-up round trip then is treated as warm; an already-warm server from a prior `touchFile` is a no-op) and integration-style coverage through `runWorkspaceDiagnostics` itself (a cold sweep pays exactly one extra warm-up round trip on top of its normal per-file touches; an already-warm sweep pays none) — guarding specifically against the warm-up regressing into a mandatory extra round trip on every sweep.
- **Registry-independent backstop orphan sweep** (closes #658) — live dogfooding found 3 `opengrep.exe`/`opengrep-core.exe` process chains with confirmed-dead parents that survived every `session_start` sweep for ~2 days. Root cause: `clients/instance-reaper.ts`'s existing `sweepOrphans` (#472) is entirely registry-driven — it only ever considers pids currently listed in some instance's `lspChildren[]`, so once a child's registry reference is lost (a stale-heartbeat entry removal per #525's asymmetric design, or a `killProcessTree` call that failed silently), that child becomes permanently invisible to every future sweep; the reaper never asks "what's actually running on this machine." New `sweepUntrackedOrphans` runs as a strictly additive SECOND layer alongside the unchanged registry-driven sweep: it enumerates live OS processes by known pi-lens-managed binary name (`MANAGED_BINARY_NAMES` — ast-grep, opengrep, opengrep-core, marksman, zizmor, typos-lsp, yaml-language-server, typescript-language-server, drawn from `clients/lsp/server.ts`'s spawn candidates) via one batched `Get-CimInstance Win32_Process` WQL query on Windows (reusing the same query pattern as the existing marker-search) or `ps -eo pid=,ppid=,args=` on POSIX, then applies a new pure decision function, `decideBackstopOrphanReaping` — a process is kill-eligible ONLY when it is NOT already tracked in any instance's `lspChildren[]` (deferred to the registry-driven reaper instead) AND its parent pid is confirmed dead via the existing identity-verified `realIsPidAlive` (never on an ambiguous/unresolvable parent pid, never on a live parent, never on binary name alone). Confirmed kills reuse the existing `killPidTree` tree-kill mechanism. Kill-attempt retry was considered per the issue's own framing: no new retry-tracking state was added — a silently-failed kill leaves the process untracked with a still-dead parent, so it stays classified as backstop-kill-eligible and the very next `session_start` sweep naturally retries it, with zero extra bookkeeping (documented in `sweepUntrackedOrphans`'s doc comment; a future hardening making `killPidTree` return a success signal for sharper logging is a reasonable follow-up, not required for correctness). Wired into `index.ts` alongside the existing `sweepOrphans()`/`registerInstance()` call site — fire-and-forget, never blocks or throws into `session_start`. New tests in `tests/clients/instance-reaper.test.ts` (`decideBackstopOrphanReaping`) mirror the existing pure/impure split: an untracked process with a confirmed-dead parent is kill-eligible; a live parent, an already-tracked pid, an unverifiable (zero/negative/NaN) parent pid, and a malformed self-parenting row are all never kill-eligible.
- **Review-graph symbol-node IDs are now collision-safe** (refs #655 — first, narrowly-scoped slice of a larger tracking issue; the rest — behavior facets, per-language adapters, `SymbolMetadataVNext` — is explicitly NOT part of this change) — `clients/review-graph/builder.ts` minted every symbol node's ID as `${file}:${name}`, so an overloaded function/method, two same-named methods on different classes in the same file, or a same-named nested function all collapsed onto ONE graph node; `pilens_module_report`'s `usedBy`/`blastRadius` sections (which read that node's incoming edges directly) could then silently merge or misattribute two genuinely different symbols' callers. Fix: a new shared helper, `clients/review-graph/symbol-id.ts`'s `buildSymbolId(file, name, kind, startLine)`, builds `${file}:${name}:${kind}:${startLine}` — enough to give every one of those concrete collision cases a distinct ID, since they always sit on different lines. Deliberately scoped DOWN from #655's full proposed `<file>:<qualified-name>:<kind>:<start-line>:<start-column>` shape: no qualified ownership (e.g. `ClassName.method` — needs an owner-chain the extractors don't compute uniformly today, real work #655 leaves for later) and no start column (review-graph's JS/TS symbols come from a different extractor, `dispatch/facts/function-facts.ts`, than module-report's own outline extractor, `tree-sitter-symbol-extractor.ts` — the two agree on start line for every function-like declaration but can diverge by a few columns for arrow functions; line already resolves every in-scope collision case without needing column, and keeps IDs comparable across both extractors). Also fixed a related dormant bug the new ID shape surfaced: some grammars' symbol queries (e.g. python's) match one real declaration under two patterns — a class method also matches the generic top-level `function_definition` rule — yielding two `Symbol` records identical in name/line/column but differing only in `kind`; the old name-only ID silently collapsed these back into one node, so the new kind-qualified ID needed a same-position dedupe (`dedupeSamePositionSymbols`, preferring the more specific kind) to avoid manufacturing phantom duplicate graph nodes. `clients/review-graph/types.ts`'s `ReviewGraphEdge` gains an optional `resolution?: "exact" | "name-only"` field (a narrow slice of #655's full call-edge-metadata proposal): a `calls`/`references` edge starts `"name-only"` (bare-name match, no scope/type info), and `resolveDeferredSymbolEdges` upgrades it to `"exact"` only when exactly one same-named real symbol exists graph-wide — otherwise it's left `"name-only"` so a consumer knows the target may be a name-collision guess. `clients/module-report.ts`'s `ModuleSymbolUsedBy` surfaces that same `resolution` field, and its internal `toEntry` now builds its own graph-node lookup key via the same shared `buildSymbolId` helper (previously a separately-hand-assembled `${normalizedPath}:${sym.name}` string) — with one language-specific wrinkle: for jsts, the lookup always uses kind `"function"` regardless of the outline's own finer-grained `sym.kind` ("method" etc.), because builder.ts's jsts graph nodes come from `function-facts.ts`, which has no method/function distinction. `REVIEW_GRAPH_VERSION` bumped `v3` → `v4` (same safe-rebuild mechanism as the v2→v3 #260 bump: a v3 snapshot's nodes/edges still use the old ID shape throughout, so `loadPersistedGraph` rejects it outright rather than merging it with newly-built v4 IDs). Architecturally, this is an IN-PLACE, versioned extension of the existing review graph — no parallel "v2 graph" — per #655's own stated decision to enrich one graph and expose projections over it rather than build a second production-code model; the existing `REVIEW_GRAPH_VERSION`/`buildGeneration` machinery already exists for exactly this kind of safe schema change. New tests: `tests/clients/review-graph/symbol-id.test.ts` (the old scheme's literal collision, the new scheme's disambiguation by kind+line, and an end-to-end same-file-different-class Python method case demonstrating two distinct graph nodes); `tests/clients/review-graph.service.test.ts` gains a v3-snapshot migration-safety test (flagged stale, blind-read rejected, a fresh build produces `v4` IDs) and a resolution-confidence test (a globally-unique callee resolves `"exact"`, an ambiguous same-named callee across two files stays `"name-only"`); `tests/clients/module-report.test.ts` gains an `"exact"`-resolution assertion on the existing cross-file-caller test, a jsts class-method graph-node-lookup regression test (guarding the "function" idKind mapping), and an ambiguous-same-name-across-files test asserting `"name-only"` is surfaced rather than a false `"exact"`.
- **`docs/` is now included in the published npm package** (closes #651) — pi.dev rewrites GitHub repo links to npm-package-relative paths for its packages listing, so every `docs/*.md` link was a dead link there since `docs/` was never in `package.json`'s `files` array. Added `"docs/"` to `files`; only the 23 already git-tracked/public docs ship (a handful of untracked local scratch files like `inspiration_*.md` are gitignored and were never part of a real publish regardless). Package size grows ~200KB (3.0MB → 3.2MB packed) — negligible. Reported by @eisterman.
- **pi-lens can now measure its own total CPU/RAM footprint — host process, every live LSP child, and every transient analyzer spawn** (closes #620) — diagnosed during a 2026-07-13 dogfooding session where a `lens_diagnostics mode=full` sweep's per-file confirmation rate collapsed under real CPU contention from ~25 concurrent `node.exe` processes (other worktrees/sessions on the same box), a theory only confirmable after the fact via manual `tasklist` correlation — pi-lens had no first-class way to answer "how much CPU/RAM is pi-lens itself using right now." New `clients/resource-sampler.ts` wraps `pidusage` (chosen over hand-rolling `/proc` vs Windows `Get-CimInstance`/perf-counters — each OS exposes process CPU/RSS differently, and pidusage already abstracts that; it's a small pure-JS package with one transitive dep, `safe-buffer`, so it bundles like the repo's other pure-JS runtime deps rather than needing an `EXTERNAL` entry in `scripts/bundle-dist.mjs`) with two consumers: (1) **long-lived processes** — `clients/instance-registry.ts`'s `InstanceEntry`/`LspChildEntry` (#449) gain `cpuPercent`/`rssBytes` fields, sampled at the existing quiet-window heartbeat cadence (`clients/quiet-window.ts`'s `buildHeartbeatResourcePatch`, bounded to 2s via `Promise.race` so a slow sample can never block the idle-window task — the per-turn-end heartbeat stays RSS-only, deliberately, since that's a much hotter path and CPU sampling there would make the measurement itself new per-turn overhead); (2) **transient analyzer children** (jscpd, knip, madge, gitleaks, govulncheck, trivy, opengrep, zizmor, …) — every `safeSpawnAsync` invocation (`clients/safe-spawn.ts`) is now bracketed by `startSpawnUsageSampler`, a 750ms poll started right after `spawn()` and stopped in the existing `child.on("close"/"error", ...)` handlers, recording peak/average CPU%+RSS into a new `spawn_resource_usage` phase entry in the existing `latency.log` (alongside the timing data already logged there) and attaching a `resourceUsage` field to the resolved `SpawnResult`. Windows-specific gotcha handled: `safe-spawn.ts` spawns with `shell: true` on Windows, so the sampled pid is `cmd.exe`'s wrapper, not the real tool's — `findDescendantPidsWindows` resolves the live descendant tree via one CIM query per poll tick (pure BFS split out as `walkDescendantPids` for testability) so a `node`/`npx`-wrapped tool is actually captured instead of reporting cmd.exe's near-zero usage. Net query surface: `clients/instance-registry.ts`'s new `computeResourceFootprint`(pure)/`getResourceFootprint` aggregate every registered instance's host + LSP children into total RSS/CPU/child-count across every pi-lens process on the machine, re-exported through the `clients/lens-engine.ts` seam and surfaced in `pilens_health`'s existing text/JSON output (a new "Resource footprint: N pi-lens instance(s) · XMB RSS · Y% CPU · Z LSP child process(es)" line, best-effort — a footprint read failure never breaks the rest of the tool). Every sampling path is best-effort end to end (a sampling failure loses a data point, never throws into the heartbeat/spawn/turn_end path it's attached to) per this repo's existing instrumentation-must-never-fail-the-operation-it-measures convention. Deliberately out of scope (tracked separately in #650): cross-process LSP server sharing/deduplication — this issue is purely about MEASURING footprint, not reducing it. New tests: `tests/clients/resource-sampler.test.ts` (pure `UsageAccumulator` peak/average math, pure `walkDescendantPids` BFS including a cycle-safety guard, `sampleProcesses` batching/absent-pid semantics, `startSpawnUsageSampler`'s tick/stop lifecycle — all against a mocked `pidusage`, no real process tables); `tests/clients/instance-registry.test.ts` gains coverage for `updateHeartbeat`'s new `cpuPercent`/`childUsage` patch fields (including "untouched, never zeroed" semantics for an unsampled tick) and `computeResourceFootprint`/`getResourceFootprint`; `tests/clients/safe-spawn-resource-usage.test.ts` covers the safe-spawn wiring (sampler start/stop timing, `resourceUsage` attachment, the `spawn_resource_usage` log entry, and that a throwing sampler never breaks the underlying spawn) against a mocked `resource-sampler.js`/`latency-logger.js`, driving a real tiny `node -e` child process end to end.
- **Cross-process LSP budget — first prototype of #449 slice 2** (refs #449) — every concurrent pi-lens process (main session + subagents + concurrent worktrees) has always spawned its own independent LSP fleet with no awareness of any other instance's load; #449 slice 1 (#474/#475) made instances mutually visible via `~/.pi-lens/instances.json` but changed no behavior. This is the first slice that acts on that visibility: a new `clients/lsp-budget.ts` sums live LSP server counts (`lspChildren.length`) across every registry entry whose owning pid is still alive (reusing `instance-reaper.ts`'s existing `realIsPidAlive` liveness check — now exported — rather than a second one) and compares against a configurable ceiling, default 16 (`DEFAULT_LSP_BUDGET_CEILING`, `PI_LENS_LSP_BUDGET_CEILING` override) — a rough RAM-budget estimate (~250MB average per LSP child × ~4GB target machine-wide budget), not yet a measured figure (#620's CPU/RSS sampling hadn't landed as of this prototype). When the machine is at or over the ceiling, the NEW session (never an already-running one) degrades its own spawn plan for the rest of that session: it skips spawning auxiliary LSP servers (`role:"auxiliary"` in `clients/lsp/server.ts` — opengrep/ast-grep/zizmor/typos/marksman) and keeps only the primary language server per file, wired via `clients/dispatch/auxiliary-lsp.ts`'s `enabledAuxiliaryLspServerIds`. This is deliberately a "position limit, not a clearinghouse" (the issue's own framing): the check (`checkCrossProcessLspBudget`) fires fire-and-forget from `session_start` alongside the existing `registerInstance`/`sweepOrphans` calls, reads the registry once, decides locally, and never blocks session start or touches another instance's live servers. Feature-flagged: `PI_LENS_CROSS_PROCESS_BUDGET=0` disables the whole check (today's behavior — always spawn the full fleet). Explicitly NOT implemented in this first cut (documented follow-up): the issue's other suggested degrade mechanism (shortening this session's own idle-reaper timeout) and slice 3 (same-workspace warm attach via the MCP IPC channel) — both remain open. This is a first prototype per the issue's own "gated on registry dogfooding" framing; the ceiling number and the choice of degrade mechanism may both need tuning once real-world multi-agent data (#620) is available. New tests: `tests/clients/lsp-budget.test.ts` (pure `decideLspBudget` decision logic against fake registry data — under/at/over ceiling, dead-parent instances excluded from the live count, env-config parsing with NaN guards, module-scope cache defaults).
- **`lens_diagnostics mode=full` breaks its confirmed/unconfirmed LSP-sweep tally down per primary server, and separates primary-vs-auxiliary findings** (closes #646) — `tools/lsp-diagnostics.ts` already split every file's result into "primary confirmation" (the real language server, e.g. typescript/pyright) vs "auxiliary findings" (cross-cutting scanners attached via `clientScope: "all"` — ast-grep, opengrep, zizmor, typos, marksman, ...) via a local `primaryServerId(filePath)` helper, but `lens-diagnostics.ts`'s `formatFullMode` had no equivalent: its `confirmedLspResults`/`unconfirmedLspResults` tally (#630/#634) was one flat aggregate with no per-server breakdown. Concretely painful during dogfooding: a real sweep came back 34/155 files unconfirmed, and diagnosing WHICH server was responsible required manually grepping `latency.log` for file extensions instead of the tool just reporting it — it turned out to be 100% one push-only server (marksman). Fix: `primaryServerId` moved out of `tools/lsp-diagnostics.ts` into `clients/lsp/config.ts` (alongside `getServersForFileWithConfig`, which it already depended on) so both tools share the exact same primary/auxiliary classification instead of `lens-diagnostics.ts` growing a second hand-copied version — `lsp-diagnostics.ts` now imports the shared helper with no behavior change. `formatFullMode` gains `tallyLspByPrimaryServer` (groups the existing confirmed/unconfirmed partition by each file's primary server id) and `tallyLspPrimaryVsAuxiliary` (splits the raw per-file diagnostics into primary vs auxiliary counts, the same separation `lsp_diagnostics` already applies to its own findings rendering) — both computed from the raw LSP-sweep results, so neither disturbs the existing confirmed/unconfirmed merge into widget-state summaries or any #634 false-clean-prevention semantics. The rendered note now reads e.g. `"⚠ LSP sweep: 121 file(s) confirmed via LSP, 34 unconfirmed (...) — by server: marksman: 0/34, typescript: 121/121 — NOT the same as 0 diagnostics for: ..."` (breakdown clause only rendered when more than one server is involved) plus a new `"LSP sweep findings: N primary (language server), M auxiliary (ast-grep/opengrep/zizmor/typos/marksman/...)"` line; `details` gains `lspServerBreakdown` (always present, per-server `{confirmed, total}`), `lspPrimaryDiagnosticsCount`, and `lspAuxiliaryDiagnosticsCount` so a caller can check this programmatically. New tests: `tests/clients/lsp/primary-server-id.test.ts` (the shared helper matches the old inline `lsp-diagnostics.ts` implementation exactly across a plain source file, a markdown/marksman file, an unmatched extension, and an auxiliary-only case); `tests/tools/lens-diagnostics.test.ts` gains coverage for the per-server breakdown grouping correctly across two different primary servers (mixed confirmed/unconfirmed), the breakdown clause being omitted for a single-server sweep, and the primary/auxiliary findings split.
- **`lens_diagnostics mode=full` no longer renders a timed-out (or errored) per-file LSP check as false-clean** (closes #630) — `formatFullMode`'s (`tools/lens-diagnostics.ts`) footer-cache reconciliation already excluded `timedOut`/`error` results (#571: `diagnostics` is a default-EMPTY placeholder for these, not a confirmed clean — see `LSPWorkspaceDiagnosticResult`'s doc comment in `clients/lsp/index.ts`), but the merge feeding the actually-rendered summary was NOT filtered the same way: the unfiltered `lspResults` went straight into `mergeDiagnosticsWithWidgetSummaries`, so a timed-out file's placeholder `[]` merged in and read to the agent as "0 diagnostics" — indistinguishable from a genuinely clean file. This is the exact #533/#570 false-negative class (`tools/lsp-diagnostics.ts`'s `confirmation: "clean"|"unconfirmed"` machinery already protects that tool against it) that `mode=full`'s merge path never got. Fix: `lspResults` is now partitioned once into `confirmedLspResults`/`unconfirmedLspResults`, both the footer write and the merge now consume only `confirmedLspResults` (a file already fed the footer-write filter is now also fed the render/details filter from the SAME partition, rather than two independently-maintained filters), and an unconfirmed file can still legitimately show findings from `widgetSummaries`/project-runner state if those independently have entries for it — only its LSP-sweep contribution is withheld. The rendered output gains a new note (mirroring the spirit, not the literal function, of `lsp-diagnostics.ts`'s `unconfirmedReasonClause`/`tallyConfirmation` — the two tools' output shapes already differ): `"⚠ LSP sweep: N file(s) confirmed via LSP, M unconfirmed (...): <paths>. NOT the same as 0 diagnostics for these files"`, distinguishing a timeout from a hard error same as #570 does upstream, composed alongside the existing `coldNote`/`abortedNote`/`freshNote`/`missingNote` in both the aborted and non-aborted return branches. `details` gains `lspFilesConfirmed`, `lspFilesUnconfirmed`, and `unconfirmedLspFiles` (always present, even when zero, matching the existing `coldRunners`-always-in-details convention) so a caller can check this programmatically without re-parsing the text. Deliberately NOT touched: the #611 tsserver-sync escape hatch (that's `lsp_diagnostics`-specific single/batch-check machinery; adding a second per-file round trip to a project-wide sweep here would reintroduce the extra-round-trip problem #629 is fixing on the other tool) — this fix only reclassifies/reports the EXISTING `timedOut` signal `runWorkspaceDiagnostics` already produces, no new LSP calls. New tests in `tests/tools/lens-diagnostics.test.ts`: a mixed confirmed-clean/confirmed-with-diagnostics/timed-out sweep result where the timed-out file is asserted to be listed as unconfirmed (not clean, not silently dropped) while the footer-write exclusion (#571) is confirmed unaffected; an errored (not timed-out) result distinguishing the note's wording; and an all-confirmed sweep confirming no unconfirmed note/zeroed details fields appear when there's nothing to report.
- **`lsp_diagnostics` directory-mode file cap raised from 50 to 100**, matching the explicit `paths` batch cap (`MAX_BATCH_FILES`) — `tools/lsp-diagnostics.ts`'s `MAX_FILES` constant now reads `100`. There was no longer a reason for the two caps to diverge: dogfooding confirmed the tool's bounded-concurrency worker pool (default 8) stays fast and timeout-free at 100 files on a real ~150-file project, so directory mode was capping well below what the underlying mechanism can already handle cleanly.
- **`lsp_diagnostics`/`lens_diagnostics` can now render a genuinely confirmed "clean" for classic `typescript-language-server`, not just "unconfirmed"** (refs #611 — read-only-diagnostics scope; the per-edit dispatch consumer is a separate follow-up, see below) — classic `typescript-language-server` is `silentOnClean: true` (`clients/lsp/server-strategies.ts`): on a clean file it publishes nothing at all, not even an empty confirmation, so per #533/#570 pi-lens's honest response was to render ANY empty result from it as `"unconfirmed"` — a single-file `lsp_diagnostics <clean-file.ts>` call could never say "clean," no matter how long you waited. `tools/lsp-diagnostics.ts`'s `classifyEmptyResult` path now attempts one more thing before giving up: when `classifyCascadeWaitTier` (`clients/lsp/cascade-tier.ts`) says the primary server is `"tier3-silent"` (push-only + `silentOnClean` + NOT native-ts7, which already publishes on clean per #558 and never reaches this branch), it calls `typescript-language-server`'s `typescript.tsserverRequest` escape hatch via the existing #237-hardened `LSPService.executeCommand` (allowlisted by advertisement) with `semanticDiagnosticsSync`/`syntacticDiagnosticsSync` — genuine synchronous tsserver request/response commands, not push/timing-dependent. An empty response body is now a confirmed "clean"; a non-empty body surfaces real diagnostics tsserver had already computed but never published, merged into the result rather than discarded. Verified live against the actual installed `typescript-language-server`/`typescript@5.9.3` (this repo's own `tsconfig.json` as the fixture project, plus a deliberately broken scratch file): the response envelope is `{executed:true, result:{seq,type:"response",command,request_seq,success,body:[...]}}`, where each `body` entry is tsserver's NATIVE protocol diagnostic shape (`message`, `category` "error"/"warning"/"suggestion", `code`, `startLocation`/`endLocation` as 1-based `{line,offset}`) — NOT the LSP `Diagnostic` shape, so a new converter (`tsserverSyncDiagnosticToLsp`) maps it to pi-lens's 0-based `LSPDiagnostic`. Also verified live: a file the client never opened (outside any tsconfig project) makes `executeCommand` reject with a tsserver `ResponseError` ("No Project.") rather than a `success:false` response — every failure mode (command not advertised, `executeCommand` throwing or timing out, a malformed response shape) falls back to exactly today's `"unconfirmed"` behavior, fail-safe and non-throwing. New tests in `tests/tools/lsp-diagnostics.test.ts` (`#611 tsserver sync escape hatch`) cover confirmed-clean via the sync path, real diagnostics surfaced via the sync path (with the 1-based→0-based location conversion asserted), fallback-to-unconfirmed on a rejected `executeCommand` call, fallback when the command isn't advertised, fallback when the service exposes neither method at all, and that a `"waits"`-tier server (covering native-ts7) never even attempts the sync path. The higher-stakes per-edit dispatch consumer (`clients/dispatch/runners/lsp.ts`/`clientWaitForDiagnostics` in `clients/lsp/client.ts`) is explicitly NOT touched by this change — it runs on every edit and #611 calls for its own dedicated latency verification (`semanticDiagnosticsSync` blocks on tsserver's real analysis queue, so a backlogged server could see no speed win, only a correctness one) before it's worth attempting there.
- **`lens_diagnostics mode=full` runs gitleaks on any git repo, not only ones with an explicit gitleaks config** (#608, dogfooding finding) — session_start and per-edit dispatch keep #130's strict opt-in gate (`GitleaksClient.hasGitleaksSignal`: a `.gitleaks.toml`/`.gitleaksignore`/package.json reference/pre-commit hook) unchanged, since that issue explicitly weighed and rejected looser tiers for the routine/low-noise path. But `mode=full` is an explicitly-requested comprehensive review, and #130's own writeup considered — but didn't ship — a "smart-default" tier: fire on any tracked git repo, since gitleaks is cheap (~10MB binary, no external DB pull, unlike trivy's 30-200MB vuln-DB download which genuinely needs consent) and its findings are advisory-only. New `GitleaksClient.hasGitRepo`/`hasGitRepo()` (`clients/gitleaks-client.ts`) checks for a `.git` entry (file or directory, so both a normal clone and a worktree's gitdir-pointer file count); `GitleaksClient.scan()` gains an optional `{ requireSignal: false }` to skip the strict gate when the caller has already applied a looser one. `clients/project-diagnostics/fresh-fetch.ts`'s gitleaks task now gates on `hasGitRepo` instead of `hasGitleaksSignal` and passes `requireSignal: false`. `trivy`/`govulncheck`/`dead-code` are unaffected — trivy's gate is a genuine download-consent boundary (unchanged), and govulncheck/dead-code are gated by hard language applicability (no `go.mod`/no Python files), not a policy preference, so there was nothing to loosen there. New tests: `tests/clients/gitleaks-client.test.ts` (`hasGitRepo` against a directory `.git`, a worktree file `.git`, and no `.git`; `scan()`'s default-strict vs. `requireSignal:false` behavior), `tests/clients/project-diagnostics/fresh-fetch.test.ts` (gitleaks now fires on a bare git repo with no explicit gitleaks marker, and still fires when both are present).
- **A genuine `silentOnClean` drift finding now files/updates a persistent tracking issue instead of only riding along in the routine docs-refresh PR** (closes #594) — the nightly `tool-smoke` workflow's `probe-clean-signal.mjs` step (#529) already computed `driftWarnings` (a mismatch between an observed LSP server's clean-scan behavior and its hand-set `silentOnClean` marker in `clients/lsp/server-strategies.ts`) but only logged it to the step's stdout and a `## silentOnClean drift` footnote in `docs/lsp-capability-matrix.md` — both of which only surface via the existing `bot/lsp-docs-refresh` auto-PR, which looks identical to a no-op capability refresh, so nobody had a reason to actually check it. `probe-clean-signal.mjs` now also writes `driftWarnings` as a small JSON summary to a fixed path (`scripts/lib/clean-signal.mjs`'s new `DRIFT_SUMMARY_PATH`, a same-job runner-tmpdir file, never committed), and a new workflow step runs a new script, `scripts/notify-clean-signal-drift.mjs`, which reads that summary and files-or-updates a SINGLE persistent GitHub issue (a fixed `nightly-drift` label + fixed title, found by title match among open issues — never a new issue every night, which would spam) when there's a real finding, and closes a prior open one (with a self-resolved comment) once a nightly run finds no drift. This is purely additive: the probe's own drift-detection logic, its "telemetry only, never a CI gate" guarantee, and the existing docs-footnote/auto-PR behavior are all unchanged. The new step is `continue-on-error: true` (mirroring every other best-effort step in this workflow) and the script itself never exits nonzero on an internal error either — filing/updating/closing an issue is a side effect, not a build gate. Auth reuses the job's existing `GITHUB_TOKEN` via `gh` (the same token the docs-refresh PR step already receives), so the job's `permissions:` block gains `issues: write` alongside its existing `contents`/`pull-requests` write grants — no new auth plumbing. Pure body-building/lookup helpers live in `scripts/lib/drift-issue.mjs` (`buildDriftIssueBody`, `findDriftTrackingIssue`), unit-tested in `tests/scripts/drift-issue.test.ts`; the `gh` CLI shell-outs themselves are untested, matching the existing pattern for `scripts/backfill-github-releases.mjs`'s own `gh` calls elsewhere in this repo.
- **opengrep moved off the full-workspace LSP sweep onto a dedicated CLI extractor** (closes #584) — `runWorkspaceDiagnostics` (`lens_diagnostics mode=full` / `lsp_diagnostics` full-workspace scans) hardcodes `clientScope: "all"` per file, and #387's deliberate single-flight-per-server serialization meant every file paid opengrep's full per-server wait-tier budget (up to 3500ms) one at a time within its server group — on a real 50-file sweep this produced 49/50 files reporting "unconfirmed (timed out)". opengrep has no `workspace/diagnostic` pull support (push-only, `docs/servercapabilities.md`) and `reopenOnResync: true` (`clients/lsp/server-strategies.ts`) means every LSP touch already forces a full re-scan of that one file anyway, so there's no incremental efficiency lost by moving it off the per-file path for bulk scans. New `clients/opengrep-client.ts` (`OpengrepClient`, mirrors `GitleaksClient`/`TrivyClient`): a single project-wide `opengrep scan --config <local rule file | auto> --json` CLI invocation, config resolution reused from the existing `resolveOpengrepConfig` (same rule-choice logic the LSP server itself uses), parsed via `parseOpengrepReport` (verified against the real installed opengrep 1.25.0 binary's JSON schema — semgrep-compatible but with some CLI-surface drift, e.g. `--files-with-matches` requires `--experimental` where semgrep's doesn't). Wired into `scheduleStartupScans` (`clients/runtime-session.ts`) on the same session-start/cached cadence as knip/jscpd/gitleaks, and into the `project-diagnostics` extractor registry (`clients/project-diagnostics/extractors.ts` + new `runner-adapters/opengrep.ts`) so `lens_diagnostics mode=full` reads its cached findings — `ERROR` severity maps to a blocking diagnostic (mirrors gitleaks secrets), `WARNING`/`INFO` to advisory. `runWorkspaceDiagnostics`'s per-file sweep now explicitly excludes the opengrep server (`clients/lsp/index.ts`'s new `excludeServerIds` touch option / `WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`) — verified every extension opengrep covers (`OPENGREP_KINDS`) already has a dedicated primary LSP server (plus the `typos` auxiliary, which attaches to the same extension set), so no file loses sweep coverage from opengrep's removal. The per-edit real-time LSP path (`clientScope: "primary"`/`"with-auxiliary"`) is completely untouched — opengrep still attaches there exactly as before. Rebased onto #587 (`applyAuxiliarySuppressions` wired into `runWorkspaceDiagnostics`) — both changes coexist: opengrep is excluded from the sweep's per-file touch, and whatever else still flows through it (ast-grep, typos, …) still gets suppression-filtered. `// nosemgrep`/`# nosemgrep` needed NO equivalent filtering added to the new CLI extractor — verified empirically (not assumed) against the real installed opengrep 1.25.0 binary that, unlike opengrep's LSP mode (which does NOT honor it natively, the reason `isNosemgrepSuppressed`/`applyAuxiliarySuppressions` exist at all, #441/#586/#587), the CLI `scan --json` path suppresses `nosemgrep`-annotated findings itself before they reach `--json` output. Verified empirically: a real `opengrep scan --config auto` run against this repo's `clients/` directory (277 files, 1074+ community rules) completed in ~19s as a single process — versus the old per-file approach's worst case of 277 individual LSP touches each up to a 3500ms timeout ceiling.
- **`lens_diagnostics mode=full` now fetches the heavyweight project analyzers FRESH instead of reading a possibly-hours-stale cache** (#585) — `mode=full`'s `refreshRunners=cached/cheap/all` used to fold in knip/jscpd/madge/gitleaks/govulncheck/trivy/dead-code findings via `extractCachedProjectDiagnostics`, a deliberately cache-only read (per its own header comment) because relaunching those analyzers concurrently with a `session_start` background scan of the same tool could double-spawn a CPU-bound process. That prerequisite is now satisfied for all three previously-unguarded clients — `gitleaks-client.ts`, `govulncheck-client.ts`, `trivy-client.ts` — which already shared `SecurityScanClient`'s `dedupeScan` in-flight guard (landed in #313, verified before wiring this up rather than re-adding it), the same pattern `KnipClient`/`JscpdClient`/`DeadCodeClient` use. New `clients/project-diagnostics/fresh-fetch.ts` (`fetchFreshProjectDiagnostics`) mirrors each analyzer's `session_start` gating (`clients/runtime-session.ts`) but always performs — or, via the de-dupe guard, *joins* — an actual run instead of skipping on a cache hit, running all analyzers in **parallel** via `Promise.all` so total wait is bounded by the single slowest one (trivy's own ~180s ceiling) rather than their sum; every fresh result is written back to cache via the same `cacheManager.writeCache` `session_start`/`turn_end` use. No extra write-ordering guard (`clients/write-ordering-guard.ts`) was added on top — an overlapping call for the same analyzer/root always resolves to the exact same in-flight promise, so concurrent writers are always writing identical data, not racing a stale write over a fresher one. `tools/lens-diagnostics.ts`'s `formatFullMode` now calls `fetchFreshProjectDiagnostics` (via the process-wide `loadBootstrapClients()` singleton, so a fresh-fetch racing session_start/turn_end shares client instances and thus in-flight runs) in place of the old cache-only extractor, and the output now notes per-analyzer elapsed time ("fetched fresh this call: knip (1597ms), jscpd (4242ms), madge (11256ms)") alongside the existing cold-analyzer honesty note (now "not applicable / unavailable this run" rather than "not yet scanned this session", since every requested analyzer is now actually attempted). `session_start`'s and `turn_end`'s own scheduling (still skip-if-cached) and per-edit dispatch are unchanged — this is additive and `mode=full`-only. **Abort handling** (found in review before merge): `formatFullMode` already threads a combined Escape/turn-abort + hard wall-clock-ceiling signal (`FULL_SCAN_WALL_CLOCK_MS`) into the LSP sweep and cheap project-runner scan, but the initial fresh-fetch wiring left it unthreaded into `fetchFreshProjectDiagnostics` — an Escape or ceiling-fire would correctly stop the rest of the scan while the analyzer fresh-fetch kept running uncancelled for up to trivy's own ~180s ceiling before the tool call could return. None of the six analyzer clients accept a cancellation token (checked each `analyze()`/`scan()` signature — none does), so `fetchFreshProjectDiagnostics` now takes an optional `signal` and races the overall `Promise.all(tasks)` against it, returning whatever has already settled rather than cancelling in-flight spawns — the same "partial is OK, a hang is not" shape `clients/deadline-utils.ts`'s `withDeadline(..., onTimeout: "undefined")` and `clients/lsp/index.ts`'s `runWorkspaceDiagnostics` already use; already-spawned processes keep running in the background (bounded by their own per-tool timeout) and still populate the cache for the next caller. Analyzers still in flight when the abort fires are reported via a new `aborted`/`abortedIds` result field, folded into `cold` (so they never silently read as "ran clean") but surfaced in the tool's text as a distinct "stopped mid-scan" note rather than conflated with the "not applicable to this project" cold note. `tools/lens-diagnostics.ts` passes the same `options.signal` it already hands the LSP sweep. Verified empirically against this repo: two successive fresh-fetch calls each took ~11s (not a cache hit) and the knip cache's `meta.timestamp` advanced between them; two concurrent fresh-fetch calls produced identical results confirming the in-flight guard held; a fresh-fetch given a 500ms abort signal returned in ~511ms with `aborted: true` and the correct still-in-flight analyzer ids, instead of the ~11s+ a full run takes. New tests: `tests/clients/gitleaks-client.test.ts`/`govulncheck-client.test.ts`/`trivy-client.test.ts` each gain a de-dupe regression test mirroring `knip-client.test.ts`'s existing pattern (two concurrent `scan()`/`analyze()` calls against the same root spawn exactly one underlying run); `tests/clients/project-diagnostics/fresh-fetch.test.ts` covers per-analyzer gating, cache-key selection (jscpd's TS-project variant, dead-code's per-language keys), a timing-based regression guard proving the analyzers run in parallel, and an abort-mid-scan test confirming a prompt partial return; `tests/tools/lens-diagnostics.test.ts` updated to mock the new `fetchFreshProjectDiagnostics`/`loadBootstrapClients` seams instead of driving the old cache-only path, plus new coverage confirming the abort signal is the SAME instance the LSP sweep receives and that an aborted fresh-fetch renders its own distinct note.
- **`module_report` flags middle-man / delegate-only classes** (#325, split from #305) — Fowler's "Middle Man" smell (a class whose methods do nothing but forward to one held field) is a whole-class, *universal-quantification* judgment ("EVERY method forwards") that ast-grep's existence matching ("this class *has* a delegate method") can't soundly express without flooding on legitimate forwarding layers, so it's implemented as a structural pass over the already-extracted outline instead of an ast-grep rule. New `clients/middle-man-analysis.ts`: per class, computes a delegation ratio — the share of real methods (accessors and constructors excluded) whose entire body is a single pure-forwarding call (`return this.field.method(...)`, or the same shape without `return` for void methods) to ONE held field — and flags the class (`flags: ["middle man"]`, plus a `delegationRatio` field) only when that ratio clears 90% *and* the class isn't a named facade/adapter/proxy/wrapper/decorator (substring guard on the class name) *and* doesn't structurally `implements` an interface (a legitimate reason for near-total forwarding). Additional false-positive guards: too few methods to judge (<2) never flags, forwards split across more than one delegate field never flags ("one held field" per the issue), and a call that transforms/reorders its arguments doesn't count as pure forwarding. Wired into `moduleReport` (`clients/module-report.ts`) right after member nesting, so the flag rides the same `flags[]`/`delegationRatio` surface `pilens_module_report`/`module_report` already use for "high fanout"/"high complexity". v1 scope: typescript/tsx/javascript, java, kotlin, csharp, swift, dart, python, ruby, rust, php (languages with a deterministic self-reference token and dot-based member access); go/C++ are left for a follow-up since neither has a text-resolvable self-token/access-operator without real AST access. New fixture-driven test suite (`tests/clients/middle-man-analysis.test.ts`) exercises the flag end-to-end through `moduleReport`, with explicit non-flagging fixtures for each guard (named adapter/facade/proxy/wrapper, typed-interface adapter, split-field forwarding, argument-transforming forwarding, and a small class too tiny to judge) alongside positive TS and Python fixtures.
- **Retroactive changelog entry: `runtimeInstall` + canonical-bin discovery for gopls/csharp-ls/fsautocomplete (Go + .NET slice of #241)** — `ensureTool()` could only auto-install servers in the plain npm/pip/gem/GitHub/maven/archive registry, so toolchain-managed LSP servers stayed PATH-only. `gopls` (`b348ac46`) and `fsautocomplete` (`34427c69`, alongside `csharp-ls`) now use `resolveAndLaunch`'s `runtimeInstall` hook: when the owning runtime (`go` / `dotnet`) is on PATH, pi-lens runs the canonical install (`go install golang.org/x/tools/gopls@latest`; `dotnet tool install --tool-path <pi-lens bin> csharp-ls`/`fsautocomplete`) — never the runtime itself — and falls back to "unavailable" otherwise. New canonical-bin discovery (`goBinCandidates`/`dotnetToolCandidates` in `clients/lsp/server.ts`) also resolves an already-installed server that landed in `$GOPATH/bin` (or `~/go/bin`) or `~/.dotnet/tools` even when that directory isn't on the user's shell PATH, with the bare command tried first so PATH stays authoritative. `rust-analyzer` got the same `cargoBinCandidates` (`$CARGO_HOME/bin`/`~/.cargo/bin`) treatment as a byproduct. This slice was deliberately narrowed to Go + .NET, the two toolchains with a mainstream user base — sourcekit-lsp/haskell-language-server/ocamllsp/nixd remain out of scope and #241 stays open for them. Covered by `tests/clients/lsp/runtime-install-discovery.test.ts` (mocked `launchLSP`/`ensureTool`, no real `go install`/`dotnet tool install` shells out in tests).

- **Nightly compat-smoke now pins avtc-pi-subagent's env vocabulary too** (#518, refs #507/#508/#476) — the subagent-extension compat smoke's Layer A already pinned `nicobailon/pi-subagents`' spawn-env contract but had no equivalent guard for the second vocabulary `subagent-mode.ts` detects (`PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID`, added in #507). `scripts/lib/compat-contracts.mjs` gains `checkAvtcChildEnv`, a resilient pattern check (grep-verified against the published `avtc-pi-subagent@1.0.3` source, `src/process-runner.ts`) asserting BOTH env-var assignments still exist — mirroring `checkNicobailonChildEnv`'s shape; `scripts/compat-contracts.mjs` now also npm-installs and reads `avtc-pi-subagent`. Layer B (`scripts/compat-smoke-behavioral.mjs`) gains two new behavioral assertions: an avtc-only PAIR (no `PI_SUBAGENT_CHILD`) correctly engages light mode, and the inverse guard — a LONE avtc var (just `PI_SUBAGENT_CHILD_AGENT`, no `PI_SUBAGENT_PARENT_PID`) correctly does NOT, the specific false-positive-protection edge case `subagent-mode.ts`'s doc comment calls out as required. `docs/subagent-compat.md` updated to reflect avtc-pi-subagent as a fully Layer A + Layer B covered contract rather than a deferred gap.
- **Regression coverage for confusable-hyphen normalization in the read-guard's content comparison** (refs #505) — #505 bundled two items: a did-you-mean suggestion (already shipped) and "Unicode confusable-hyphen normalization before content comparison" (U+2010/2011/2012/2013/2014/2212 -> ASCII hyphen), comparison-only, never applied to written content. Investigating this bundled item found it was already delivered, under a different name, by the host-alignment normalization from #257: `normalizeForGuardMatch` (`clients/host-edit-normalize.ts`) folds `HOST_UNICODE_DASHES` (U+2010, U+2011, U+2012, U+2013, U+2014, U+2015, U+2212 -> ASCII `-`) — exactly the six codepoints #505 names, plus U+2015 — and is precisely the `normalizeContent` that `resolveOldTextEdits` (`clients/read-guard-tool-lines.ts`) uses on its *primary* oldText->range match, ahead of the Tier A/B/C autopatch fallbacks. No production normalization code was added (a second, divergent mechanism would only duplicate #257's). Added: explicit tests pinning this behavior under the #505 framing (`tests/clients/read-guard-tool-lines.test.ts` — ASCII oldText vs. each of the six confusable dashes in file content and vice versa; a hyphen-only difference does not mask an otherwise-different line; a genuine unrelated mismatch still blocks) and a written-content-preservation test (`tests/clients/partial-edit-apply.test.ts` — a newText containing a deliberate em-dash is written to disk byte-for-byte, confirming the normalization never leaks into what gets written), plus a doc comment on `normalizeContent` cross-referencing #505 for discoverability.
- **Persistent `bus-events.log` for `pilens:files:touched`/`pilens:diagnostics` publish outcomes** — both `pi.events` bus producers (`clients/bus-publish.ts` #482, `clients/diagnostics-publish.ts` #502) are fire-and-forget: on failure or a structural no-op (never wired, kill switch off) they only invoked an optional `dbg` callback, which is a documented no-op in the MCP host (`clients/mcp/session.ts`'s `dbg: noop`) — the same failure shape as the #544 MCP `session_start` incident, just for the bus instead. `clients/bus-events-logger.ts` now writes a small NDJSON summary line (`~/.pi-lens/bus-events.log`, house pattern from `clients/latency-logger.ts`) for every meaningful publish outcome: `emitted` (with a file/diagnostics count and, for diagnostics, the monotonic `seq`) and `emit_failed` (with the error) are logged on every call; `skipped_unwired` and `skipped_disabled` are logged once per process (they're static, session-lifetime facts — wiring happens once at extension factory time, the kill switch is a startup env read — so logging them per publish attempt would spam an identical line with no new information for the life of a long MCP session). The empty-batch no-op is not logged at all (every call site already guards against calling with nothing to report). The existing `dbg` callback contract is unchanged — this is additive, not a replacement.
- **MCP auto session_start is now visible and self-healing** (#544) — `PI_LENS_MCP_AUTO_SESSION=1`'s self-triggered `session_start` on `initialize` previously only logged to stderr (`console.error`), which Claude Code never surfaces, and had no retry if it never fired or threw before completing — a real incident left a long-lived MCP connection cold for its whole lifetime with no way to tell short of `claude --debug` log spelunking. `mcp/server.ts` now tracks `{ attempted, succeeded, firedAt, error }` state through `maybeAutoSessionStart()` instead of a bare fired boolean, and `pilens_health` surfaces it as an `autoSession` field (`null` when `PI_LENS_MCP_AUTO_SESSION` isn't set at all, distinguishing "feature off" from "attempted and failed"). Self-heal: the first `tools/call` on a connection now also invokes `maybeAutoSessionStart()`, which re-triggers `runSessionStart` if it never attempted, is still in flight, or previously failed — guarded so it's a no-op once a run has already succeeded (never re-runs session_start on every tool call).
- **`read_symbol` self-healing misses + doc-comment inclusion + duplicate-name disambiguation** (#523, both the pi tool and its `pilens_read_symbol` MCP mirror) — a 2026-07-11 dogfooding assessment found a miss cost an extra round-trip (miss → `module_report` → retry) for what's usually a typo or a qualified name, and that the returned body excluded an attached doc comment even though an agent reading a symbol to edit it needs the contract above it. Four changes, same `readSymbol` (`clients/module-report.ts`): (1) **doc-comment inclusion** (issue author's own follow-up: "probably the highest-value item") — the returned range now starts at an attached doc comment's start line rather than the declaration line, reusing #517's `extractDocCommentInfo` position-based, blank-line-gap-aware attachment computation (a new `docStartLine` field on `Symbol`, alongside the existing `doc` summary); the read-guard coverage recorded for the read (`tools/module-report.ts`'s `recordSymbolRead` tie-in) is extended to match, so editing only the doc comment on an already-read symbol is not wrongly zero-read/out-of-range-blocked. A symbol with no attached comment, or one separated by a blank-line gap, is unaffected. (2) **did-you-mean on miss** — a miss embeds the ~3 nearest symbol/callback names in the file (drawn from the same extraction data `module_report` already builds, no re-parse) via a small dedicated Levenshtein (character edit-distance) similarity function, threshold 0.45 on a normalized 0–1 score, so a typo self-corrects in one turn. Deliberately NOT built on the read-guard's `findSimilarLines`/`tokenSimilarity` (#505) — that does Jaccard similarity over whitespace-tokenized *line content* for relocated-block suggestions, a different comparison shape (a single identifier is one token to it, so a one-character typo on a long name scores 0); no existing levenshtein/editDistance utility was found elsewhere in the codebase. (3) **`Class.method` qualification** — `symbol` accepts a dotted name resolving to a member via line-range containment within the named parent (the same containment shape #301's member nesting uses for the outline, computed directly here since `readSymbol` works off the flat extractor list); an unresolved qualifier (unknown parent, or no matching member) falls through to the plain unqualified lookup and then the did-you-mean miss path, never a crash. (4) **duplicate-name disambiguation** (lowest priority per the issue) — when multiple same-file symbols share the requested name (overloads, an interface and a function sharing a name), the first match is still returned by default (unchanged behavior) but the response now sets `ambiguous: { count, kinds }` and the tool/MCP text notes it; a new optional `kind` parameter picks a specific match.
- **Shared `makeRunnerCtx()` test helper for dispatch-runner tests** (#187, Tier 2 follow-up to #171) — #171 consolidated the three parallel `ExtensionAPI` mocks onto `tests/support/pi-mock.ts`, but the unrelated `DispatchContext` shape (`clients/dispatch/types.ts`) used by `tests/clients/dispatch/runners/*.test.ts`/`dispatch/rules/*` stayed fragmented: ~26 files each hand-rolled their own local `createCtx(filePath, cwd)`. New `tests/support/runner-ctx.ts` exports `makeRunnerCtx(filePath, cwd, overrides?)`, typed against the real `DispatchContext` and filling in the fields runners actually read (`kind: "jsts"`, `fileRole: "source"`, `autofix: false`, `deltaMode: true`, a fresh `FactStore`, `hasTool` resolving `true`, no-op `log`), with per-test overrides merged on top. `tests/support/runner-ctx.test.ts` covers the helper itself. `ruff.test.ts`, `oxlint.test.ts`, and `biome-check-runner.test.ts` are migrated as the template (pure test-setup refactor, no assertion changes); the remaining ~23 bespoke `createCtx` blocks are tracked in #187 for opportunistic follow-on migration. `AGENTS.md` gains a "Testing dispatch runners (#187)" note pointing future runner tests at the helper.
- **Auto-install lua-language-server via the archive-tree bundle machinery** (#564, split from #241) — reuses the auto-install path built for clangd: a new `lua-language-server` `ArchiveSpec` in `clients/installer/index.ts` (platform/arch URL resolver over LuaLS's GitHub releases, verified against the live 3.18.2 release asset listing rather than guessed) and `LuaServer.spawn` (`clients/lsp/server.ts`) converted from plain PATH-only `createInteractiveServer` to `resolveAndLaunchTreeBinary`, same as `CppServer`. Resolution order: a system `lua-language-server` on PATH wins; otherwise the managed bundle is installed (when allowed) and `bin/lua-language-server` launched from within it; neither available degrades gracefully (coverage notice, never a hard failure). Covers darwin/linux × x64/arm64 and win32 × x64 (LuaLS has no win32/arm64 build). One asset-shape difference from clangd found during verification: LuaLS's release archives have **no wrapping version directory** (`bin/`, `LICENSE`, `locale/` sit at the archive root), so this entry uses `stripComponents: 0` where clangd uses `1`. Covered by `tests/clients/installer/archive-platform-url.test.ts` (URL-resolution matrix) and a new `tests/clients/lsp/lua-tree-binary.test.ts` (mocked `launchLSP`/`getToolPath`/`ensureTool` — PATH-first, fallback to an already-extracted bundle, on-demand install, and graceful skip when `allowInstall` is false; no real network/download in tests).
- **`lsp_diagnostics` separates primary-server confirmation from auxiliary-scanner findings, plus a new `serverScope` param** (closes #617, dogfooding finding) — `clientScope: "all"` (the tool's default) touches every attached server for a file, including cross-cutting auxiliaries (ast-grep, opengrep, zizmor, typos, marksman) — a real dogfooding run against `pi-drykiss` returned 55 diagnostics that were 54 ast-grep findings and 1 typescript entry, and the agent's own summary of the result glossed over the fact typescript HAD confirmed the file clean (verified after the fact from `latency.log`'s `lsp_diagnostics_aggregate` entries — `typescript: diagnosticCount:0, health:"ok_empty"` on every file) — real signal buried in aux noise, not a data-loss bug. `tools/lsp-diagnostics.ts` now: (1) always reports the file's actual language server's confirmation (clean/N diagnostics/unconfirmed/timed out) on its own line/section (`primaryServerId(filePath)`, keyed off `LSPServerInfo.role !== "auxiliary"`), independent of how many auxiliary findings accompany it, in single-file, batch, AND directory renders (`Primary LSP (typescript): confirmed clean.` vs. a separate `Auxiliary findings (N):` section) — the batch/directory `cleanFiles`/`unconfirmedFiles` tally was ALREADY primary-only (unchanged), only the flat findings listing was unlabeled; (2) a new optional `serverScope: "primary" | "all"` parameter (default `"all"`, preserving today's behavior) that, when `"primary"`, passes `clientScope: "primary"` to the underlying `touchFile` call and skips every auxiliary scanner entirely — for when the caller just wants "does this have real type errors," fast and low-noise. Deliberately NOT a full removal of auxiliary scanning from this tool: `clientScope: "all"` is the only path that surfaces ast-grep/opengrep findings for a file the agent hasn't dispatched through an edit this session (the same run's Semgrep ReDoS false-positive at `glob-utils.ts:77` was caught exactly this way), so the fix separates the two signals instead of dropping one. New tests in `tests/tools/lsp-diagnostics.test.ts`: single-file and directory-mode splitting of mixed-source diagnostics into Primary/Auxiliary sections, `serverScope: "primary"` threading `clientScope: "primary"` into `touchFile`, and the default still passing `"all"`.
- **`lens_diagnostics mode=full`/`lsp_diagnostics` no longer burns a full per-file wait on EVERY markdown file in a sweep** (closes #645, dogfooding finding against pi-drykiss) — marksman (the markdown LSP, `clients/lsp/server.ts`) is push-only and its value is CROSS-file (broken intra-repo links, missing anchors), which needs its own one-time whole-workspace index build before a push is meaningful; `server-strategies.ts`'s existing 1500ms `aggregateWaitMs` was tuned for a single warm per-edit touch, not a full-tree sweep. A `runWorkspaceDiagnostics` sweep instead fires a `didOpen` for every markdown file in the project, each one independently racing the SAME cold index build — a real ~155-file project with 34 markdown files came back 34/34 `diagnosticsTimedOut:true` (rendered `unconfirmed` per #634, not falsely clean, but ~51s of structurally-doomed wait for zero signal). Fix (option (a)/(c) from the issue, generalized rather than marksman-hardcoded): `DiagnosticStrategy` gains an optional `workspaceIndexing`/`workspaceIndexingWarmWaitMs` pair (`clients/lsp/server-strategies.ts`) marking a push-only server whose value depends on a one-time index rather than a per-file cost; `runWorkspaceDiagnostics` creates one `SweepIndexGate` per sweep call (`clients/lsp/index.ts`) and threads it through every `touchFile` call via a new `sweepIndexGate` option — the FIRST file touching a `workspaceIndexing` server in one sweep still pays the full `aggregateWaitMs` (1500ms), and every subsequent file in the SAME sweep uses the much shorter `workspaceIndexingWarmWaitMs` (250ms for marksman) instead of re-paying the full budget. Deliberately NOT a global/session-lifetime cache — the gate is a plain per-call object, never stored on `LSPService`, so it can't leak into a later sweep or affect a per-edit touch (which never receives one, and therefore always gets the full budget exactly as before — marksman's single-touch per-edit behavior is completely unchanged, per the issue's explicit non-goal). A genuine timeout on a warm-wait touch still reports `diagnosticsTimedOut`/`inconclusive` exactly as before — the #634 unconfirmed-not-false-clean contract is untouched, this only shrinks the wasted wait for files that pile in behind the first one. New tests: `tests/clients/lsp/workspace-diagnostics-sweep-index-gate.test.ts` asserts the first/subsequent wait-budget split directly (the `ms` argument passed to `waitForDiagnostics`), that a genuine later timeout still reports `timedOut: true`, and that a non-`workspaceIndexing` server (python) is unaffected by the gate.
- **Test-runner support for PHPUnit and mix test (ExUnit)** — `clients/test-runner-client.ts`'s per-language `RUNNERS` table gains two new entries. PHPUnit is detected via `phpunit.xml`/`phpunit.xml.dist` or a `composer.json` `require`/`require-dev` dependency on `phpunit/phpunit`, invoked via a local `vendor/bin/phpunit` binary when present (falling back to a global `phpunit`), and its default text summary (`OK (N tests, M assertions)` / `Tests: N, Assertions: M, Errors: E, Failures: F, Skipped: S.`) is parsed for pass/fail/skip counts and individual failure names. `mix test` (Elixir/ExUnit) is detected via `mix.exs` and invoked as `mix test <testFile>`, with its `N tests, M failures` summary line parsed similarly. Both add a new `SOURCE_TO_TEST_PATTERNS` entry; test-file discovery reuses the mirrored-directory mechanism from #547 (`relativeSourceDir`) rather than a second parallel one, plus a small targeted addition new `sourceRootMirroredCandidates` helper for the one thing that mechanism didn't already cover: stripping a conventional source-root segment (`src`/`lib`/`app`) and mirroring under each pattern's own configured test root (`SOURCE_TO_TEST_PATTERNS[i].dirs`) — PHPUnit's class-name convention (`src/Foo/Bar.php` -> `tests/Foo/BarTest.php`) and ExUnit's basename-suffix convention (`lib/accounts/user.ex` -> `test/accounts/user_test.exs`, whose `test/` root is singular and wouldn't otherwise be checked) are both discoverable.

### Performance

- **Pinned `jscpd` bumped from `3.5.10` to `5.0.12`** (#582) — the pin dated back to a real v4 *packaging* bug (`reprism`'s `lib/languages/` dir missing from the published tarball), not a compatibility decision, so v5's ground-up Rust rewrite needed independent re-verification rather than a blind bump. Confirmed by actually installing `jscpd@5.0.12` and running it (not just reading the npm page): the published package is correctly shaped (a real per-platform native binary — `jscpd-windows-x64-msvc` etc. — resolved via `optionalDependencies`, no missing-directory regression); every CLI flag `clients/jscpd-client.ts`'s `runScan()` passes (`--min-lines`, `--min-tokens`, `--reporters json`, `--output`, `--ignore`, plus the positional `.` path) still exists with the same meaning; and the JSON reporter's schema is unchanged for every field `parseReport()` actually reads (`statistics.total.duplicatedLines/lines/percentage`, `duplicates[].firstFile`/`secondFile.name`+`.start`, `.lines`, `.tokens`) — verified against both a synthetic two-file fixture and a real scan of this repo's `clients/` directory, including the exact cwd/positional-arg pattern pi-lens spawns with (`cwd` = scanned dir, arg `"."`), so no adapter changes were needed. Behaviorally, v5 found more clones on this repo (128 vs. v3's 97) and computes its `lines`/`percentage` denominator differently (raw physical lines vs. v3's blank/comment-stripped count — a display-only figure; the unused `formatResult()` helper is the only consumer) — noted as a real, if minor, behavior difference rather than glossed over. The core motivation checked out: ~54x faster on this repo's `clients/` directory (jscpd's own reported detection time 4.105s -> 76ms; wall clock ~4.8s -> ~0.4s), meeting the claimed 24-37x. `clients/installer/index.ts`'s stale v3.5.10-justification comment is replaced with what was verified and why v5 was chosen.
- **`KnipClient.runAnalyze()` now caches between runs** (#580) — `session_start` and every single `turn_end` (`clients/runtime-turn.ts:390`) each spawned `knip` fresh, forcing a full AST-traversal rescan of the whole project on every call. `runAnalyze()` now passes `--cache --cache-location <dir>` (knip's own disk cache, invalidated conservatively via mtime + file size), with `<dir>` routed through `getProjectDataDir(targetDir)` (`path.join(getProjectDataDir(targetDir), "cache", "knip")`) rather than knip's own `node_modules/.cache/knip` default — matching the existing project convention (`cache-manager.ts`, `call-graph.ts`) and covered by the repo-standing `.pi-lens/` gitignore entry, so the cache never risks getting committed. Verified against the installed knip 6.26.0 (no pinned version in `package.json`; resolved via the installer) rather than trusting the docs blindly: found and worked around a real Windows-specific bug where knip's own auto-`mkdir` for a not-yet-existing `--cache-location` directory silently fails (ENOENT, swallowed internally, only surfaced via `--debug`), degrading every run back to an uncached scan with no visible error. `runAnalyze()` now pre-creates the cache directory (`fs.mkdirSync(cacheLocation, { recursive: true })`) before spawning, sidestepping the bug entirely — confirmed end-to-end against this repo: a warm run reused the pre-created cache and was ~47% faster (1.0s vs. 1.9s) than the cold run, with byte-identical JSON output. Caveat carried over from knip's docs (intentionally not auto-handled): a cached run does not detect a newly-added `.gitignore` file — the cache directory must be deleted to pick it up. New test in `tests/clients/knip-client.test.ts` asserts `runAnalyze()`'s spawned args contain `--cache` and the exact `--cache-location` path, and that the directory actually gets created ahead of the spawn.
- **zizmor is no longer an LSP candidate for non-GitHub-Actions YAML files** (closes #636) — surfaced while investigating a separate, unrelated dogfooding report of a slow ~5s edit on `.github/workflows/ci.yml` (concluded NOT a bug: the deliberate cost of zizmor's online GitHub-API audit, already escapable via `ZIZMOR_OFFLINE=1`). That investigation's sibling finding: `ZizmorServer` (`clients/lsp/server.ts`) declares `extensions: KIND_EXTENSIONS["yaml"]`, so it was a candidate LSP server for EVERY `.yaml`/`.yml` file, not just actual workflow/action files — the header comment already noted zizmor "only ever emits findings for actual workflow/action files... other YAML is a quiet no-op," but that's a claim about zizmor's *output*, not about whether pi-lens still pays the LSP round-trip *cost* for files it can never report on. Verified empirically rather than assumed: installed real `zizmor` (`pip install zizmor`, v1.26.1) and spoke raw LSP over stdio to a `zizmor --lsp` process against a fixture repo — `.github/workflows/ci.yml` got a `publishDiagnostics` in ~113ms, while a plain `docker-compose.yml` got **no `publishDiagnostics` at all**, not even an empty one, within a 5s window. Cross-referenced against `clients/lsp/server-strategies.ts`'s `zizmor` strategy (`seedFirstPush: true`, `aggregateWaitMs: 2000`): since zizmor never publishes anything for a non-target file, `waitForDiagnostics` has nothing to resolve early on and burns its full per-server budget (2000ms, bounded by the per-edit caller cap) on every such touch, for zero signal, on every edit of any non-GitHub-Actions YAML file (docker-compose.yml, Kubernetes manifests, other CI configs, …) in any project with zizmor installed. Fix: new optional `LSPServerInfo.pathFilter` hook (`clients/lsp/server.ts`) — an additional, narrowing-only candidacy gate beyond `extensions` — wired into `getServersForFileWithConfig` (`clients/lsp/config.ts`). `ZizmorServer.pathFilter` is `isZizmorAuditTarget` (new export, `clients/zizmor-config.ts`), mirroring zizmor's own input-collection rules exactly: `.github/workflows/*.y[a]ml`, `action.yml`/`action.yaml` (anywhere in the repo — composite actions aren't root-only), and `.github/dependabot.y[a]ml` (GitHub only ever reads that one location, so a root-level `dependabot.yml` deliberately does NOT match). No other server needed this hook — zizmor is the only auxiliary whose extension match is provably broader than its useful path set. New tests: `tests/clients/zizmor-config.test.ts` unit-tests `isZizmorAuditTarget` against workflow/action/dependabot paths (including Windows-separator and absolute-path forms) and common non-matches (docker-compose.yml, k8s manifests, issue-template YAML, a root-level dependabot.yml); `tests/clients/lsp/server-policy.test.ts` exercises the real `getServersForFileWithConfig` end-to-end, proving a workflow file's candidate list includes `"zizmor"` and a plain YAML file's does not (while the primary `"yaml"` language server still attaches to both).

### Changed

- **Unify the directory-walk decision shared by the three source walkers** (refs #191) — `source-filter.ts` (`collectSourceFiles`/`collectSourceFilesAsync`), `language-profile.ts` (`collectSourceFilesForWarmup`), and `startup-scan.ts` (`countSourceFilesWithinLimit`/`countSourceFilesWithinLimitAsync`) each re-implemented a `readdirSync` + ignore-matcher + exclude-dir walk (the SonarCloud duplication flagged on PR #188's async variants). New `clients/source-walker.ts` centralizes the two genuinely-duplicated pieces — the `readdirSync`-with-try/catch boilerplate (`readDirEntriesSafe`) and the "should I recurse into this directory" decision (`shouldRecurseIntoDir`: ignore-matcher + exclude-dir-name, plus the generated-artifact-directory and symlink-following checks that only `source-filter.ts` opted into) — while each caller keeps its own loop shape, extension/regex rules, build-artifact + generated-header filtering, and hard-cap behavior exactly as before; none of that is silently unified. New `tests/clients/source-walker-equivalence.test.ts` pins a single fixture tree exercising every point where the three callers are supposed to disagree (extension sets, generated-dir skipping, build-artifact shadowing, generated-header sniffing) and asserts each walker's exact historical output, on top of the existing per-file test suites (all of which still pass unmodified). This is only the walker-unification item of #191 — the `isBuildArtifact` memo (already shipped, PR #496), the madge/`actionable_warnings` p99 tail, and `/lens-perf` surfacing remain open.
- **Adopt pi's dynamic tooling for situational ast-grep/lsp-navigation tools** — pi's `getActiveTools`/`setActiveTools` API lets an extension register tools inactive and activate a subset per-turn, so a lean default tool list doesn't force every situational tool onto every turn. Of pi-lens's 12 pi tools, 6 stay always-active (`lens_diagnostics`, `lsp_diagnostics`, `module_report`, `read_symbol`, `read_enclosing`, `symbol_search`); the other 5 (`ast_grep_search`, `ast_grep_replace`, `ast_grep_outline`, `ast_grep_dump`, `lsp_navigation`) are registered but start inactive, and a new always-active loader tool, `pi_lens_activate_tools` (`tools/activate-tools.ts`), lets the model activate the ones it needs via `pi.setActiveTools([...active, ...requested])` — additive, per the docs' contract; newly-activated tools are callable starting the next turn. Feature-detected the same way as the existing `agent_settled` registration (try/catch, "older pi host?"): `@earendil-works/pi-coding-agent` is a broad, unrestricted peer dependency, so on a host without `getActiveTools`/`setActiveTools` the 5 situational tools are simply left statically active — a silent, graceful fallback matching pi's own behavior on hosts without native deferred-loading support, not a thrown error. The session-start orientation text (`SESSION_START_GUIDANCE`) now calls out which tools are situational and names the loader.
- **Auxiliary-LSP profile lookup: shared blocking/semantic policy + memoized source lookup** (refs #277 R7) — all four `AUXILIARY_LSP_PROFILES` entries (opengrep, ast-grep, zizmor, typos) carried byte-identical `semantic` lambdas (block on ERROR only when the workspace opted into curated/authored rules, else advisory); extracted to a single shared `blockOnErrorWhenAllowed` so a future policy change is one edit instead of four. `findAuxiliaryProfileForSource` — called once per diagnostic, previously re-scanning all profiles' regexes every time — now memoizes by exact `source` string in a module-level cache, safe because `AUXILIARY_LSP_PROFILES` is a fixed const never mutated at runtime. Behavior-preserving; existing `tests/clients/dispatch/auxiliary-lsp.test.ts` and `tests/clients/dispatch/nosemgrep-suppression.test.ts` pass unmodified.
- **`dead-code-client.ts` and `knip-client.ts` no longer each hand-roll their own copy of the "climb up looking for a project-root marker" loop** (closes #625) — both `resolveProjectRoot` methods carried a byte-identical depth-64 climb with an inline `isAtOrAboveHomeDir` check per iteration and a `.git`/`.hg`/`.svn` boundary short-circuit, differing only in their marker list (Python project files vs. `package.json`/knip configs) — exactly the duplication `clients/path-utils.ts`'s `findNearestContaining` doc comment already called out as the thing it was meant to be the single source of truth for, except that helper had no boundary concept yet. New `findNearestMarkerRoot(startDir, markers, { boundaries, homeDir })` in `clients/path-utils.ts` extends the same climb (home-ceiling check, then marker match, then boundary short-circuit, then depth-capped parent step, `null` on failure — never a fallback to `startDir`) parameterized by both marker list and boundary list; both call sites now delegate to it instead of maintaining their own copy. Before touching either implementation, pinned the EXACT existing behavior with new tests run against the pre-migration code first (nested-directory resolution, home-dir-itself, above-home ancestor, VCS-boundary stop, no-marker-found) — `tests/clients/dead-code-client.test.ts` gained the same shape of pin tests `tests/clients/knip-client.test.ts` already had (which is how the two were confirmed to be genuine, safe-to-merge duplicates rather than assumed identical); all pins pass unchanged before and after the migration, plus new dedicated `findNearestMarkerRoot` unit tests in `tests/clients/path-utils.test.ts` (including a boundary-vs-marker-at-the-same-directory ordering case, since the marker check must win when both are found in the same directory). Deliberately left alone after auditing: `clients/package-root.ts`'s `getPackageRoot` (same loop shape but a different contract — resolves pi-lens's OWN install root from `import.meta.url`, not a user project, so the home-dir ceiling doesn't apply; and on failure it falls back to the last-reached directory rather than returning `null`, an intentionally different "always resolve to something" semantics that a shared null-on-failure helper would have silently changed); `clients/startup-scan.ts`'s `findNearestProjectRoot` (already shared correctly by `clients/runtime-session.ts`, fixed marker list, no boundaries — confirmed fine as-is, not a duplicate); and everything else that turned up in a grep for the generic `path.dirname(current)`/`parent === current` termination idiom (`clients/dispatch/runner-context.ts`, `.../runners/{shellcheck,shfmt,vale}.ts`, `.../runners/utils/runner-helpers.ts`, `clients/lsp/server.ts`, `clients/file-utils.ts`'s `resolveGitIgnoreRoot`, `clients/formatters.ts`) — each read individually and confirmed to be a genuinely different pattern (single tool-config-file probes, binary/dependency location resolution, gitignore-root resolution with its own always-a-fallback contract, or completely unrelated realpath canonicalization), not assumed to match just because the loop shape looks similar.
- **`lsp_diagnostics`' batch/directory scan now serializes touches within a single LSP server the same way `lens_diagnostics mode=full` has since #387** (closes #631) — `tools/lsp-diagnostics.ts`'s `collectBatchDiagnostics` used to fan a `paths`/directory file list out across a flat, server-oblivious bounded-concurrency pool (`mapWithConcurrency`, default 8, max 16): up to 8 concurrent touches at the SAME shared, single-threaded LSP server whenever a batch was mostly one language (the common case). `runWorkspaceDiagnostics` (`clients/lsp/index.ts`, the engine behind `lens_diagnostics mode=full`) has been protected against exactly this since #387 (concurrent touches to one server queue server-side instead of parallelizing, cascading per-file timeouts by queue position — observed 51/123 files "timed out" purely from queue position in a flat pool), but `lsp_diagnostics` had no equivalent guard; its current 100-file caps happened to hold up empirically, but that was incidental, not a designed safety property. Extracted `runWorkspaceDiagnostics`'s inline grouping-by-primary-server and one-worker-per-server-group scheduling into two reusable, exported primitives in `clients/lsp/index.ts` — `groupFilesByPrimaryServer` (keys off the same `getServersForFileWithConfig` grouping `runWorkspaceDiagnostics` already used) and `runPerServerGroups` (at most one in-flight `processGroup` call per server group, parallelized across distinct groups up to a `concurrency` cap) — and refactored `runWorkspaceDiagnostics` itself to call them instead of keeping a second, drifting copy of the same logic inline. `tools/lsp-diagnostics.ts`'s `mapWithConcurrency` now groups its file list the same way and schedules through `runPerServerGroups`, preserving the original flat pool's positional result ordering (`results[i]` still matches `items[i]`, via a per-file pending-index queue that also handles duplicate paths in an explicit `paths` batch). The `concurrency` parameter's meaning changes accordingly — it now caps how many DISTINCT server groups run at once, not how many individual files run at once; a single-language batch (the overwhelmingly common case) collapses to one group and runs effectively serially regardless of `concurrency`, which is the CORRECT, intended #387 behavior, not a regression (the tool's parameter description is updated to say so explicitly). New `tests/tools/lsp-diagnostics-per-server-concurrency.test.ts` mirrors `tests/clients/lsp/workspace-diagnostics-per-server.test.ts`'s own proof of the #387 property, driven through the actual `lsp_diagnostics` tool: N files targeting the SAME primary server never have more than 1 in-flight touch at a time while files targeting DIFFERENT servers run concurrently, and `concurrency: 1` correctly forces even distinct server groups to run one at a time. `tests/tools/lsp-diagnostics.test.ts`'s existing mock of `clients/lsp/index.js` now uses `vi.importActual` to keep the real `groupFilesByPrimaryServer`/`runPerServerGroups` wired through alongside its faked `getLSPService` — all 35 existing tests pass unmodified against the real scheduling primitives, not a mock that would trivially satisfy the new behavior either way.

### Fixed

- **`nested-ternary`/`long-parameter-list`/`no-dupe-class-members` had ZERO coverage in the ast-grep NAPI fallback runner on `.ts` files** (closes #660, follow-up finding from #657) — `clients/dispatch/runners/ast-grep-napi.ts`'s `TREE_SITTER_OVERLAP` skip-set assumed the tree-sitter query runner already covered these 5 rule ids (`constructor-super`, `empty-catch`, `long-parameter-list`, `nested-ternary`, `no-dupe-class-members`), so the NAPI runner deliberately skipped loading its own copies to avoid double-reporting. Investigated via `git log --follow` on the disabled tree-sitter query (`rules/tree-sitter-queries/typescript-disabled/nested-ternary.yml`): the disable was an intentional April 2026 architecture decision (`84dca1ee`, "ast-grep owns TS/JS rules; tree-sitter owns multi-language structural rules"), not a broken query — so re-enabling the tree-sitter query would have fought that decision, not fixed a bug. Auditing the full skip-set found the false assumption applied to 3 of the 5 entries: `nested-ternary`, `long-parameter-list`, and `no-dupe-class-members` have no active tree-sitter query at all (disabled/never written, confirmed against `clients/tree-sitter-query-loader.ts`'s `-disabled/`-directory exclusion), yet all had a perfectly good, shipped, active ast-grep rule sitting unused. Before #657's fix, `nested-ternary` coverage on `.ts` files was accidentally coming ONLY from the `nested-ternary-js` twin cross-firing on `.ts` — exactly the double-firing bug #657 closes — so closing that bug correctly also closed this rule's only accidental coverage path. The other 2 entries (`constructor-super`, `empty-catch`) are disabled everywhere (ast-grep AND tree-sitter, `rules-disabled/`, #206), so skipping them was already a no-op either way. Fix: removed the entire `TREE_SITTER_OVERLAP` skip-set and its check — a blanket assumption-based list with no verification mechanism is exactly how this drifted false for 3/5 entries without anyone noticing. New tests in `tests/clients/dispatch/runners/ast-grep-sonar-rules.test.ts` cover `nested-ternary` firing on `.ts` again (chained + parenthesized nested ternary, no self-match on a single ternary, and no cross-firing of the `-js` twin per #657) and `long-parameter-list` firing on a 5-required-parameter `.ts` function. Investigating `no-dupe-class-members` surfaced a separate, deeper bug (filed as #663): its rule YAML uses a top-level `utils:` block that `yaml-rule-parser.ts`/the native `findAll` call silently drop, so it still doesn't fire post-fix — documented with a test pinning the current (still-gapped) behavior rather than a false claim of coverage; #663 affects 5 shipped rules total.
- **ast-grep NAPI fallback runner no longer silently drops a rule's `utils:` block** (closes #663, found while adding a regression test for #660) — `clients/dispatch/runners/yaml-rule-parser.ts`'s `YamlRule` interface had no `utils` field, and `ast-grep-napi.ts`'s native `findAll` call only ever built `{ rule, constraints }`, so any rule with a top-level `utils:` block (reusable named matchers referenced via `matches: <name>` inside `rule`/`constraints` — standard ast-grep YAML syntax) got that block dropped: the unresolved `matches: <name>` reference then either made napi reject the whole rule (silently swallowed by the surrounding `try { ... } catch { matches = []; }`) or matched nothing, so the rule produced zero diagnostics with no error surfaced anywhere. 5 shipped rules declare `utils:` — `no-dupe-class-members`, `no-dupe-keys`, `no-dupe-keys-js`, `rust-2024-let-chain-candidate`, `unnecessary-react-hook` — two of them (`no-dupe-keys`/`-js`) real correctness rules, not cosmetic. Scoped to the NAPI in-process fallback only; the ast-grep CLI/LSP path (used whenever the `ast-grep` binary is installed) already handles `utils:` correctly. Fix: `YamlRule` gains `utils?: Record<string, YamlRuleCondition>` (parsed straight through by the existing faithful js-yaml parse, no parser changes needed), and the native config construction in `evaluateAstGrepRules` now passes `utils` through alongside `rule`/`constraints` — verified against `@ast-grep/napi`'s own `NapiConfig.utils: Record<string, Rule>` type declaration, the exact shape napi's native engine expects. Also: the swallowed `findAll` rejection is now logged via the runner's existing `ctx.log` (a new optional `AstGrepEvaluateOptions.log` sink), so a future malformed-or-unresolvable rule doesn't silently produce zero diagnostics again with zero trace. New tests (`tests/clients/dispatch/runners/ast-grep-utils-block.test.ts`): `no-dupe-keys`/`no-dupe-keys-js` fire on their real duplicate-key fixtures, and (post-#660's removal of `TREE_SITTER_OVERLAP`) `no-dupe-class-members` now also fires end-to-end — all three verified through the actual `runner.run()` front door; `unnecessary-react-hook` (still gated out of the full runner path by an unrelated pre-existing filter — the runner's typescript/javascript-only language filter, since it's tagged `Tsx`, untouched by this fix) is verified directly against napi's real native engine using the exact `{ rule, constraints, utils }` shape the fix now builds; `rust-2024-let-chain-candidate`'s `utils:` block is confirmed to survive YAML parsing (this napi build has no Rust parser at all — Rust rules run via the CLI/LSP path only, already covered by `ast-grep-catalog-rules.test.ts`'s CLI-based fixtures).
- **ast-grep `-js` twin rules no longer double-fire on `.ts` files in the in-process NAPI runner** (closes #657, confirmed via `~/.pi-lens/latency.log` dogfooding — `hardcoded-url`/`hardcoded-url-js` both firing on one line of a real `.ts` file) — TypeScript's grammar is a syntactic superset of JavaScript, so a `language: JavaScript`-tagged rule using generic node kinds (`variable_declarator`, `assignment_expression`, …) still matched a parsed `.ts` root in `clients/dispatch/runners/ast-grep-napi.ts`'s `evaluateAstGrepRules`, which only filtered OUT non-TS/JS languages (Python, Go, …) but never distinguished TypeScript from JavaScript for the family it actually supports. Audited all 66 `<base>.yml`/`<base>-js.yml` candidate pairs in `rules/ast-grep-rules/rules/`: 65 have byte-identical `rule:`/`message:`/`severity:` bodies (intentional twins — the ast-grep CLI/LSP surface, which DOES correctly gate by `language:`, needs both files shipped for real per-grammar `.js` coverage, per the existing authoring convention in `skills/pi-lens-write-ast-grep-rule/SKILL.md`); 1 (`no-flag-argument`/`no-flag-argument-js`) is genuinely different (`required_parameter` vs `assignment_pattern` node kinds for typed vs default params) and was left untouched. ast-grep's rule schema has no multi-language single-rule mechanism (`language:` is a single required value, confirmed against `rule-schema.json` and every other rule in the catalog), so — matching the issue's own fallback ask — the fix scopes the NAPI runner's dispatch itself: new `ruleLanguageForFile()` maps a file's extension to `"typescript"`/`"javascript"` and a rule's `language:` tag must now match the FILE's actual grammar (not just be "some" JS/TS value) to run against it, closing the bug structurally for every current and future twin pair rather than deleting/merging the intentionally-duplicated rule files. Auditing also surfaced a real, separate copy-paste bug: 7 of the 65 "twin" `-js.yml` files (`array-callback-return-js`, `jwt-no-verify-js`, `no-extra-boolean-cast-js`, `no-implied-eval-js`, `no-javascript-url-js`, `no-sql-in-code-js`, `weak-rsa-key-js`) still carried `language: TypeScript` — their language tag was never updated off the copy-pasted base file, so both files always ran under the SAME language and each had zero actual `.js` coverage under the CLI/LSP path (confirmed against `rules/rule-catalog.json`, which already listed `javascript` for 4 of these ids); corrected all 7 to `language: JavaScript`. New regression coverage: a runner-level vitest test (`tests/clients/ast-grep-rule-precedence-followups.test.ts`) reproduces the exact bug with both a synthetic generic-node-kind rule pair and the real bundled `hardcoded-url`/`hardcoded-url-js` rules, asserting exactly one twin fires per file extension; a new catalog-hygiene script, `scripts/audit-astgrep-rule-pairs.mjs` (`npm run audit:astgrep-rule-pairs`, wired as a new blocking CI lint step), flags any future rule pair with an identical body under the SAME `language:` tag, an identical-body twin whose language tags aren't exactly `{TypeScript, JavaScript}`, or an identical-body twin whose `message`/`severity` has drifted — it would have caught the 7-file copy-paste bug directly. `docs/ast-grep_rules_catalog.md` regenerated (`npm run docs:rule-catalogs`) to reflect the 7 corrected language tags. Deliberately out of scope per the issue: the SEPARATE tree-sitter-engine `rules/rule-catalog.json` catalog (its own `canonical_concept` grouping mechanism, untouched). Also noticed but NOT fixed here (follow-up filed): `nested-ternary` is listed in this same runner's `TREE_SITTER_OVERLAP` skip-set on the assumption the tree-sitter query runner covers it, but that query currently lives under `rules/tree-sitter-queries/typescript-disabled/` (disabled) — meaning `nested-ternary` coverage on `.ts` files was accidentally coming ONLY from the `nested-ternary-js` cross-firing bug this PR fixes, and is now genuinely uncovered by the NAPI runner on `.ts` files (the ast-grep CLI/LSP path, when available, is unaffected since it already gates by language correctly).
- **Dynamic tool deactivation for the 5 situational tools now actually runs, instead of failing silently on every session_start** (closes #643, found via `~/.pi-lens/sessionstart.log` dogfooding) — the #dynamic-tooling feature (`pi.getActiveTools`/`setActiveTools`) deactivates `ast_grep_search`/`ast_grep_replace`/`ast_grep_outline`/`ast_grep_dump`/`lsp_navigation` at load so the model must call `pi_lens_activate_tools` to bring them in, but the call to `setActiveTools` ran synchronously in `index.ts` right after `registerTool`, still inside the extension's own load/activation function — a point at which the runtime is not yet initialized, so the call structurally cannot succeed on ANY host regardless of version. Every session's log showed the identical caught error, `Error: Extension runtime not initialized. Action methods cannot be called during extension loading`, misleadingly logged as `dynamic tool deactivation failed (older pi host, or tools not registered?)` — the feature-detection `typeof` guard already proved the host supports the API, so the real problem was call-site timing, not host capability. Net effect: the 5 lazy tools were likely never successfully deactivated even once since the feature shipped, defeating the point of the on-demand design. Fix: moved the `getActiveTools()`/`setActiveTools()` call (same feature-detection guard, same filtered-list computation) out of the synchronous registration block and into the existing `pi.on("session_start", ...)` handler, which fires after the extension has finished loading — the correct lifecycle point. Runs on every `session_start` firing (fork/reload/new/resume all fire it in one process's lifetime); safe to re-run each time since `setActiveTools` just replaces the current active set rather than being additive/stateful across calls. The caught-error log message was also reworded, since "older pi host" is now an accurate description if the call still fails there. New/updated tests in `tests/index-wiring.test.ts` drive the real `session_start` handler (mocking `clients/bootstrap.js`/`clients/runtime-session.js` the same way `tests/index-integration.test.ts` does, to keep it a fast wiring check) and assert the 5 situational tools land inactive after it fires, both on a dynamic-tooling host and — unchanged — statically active as a graceful fallback on a host without the API.
- **`lsp_diagnostics`'s `serverScope: "primary"` actually skips auxiliary scanners now, and the common case drops back to one LSP round trip per file instead of two** (closes #629, #619 regression, live-debugging finding via `latency.log`) — `collectDiagnosticsForFile` (`tools/lsp-diagnostics.ts`) took the `touchFile` branch (correctly `clientScope`-scoped, `collectDiagnostics: true`) whenever `waitMs` was passed or `serverScope: "primary"` was requested, but only ever read `touched?.inconclusive` off its return value and then discarded the array — a second, UNCONDITIONAL `lspService.getDiagnostics()` call (which takes no `serverScope`/`clientScope` argument at all and always queries every registered server) supplied the actual diagnostics content every time. Confirmed live via `latency.log`: a `serverScope:"primary"`/`waitMs:1000` call showed the primary confirmation touch timing out on typescript alone (`clientScope:"primary"`) while a separate `lsp_diagnostics_aggregate` entry for the SAME file, moments later, showed all 4 servers touched (`opengrep` waiting its full 3500ms) — two genuinely separate round trips merged into one inconsistent result, and `serverScope: "primary"`'s own doc comment ("for when the caller just wants confirmation, not a full security/lint pass") was silently broken since the day #619 introduced the parameter. Fix: `touched` — already the correctly-scoped, already-collected `LSPDiagnostic[]` — is now used directly as the diagnostics content whenever the `touchFile` branch was taken and it resolved to something defined; the `getDiagnostics()` call only still runs as a fallback when `touchFile` itself couldn't produce a result (service destroyed, no clients resolved) or when neither `waitMs` nor `serverScope: "primary"` was set in the first place (the pre-existing `openFile`-only path, genuinely unchanged — it never collected anything and still needs the follow-up read). `applyAuxiliarySuppressions` (#586's `// nosemgrep`-style inline-suppression filtering) runs on whichever path produced the diagnostics, so suppression behavior is identical either way. New/updated tests in `tests/tools/lsp-diagnostics.test.ts`: `serverScope: "primary"` and the default `waitMs`-only path now both assert `getDiagnostics` is NOT called at all (not just that the final content matches); a dedicated test proves the rendered diagnostic content comes from `touchFile`'s own return value even when a still-wired `getDiagnostics` mock returns a different (aux-only) finding, which must never leak through; a fallback test confirms `getDiagnostics` is still called when `touchFile` resolves to `undefined`; and a regression guard pins the untouched `openFile`-only path (`getDiagnostics(path, "full")` called, `touchFile` never called) when neither `waitMs` nor `serverScope: "primary"` is set.
- **Per-edit test-runner findings: fixed a broad vitest include glob producing vacuous `0p/0f` "passes" on plain source files, and stopped silently discarding real results when the turn advanced mid-run** (closes #628, live dogfooding on pi-drykiss) — `~/.pi-lens/sessionstart.log` from a real session showed `src/background-review.ts → test vitest src\background-review.ts (self)` followed by `PASS 0p/0f (0ms)`: a plain source file was targeted as its own test ("self" strategy) and vitest, finding zero actual tests in it, reported a technically-true, practically-meaningless pass. Root cause: `isTestFile()` (`clients/test-runner-client.ts`) let a vitest project's own `test.include` glob override a clean `detectFileRole(...) !== "test"` naming-convention verdict on ANY match — a common real-world glob shape like `src/**/*.ts` (or an unrestricted `**/*.ts`) matches every source file in the tree, not just tests, so the override fired on ordinary edited files. Fixed by tightening the override to a NARROW glob signal (`isNarrowTestGlob`): trusted only when a literal path segment names a conventional test location (`tests/`, `test/`, `spec/`, `specs/`, `__tests__/` — preserving the legitimate case this override exists for, a project whose tests live in such a directory with no `.test.`/`.spec.` in the filename) or the glob's static suffix after its last wildcard encodes more than the bare language extension (e.g. `**/*.check.ts`, preserving the existing unconventional-naming test case); a bare `src/**/*.ts`/`**/*.ts` now fails both checks and no longer self-classifies. Pinned first: new tests reproducing the exact `background-review.ts`-shaped bug were confirmed to fail against the pre-fix code, then confirmed to pass after the fix, alongside the pre-existing narrow-glob legitimate case and a new bare-`tests/`-directory legitimate case, both still passing. Second, independent bug in the same turn_end test-fire path (`clients/runtime-turn.ts`): the deliberately async/non-blocking test-fire (`Promise.allSettled` over `runTestFileAsync`, so tests never stall an edit) detected `runtime.turnIndex !== firedAtTurn` (the turn advanced — another edit landed — before the fired subprocess resolved) and unconditionally discarded the results, logging `discarding test results — turn advanced while tests ran` and writing nothing to the `test-runner-findings` cache the next turn's context injection reads from; real logs showed this firing routinely in a fast-moving session, not as a rare edge case, so the "next turn gets test feedback" contract was regularly unfulfilled. Fixed by caching the results regardless of staleness — a late result is still real information about what's currently broken — tagged `stale: true` and with a `[from a prior turn — …]` prefix on the cached content so a downstream consumer (or the agent reading it) can tell it wasn't from the immediately-preceding edit. Also extended turn_end's test-fire loop to target the test companions of this turn's cascade neighbors (files that import an edited file), not just the edited file itself — a neighbor's own tests can break even though the neighbor's source wasn't touched. Reuses `cascadeResults`, already computed earlier in the same turn_end for the LSP cascade-diagnostics merge (#450's deferred-cascade drain) — no second reverse-dependency walk, and the neighbor set inherits whatever budget (`CASCADE_NEIGHBOUR_BUDGET`) the cascade compute already applied, so this can't turn into unbounded per-edit work. New tests in `tests/clients/runtime-turn-session.test.ts` cover: a stale result still gets cached (with the `stale`/prior-turn tagging), a non-stale result is cached without the tag, and a cascade neighbor's test companion is fired alongside the edited file's own target. Once the underlying data was trustworthy, added a new cache-only extractor (`clients/project-diagnostics/runner-adapters/test-runner.ts`, registered in `extractors.ts`) so `lens_diagnostics mode=full` can also surface `test-runner-findings` per-file, the same way jscpd/knip/madge already do — reads the cache's newly-added structured `results: TestResult[]` field (alongside the pre-existing formatted `content` string the context-injection consumer reads), emits one blocking diagnostic per test failure, and never launches a test run itself (running a whole suite is explicitly out of scope). Covered by new tests in `tests/clients/project-diagnostics.test.ts` (adapts cached failures into per-file diagnostics with the failure name/message; an all-passing cached result contributes nothing).
- **`lens_diagnostics mode=full`'s workspace sweep no longer front-loads an entire server group's `didOpen` burst in one uninterrupted pass** (closes #621, dogfooding finding) — dogfooding on `pi-drykiss` (~150 TS files) found a full sweep collapsing to near-100% per-file timeouts on every recent run, despite the #608/#616 fixes around the same window being confirmed innocent. Root cause: #608's fix (correctly) pre-opens every file in a server group *before* the per-file diagnostics-wait loop starts, so `WatchedFilesQueue`'s 100ms debounce coalesces the resulting watched-files notifications into one project recheck instead of N cascading ones — but for a single-language project (the common case, one server = one group) that meant firing ALL ~150 files' `didOpen` in one burst, dumping the whole batch on tsserver's single-threaded request queue at once and forcing it to ingest/typecheck the entire burst before any per-file diagnostics request even got a turn. `lsp_diagnostics`' batch/directory mode (`tools/lsp-diagnostics.ts`) never hit this because its flat bounded-concurrency worker pool (default 8) only ever has ~8 files in flight — confirmed directly: an explicit 100-file `paths` batch on `pi-drykiss` via that path completed fast with zero timeouts, while the same project's `mode=full` sweep timed out on nearly every file. The fix chunks `runWorkspaceDiagnostics`'s (`clients/lsp/index.ts`) pre-open+process cycle to `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE` (default 8, env-tunable via `PI_LENS_LSP_WORKSPACE_PREOPEN_CHUNK`, matching `lsp_diagnostics`' own default concurrency) instead of the whole group: each chunk's opens still land inside the same 100ms debounce window and coalesce into one flush (preserving #608's "no per-file cascade" guarantee — verified an explicit chunked-burst test still coalesces, never regressing to one flush per file), but no single burst dumped on the server ever exceeds the chunk width regardless of total group size, matching the same bounded-concurrency shape that was already proven safe in `lsp_diagnostics`. #387's per-server serialization (one in-flight touch per server, parallel across distinct servers) is unchanged — this only paces the pre-open burst *within* a group, it does not flood across servers. New `tests/clients/lsp/workspace-diagnostics-sweep-preopen-chunk.test.ts` proves both properties directly against the real `WatchedFilesQueue` coalescing primitive: a 40-file group never bursts more than the configured chunk size while still coalescing one flush per chunk (5, not 1 and not 40), and a group smaller than the chunk size is unaffected (still a single flush). The existing `workspace-diagnostics-sweep-batch-open.test.ts` (#608) is updated in place — its 20-file fixture now spans 3 chunks at the default width, so it asserts 3 flushes instead of 1, while still guarding against the original one-flush-per-file regression.
- **Launching Pi from `$HOME` and then editing an absolute-path file in another repo could block Pi's event loop for 470-500+ SECONDS while the cascade pipeline walked the entire home directory** (closes #622, live dogfooding report) — `runPipeline -> computeCascadeForFile -> buildOrUpdateGraph -> getGraphSourceFiles -> collectSourceFiles -> scan -> isIgnored -> minimatch` enumerated 206,551+ files before the review-graph's `maxGraphFiles` safety cap (#250) even had a chance to trip, because that cap counts post-filter *kept* files, not directory entries visited — an unfiltered tree the size of `$HOME` costs the full readdir/stat walk regardless of how quickly the cap would reject it once hit. Root cause: `buildOrUpdateGraph` (`clients/review-graph/builder.ts`) trusts its `cwd` parameter to already BE a validated project root, an assumption 3 of its 4 real call sites break by passing pi's raw session/pipeline cwd straight through with zero validation — `dispatch/integration.ts`'s `computeCascadeForFile` (the exact path in this issue's stack trace), `mcp/analyze.ts`'s warm-analysis graph update, and `dispatch/runners/tree-sitter.ts`'s `runBlastRadiusInBackground` — while the 4th (`runtime-session.ts`'s session-start call-graph task) is already safe, gated behind `startupScan.canWarmCaches`, itself already rejecting a home-dir cwd. This is the same class of `$HOME` escape `startup-scan.ts`, `dead-code-client.ts`, and `knip-client.ts` each independently closed for their own walks (#250/#253), but the review-graph builder — the one component actually named in this issue's live-inspector stack — never got the equivalent guard. Fixed at the single shared choke point instead of patching each of the 3 unsafe callers separately: `_doBuildGraph` (`clients/review-graph/builder.ts`) now checks `isAtOrAboveHomeDir(path.resolve(cwd))` (`clients/path-utils.ts`, the same ceiling helper the other 4 call sites already use) before any walk, seq-fastpath attempt, or cache lookup runs, and returns an empty, unpersisted graph tagged `mode: "skipped", skipReason: "unsafe_root"` (mirroring the existing `too_many_files` skip shape/`GraphBuildInfo` contract) rather than falling back to walking `cwd` anyway — matching the issue's own stated expected behavior ("skip graph construction when no safe project root exists"). A normal project cwd (the overwhelmingly common case, including one nested under `$HOME` like `~/code/app`) is completely unaffected — only a cwd that IS `$HOME` or an ancestor of it is rejected. The secondary, lower-priority item from the investigation — whether `collectSourceFilesAsync`'s cap should also account for total directory entries visited, not just kept files, as defense in depth — was deliberately left out of this fix (real but separate scope) rather than bundled in. New tests in `tests/clients/review-graph.service.test.ts`: a `buildOrUpdateGraph(os.homedir(), ...)` call now skips near-instantly (`skipReason: "unsafe_root"`, empty graph, well under the ~500s the unfixed walk took) instead of ever starting the walk, and a regression guard confirms a normal project living UNDER home still builds normally.
- **A genuine LSP server crash could go completely unlogged when the JSON-RPC connection tore down before the process's own `exit` event fired** (closes #618, follow-up to #615) — a live sweep against `pi-drykiss` showed its `ast-grep` client dying and respawning 5 times in ~75s with NOTHING in `latency.log` explaining why (no exit code, no signal, no stderr) — investigation traced it to `setupConnectionLifecycle` (`clients/lsp/client.ts`)'s `lsp_server_unexpected_exit` log being gated on `wasConnected` (captured at the moment the process `exit` event fires): a genuine crash's `connection.onClose`/`onError` handler can run synchronously off the dying stdio pipe and flip `isConnected` false BEFORE `exit` fires, so by the time the exit handler ran, the crash looked indistinguishable from an intentional `clientShutdown()` call and the log was silently skipped. Fixed by adding an explicit `LSPClientState.shutdownRequested` flag, set ONLY by `clientShutdown()`, and gating the exit log on that instead of `isConnected` — a genuine crash is now always logged regardless of event ordering. Also added the missing `exitSignal` (Node's `exit` event's second argument, previously dropped entirely) and a `stderrTail` (last 20 lines, reusing the existing `recentStderr` ring buffer already exposed on the client) to the log's metadata, so a future crash is actually diagnosable instead of just "respawned, uptime was Xms". New `tests/clients/lsp/client-crash-logging.test.ts`, using the same real fake-LSP-server child process the existing integration tests spawn: confirms a `SIGKILL`'d child (no `shutdown()` call first) logs `lsp_server_unexpected_exit` with the real exit signal and a stderr tail, and that an intentional `client.shutdown()` still logs nothing — both against a real process, not a mock. Confirmed the new test fails against the pre-fix `wasConnected` gate (missing `exitSignal`) before confirming it passes against the fix.
- **`runWorkspaceDiagnostics`'s batch pre-open pass (#608) could hang an entire full-workspace sweep un-abortably** (closes #615, live dogfooding incident) — `preOpenGroupFiles`, added by #608/#610 to fix a watched-files debounce issue, ran ahead of the sweep's already-`withDeadline`-wrapped per-file loop but had no bound of its own: a hung `getClientsForFile` (stuck server spawn/initialize) or `notify.open` (stuck notification write) call froze the whole sweep with no heartbeat, and pressing Escape didn't help either — the loop's `signal?.aborted` check only runs between files, never while one is mid-await. Live symptom: `lsp_workspace_diagnostics_start` logged, then 13+ minutes of total silence, unresponsive to abort. Fix adds two independent bounds around the pre-open attempt: `withDeadline(..., { ms: perFileMs, onTimeout: "undefined" })` (catches a hang even with no user action) and a `Promise.race` against the abort signal (an explicit Escape/turn-abort now unblocks immediately instead of waiting out the rest of the per-file budget). New tests in `tests/clients/lsp/workspace-diagnostics-sweep-batch-open.test.ts` (`#615` block): a pre-open call that never resolves doesn't hang the sweep, and aborting mid-pre-open unblocks well before the per-file deadline would fire — both confirmed to actually hang/timeout against the pre-fix unbounded code before confirming they pass against the fix. Added a standing invariant to AGENTS.md's Performance section: any new async step added to an existing bounded loop needs BOTH a timeout bound and an abort-signal race, not just one — this is the second time in one day a bounded-loop change shipped with an unbounded new step inside it.
- **`lens_diagnostics mode=full` now runs the heavyweight-analyzer fresh-fetch CONCURRENTLY with the LSP sweep instead of after it, fixing a real-world case where all 7 analyzers went cold** (closes #613, dogfooding finding) — `formatFullMode` (`tools/lens-diagnostics.ts`) `await`ed `fetchFreshProjectDiagnostics` (#585/#590) only AFTER its own `Promise.all([runWorkspaceDiagnostics, ...])` had already fully resolved, sequentially spending the SAME shared wall-clock ceiling (`FULL_SCAN_WALL_CLOCK_MS`) the LSP sweep had already eaten into — despite a comment directly above the call claiming it was "run in parallel with the rest." On a real ~150-file project the LSP sweep alone can take 100+ seconds, leaving the analyzer fetch almost no budget before the shared abort signal fired — killing ALL 7 analyzers (including unconditional ones like knip/jscpd/madge that should always attempt) before any could complete, and rendering `(7 cold: govulncheck, trivy, dead-code, knip, jscpd, madge, gitleaks)` on a scan that had, in fact, barely started the analyzer phase at all. Fix: `fetchFreshProjectDiagnostics`'s promise (built via `analyzersPromise`, gated the same way by `shouldIncludeProjectRunners`) is now included in the SAME `Promise.all` as the LSP sweep and the cheap project-runner scan, so all three phases race the same signal from the same starting point — genuinely concurrent, not stacked. There's no data dependency between the phases (the analyzer fetch never reads the LSP sweep's results), so nothing else about the merge/reconcile logic below needed to change. New test `tests/tools/lens-diagnostics.test.ts` ("starts the analyzer fresh-fetch CONCURRENTLY with the LSP sweep, not after it resolves") holds the LSP sweep's mock promise open and asserts `fetchFreshProjectDiagnostics` has already been invoked before the sweep resolves — verified this test actually fails against the pre-fix sequential code (confirmed by temporarily reverting the fix and re-running it) before confirming it passes against the fix.
- **`lens_diagnostics mode=full`/`lsp_diagnostics` full-workspace sweeps no longer defeat #271's watched-files debounce, fixing a 0%-100% run-to-run false-timeout rate** (closes #608) — dogfooding a full sweep on an unchanged ~151-file TS project showed a wildly variable per-file timeout rate across successive runs (0%, 100%, 33%, 64%) with the tsserver process never restarted in between, ruling out cold-indexing and already-ruled-out #591 (opengrep exclusion). Root cause: `handleNotifyOpen` (`clients/lsp/client.ts`), the first time it sees a not-yet-open file, enqueues a `workspace/didChangeWatchedFiles` notification via `WatchedFilesQueue` (`clients/lsp/watch-queue.ts`, #271) — built specifically to coalesce a *burst* of file-opens into ONE project-wide recheck instead of N, because classic tsserver (and most push-diagnostics servers) kicks off an expensive full re-analysis on every such notification. `WatchedFilesQueue.enqueue` only arms its 100ms debounce timer on the FIRST call in a burst and just accumulates on every call after — but `runWorkspaceDiagnostics` (`clients/lsp/index.ts`) processes files SERIALLY, waiting up to several seconds per file for its own diagnostics before touching the next one, so consecutive first-opens during a sweep always landed far outside that 100ms window: every previously-unopened file fired its OWN watched-files notification, each independently triggering a project-wide recheck, and later files timed out purely from queueing behind those rechecks — not because anything was actually wrong with them. Whether a given run hit this depended entirely on how many swept files happened to already be open from earlier per-edit dispatch activity that session, explaining the run-to-run variance. Fix: each server group's files are now batch pre-opened (`preOpenGroupFiles`) in one fast, back-to-back pass — with no diagnostics wait between opens — immediately before that group's existing per-file diagnostics loop starts (right after the #387 Item 2 `workspace/diagnostic` pull attempt, so a group whose pull succeeds skips pre-opening entirely and still does zero per-file opens). Firing every open notification with no wait between them keeps every `enqueue()` call inside the 100ms debounce window, so `WatchedFilesQueue` coalesces them into a single flush per server the same way a per-edit dispatch burst already does; by the time the main per-file loop's own `touchFile` call runs, each document is already in `openDocuments`, so `handleNotifyOpen` takes the cheap already-open `didChange` branch and enqueues nothing further. File content read during pre-open is cached so the main loop doesn't re-read the same file from disk. Preserves, unmodified: #387's per-server serialization (pre-opening is itself serialized WITHIN a server group and parallelized ACROSS groups, the identical shape as the diagnostics loop it precedes — sending concurrent opens to one server would reintroduce the exact flooding pathology #387 fixed), #571's inconclusive-signal handling, #586's auxiliary-suppression filtering, and #591's opengrep sweep-exclusion (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`, reused as-is for pre-open's own `getClientsForFile` call) — none of these paths were touched. Per-edit dispatch's own burst-coalescing (`handleNotifyOpen`/`WatchedFilesQueue` themselves) is completely unchanged; this fix is scoped entirely to `runWorkspaceDiagnostics`. New `tests/clients/lsp/workspace-diagnostics-sweep-batch-open.test.ts` imports the real `WatchedFilesQueue` (not reimplemented) and a fake client whose `notify.open` mirrors `handleNotifyOpen`'s open/already-open branch split plus a deliberately slow (150ms, past the debounce window) `waitForDiagnostics` standing in for tsserver's real per-file latency — proving a 20-file sweep over previously-unopened files produces exactly one watched-files flush instead of 20. `tests/clients/lsp/workspace-diagnostics-per-server.test.ts` (#387), `-opengrep-exclusion.test.ts` (#591), and `-suppression.test.ts` (#586) all pass unmodified.
- **Managed npm tools now re-install when their `packageName` version pin changes** (#589) — `ensureTool()`/`getToolPath()` (`clients/installer/index.ts`) resolved an already-installed managed tool purely by existence + runnability: `verifyToolBinary()` spawned `<bin> --version` and checked only the exit code, discarding the reported version string, so an installed tool never picked up a later pin bump in code (e.g. a `jscpd@3.5.10` -> `jscpd@X.Y.Z`-style change) — it kept running whatever version it happened to install first, forever. Fix, scoped to `installStrategy: "npm"` entries with an explicit `@version` in `packageName` (the only kind with drift to detect — unpinned entries like `madge` have none): `verifyToolBinary` now accepts an optional `onVersionOutput` callback, invoked with the captured `--version` stdout on success; `getToolPath()`'s managed-local-install checks pass it for pinned npm tools, stashing the parsed version (`extractVersionToken`) into a new `lastManagedInstallVersion` map keyed by toolId. `ensureTool()`'s existing-install path compares that against the current pin (`parsePinnedVersion`) and, on mismatch, recurses through the EXISTING `forceReinstall` codepath rather than a new mechanism. Deliberately piggybacks on the spawn `verifyToolBinary` already performs on `ensureTool`'s slow path (post cache-miss) — the in-memory session cache and the 24h persistent probe cache are untouched, so a matching-version tool still resolves with zero new spawns on the hot path, and drift is detected at most once per session (or once per probe-cache TTL, whichever the caller hits first). Investigated whether `installStrategy: "github"/"maven"/"archive"` tools have the same drift bug: they do (an archive tree bundle's extract dir, e.g. `TOOLS_DIR/clangd`, isn't version-named, so bumping a hardcoded version constant in its download URL doesn't force a fresh extract), but none of those resolution paths ever spawn `--version` during discovery (`findGitHubToolPath`/`getArchiveTreeBundlePath` are pure `fs.access` checks) — there is no existing spawn to piggyback on, so fixing them would mean inventing a new mechanism, explicitly out of scope for this fix; tracked separately. New `tests/clients/installer/version-drift.test.ts` covers: a stale-versioned `jscpd` install forces reinstall; a matching-version install resolves normally with no install spawn; a second `ensureTool()` call on an unchanged, matching install hits the in-memory cache with zero new spawns; an unpinned npm tool (`madge`) is completely unaffected; and `getToolPath()` itself still reports a drifted binary as found (discovery is not gated on version — only `ensureTool()` routes drift to reinstall).
- **`// nosemgrep` inline suppression now honored by `lsp_diagnostics`/`lens_diagnostics`, not just per-edit dispatch** (#586) — pi-lens's own `# nosemgrep`/`// nosemgrep` inline-suppression parser (`isNosemgrepSuppressed`, added for #441 because opengrep's LSP mode doesn't honor it natively) was only ever consulted from the per-edit dispatch runner (`clients/dispatch/runners/lsp.ts`), so a suppression comment that correctly hid a finding during real-time editing still surfaced the identical finding when the same file was queried via the standalone `lsp_diagnostics` tool or `lens_diagnostics mode=full`'s workspace sweep — a real dogfooding report caught opengrep's `detected-github-token`/`detected-jwt-token` rules re-flagging an already-suppressed line. Extracted the profile-lookup-then-`isSuppressed` logic that `clients/dispatch/runners/lsp.ts` already had into two shared, generic helpers in `clients/dispatch/auxiliary-lsp.ts` — `isAuxiliaryDiagnosticSuppressed` (single-diagnostic predicate) and `applyAuxiliarySuppressions` (list filter) — so any `AUXILIARY_LSP_PROFILES` entry with an `isSuppressed` callback (currently only opengrep's) is honored uniformly, not hardcoded to opengrep. `clients/dispatch/runners/lsp.ts` now calls the shared predicate instead of duplicating the lookup; `tools/lsp-diagnostics.ts`'s `collectDiagnosticsForFile` and `clients/lsp/index.ts`'s `runWorkspaceDiagnostics` now filter their raw diagnostics through `applyAuxiliarySuppressions` using the file content already read at each call site (fail-open to unfiltered diagnostics if the content read itself failed). Covered by new tests in `tests/clients/dispatch/auxiliary-lsp.test.ts` (the shared helpers directly), `tests/tools/lsp-diagnostics.test.ts` (single-file and batch `lsp_diagnostics` suppression), and `tests/clients/lsp/workspace-diagnostics-suppression.test.ts` (the `runWorkspaceDiagnostics` sweep).
- **`high-fan-out`/`high-complexity` no longer false-positive on `describe()`/`it()` test bodies** (#577, found via #576's live-evidence follow-up) — running the real dispatch pipeline against 4 real test files in this repo showed every one triggering a `high-fan-out`/`high-complexity` finding on the outer `describe(...)` callback (up to "51 distinct functions" / cyclomatic complexity 15), while the other 11 `FactRule`s in `clients/dispatch/rules/` had no evidence of test-file noise. Root cause was two-fold, confirmed by directly running `functionFactProvider` against the reported lines: (1) `expect(x).matcher(...)` assertion chains each counted as a *distinct* callee in `high-fan-out`'s fan-out count, because the callee text is the verbatim member-expression source including the differing arguments (`expect(a).toBe` vs `expect(b).toContain` are never deduplicated); (2) the shared tree-sitter walk that computes a function's `outgoingCalls`/`cyclomaticComplexity`/`maxNestingDepth` (`clients/dispatch/facts/function-facts.ts`) doesn't stop at nested-function boundaries, so a `describe()` wrapper's own metrics aggregate every call and branch from ALL of its nested `it()` bodies (each of which also gets its own, correctly-scoped, separate `FunctionSummary`) — a `for` loop inside several sibling `it()`s sums into the enclosing `describe()`'s complexity even though no single test is complex. Scoped narrowly to just these two rules (not a runner-level `skipTestFiles` on `fact-rules.ts`, which would have silently dropped signal from the other 11 rules too, and not a blanket `ctx.fileRole === "test"` exemption, which would have also suppressed genuinely complex test HELPER functions). New shared `clients/dispatch/rules/framework-call-noise.ts` (module named to avoid the pre-existing `test-*.ts` gitignore pattern meant for ad-hoc scratch scripts) exports two call-name-based heuristics used by both rules: `isTestFrameworkNoiseCall` extends `high-fan-out`'s existing "meaningful calls" filter (same style as its `console.*`/`Math.*`/etc. exclusions) with `expect(...)`/`expect`, test lifecycle names (`it`/`test`/`describe`/`beforeEach`/`afterEach`/`beforeAll`/`afterAll`, including `.only`/`.skip`/`.each` variants), and `vi.*`/`jest.*` mock-library prefixes; `isTestSuiteOrganizer` skips evaluating a function entirely (in both rules) when its raw outgoing calls include a direct call to `it`/`test`/`describe` — i.e. it groups nested tests rather than implementing logic itself. Verified empirically that call-name filtering alone was insufficient for the largest reported case (52 calls filtered down to ~25, still over the 20 threshold) before adding the organizer check. A genuinely tangled test HELPER function that does NOT itself call `it`/`describe`/`test` is still flagged by both rules (preserving real signal), and production (non-test) files are completely unaffected — both covered by `tests/clients/dispatch/rules/high-fan-out-complexity-test-noise.test.ts`, which also reproduces the real `describe()`-wrapping-multiple-`it()`s shape from `tests/clients/widget-state.test.ts` end-to-end through `functionFactProvider`.
- **`fact-rules` diagnostics now stamp `tool: "fact-rules"` instead of their own rule id, fixing scattered turn-summary grouping** (#578) — `clients/dispatch/types.ts`'s `Diagnostic` contract documents `tool` as "which runner produced this" and `rule` as "the specific check within it"; `ast-grep`'s runner follows it correctly (one `tool: "ast-grep"` value shared across every pattern), but all 13 rules under `clients/dispatch/rules/*.ts` (the `fact-rules` runner) stamped their own rule id into `tool` instead (`{ tool: "high-fan-out", rule: "high-fan-out" }`), so `clients/turn-summary.ts`'s collapsed one-liner — which groups by `event.tool` — scattered N fact-rule findings into N separate per-rule-name buckets instead of one clean `fact-rules N` bucket like every other runner. Fixed at the emission source in all 13 files (`async-noise.ts`, `async-unnecessary-wrapper.ts`, `cors-wildcard.ts`, `error-obscuring.ts`, `error-swallowing.ts`, `high-complexity.ts`, `high-fan-out.ts`, `high-import-coupling.ts`, `missing-error-propagation.ts`, `no-commented-credentials.ts`, `pass-through-wrappers.ts`, `placeholder-comments.ts`, `unsafe-boundary.ts`) — `rule` is untouched, so `detectFactRuleId` (`clients/dispatch/integration.ts`, rule-first with an id-prefix fallback) and `code-quality-warnings.ts`'s `warning.rule ?? warning.tool` grouping both keep resolving to the specific rule id. Also closed a latent gap the fix surfaced: `error-obscuring.ts` and `error-swallowing.ts` had no `rule` field at all (only `tool` carried the rule id), which would have made `code-quality-warnings.ts`'s `rule ?? tool` fallback collapse them into `"fact-rules"` too post-fix — both now set `rule` explicitly, matching the other 11 rules' existing shape. New test in `tests/clients/turn-summary.test.ts` constructs three fact-rule diagnostics with distinct `rule` values sharing `tool: "fact-rules"` and asserts they collapse into a single `fact-rules 3` bucket via `formatTurnSummaryLine`.
- **`clientScope: "all"` (the standalone `lsp_diagnostics` tool and `lens_diagnostics_full`) now gets per-server-aware diagnostics timeouts instead of one flat cap for every spawned server** (#573, found investigating the #570 incident: a 150-file `lens_diagnostics_full` scan took 114s and saturated the TypeScript LSP server) — `LSPService.touchFile` (`clients/lsp/index.ts`) already computed a `perServerTimeout` that reads each server's own `aggregateWaitMs` budget from `server-strategies.ts` (TS ~1s, rust-analyzer 3s, opengrep 3.5s, …), but only wired it up for the single-server `"primary"` hot path (#203) and, later, `clientScope: "with-auxiliary"` (#242). `clientScope: "all"` fell through to the pre-#203 flat `callerCap ?? modeFloor` branch — a leftover from #203 explicitly deferring the "full/cascade path," never revisited when #242 added per-server budgeting for auxiliaries. Every server (fast primary, slow auxiliary) was held to one shared number, either wasting time on a fast server capped to a slow auxiliary's ceiling (multiplied across a full-workspace scan) or starving a slow auxiliary of the time its own strategy says it needs. Fix: `clientScope === "all"` is now included alongside `"with-auxiliary"` in the `perServerTimeout` branch, so each spawned server's individual `waitForDiagnostics` call is bounded by `min(callerCap, ownStrategyBudget)` rather than the flat number. The touch's overall detection deadline (used only for the `lsp_diagnostics_timeout` latency log) is unchanged — it's still `Math.max(...)` over every spawned server's timeout, so "all" still waits for the slowest server before logging a timeout; nothing about auxiliary coverage regresses. `envWait` (`PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`) and the existing `"primary"`/`"with-auxiliary"` behavior are untouched — this is purely additive coverage of the previously-flattened `"all"` case. Evaluated whether the richer live capability-matrix data (`getCapabilitySnapshots()`'s push/pull `mode`, `diagnosticProviderKind`) should feed `perServerTimeout` beyond what `server-strategies.ts` already encodes; found no case in the current budgeting logic it would improve (the existing `aggregateWaitMs`/`silentOnClean` table already differentiates push vs. pull servers where it matters) — left as a documented non-integration rather than added complexity; `cascade-tier.ts`'s existing capability-matrix consumption is a separate lane (cascade-lane skip-decision, not touch-time budgeting) and is unaffected. New tests in `tests/clients/lsp/service-touch-collect.test.ts` (`#573`): each server on the `"all"` scope gets its own caller-cap-bounded deadline rather than a shared flat number; a fast primary's own wait isn't held to a slow auxiliary's larger budget; a tight caller cap still binds every server as a ceiling; the env override still wins; and a regression guard confirming `"primary"`/`"with-auxiliary"` per-server behavior is unchanged.
- **A timed-out LSP diagnostics check no longer presents as a confirmed-clean result and no longer erases known-good diagnostic state** (#570, found via live log analysis on a dogfooding project) — `LSPService.touchFile` (`clients/lsp/index.ts`) already tracked `notifyWriteTimedOut`/`diagnosticsTimedOut` per touch (logged to `lsp_touch_file` latency events) but never used them to gate anything: an inconclusive empty `collected` result was cached and returned identically to a genuinely server-confirmed empty result. Concretely, a timeout unconditionally deleted `lastKnownDiagnostics`/`lastKnownContentHash` for the file (so a hot-path consumer like `actionable-warnings` at `turn_end` would see "no known diagnostics" for a file that may still have real errors), and callers of `touchFile` (the per-edit dispatch runner, the `lsp_diagnostics` tool) had no way to distinguish "confirmed 0" from "timed out, defaulted to 0". Fix: `touchFile` now computes `inconclusive = notifyWriteTimedOut || diagnosticsTimedOut` (deliberately touch-wide/conservative — `collected` merges diagnostics across every spawned server, so even a partial per-server timeout means the merge may be incomplete) and (1) skips the `lastKnownDiagnostics` set-or-delete block entirely when inconclusive, leaving whatever was cached from the last confirmed check untouched; (2) flags the returned diagnostics array with a non-enumerable `inconclusive: true` bonus field (a plain array otherwise — existing callers that only read it as an array are unaffected) so callers that care can check `.inconclusive` without a breaking return-type change across `touchFile`'s ~10 production call sites. Two consumers wired: `clients/dispatch/runners/lsp.ts` (the per-edit path feeding the footer/widget via `recordDiagnostics`) now returns `status: "skipped"` (same treatment as "no LSP client was ready") instead of `"succeeded"` with an empty diagnostics list when the touch was inconclusive — this automatically feeds the existing dispatcher coverage-notice mechanism (`getCoverageNotice`), so an inconclusive edit is flagged the same way a fully-skipped one is. The standalone `lsp_diagnostics` tool (`tools/lsp-diagnostics.ts`, and its `pilens_lsp_diagnostics` MCP mirror, which reuses the same `createLspDiagnosticsTool()`) now threads the priming `touchFile`'s `inconclusive` flag through `collectDiagnosticsForFile`/`collectFileDiagnosticResult` and folds a timed-out check into the existing #533 "unconfirmed" bucket (never a bare "0 diagnostics"), while distinguishing WHY in the rendered text/compact-render/batch-and-directory tallies ("N timed out" vs. #533's "cannot confirm clean — push-only, silent-on-clean") via a new per-result `timedOut` field and `timedOutFiles`/`unconfirmedReasonClause` aggregation, rather than collapsing the two distinct reasons into one misleading message. The non-timeout path is unchanged: a genuinely fast, confirmed empty result still clears the cache and reports clean exactly as before. New tests: `tests/clients/lsp/service-touch-collect.test.ts` (`#570` describe block — a timed-out touch does NOT clear a prior confirmed non-empty `lastKnownDiagnostics` record; a confirmed non-timeout empty result still clears it as before), `tests/clients/dispatch/runners/runner-status-semantics.test.ts` (the lsp runner returns `"skipped"` on an inconclusive touch), `tests/tools/lsp-diagnostics.test.ts` (`#570` describe block — single-file/batch renders distinguish timed-out from confirmed-clean/silent-unconfirmed).
- **`lens_diagnostics` mode=full and `lsp_diagnostics` now reconcile fresh scan results into the footer/widget-state cache** (#571) — `recordDiagnostics` (`clients/widget-state.ts`), the sole writer to the footer's `allDiagnostics` store, previously had exactly one caller: `pipeline.ts`'s per-edit dispatch. A `lens_diagnostics` mode=full workspace scan or a standalone `lsp_diagnostics` check fetches fresh, authoritative diagnostics for files it examines but, until now, only reported that data back to the caller — never reconciled it into the footer. Practical consequence: if file A's diagnostics only became stale/fresh because of a change in file B (e.g. a shared interface), and A itself was never directly re-edited, the footer for A could stay stale indefinitely even after a scan proved the fresher truth. Both tools now call a new shared choke point, `clients/widget-state.ts`'s `reconcileScanDiagnostics(filePath, diagnostics, confirmed, writeIndex?)`, for each file they get a result for; `index.ts` injects the same monotonic `RuntimeCoordinator.nextWriteIndex()` source `pipeline.ts`'s per-edit writes draw from, so the existing `WriteOrderingGuard` (#555/#560) can't be clobbered by (or clobber) a concurrent, genuinely newer per-edit write for the same file. Guardrail: a result the check can't vouch for is never reconciled. `#570` landed in the same window as this fix (see above) and its `inconclusive` signal (`touchFile`'s non-enumerable `.inconclusive` flag, set when the notify write or the diagnostics wait itself timed out) is what BOTH tools now key off: `lsp_diagnostics` reuses it directly (threaded from `collectFileDiagnosticResult`/`runFileDiagnostics`'s own `timedOut`/`confirmation` fields), and `lens_diagnostics` mode=full's per-file LSP sweep (`runWorkspaceDiagnostics`) reads the same flag off each `touchFile` call's result and ORs it with its own outer per-file deadline/throw (`LSPWorkspaceDiagnosticResult.timedOut`) — either reason skips reconciliation for that file. No new batching/throttling was added for full-scan bursts — `recordDiagnostics`'s render trigger is a standard TUI dirty-flag request (already exercised by today's per-edit multi-file cascades without dedicated batching) and each write is independent/synchronous, so a scan touching many files behaves the same as N sequential per-edit writes already do. New tests: `tests/clients/widget-state.test.ts` (`reconcileScanDiagnostics`'s confirmed/unconfirmed gating and write-ordering-guard interaction), `tests/tools/lens-diagnostics.test.ts` and `tests/tools/lsp-diagnostics.test.ts` (each tool's confirmed-vs-timed-out/unconfirmed reconciliation wiring, including batch mode).
- **Native TS7 no longer inherits classic typescript-language-server's `silentOnClean` cascade fast path** (#558, reverts #541) — PR #541 (2026-07-11) classified TS7's native `tsc --lsp --stdio` launch variant (PR #526) as `silentOnClean`, letting the cascade lane (`clients/lsp/cascade-tier.ts`) skip its in-lane diagnostic wait for native-ts7 edits on the strength of a clean-signal probe run that appeared to show it silent, same as classic. A 2026-07-12 dual-environment re-measurement (nightly CI on Linux and a live local run on Windows dev, same `typescript@7.0.2` both times) found native-ts7 now publishes 2 version-less diagnostic sets on the clean transition (`cleanPubs=2(v:0)`) — it is NOT silent, a drift from the #541 measurement, and skipping the wait could miss or delay real diagnostics on native-ts7 edits. `cascade-tier.ts`'s classifier again routes a `launchVariant === "native-ts7"` snapshot through the fail-safe full-wait path; `server-strategies.ts`'s `silentOnClean: true` for `"typescript"` is effectively classic-only again. Classic typescript-language-server is unaffected — re-confirmed silent (`cleanPubs=0(v:0)`) in the same run. `scripts/probe-clean-signal.mjs`'s nightly drift check no longer routes native-ts7 rows through classic's shared marker; it now compares them against an explicit `false` expectation, so a future TS7 build that goes silent again surfaces as a `silent-not-marked` signal instead of being silently skipped.
- **LSP diagnostics no longer briefly cache stale results after rapid edits** — `textDocument/publishDiagnostics` pushes were written into the diagnostics cache (`pushDiagnostics`) unconditionally, even when the server's own reported `version` field showed the push was computed against an EARLIER edit than the one currently in flight (e.g. the server was still finishing analysis of edit N when edit N+1 already landed). `isVersionStale()` (`clientWaitForDiagnostics`'s staleness check) only gated whether a diagnostics *wait* resolved early — it was never consulted by the plain read path (`client.getDiagnostics()`/`getAllDiagnostics()`/`pruneDiagnostics()`), so a late push could get cached and served as "current" until the next genuinely fresh push overwrote it: diagnostics would transiently show stale results right after a rapid edit, then self-correct a moment later once real analysis caught up. The `publishDiagnostics` handler in `clients/lsp/client.ts` now drops a push before it reaches the cache (no `pushDiagnostics.set`, no `diagnosticsVersion` bump, no `diagnostics` event emit) whenever the push reports a version that's behind the currently-tracked document version for that path — checked at write time (after the debounce timer fires, not at notification-receipt time) so a push that arrives fresh but whose debounce window straddles a later edit is still caught. Version-less servers (no `version` field reported) are unaffected — that remains an intentional, documented tradeoff, unchanged by this fix. A dropped push correctly emits nothing, so a pending `clientWaitForDiagnostics` call still falls through to its other resolution paths (a later genuinely-fresh push, or the existing timeout backstop) rather than resolving on stale data. Deliberately out of scope, left as known follow-ups: the pull-diagnostics path (`clientRequestPullDiagnostics`/`clientRequestWorkspaceDiagnostics`) has no version stamp to compare against in this codebase's current handling, so nothing analogous is applied there; and `diagnosticsVersion` remains a single global counter rather than per-path, so an unrelated path's fresh push can still satisfy a wait baselined on this path's version — both are separate, larger-blast-radius changes.
- **`recordDiagnostics` drops superseded writes to the widget-state diagnostics cache (same race class as #555)** — pi-lens deliberately allows concurrent pipeline runs for the same file across different same-turn edits (dedupe key is `filePath + contentHash`, not just `filePath`), so an older edit's pipeline can still be running analysis (e.g. a slow lint/LSP runner) when a newer edit's pipeline has already finished. `clients/widget-state.ts`'s `recordDiagnostics()` — the store `lens_diagnostics` and the TUI widget read as "current" for a file — previously overwrote a file's diagnostics unconditionally on every call, with no ordering check. If the older edit's write landed after the newer edit's, the diagnostics shown for that file could transiently reflect the older, superseded edit rather than the one currently on disk — self-correcting only if/when a later write happened to land. `recordDiagnostics` now takes an optional `writeIndex` (threaded from the monotonic per-edit token already assigned in `clients/runtime-tool-result.ts`, via `clients/pipeline.ts`) and drops a write whose token lags the last one already recorded for that path — no diagnostics/count/timestamp update and no render trigger for a dropped write, so it can't partially corrupt the winning write's state. Call sites with no ordering token (e.g. `clients/mcp/analyze.ts`'s on-demand recorder) are unaffected, same as version-less LSP servers in the #555 fix. Extracted the guard shape into a small, reusable, non-diagnostics-specific primitive, `clients/write-ordering-guard.ts`'s `WriteOrderingGuard` (a `Map`-backed "only proceed if this token is >= the last-seen token for this key" check), available for #557's tracked follow-up (`code-quality-warnings.ts`, suspected same bug shape) rather than duplicating the check a second time. `clients/lsp/client.ts`'s already-merged #555 fix is left as-is (additive only, not retrofitted).
- **Dropped the redundant `ast_dump` tool registration** — `createAstGrepDumpTool`/`createAstDumpTool` (`tools/ast-dump.ts`) were two separate `registerTool` calls wrapping the exact same implementation under two names, doubling that tool's weight in the tool list for zero functional benefit. `ast_grep_dump` (the already-documented preferred name, referenced throughout `AGENTS.md`/`docs/agent-tools.md`/the ast-grep skill) is kept; the `ast_dump` alias registration and its now-unused `createAstDumpTool` export are removed.
- **`bus-events.log` registered with the log-cleanup retention sweep, and `MANAGED_LOG_FILES` replaced with auto-derivation** — #551 added `clients/bus-events-logger.ts` (`~/.pi-lens/bus-events.log`) but never added it to `clients/log-cleanup.ts`'s hand-maintained `MANAGED_LOG_FILES` array, so it grew unbounded and untouched by retention — the third time this exact class of mistake has happened (after actionable-warnings/ast-grep-tools/dead-code, per the module's own doc comment). Rather than patch the array again, `clients/ndjson-logger.ts`'s `createNdjsonLogger` now self-registers the absolute path of every static-`filePath` instance into an exported registry (`getRegisteredLogFiles()`) at construction time — i.e. at the `*-logger.ts` module's own load time, with zero action needed in `log-cleanup.ts`. `log-cleanup.ts`'s new `getManagedLogFiles()` derives its list by unioning: (1) that registry, filtered to the target directory; (2) a direct `~/.pi-lens/*.log` directory read (excluding rotated-backup names via the existing `ROTATED_BACKUP_RE`) as an import-order safety net, in case a future logger module is only ever dynamically imported and hasn't self-registered by sweep time; (3) a small explicit `UNMANAGED_STRAGGLER_LOG_FILES` list (currently just `sessionstart.log`, which predates `createNdjsonLogger` and writes via a bespoke `fs.appendFile` in several modules, so it can't self-register). Both `rotateLogIfNeeded`'s sweep and `getLogStorageSummary` now read from `getManagedLogFiles()` instead of the removed static array — a new global log built on the shared `createNdjsonLogger` writer gets retention/rotation coverage automatically, no second list to remember. Audited the four loggers that separately pass their own `maxBytes`/`backupPath` to `createNdjsonLogger` (`actionable-warnings-logger.ts`, `ast-grep-tool-logger.ts`, `dead-code-logger.ts`, `read-guard-logger.ts`, all rotating at a ~1MB default into a `.log.1` backup) against the centralized 10MB-default sweep (which rotates into a `.<timestamp>.log` backup): both mechanisms are active on the same files, but not in real conflict — the lower per-instance threshold fires first in practice, so the centralized sweep is a rarely-triggered backstop for those four, not a race; left as-is rather than restructuring rotation for an unrelated set of files.
- **Test runner now resolves mirrored test-tree layouts** (#547) — `TestRunnerClient.findTestFile` only checked same-directory, `dir/__tests__/`, and flat top-level `tests/<basename>` locations, missing the common "mirrored subdirectory" layout (`tests/<same-relative-subdir>/<basename>.test.ts`) used by this repo itself (`clients/knip-client.ts` → `tests/clients/knip-client.test.ts`). As a result, pi-lens's own `turn_end` test-runner integration could only resolve its own tests via the slower/brittle import-scan fallback. `findTestFile` now also checks `tests/<relative-subdir>/` and `__tests__/<relative-subdir>/` for TS/JS exact-match candidates, and the equivalent mirrored directory for the Python glob search (`test_*.py` / `*_test.py`), before falling back to import scanning. Additive — existing same-dir, `__tests__/`, and flat-`tests/` candidates are unchanged and still checked first. Two follow-up gaps closed in the same fix: (1) `detectRunner`'s `node_modules` check only looked in `cwd`, missing hoisted monorepo layouts (npm/yarn/pnpm workspaces) where a workspace package's own `node_modules` doesn't exist and vitest/jest only live at the workspace root several directories up — `findHoistedNodeModulesPackage` now walks up parent directories looking for the runner package, bounded to `MAX_NODE_MODULES_WALK_UP` (5) levels and stopping at the filesystem root, never an unbounded walk. (2) Python suites that group tests by kind (`tests/unit/`, `tests/integration/`) rather than mirroring the source tree still fell through to the import-scan fallback, which doesn't handle `.py` files at all — `findPytestMatchRecursive` now does a depth-bounded breadth-first search under the test root (`MAX_PYTEST_RECURSE_DEPTH`, 3 levels, skipping hidden dirs and `__pycache__`) as a last resort before import scanning, only engaged when the exact-mirror candidates don't match so it never overrides the existing mirrored-match preference. Two more follow-up gaps closed: (3) `getTestRunTarget` called `findTestFile` unconditionally even when the edited file was itself already a test file — e.g. editing `foo.test.ts` directly stripped the extension to basename `foo.test` and searched for nonsense candidates like `foo.test.test.ts`, found nothing, fell through the import-scan fallback (which also finds nothing, since nothing imports a test file), and returned `null` — silently disabling the `turn_end` test-runner integration for the very common case of editing a test file directly. `getTestRunTarget` now checks whether the edited file is itself a test file (reusing the shared `detectFileRole` classifier from `clients/file-role.ts` — no second parallel detector) and, if so, skips discovery entirely and returns the file itself as the target (new `strategy: "self"`), while still preferring the existing failed-first rerun path when that same test file is already in the known-failing set. (4) Added a best-effort, text-only scrape of a vitest config's `test.include`/`test.exclude` arrays (`parseVitestTestGlobs`, cached per `cwd`) as a secondary signal alongside `detectFileRole` — deliberately not a real config load (no ESM/TS execution, which `runtime-turn.ts`'s per-edit `turn_end` hot path can't afford), just a regex extraction of a plain string-literal array when the config is written in that simple shape, falling back to `null` (zero behavior change) for anything more dynamic (function calls, spreads, computed values) or when no vitest config exists.

## [3.8.69] - 2026-07-11

### Fixed

- **Warm MCP server no longer silently serves stale code after a rebuild** (#535, refs #514/#256) — the long-lived warm server loads its code once at process start and never re-reads disk, so a `npm run build:dist`/merge that lands after the server started went completely undetected: dogfooding a post-#517 rebuild through an already-running server still returned the pre-#517 `pilens_module_report` schema, the exact "plausible-but-wrong" failure the #240/#511 honesty doctrine exists to prevent. Fix: at startup, `mcp/build-staleness.ts`'s `computeBuildStamp` captures the mtime of the server's OWN entry file (resolved via `import.meta.url`, never a hardcoded repo path — the server may run from an installed package); every `tools/call` and the IPC side-channel handler re-check via a `StalenessGate` (one `fs.stat`, cached at most once/second — same shape as the #492 cross-process reader, so a burst of calls costs one stat). On a detected mismatch: `pilens_analyze` (a stateless per-file dispatch with no warm-only dependency) force-routes through the EXISTING `mode=fresh` worker fork even when the caller asked for warm, tagging the result `servedBy: "fresh (warm code stale — restart the Claude session to re-warm)"`. Every other tool depends on state that only exists inside this process (the in-memory review graph behind `pilens_module_report`/`pilens_symbol_search`, the warm LSP fleet behind `pilens_lsp_navigation`/`pilens_lsp_diagnostics`, the CacheManager/latency log behind the rest) — a fresh fork would answer with an EMPTY graph, a worse result than a stale-but-populated one, so those get an honest `warmCodeStale: true` warning appended instead of routing. The PostToolUse hook's warm-IPC-first path (`clients/mcp/ipc.ts`) gets the same protection for free: on stale, the IPC handler replies with an error, which the hook bin (`mcp/analyze-cli.ts`) already treats as "no usable warm server" and falls back to its own cold, load-fresh-from-disk analysis — no new fresh-fork plumbing needed there. Kill switch: `PI_LENS_WARM_STALENESS_CHECK=0`.
- **`lsp_diagnostics`/`lens_diagnostics` no longer render an unanswerable scope as "0 diagnostics"** (#533) — dogfooding saw a live session render `workspace — 0 diagnostics` from `lsp_diagnostics` against classic typescript-language-server, a push-only server that publishes NOTHING on a clean→clean transition (`silentOnClean`, `server-strategies.ts`) — that "0" is unverifiable: it can mean clean, still-analyzing, or never-asked (the #240 doctrine, now applied to the tool surface). Root cause: `collectFileDiagnosticResult` (`tools/lsp-diagnostics.ts`) treated an empty diagnostics array as unconditionally clean, with no path for "the server never confirmed this." Fix: a new `classifyEmptyResult` helper reuses the #458 cascade lane's own classifier (`classifyCascadeWaitTier`, `clients/lsp/cascade-tier.ts`) — the same live capability-snapshot + `silentOnClean` check already trusted there — to mark an empty result `"unconfirmed"` when it came from a push-only, silent-on-clean server, vs. `"clean"` otherwise (pull servers, or push servers not known to be silent). Batch and directory aggregation now tally clean vs. unconfirmed per file and surface both in the tool result text (`"7 clean · 2 unconfirmed (server cannot confirm — push-only, silent-on-clean...)"`) and the compact render (`lsp_diagnostics across 12 files — 3 diagnostics · 7 clean · 2 unconfirmed`) — an unconfirmed-containing result can never compact-render as a bare diagnostic/clean count. `lsp_diagnostics` has no workspace-pull mode today (only file/paths/directory), so there is no workspace-pull path to gate on the capability snapshot's mode; the directory/batch per-file scan itself IS the fallback the issue's directive asks for, now made honest. Separately, `lens_diagnostics mode=full`'s cache-only extractor registry (`clients/project-diagnostics/extractors.ts`, knip/jscpd/madge/gitleaks/govulncheck/trivy/dead-code) silently skipped any analyzer with no cache entry — indistinguishable from an analyzer that ran and found nothing clean. `extractCachedProjectDiagnostics` now also returns which extractor ids are cold (never populated this session), and the tool appends an actionable note naming each cold analyzer and what warms it (following #511/#514's honesty-warning shape), both in the result text and the compact render's `(N cold: knip, jscpd, ...)` suffix — a fully-cold registry can no longer render as a plain "clean". Fail-safe throughout: any classification error defaults to the pre-#533 behavior (clean / no cold note) rather than manufacturing a new failure mode.
- **Test hermeticity for `~/.pi-lens` machine-global state + reaper heartbeat-staleness gap** (#525, refs #515/#474/#449) — dogfooding found a test-fixture instance (`Temp/pi-lens-turn-summary-*` projectRoot) in the developer's REAL `~/.pi-lens/instances.json`, alongside the genuine live session, ~17h after the test run that created it. Two fixes: (1) every machine-global writer (`instances.json`, `probe-cache.json`, all loggers, managed tool/bin dirs, LSP server storage) already routed through the single `getGlobalPiLensDir()` helper (`clients/file-utils.ts`) except four stragglers that bypassed it with a direct `os.homedir()` call (`diagnostic-logger.ts`, the installer's `PROBE_CACHE_PATH`, `biome-client.ts`/`jscpd-client.ts`'s managed-bin lookups, and `lsp/server.ts`'s `tryGemInstall`) — all four now route through it too, and the helper gained a `PI_LENS_HOME` env override (the machine-scoped sibling of the existing project-scoped `PILENS_DATA_DIR`), same pattern as #515's `PI_LENS_CONFIG_PATH` for `config.json`. `tests/support/vitest-setup.ts` now points `PI_LENS_HOME` at a per-worker `mkdtemp` directory (not a nonexistent path — the instance registry actively writes into this root during normal operation), so no test can leak into the real homedir again; a regression test (`tests/clients/pi-lens-home-hermeticity.test.ts`) proves `registerInstance` never touches the real registry file. (2) Root-caused why the fixture entry survived a reap 13h later: `decideOrphanReaping` (`clients/instance-reaper.ts`) only ever classified a parent instance as dead via raw `process.kill(pid, 0)` pid-liveness — unlike child LSP pids, which get a command-line/marker identity check to guard against a recycled pid, the parent pid had NO identity verification at all (there was nothing to check it against). Windows recycles pids far more aggressively than POSIX (no zombie/wait-reaping semantics holding a dead pid "reserved"), so over a long enough window a dead parent's pid is very plausibly reassigned to a live, unrelated process, and `isPidAlive` — correctly, per its own conservative contract — reports it alive forever. `decideOrphanReaping` now also checks heartbeat staleness (new `STALE_HEARTBEAT_MS`, 6 hours), with a deliberate ASYMMETRY by consequence: **staleness cleans registry ENTRIES, never enables kills**. A pid-alive-but-stale instance goes into a new `staleInstances` bucket — its entry is dropped from `instances.json`, but nothing is killed and its children stay marker-protected. Why the asymmetry: heartbeats only fire at turn end (`runtime-turn.ts`) and run settle (`quiet-window.ts`) — no timer exists — so a pi session left open but unused overnight legitimately goes >6h stale while genuinely alive with a warm LSP fleet; killing on staleness would take that fleet down under the idle session, and `matchProcess` identity verification would NOT save it (the children really are that instance's servers — the matcher guards against pid reuse, not against misclassifying a live parent). Process kills still require a pid-confirmed-DEAD parent, exactly as before. Note for anyone reading the real `~/.pi-lens/instances.json` on this machine: any lingering test-fixture entries from before this fix age out of the registry automatically at the next session_start sweep (kills still require a dead pid) — no manual cleanup needed.
- **Bundled skills namespaced with a `pi-lens-` prefix to avoid user-skill collisions** (#519, reported by @orest-tokovenko-block) — pi discovers both extension-bundled and independently-installed user skills by their frontmatter `name`, and pi-lens's generic skill names collided with unrelated user skills sharing the same name; on a collision, discovery precedence silently skips one copy with a conflict warning (`"ast-grep" collision: ... pi-lens/skills/ast-grep/SKILL.md (skipped)`), so the bundled skill simply stopped being offered. All four bundled skills are renamed with their directories (`git mv`, history preserved) and frontmatter `name` updated to match: `skills/ast-grep` → `skills/pi-lens-ast-grep`, `skills/lsp-navigation` → `skills/pi-lens-lsp-navigation`, `skills/write-ast-grep-rule` → `skills/pi-lens-write-ast-grep-rule`, `skills/write-tree-sitter-rule` → `skills/pi-lens-write-tree-sitter-rule`. Behavior is otherwise unchanged — only the discovery name/path moved. User-facing: anyone who previously invoked the bundled skill explicitly (e.g. `/ast-grep`) must now invoke it by its namespaced name (e.g. `/pi-lens-ast-grep`). `tests/index-wiring.test.ts`'s skill-resolution test now asserts all four namespaced directories exist AND that none of the old generic names exist (a regression guard against renaming back).
- **Project ast-grep rule precedence follow-ups** (refs #497) — the shared raw-LSP/NAPI discovery seam now walks project and bundled native/CodeRabbit rule trees recursively in deterministic project-primary → project-secondary → bundled-native → bundled-CodeRabbit order. Mutable project-rule caches fingerprint relative paths and contents, so equal-size or preserved-mtime edits, ID changes, renames, additions, and removals invalidate correctly. Synthesized configs and merged rule artifacts are isolated per workspace root in multi-root processes, while NAPI receives the explicit dispatch project root instead of relying on incidental `process.cwd()`. Cross-layer same-ID rules still keep the higher-precedence winner; same-layer duplicates remain raw `sg` errors and now produce equivalent blocking NAPI configuration diagnostics without exposing private paths. Note: recursive bundled discovery also activates the vendored CodeRabbit CWE catalog (~184 rules, all under language subdirectories) for the first time — on the previous top-level-only discovery it silently loaded zero rules in both the NAPI and raw-LSP paths; a regression test now pins that a nested CodeRabbit rule actually fires.
- **Turn-summary renderer no longer crashes pi on over-width lines** (#513) — both render paths in `clients/turn-summary-render.ts` ignored the `width` parameter of pi-tui's `Component.render(width)` contract, and pi-tui hard-crashes the whole host (`uncaughtException: Rendered line N exceeds terminal width`) on any rendered line wider than the terminal — a 133-column collapsed summary line took down a live 120-column session in the first real-world dogfooding run after #500. The dual-signature `truncateToWidth` shim previously private to the footer widget (`widget-state.ts`) is extracted to a shared `clients/tui-fit.ts` (`fitLine`/`fitLines`), and every turn-summary line — collapsed and expanded — is now fitted to the width the TUI hands us (non-positive/non-finite widths pass through untruncated rather than emitting empty lines). Regression tests measure with the real `visibleWidth`, since the mock-based renderer tests were exactly what let this ship.
- **`module_report`'s `usedBy`/`semantic` degradation is now honest instead of silent** (#511) — dogfooding found `pilens_module_report` (MCP, warm server) on a recently-added file returning `provenance.usedBy: "none"` and `semantic.source: "none"` with no explanation, indistinguishable from a fully cold (never-built) review graph. Root cause: `moduleReport` (`clients/module-report.ts`) reads the review graph read-only by contract (#256, never builds) — legitimate when the graph just hasn't been rebuilt since a file was added, producing a genuinely stale-but-warm graph (the reported case: the persisted `review-graph.json` predated the file's addition by 12 days, so it had 9,121 nodes but none for the new file). Not a wiring bug — the MCP server read the correct, current graph for that project (`getProjectDataDir`/`normalizeMapKey` keying checked out fine); it was simply stale. Fix: `moduleReport` now distinguishes "no graph at all" (`!graph`, an honest cold start) from "a graph exists but has no node for this file" (`graph && !hasGraphNode`) and pushes an actionable warning in the latter case naming `pilens_rebuild` as the fix, instead of looking identical to the fully-cold case.

- **Subagent light mode now also detects avtc-pi-subagent children** (#507) — `isSubagentSession()` (`clients/subagent-mode.ts`) only recognized `PI_SUBAGENT_CHILD=1`, the marker nicobailon/pi-subagents sets; avtc-pi-subagent (the spawn engine under avtc-pi-feature-flow) is the same real child-process execution model but never sets that var — it sets `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` instead (grep-verified against avtc-pi-subagent@1.0.3). Consequence: light mode silently never engaged for its children, which run the full heavyweight session-start scan suite, multiplied by feature-flow's parallel-reviewer fan-out (up to ~9 subagents per review round). Detection now also treats the PAIR `PI_SUBAGENT_CHILD_AGENT` + `PI_SUBAGENT_PARENT_PID` (both non-empty) as a subagent signal — requiring the pair rather than either var alone is a deliberate false-positive guard, since a lone var set by some unrelated tool must not trigger light mode. `PI_LENS_SUBAGENT_FULL=1` remains the universal opt-out for both vocabularies. `getSubagentIdentity()` now also reports which vocabulary matched (`marker: "pi-subagents" | "avtc-pi-subagent"`), surfaced in the `subagent_light_mode` latency phase so dogfooding can tell the ecosystems apart. The issue's Layer A pinned-contract + Layer B behavioral compat-smoke additions for avtc-pi-subagent are a deferred M-effort follow-up, not part of this fix.
- **Generated ast-grep/LSP config now honors project-first same-id rule precedence** (#497, reported by @anasalsbey-glitch) — the in-process NAPI runner (`evaluateAstGrepRules`) has always deduped same-id rules project-first (a project rule dir shadows a bundled rule with the same `id`), but the generated raw sgconfig for the ast-grep LSP (`clients/sgconfig.ts`'s `resolveBaselineSgconfig`) listed the project and bundled rule dirs side by side in `ruleDirs`, and raw `sg`/the ast-grep LSP hard-errors ("Duplicate rule id") the instant two listed dirs share an id — verified against a real `sg scan` repro before touching any code. Fix: `resolveBaselineSgconfig` now materializes a single merged, deduped rule directory (doc-level, so multi-rule YAML files are handled correctly) from the SAME project-first-ordered dir list the NAPI runner now also derives from (`shippedRuleDirsInPrecedenceOrder`, shared between both surfaces so they can never drift and disagree on the winner) — `ruleDirs` in the generated config lists just that one directory. Same-layer duplicates (two files in the SAME source dir sharing an id) are deliberately copied through unmodified rather than deduped, so `sg` still hard-errors on them exactly as before — this only resolves the CROSS-layer (project vs. bundled) collision the NAPI runner already tolerated. The merged directory is content-fingerprinted (source dir mtimes) so a mid-session project rule change invalidates the cached config instead of serving a stale winner set. Windows-safe: copies files rather than relying on symlinks (unavailable without elevation on Windows) or junctions (directory-level only, unusable for filtering individual files). — both `no-typeof-undefined` twins previously exempted only `__dirname`/`__filename` from the "use `=== undefined`" hint, so guard-clause idioms like `typeof window === "undefined"` (the standard SSR pattern for browser-only globals, since directly referencing an absent global throws a `ReferenceError`) were flagged as findings. Both rules' `rule.any` now carry a shared `constraints.X.not.regex` excluding `window`, `document`, `navigator`, `self`, `location`, `localStorage`, and `sessionStorage` alongside the existing `__dirname`/`__filename` — a metavariable constraint rather than enumerated `not:` patterns, verified identical (via an napi AST dump) across both the TypeScript and JavaScript grammars, so the twins stay behaviorally aligned. `typeof <declared identifier> === "undefined"` still fires. Separately, `hardcoded-url-js.yml` declared `language: TypeScript` instead of `JavaScript`, so JavaScript/JSX-only syntax never ran through its intended grammar; fixed to `language: JavaScript`, with a new JSX fixture (`const render = () => <a href="http://localhost:3000">...</a>`) proving localhost/API URL detection still fires. Fixture-first: `rule-tests/no-typeof-undefined(-js)-test.yml` and `hardcoded-url-js-test.yml` gained the issue's repro cases, confirmed RED against the pre-fix rules (3 fixtures failing), GREEN after (251/251 `ast-grep test` fixtures pass). Canonical rule catalog (`docs/ast-grep_rules_catalog.md`) regenerated via `npm run docs:rule-catalogs` to move `hardcoded-url-js` from the TypeScript to the JavaScript section.

### Added

- **`silentOnClean` drift check on the nightly clean-signal probe** (#529) — the #458 tier-aware cascade classification hinges on the hand-set `silentOnClean` marker in `clients/lsp/server-strategies.ts` (today set only for classic `typescript`, measured manually on 2026-07-08); a server update could silently change the answer with no automated re-check. `scripts/lib/clean-signal.mjs` gains a pure `checkCleanSignalDrift`/`findCleanSignalDrift` pair that compares an observed `clean-behavior` classification against the marker: `marked-not-silent` when the marker says silent but the probe saw a real publish (marker too pessimistic — cascade is skipping a wait it doesn't need to), `silent-not-marked` when an unmarked server probes silent (the pre-#458 tsserver situation — cascade burns the full in-lane wait it could skip). Per the #240 doctrine applied to the check itself, `unknown` observations are NEVER treated as drift evidence in either direction — a slow/absent server isn't proof of anything. `scripts/probe-clean-signal.mjs` (already run nightly by tool-smoke, riding the existing `LSP_FIXTURES` clean-fixture infrastructure) now runs this check after classification, resolved through the same clean-fixture-wins `targetLang` logic the matrix merge already used (so the console report, the matrix row, and the drift footnote can never disagree), and writes any mismatch to a new `## silentOnClean drift (nightly-generated)` section in `docs/lsp-capability-matrix.md` — telemetry only, **never a CI gate**, matching the rest of the probe's best-effort/always-exit-0 design. The native TS7 launch variant (`typescript7`/`typescript7-clean`, #524/#526) is deliberately excluded from the comparison: it shares the "typescript" server-strategy key with classic, but the marker is documented classic-only, so comparing it would produce misleading drift rather than a real signal. Live-verified locally: classic `typescript-clean` probes `silent` (tier 3), consistent with the existing marker (no drift reported); the native `typescript7-clean` variant (via a real `typescript@7` install into the fixture's temp workspace) also probes `silent`, correctly excluded from comparison rather than silently validated. 21 new unit tests (`tests/scripts/clean-signal.test.ts`) cover both drift directions, the consistent case, and the never-collapse-unknown guard.
- **Nightly smoke coverage for the native TypeScript 7 LSP path** (#530, follow-through on #524/#526) — the native `tsc --lsp --stdio` selection previously shipped verified-by-documentation only (the repo pins typescript 6.x), so upstream drift in the `--lsp` flag, stdio handshake, or publish behavior had no regression guard. `scripts/smoke-tools.mjs` gained two fixture-level extensions: an optional `setup` step (string/argv command run in the COPIED temp workspace before `touchFile`, bounded by a 120s timeout — new `typescript7`/`typescript7-clean` fixtures use it to `npm install typescript@7 --no-save --no-audit --no-fund`, since typescript-go's per-platform native binary can't be a committed static fixture; a setup failure reports a distinct `setup-failed` status and never a false pass) and an optional `expectLaunchVariant` assertion (checks the live `getCapabilitySnapshots(file)` `launchVariant`, e.g. `"native-ts7"` — a silent fallback to the classic `typescript-language-server` now FAILS even when a diagnostic arrived, since native and classic share the same `"typescript"` server id and a diagnostic alone can't distinguish them). Verified live against a real `typescript@7.0.2` install: `tsc --lsp --stdio` genuinely speaks LSP framing over stdio and PR #526's assumed invocation is correct. `typescript7-clean` doubles as the future #529 clean-signal probe workspace for the native variant.
- **`symbol_search` pi tool + always-warm word index (#348 phase 1)** — the word index (identifier inverted index + BM25, #162) previously had exactly one build path (a full-mode-only deferred session task), so it was absent in quick-only sessions and stale after that unless something else happened to rebuild it; empirically `symbolSearch("moduleReport", cwd)` on this repo returned `available: false`. It now gets the same load → rebuild-if-stale → persist lifecycle the call-graph task already uses (`clients/runtime-session.ts`'s new shared `buildOrRefreshWordIndex`, reusing `isProjectSnapshotFresh`/project `seq` — the same edit-provenance signal, not a new one), wired into BOTH the full-mode `runTask("word-index", …)` and the existing quick-mode cold-start warmup pass (the same ~2s-deferred background pass that already warms scan-context + language-profile after a quick first session) — no new mechanism, per the ratified #348 decisions. The file-walk-and-read step is now one shared `collectWordIndexDocs` helper (`clients/word-index.ts`) instead of three near-duplicate copies. A pre-existing snapshot-merge bug is fixed alongside this: `saveRuntimeProjectSnapshot` could "launder" a stale snapshot's leftover word index into looking fresh by re-stamping it with the current `seq` on an unrelated intermediate save — it now only carries the prior index forward when it was built at the SAME seq. Second, `symbol_search` is now a registered **pi tool** (mirroring the existing MCP-only `pilens_symbol_search`) — the entry point of the discovery funnel: `symbol_search` finds ranked candidate files by identifier, `module_report` explains one, `read_symbol` reads the exact body. A cold query (no index yet, e.g. an MCP-only session that never ran `pilens_session_start`) never blocks: it triggers one bounded background build per cwd (deduped via an in-flight guard) and returns `available: false` with an actionable retry hint immediately. Both the new pi tool and the existing MCP `pilens_symbol_search` (which predates #517) now return the slimmed #517 payload: hits carry `startLine`/`endLine` (the best-matching line; read derivation `offset=startLine, limit=endLine-startLine+1`, documented in both tool descriptions) instead of a raw `lines[]` array or a per-hit `read` block, and MCP's JSON is compact (unindented) like `module_report`'s. The `ast_grep_search` 0-match hint and the session-start orientation guidance now route name/usage lookups toward `symbol_search`/`module_report`/LSP `findReferences` instead of only suggesting another AST retry or grep. Phase 2 (per-edit incremental word-index maintenance) remains out of scope and tracked on the still-open #348.
- **Warm per-edit word-index maintenance, review-graph style (#348 phase 2, closes #348)** — phase 1 kept the word index fresh only via a full session-scoped rebuild; a burst of edits mid-session went unreflected in `symbol_search` rankings until the next rebuild. `clients/word-index.ts` gains a forward index (`WordIndex.forward`: file → per-token distinct-line counts, optional so a pre-phase-2 index/persisted snapshot deserializes as `forward: undefined`) plus `updateWordIndexDocument`/`removeWordIndexDocument`, which use it to do a single-document replace mechanically — subtract this file's own contribution from postings/docLengths/totalTokens/docCount via the forward entry, then add the new one, without rescanning unrelated files. A caller handed a forward-index-less index (old shape) must fall back to a full rebuild — never migrated in place. The per-edit seam lives at the SAME call site as the review graph's `buildOrUpdateGraph` (`computeCascadeForFile` in `clients/dispatch/integration.ts`), reusing content the pipeline already read (no extra I/O); it is keyed by `path.resolve(filePath)` — deliberately NOT the cascade's own `normalizeMapKey`-based key, since the word index's own keys come from the file-walk's plain `path.resolve()` shape and using the wrong key would silently create an orphaned duplicate entry instead of replacing the doc. Cold-session handoff rule: `wordIndex` `null`/absent (nothing loaded yet) is a documented no-op — phase 1's lifecycle/background build owns "cold", never this seam; the update function itself has no `await`, so two overlapping deferred cascades (#450) can never interleave mid-mutation. Files over the existing `WORD_INDEX_MAX_BYTES` cap are removed/absent from the index, never partially indexed; a file the pipeline couldn't read (deleted, transient race) is also a no-op — deletions age out at the next full rebuild, an accepted scope boundary matching the review graph's own. Persistence reuses (generalized, not copied) the review graph's #260 debounced-persist circuit-breaker: `clients/persist-debounce.ts`'s new `createDebounceScheduler` factors out the coalesce-and-flush timer bookkeeping both caches need, while each owns its own serialize+write (the graph writes its own cache file; the word index merges into the shared project-snapshot file via `saveProjectSnapshot`, preserving unrelated fields and honoring the existing seq-laundering guard, extended with a regression test for the previously-untested stale-seq case). New `PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS` env override and `flushWordIndexPersistsForTests()` mirror the graph's `PI_LENS_GRAPH_PERSIST_DEBOUNCE_MS`/`flushReviewGraphPersistsForTests`; `tests/support/vitest-setup.ts` defaults the new debounce to 0 for the same reason it already does for the graph's. The load-bearing test is an equivalence-property check: k randomized incremental edits/additions/removals against an index must produce identical state (postings/df/N/avgdl) AND identical query rankings to a from-scratch `buildWordIndex` over the same final corpus — covering a term disappearing entirely from a doc, a doc shrinking, a doc growing, a doc removal, a brand-new doc, and an unrelated doc verified untouched via forward-index reference identity.
- **Warm-mode `pilens_analyze` maintains the review graph + word index; MCP graph-staleness signal; `pilens_read_enclosing` (#536, closes #536, refs #522)** — the 2026-07-11 capability-depth parity audit's cheapest-first follow-ups, one PR. (1) **DECISION: retires the #256 "read-only facade" contract for warm mode.** `pilens_analyze`'s warm path (the long-lived MCP server; `fresh` stays read-only — it's an ephemeral forked worker, see `mcp/worker.ts`) now calls `buildOrUpdateGraph` for the analyzed file on a successful, blocker-free dispatch — the SAME call pi's per-edit cascade path makes (`computeCascadeForFile`, `clients/dispatch/integration.ts`), gated by the same `CASCADE_GRAPH_KINDS` file-kind set (now exported for this reuse) and the same "skip on blockers" rule. `buildOrUpdateGraph` owns its own debounced persist/seq machinery internally, so this is the only call needed. Consequence: `pilens_module_report`'s usedBy/blastRadius and `pilens_symbol_search`'s centrality now reflect files analyzed via MCP, not just session-start state — verified end-to-end on a two-file fixture where warm-analyzing the importer flips the imported file's `semantic.source` from `"none"` to `"review-graph"`. Rides the SAME seam for the #348 phase 2 word-index per-edit primitive (`updateWordIndexDocument`/`removeWordIndexDocument`), mirroring (not reusing directly — that function is module-private) `updateWordIndexForCascade`'s rules: a per-cwd live `WordIndex`, loaded once from the persisted snapshot and mutated in place thereafter (the MCP-process equivalent of `runtime.wordIndex`, since MCP has no RuntimeCoordinator to hold it); no forward index cached ⇒ no-op; an oversized file is removed, never partially indexed; a successful update schedules the existing `scheduleWordIndexPersist` debounce (`PI_LENS_WORD_INDEX_PERSIST_DEBOUNCE_MS`) — no second persist mechanism; keyed by `path.resolve(absPath)` to match the word index's own build-path key shape, not `normalizeMapKey`. `symbolSearch()` (`clients/lens-engine.ts`) now prefers this warm in-memory copy over a fresh disk read when one exists, so a `pilens_symbol_search` query immediately following a warm analyze in the SAME process sees the update without waiting on the debounce or a rebuild — verified with a smoke test seeding a snapshot missing a new identifier, warm-analyzing a new file containing it, then confirming `pilens_symbol_search` ranks it with no `pilens_rebuild`/`pilens_session_start` in between. (2) **Graph-staleness signal [S].** `pilens_module_report` and `pilens_symbol_search` gain a staleness hint when their backing data is present but old — extends #514/#511's honesty-warning shape from "missing node" to "aging graph". Surfaces the ALREADY-persisted timestamps (`ReviewGraph.builtAt` for module_report, additively threaded through as `ModuleReport.graphBuiltAt`; `ProjectSnapshot.generatedAt` for symbol_search, additively returned as `SymbolSearchResult.snapshotGeneratedAt`) — no new timestamp had to be invented. A `> 10min` age appends `"<label> last updated <Nm/h/d> ago; run pilens_analyze on recently-changed files, pilens_session_start, or pilens_rebuild to refresh it."` to both the text summary and the JSON payload. MCP-only per the decision: pi's own graph is per-edit warm, so the same line there would be pure noise. (3) **`flushPending` parity for `pilens_diagnostics` [S] — investigated, not wired, because there's genuinely nothing to flush.** pi passes `() => flushDebouncedToolResults()` as `createLensDiagnosticsTool`'s 4th arg; the MCP instantiation passes none. Traced `flushDebouncedToolResults` (`clients/runtime-tool-result.ts`) to a module-level `debouncedPipelines` map that ONLY `handleToolResult` populates — pi's `tool_result` event handler, which the MCP process never calls (`pilens_analyze` routes through the independent `clients/mcp/analyze.ts` facade, calling `dispatchLintWithResult` directly). That map is therefore provably always empty in the MCP process; wiring the flush would be a no-op dressed as a fix. Documented in place at the `mcp/server.ts` instantiation site rather than silently left unexplained. (4) **`pilens_read_enclosing`** (closes #522 item 1) — `readEnclosing` is now re-exported from `clients/lens-engine.ts` and mirrored as a new MCP tool with the same file+line(+kinds/maxLines/onOversize/aroundLine) shape as the pi `read_enclosing` tool and the same header-line-then-body rendering convention as `pilens_read_symbol` (#512); MCP has no read-guard, so — like `pilens_read_symbol` — it returns the body with no coverage recording, an intentional gap, not a bug. `docs/agent-tools.md`'s mirror-exceptions note now lists only `ast_grep_outline`/`ast_grep_dump` as pi-only; `AGENTS.md`'s MCP tool count moves 15 → 16 (both the server.ts file-header comment and the tool-list bullet).
- **Native TypeScript 7 language-server selection** (#524) — TypeScript projects now inspect the nearest workspace-local `typescript` package before starting an LSP, including dependencies hoisted above a nested monorepo package root. Version 7+ launches that package's matching `node_modules/.bin/tsc --lsp --stdio`, avoiding the previous silent fallback to pi-lens's managed TypeScript 5/6 `tsserver.js`; a nearer TypeScript package always shadows an ancestor (including when that nearer install is malformed/partial — a `node_modules/typescript/` directory with no `package.json` stops resolution there instead of silently falling through to an ancestor TS 7 hoist), and TypeScript <=6 retains `typescript-language-server --stdio` with the existing `TSSERVER_PATH` initialization. Resolution is deliberately workspace-relative (including Windows `.cmd`/`.exe` shims), never a bare global `tsc`, and missing binaries or invalid metadata fall back to the classic discovery path. The launched variant is now recorded on the capability snapshot (`launchVariant: "classic" | "native-ts7"`), so the #458 cascade-lane tier classifier no longer inherits the classic server's `silentOnClean` tier-3 marker for the native TS7 binary — an unverified Go-native server falls back to the fail-safe full in-lane wait instead of risking a dropped clean→clean diagnostic (refs #529, the pending clean-signal probe).
- **`module_report` doc-comment summaries + `view: "compact"` (#512, slices 1/3/4)** — dogfooding measured `module_report` costing ~1,900 tokens vs ~2,100 to read a representative 266-line file whole, only a ~10% saving; this closes most of the gap. Each `ModuleSymbolEntry` now carries a `doc` field — the first sentence/line of an attached doc comment (whitespace-collapsed, capped ~120 chars) — extracted structurally in the SAME tree-sitter pass that already builds decorators/visibility (`tree-sitter-symbol-extractor.ts`'s new `extractDocComment`, preceding-sibling `comment`-node traversal, position-matched since web-tree-sitter materializes a fresh node object per `.children`/`.parent` access with no stable identity); JS/TS is the primary target, and any grammar sharing the conventional `comment` node shape (Python confirmed) gets it for free. New `view: "compact"` (pi tool + `moduleReport()` engine option, opt-in — default stays JSON) renders the full report as line-oriented text (one line per symbol/member/callback, e.g. `77-81  fn  _resetAgentNudgeForTests()  — Test-only: clear accumulator state.`) via new `renderCompactModuleReport`, at roughly half the byte size of the JSON view for the same file. Also cut real duplication from the JSON schema: per-symbol `read: {path, offset, limit}` blocks are gone (both tool descriptions now document the derivation — `offset = startLine`, `limit = endLine - startLine + 1`, path = the report's own `path`); `recommendedReads` entries carry `{reason, symbol, startLine, endLine}` instead of repeating the read block; `flags` no longer duplicates `"exported"` (the boolean field already carries it) — cross-file sections (`blastRadius.files[].read`, `usedBy[].file`) are untouched since they legitimately need their own path. The MCP mirror (`pilens_module_report`/`pilens_read_symbol`) now matches the pi tool's compact (unindented) JSON instead of pretty-printing, gained `focus`/`view` passthrough, and `pilens_read_symbol` no longer restates name/kind/startLine/endLine in a trailing JSON block after a header line that already carries them. Deliberate schema break — existing tests updated for the new shape. Follow-ups tracked in #512: MCP parity for `read_enclosing`/`view:"summary"` (slice 2) and size-aware honesty when the outline would cost more than reading the file (slice 5).
- **Expert LSP for Elixir** (#498) — Expert is now an auto-installed alternate to ElixirLS for `.ex` and `.exs` files. ElixirLS remains the default; add `"elixir"` to `disabledServers` in `.pi-lens/lsp.json` to select Expert. pi-lens launches Expert with its required `--stdio` flag and downloads the official bare GitHub release binary for macOS, Linux, or Windows (Windows arm64 uses the x64 build through emulation).
- **`pilens:files:touched` bus event** (#482) — pi-lens's first `pi.events` broadcast surface. Every autonomous file write pi-lens makes outside the agent's own tool calls — dispatch autofix (biome/ruff/eslint/stylelint/sqlfluff/rubocop/ktlint/rust-clippy/dart-fix/golangci-lint/detekt/ktfmt/markdownlint/oxlint) and formatter runs (immediate or deferred-at-`agent_end`), plus the conservative actionable-warnings LSP autofix — now emits a versioned `{ v: 1, source: "pi-lens", reason: "autofix" | "format", paths, cwd }` payload via `clients/bus-publish.ts`'s `publishFilesTouched`, one event per logical write batch. Fire-and-forget (never affects write-path success/latency) and null-safe when unwired (unit tests, the MCP server's no-pi-host path). Deliberately excludes agent-authored edits (partial-edit-apply preflight, ast-grep/lsp-navigation tool calls) — the host already knows about those. Kill switch `PI_LENS_BUS_PUBLISH=0`. See `docs/features.md` ("Bus Events") for the full contract; refs #478.
- **`agent_settled` quiet window** (#483) — pi 0.80.6 added an `agent_settled` extension event, emitted once the whole agent run (including any retry/continue loop) goes fully idle, on both normal completion and aborts. New `clients/quiet-window.ts` registers a handler (feature-detected — the SDK's `pi.on` accepts any event string with no validation, so this is a safe no-op on older pi hosts) that schedules deferred, expensive work in that guaranteed-quiet gap, additive to the existing `turn_end` settle (unchanged). Ships with two built-in tasks run through a small sequential task registry (`registerQuietWindowTask`, so #458/#236 can plug in later without touching the scheduler): a second, more generous settle attempt (`PI_LENS_QUIET_WINDOW_WAIT_MS`, default 15000ms) for cascade computes still carried over past the `turn_end` cap, and the #449 instance-registry heartbeat refresh moved off the turn hot path. Tolerates the event firing multiple times per session (re-entrant runs are skipped, never queued); every task failure is isolated and swallowed; the handler itself never awaits the task chain (kicked off fire-and-forget) so it can't hold up the SDK returning control to the next turn. Kill switch `PI_LENS_QUIET_WINDOW=0`. Logs a `quiet_window` latency phase with per-task `{name, durationMs, ok}` plus a `skipped: "in-progress" | "disabled"` marker.
- **Tier-aware cascade-lane LSP waits** (#458, re-scoped from the original learned-deadline design after dogfooding: `docs/lsp-capability-matrix.md`'s nightly-refreshed capability matrix already answers the classification question, and #483's quiet window gives the cascade lane somewhere to resolve the ambiguity out-of-lane) — the deferred cascade neighbor-touch fan-out (`clients/dispatch/integration.ts`'s `computeCascadeForFile`) used to actively wait up to its per-touch budget (~1000-2000ms) for `textDocument/publishDiagnostics` on every neighbor, even for a Tier-3 (push-only, silent-on-clean) server that can never distinguish "clean" from "still analyzing" that way — typescript-language-server is the lone core-set instance, and dogfooding measured ~221 such `lsp_diagnostics_timeout` events/day. New `clients/lsp/cascade-tier.ts` classifies each cascade touch's primary server from the LIVE capability snapshot (`workspaceDiagnosticsSupport.mode`) combined with a new `silentOnClean` marker on that server's `DiagnosticStrategy` (`server-strategies.ts`, set only for `typescript`) — never a hardcoded server-name check at the call site, and any ambiguous/missing snapshot fails safe to today's full wait. A Tier-3 touch still fires its didOpen/didChange notify (the server starts real work) but skips the in-lane wait and records the touch as outstanding; a new quiet-window task (`cascade_tier3_reconcile`) checks each outstanding touch against the client's diagnostics cache at the `agent_settled` idle point — diagnostics arrived since the touch ⇒ `resolved-found`/`resolved-clean`, nothing arrived ⇒ `unresolved` (never silently treated as clean — the #240 doctrine holds). Kill switch `PI_LENS_TIER_AWARE_CASCADE=0` restores the old full-wait behavior outright. Logs a `cascade_tier3_skip` cascade-log phase per skipped touch and a `cascade_tier3_reconcile` phase at the quiet window with resolved-found/resolved-clean/unresolved counts and touch ages. Review follow-up (refs #458) hardened the reconcile path: outcomes are decided by the client's PER-FILE publish timestamp (`getAllDiagnostics()`'s `ts`), not the client-wide `diagnosticsVersion` counter — a cascade touches multiple neighbors on the same tsserver client, so a counter advanced by neighbor A's publish could falsely "prove" a silent neighbor B `resolved-clean` (#240 violation); `touchedAt` is sampled BEFORE the notify so a publish racing the record can't be misread as pre-touch; and the quiet-window reconcile looks clients up via `getWarmClientForFile` (warm-only) instead of the get-or-create accessor, so it can never resurrect an idle-reaped server just to write a log line — a warm-miss reconciles as `unresolved`.
- **Inline agent nudge for out-of-view file mutations** (#485) — deferred-cascade autofixes and formatter writes that land AFTER a tool result (turn_end settling, #483's quiet window) were invisible to the model; an agent running `git status` at the top of a fresh run would find working-tree changes it never made and burn turns investigating. New `clients/agent-nudge.ts` subscribes read-only to the `pilens:files:touched` bus event (#482) via `pi.events.on` (feature-detected — no-op on older pi hosts with no `pi.events`/`.on`), accumulates touched paths across the session, filters them down to files the session actually read or edited (the read-guard's `getReadHistory`/`getEditHistory`, keyed via `normalizeMapKey` for every map access), and injects at most one terse context message per delivery via the same `context` extension event `clients/runtime-context.ts` already uses for turn-end findings: `pi-lens: 2 file(s) were autofixed after your last turn: a.ts, b.ts — working-tree changes to these are expected; re-read before editing.` (capped at 5 names + "and N more"). The accumulator is cleared only on actual injection — never on `turn_start`/`agent_end`/`agent_settled` — so files touched at one run's `turn_end` still nudge at the very next run's first turn in the same session (`context`/`transformContext` fires before every provider call, including the first one of a new `agent_start`). This subscriber never emits back to the bus, so the #482 loop guard's write side has nothing to trip. Kill switch `PI_LENS_AGENT_NUDGE=0`. Logs an `agent_nudge` latency phase with `{filesTotal, filesShown, filesFiltered, reasonMix}` on injection. See the "Three channels, three audiences" doctrine in `AGENTS.md` (bus events → extensions #482, display-only entries → the human #484, context nudges → the model, this feature).
- **Opt-in per-run transcript summary** (#484) — pi-lens's write-path effects (diagnostics found, autofixes applied, autoformats applied) previously only surfaced as transient inline text or the `/lens-health` command; nothing persisted in the transcript for a human reviewing the session later. New `turnSummary.enabled` config key (default **false** — opt-in; also `lens-turn-summary` CLI flag) turns on a `clients/turn-summary.ts` collector that accumulates `{file → events}` across the RUN from the SAME seams that already produce these signals — no new collection plumbing: the immediate write/edit pipeline result (`clients/runtime-tool-result.ts`, newly-surfaced `PipelineResult.diagnostics`/`formattersUsed`/`fixedCount`/`autofixTools`), the `agent_end` deferred-format completion and the experimental actionable-warnings LSP autofix pass (both in `clients/runtime-agent-end.ts`). The single `pi.sendMessage({customType: "pilens:turn-summary", display: true, details})` entry is emitted at the **`agent_settled` quiet window** (#483 scheduler, `turn_summary_emit` task), NOT at turn_end — a load-bearing choice verified against the installed pi 0.80.6 SDK: `sendCustomMessage` STEERS the live model conversation when the session is streaming, and a mid-run turn_end plausibly fires while streaming; at settle the session is idle, so sendMessage takes the safe append branch. The collector therefore survives turn boundaries (NOT cleared in `beginTurn`) and is consumed exactly once per settle; grain is one entry per RUN (never per-file or per-turn). Honest SDK caveat: this entry is NOT display-only — a `CustomMessageEntry` participates in LLM context (`display` only controls TUI rendering; `buildSessionContext` converts every such entry into a user message on later context builds), so the entry `content` is kept to the single ~80-char collapsed line (an accepted, owner-approved residue, largely redundant with the #493 agent nudge); the structured `details` payload never reaches the model. A registered `registerMessageRenderer` (`clients/turn-summary-render.ts`) renders it natively collapsible/expandable via pi's own entry-expansion toggle: collapsed is one tool-grouped line in the pi-lens brand accent (`pi-lens: 3 diagnostics (eslint 2, tsserver 1) · 2 autofixed (ruff 1) · 1 reformatted (prettier 1)`), expanded is FILE-MAJOR — each touched file lists its formats/autofixes/diagnostics (tool + rule id + line) in its own block, answering "what happened to x.ts?" rather than "what happened this run?". Both `pi.sendMessage` and `pi.registerMessageRenderer` are feature-detected (no-op, never throws, on older pi hosts). The redundant info-level "pi-lens deferred format applied to..." toast in `runtime-agent-end.ts` is suppressed when this entry is opted in (the failure/warning toast is untouched either way). Logs a `turn_summary` latency phase with `{files, diagnostics, autofixes, formats}` on emit. See the "Three channels, three audiences" doctrine in `AGENTS.md` (bus events → extensions #482, this feature → the human, context nudges → the model #485).
- **Cross-process touched-files nudge** (#492) — #485's inline nudge only covered ONE process: a subagent spawned as a real child `pi` process (the nicobailon/pi-subagents model) never saw the parent's autoformats, and the parent never saw a child's — both real pain cases (`git status` finding unexplained `M` files; a parent's next edit hitting stale `oldText` after a child's pi-lens reformatted on top of its edits). New `clients/recent-touches.ts` adds a project-scoped `recent-touches.json` (via `getProjectDataDir(cwd)`) that every pi-lens instance both writes to and reads from: a ~50-entry ring buffer of `{path, reason, ts, pid, sessionId?}`, atomic tmp+rename writes (same pattern as the #474 instance registry). The producer is wired into the EXISTING `publishFilesTouched` seam (`clients/bus-publish.ts`) — parent and child run identical code, and the record is populated even when no `pi.events` bus is wired (bare/MCP hosts), since the on-disk record is the only one of the two deliveries that survives a process boundary. Two consumers feed the SAME #485 accumulator (`clients/agent-nudge.ts`'s new `recordCrossProcessTouches`) so exactly one batched context message is ever injected regardless of how many files came from which channel: a **child at `session_start`** reads entries from other pids within a 15-minute freshness window whose file still exists (no read-guard history exists this early, so relevance is recency + existence only); a **parent at `turn_start`** does a single mtime-gated `fs.stat` (zero reads/parses when nothing changed since the last turn) and, on a genuine change, applies the SAME shared baseline filter (foreign pid + 15-minute freshness + file still exists — one private helper both readers call, so they can never drift) plus a consumed-ts cursor; beyond that baseline the parent has deliberately no read-guard drop path — a parent about to commit needs attribution even for files it hasn't read yet this session. `AccumulatedFile` gained an `origin: "local" | "cross-process"` field; a file seen via both channels always reads as `"local"` (sticky — once the session's own bus has reported a touch, the local wording is the more precise, more actionable framing, and "another instance" framing no longer applies). Attribution is three-way and never assigns a local file to another instance: a pure-local batch keeps the unchanged #485 wording ("after your last turn"), a pure cross-process batch reads "by another pi-lens instance (e.g. a subagent's)", and a mixed batch reads "after your last turn (N of them by another pi-lens instance)" — always one message, never split. The `agent_nudge` latency phase gained `originLocal`/`originCrossProcess` counts. Reuses the existing `PI_LENS_AGENT_NUDGE=0` kill switch for the producer and both consumers (no new env var); `PI_LENS_BUS_PUBLISH=0` also silences the record append, since the producer lives inside `publishFilesTouched` (both deliveries of a touch die together behind that gate). Deliberately NOT gated on subagent light mode (#449), since this is a cheap file read, not a heavyweight scan. No IPC, no daemon, no `fs.watch` — a passive file, per the #449 no-daemon doctrine.

- **`pilens:diagnostics` bus event + `pilens:files:touched` fix provenance** (#502) — extends the #482 producer family from "which files changed" to "what pi-lens knows about them", so terminal-native diff/review extensions can render pi-lens's findings as inline annotations in their own views instead of pi-lens owning a review UI. New `clients/diagnostics-publish.ts` publishes a versioned `pilens:diagnostics` event — `{v: 1, source: "pi-lens", cwd, seq, ts, files: [{path, diagnostics: [{ruleId?, severity, line?, col?, message, tool, fixable?}], truncated?}]}` — once per write batch, immediately after the batch's final per-file diagnostic set is committed (post-format, post-autofix, post-dispatch), so it always reflects the LATEST state rather than an intermediate runner result. Follows LSP `publishDiagnostics` staleness semantics: full-replace per file (never a delta), explicit `diagnostics: []` on a dirty→clean transition (fired exactly once, tracked via a module-level reported-paths set), monotonic `seq`+`ts` so out-of-order receipt resolves deterministically, and `pilens:files:touched` (#482) documented as an invalidation hint (a touched path's held diagnostics are provisional until the next diagnostics event mentions it). Capped at 12 diagnostics per file per event (errors prioritized), file contents never inline; reuses the `PI_LENS_BUS_PUBLISH=0` kill switch. The `PilensDiagnosticsPayload` schema is reserved for #478's future `pilens:rpc:diagnostics` pull response (push and pull share one shape). Separately, `FilesTouchedPayload` (#482) gains an additive optional `fixes?: {path, tool, ruleId?, kind: "autofix" | "format"}[]` field for fix provenance — lets a diff/review consumer distinguish a pi-lens-mechanical hunk from an agent edit; old consumers ignore the field. Before/after file content is intentionally omitted from v1. Full contract in `docs/features.md` ("Bus Events").

### Changed

- **Native TS7 LSP variant reclassified `silentOnClean` (closes #541, follow-through on #458/#526/#529)** — PR #526 excluded the native TypeScript 7 launch variant (`tsc --lsp --stdio`) from the #458 tier-3 cascade classification as a fail-safe, since `silentOnClean` had only been measured against classic `typescript-language-server`. The #529/#540 clean-signal probe has since measured native-ts7 directly (`typescript7-clean` fixture, repeated local runs): silent on clean transitions, same as classic. Per the maintainer's decision (prefer fast cascade waits), `clients/lsp/cascade-tier.ts`'s classifier no longer branches on the snapshot's `launchVariant` — both variants now skip the in-lane wait. `scripts/lib/clean-signal.mjs`'s nightly drift check also lifts its typescript7 exclusion, routing native rows to the shared `typescript` strategy marker instead of skipping them — the rollback safety net: if a future TS7 build starts publishing on the clean fixture, the drift check now emits a `marked-not-silent` warning instead of silently missing the regression.
- **Per-walk `isBuildArtifact` sibling-probe memo** (#191, item 1 of 4) — `findSourceSibling`/`isBuildArtifact` (`clients/source-filter.ts`) probe for a higher-precedence source sibling (e.g. does `foo.ts` exist next to `foo.js`?) via `fs.existsSync`; call sites with repeated/overlapping lookups (e.g. `filterSourceFiles` handed an overlapping candidate list) re-issued identical probes. `#191` deliberately deferred this because a *persistent* memo has an awkward invalidation problem — siblings can change between scans, and a stale key risks silently misclassifying a file (lost detection). This ships the narrower, invalidation-free version instead: an optional per-walk `ArtifactProbeCache` (`createArtifactProbeCache()`), created at the start of one `collectSourceFiles`/`collectSourceFilesAsync`/`filterSourceFiles` call and discarded when it returns — no persistent or module-global cache, nothing to invalidate. Callers that don't pass a cache get exactly today's behavior. Keyed via a new cheap, syntactic-only `normalizeEphemeralMapKey` (slash-fold + win32 lowercase, no `realpathSync`) rather than the existing `normalizeMapKey` — using the latter here was measured ~11x *slower* than the `existsSync` probe it would replace, because it resolves nonexistent candidate paths via its own ancestor-walking `existsSync` calls; `normalizeEphemeralMapKey` is intentionally scoped to ephemeral, single-process, single-walk caches only. Measured ~70% faster on a fixture modeling realistic overlapping-lookup call shapes (500 pairs × 4x duplication); near break-even on this repo's own tree, which is pure TypeScript with no compiled `.js` siblings on disk to re-probe — an honest finding, not a regression.

## [3.8.68] - 2026-07-10

### Added

- **Subagent light mode** (#449) — the nicobailon/pi-subagents extension spawns each subagent as a child `pi` CLI process and sets `PI_SUBAGENT_CHILD=1` unconditionally in every child's environment, so a fan-out of N subagents in the same cwd previously paid N full LSP pre-warms plus N sets of heavyweight startup scans — mostly wasted on short-lived task agents. `clients/subagent-mode.ts` adds `isSubagentSession()`, detected once at session start; when engaged, both the LSP pre-warm (explicit `warmFiles` and the dominant-language auto-warm) and the knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy startup scans are skipped, extending the same `skipHeavyweightScans` gate #462 introduced for slow filesystems. Per-edit LSP dispatch and the in-process scans (todo/call-graph/codebase-model/ast-grep-exports/word-index) are untouched, so a subagent that actually edits code still gets diagnostics and symbol search. Escape hatch: `PI_LENS_SUBAGENT_FULL=1` forces full behavior even inside a detected subagent session. Logged to the latency log as a `subagent_light_mode` phase with the subagent's `runId`/`agentName` (from `PI_SUBAGENT_RUN_ID`/`PI_SUBAGENT_CHILD_AGENT`) when present.
- **Cross-process instance registry** (#449 slice 1) — a tiny machine-global registry (`~/.pi-lens/instances.json`, `clients/instance-registry.ts`) now records every live pi-lens process: pid, project root, live LSP child servers (pid/serverId/command/spawn marker), RSS, and a heartbeat. Registered at `session_start`, updated opportunistically at `turn_end` (piggybacked on the existing per-turn touchpoint, no new timer), and deregistered synchronously at `session_shutdown`. Pure observability substrate for now (zero dispatch/behavior change) — the groundwork later slices (cross-process LSP budget, same-root warm attach) will build on. Reads are corruption-safe (garbage/missing file ⇒ empty, never throws); writes are atomic tmp+rename. `PI_LENS_INSTANCE_REGISTRY=0` disables it entirely.
- **Slow-filesystem mode** (#462) — WSL 9p mounts (`/mnt/c/...`) measure ~1.3ms/`stat` vs ~17µs native (75x), so an unbounded synchronous tree walk (e.g. a 5,000-file project) could cost ~6.5s of stat time alone and freeze the TUI. `clients/slow-fs.ts` adds a cheap session-start probe (median of up to 15 `fs.statSync` calls under the project root) that classifies the workspace by measurement, not path shape, so it also catches drvfs/NFS/SMB rather than 9p-only. In slow-FS mode the sync `collectSourceFiles` walker clamps to a reduced 500-file cap (the async twin is unaffected), and the knip/jscpd/madge/dead-code/govulncheck/gitleaks/trivy background scans are skipped at session start with a visible notice instead of silently returning stale/empty results. Escape hatches: `PI_LENS_ALLOW_SLOW_FS_SCAN=1` disables slow-FS mode entirely; `PI_LENS_FORCE_SLOW_FS=1` forces it on for testing or when the probe under-fires; `PI_LENS_SLOW_FS_THRESHOLD_US` overrides the 500µs default. The verdict is logged to the latency log as a `slow_fs_probe` phase for dogfooding.
- **Subagent-extension compat smoke** (#476) — pi-lens's subagent-compatibility features (#473/#474/#475) were built on reverse-engineered facts about the nicobailon/pi-subagents and `@tintinweb/pi-subagents` extensions plus the pi SDK itself. A new nightly `.github/workflows/compat-smoke.yml` makes that compatibility empirical: Layer A (`scripts/compat-contracts.mjs`) npm-installs the real third-party packages and mechanically re-verifies six pinned contracts with resilient pattern matchers (`scripts/lib/compat-contracts.mjs`) against the installed source — no `pi` process, no LLM. Layer B (`scripts/compat-smoke-behavioral.mjs`) installs the packed pi-lens tarball into a real `pi` (the same mechanism `install-smoke.yml`'s `pi-load` job uses) and drives `pi --mode rpc` to assert, through pi-lens's own latency log, that subagent light mode engages under `PI_SUBAGENT_CHILD=1`, that `PI_LENS_SUBAGENT_FULL=1` overrides it off, and that zero LSP-server processes survive a graceful pi exit (the #472 orphan class). Both layers run `continue-on-error`; a failure opens/refreshes a single tracking issue rather than reddening the nightly. `docs/subagent-compat.md` records the exact pinned contracts (file + version last verified) and the three env levers (`PI_LENS_SUBAGENT_FULL`, `PI_LENS_CONCURRENT_SESSION_GUARD`, `PI_LENS_INSTANCE_REGISTRY`).

### Changed

### Fixed

- **Orphaned LSP server processes no longer survive abnormal session exit** (#472) — the #234 teardown constraint (no child spawn during `session_shutdown`, else libuv aborts) only covers CLEAN shutdown; a crashed/hard-killed/OOM'd session never runs teardown, and Windows does not kill children when a parent dies, so the whole LSP fleet could leak (7 orphaned ast-grep pairs found in the wild, up to 13 days old, ~700MB). `killProcessTree`'s `processExiting` branch only ever killed the DIRECT child (for shell/`.cmd`-wrapped servers that's the wrapper, not the real server) — its comment claiming Windows grandchildren "are reaped by the OS as the host exits" was false and is now corrected in place (behavior unchanged: still direct-child-only, per #234). The real fix is the #449 instance registry's orphan reaper: every LSP child (core and auxiliary, uniformly, at the shared `clients/lsp/launch.ts`/`client.ts` spawn/kill seam — no per-server special casing) is now recorded with its pid, resolved command, and — when the launch args carry a temp-config-style value (e.g. ast-grep's `--config <tmp sgconfig path>`) — a per-spawn-unique marker for command-line re-identification when the pid chain is broken (the synthesized baseline sgconfig now embeds the owning pid in its filename — `baseline-<pid>.sgconfig.yml`, with age-based cleanup of stale siblings — so the marker really is unique per instance; the previous shared `baseline.sgconfig.yml` would have made the marker fallback match every live ast-grep on the machine). `clients/instance-reaper.ts` sweeps at every `session_start`: a pure `decideOrphanReaping` function (conservative liveness — ESRCH-only counts as dead, EPERM/ambiguous never does; markers claimed by any live instance are never search-killed; pid kills are identity-verified against a batched command-line lookup so a recycled pid is never killed blind) decides what to kill, and an impure `sweepOrphans` executes it (`taskkill /F /T` on Windows, process-group kill on POSIX) plus a marker-based `Get-CimInstance` command-line search fallback for the broken-pid-chain case. Also resolves ast-grep's platform-native exe directly (`resolveAstGrepNativeExe`, `@ast-grep/cli-<platform>-<arch>` packages) ahead of the node-bin-wrapper candidate — one less orphanable process layer.
- **Concurrent in-process subagent binds no longer tear down the parent's LSP fleet/runtime state (#473)** — extensions that build a fresh `AgentSession` and call `session.bindExtensions()` *inside the same Node process* as the parent pi session (tintinweb/pi-subagents-style) reuse pi's process-global extension-loader cache, so the subagent's `session_start` re-invoked pi-lens's SAME module-scope singletons the parent was still using — `resetLSPService({fast:true})` killed every live LSP client and `runtime.resetForSession()` bumped the session generation, silently orphaning the parent's in-flight continuations (parked cascades, diagnostics waits) mid-turn, with no visible error. New `clients/session-lifecycle.ts` classifies each `session_start`/`session_shutdown` as `primary` / `sequential-replacement` / `concurrent-secondary` by probing whether the previously-registered ctx is still active (an SDK-wrapped accessor throws pi's own stale-ctx error only for real sequential replacement — `newSession`/`fork`/`switchSession`/`reload` — never for a concurrently-live sibling). A `concurrent-secondary` session_start now skips `handleSessionStart` and the runtime-identity update entirely and rides the already-initialized shared infra; its later shutdown skips the destructive teardown too. Classification is fail-safe: any inconclusive signal (probe failure, no prior session) falls back to today's full-reset behavior, and `PI_LENS_CONCURRENT_SESSION_GUARD=0` disables the guard outright. Zero behavior change for the common single-session process.
- **Review-graph snapshot persist is now atomic (tmp + rename)** — the debounced cache write went straight to `review-graph.json` with a plain `fs.writeFile`, so a concurrent reader (another process's blind load, or the tier-2 disk load under CI's parallel test runners) could observe a created-but-partially-written file, fail the JSON parse, and silently fall open to a full whole-repo rebuild. The write now lands in a `.tmp-<pid>` sibling and is renamed into place (atomic on POSIX and Windows), so a snapshot either doesn't exist yet or is complete; the process-exit flush uses the same pattern. This was the flaky `expected 'cached' to be 'full'` CI failure in the #300 git-stamp tests.
- **`servercapabilities.md` merge guard survives schema changes and merges bullet sections (#469)** — the #390 nightly merge guard for `scripts/server-capabilities.mjs` required the prior and freshly-generated table headers to be byte-identical before merging, so adding the `ws-pull` column silently disabled the guard and last night's ubuntu-only run dropped the rust and php rows (and their capability-key / executeCommand bullets) entirely. Fixed by reshaping prior rows onto the new header **by column name** (`reshapeRowsByName` in `scripts/lib/md-matrix.mjs` — columns the prior doc lacked are filled with the `·` placeholder, columns dropped from the new schema are simply not carried) and by merging the two bulleted sections ("Raw advertised capability keys", "Advertised executeCommand allowlists") for preserved servers, which the original guard never touched at all (`parseBulletSection`/`mergeBulletSection`). The whole merge is now a pure, unit-tested function (`mergeServerCapabilitiesDoc`) that never spawns an LSP server and fails open (writes the fresh doc, logs to stderr) if either doc's table is unparseable.
- **pi-lens loads under pi's Bun-compiled binary again** (#335) — pi ships as a `bun build --compile` single-file executable and loads extensions inside that embedded runtime, whose module resolver does not traverse the extension's on-disk `node_modules` for a bare specifier. So a static `import { minimatch } from "minimatch"` in `file-utils.js` (and every other third-party bare import) failed with `Cannot find package`, dropping the jscpd/todo/complexity analyzers into degraded mode. `dist/index.js` is now bundled into one self-contained file (`scripts/bundle-dist.mjs`, wired into `build:dist`) that inlines the pure-JS deps (minimatch, js-yaml, vscode-jsonrpc and transitives), so nothing is imported by bare specifier at load time. Host-provided packages (typebox, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`) and the native/wasm packages (`@ast-grep/napi`, web-tree-sitter) stay external; the two lazy native/wasm accessors resolve to an absolute path via `createRequire` before dynamic-importing (a bare specifier fails under the compiled host, an absolute path does not), with the bare specifier kept as a fallback for other runtimes. esbuild is run through `node <npm-cli> exec` (shell-free, not the `npx.cmd` shim), installing into npm's cache rather than the project tree, so the build adds no dependency and works on a from-source `--omit=dev` install; a `createRequire` banner is prepended so the bundled CJS deps load under pure-ESM Node. The reporter's Windows junction workaround does not port to the Linux compiled host, so bundling is the cross-platform fix.

## [3.8.67] - 2026-07-09

### Added

- nightly clean-signal probe: probe-clean-signal.mjs generalized per-server (Tier 2 publishes-empty vs Tier 3 silent-on-clean among push LSPs) and wired into the tool-smoke nightly so the capability matrix's clean-behavior column self-populates (#460). The probe is phase-aware (dirty touch proves liveness; clean transitions are the discriminator) and classifies 4-way: `publishes-versioned` (tier 2 — affirmative + currency-proven, ast-grep), `publishes-unversioned` (tier 2\* — a version-less publish still early-returns the wait at runtime since the client accepts it as fresh, but currency is only temporally correlated: a staleness-risk note, not a latency cost — opengrep, yaml), `silent` (tier 3 — alive on dirty, silent on clean: the budget-wait case and #458's learned-deadline target set — typescript on a clean file), and `unknown` (no publish — conservatively unclassified). Clean fixtures (`typescript-clean`) are authoritative for a lang's row: typescript measurably re-publishes while dirty but goes silent once clean, so the dirty-fixture 2\* is overridden by the clean fixture's tier 3.
- nightly LSP-docs commit-back (#390): the tool-smoke nightly now also runs `server-capabilities.mjs` (regenerating `docs/servercapabilities.md` incl. the ws-pull column) and opens/updates a single auto-PR (`bot/lsp-docs-refresh`) with the regenerated `lsp-capability-matrix.md` + `servercapabilities.md` — previously these were generated in CI then discarded. All three generators now **merge** into their docs (keyed by lang/server), so a server the ubuntu host can't spawn keeps its prior dev-box row and the nightly can never regress a richer run. The phase-aware probe refined opengrep's hand-noted Tier 2 to 2\* (re-publishes on clean scans, so the wait early-returns — but version-lessly, so currency is unproven).
- **`serverOverrides` — per-server `initializationOptions` in project config** (#434) — `.pi-lens/lsp.json` (or `.pi-lens.json` / `pi-lsp.json`) accepts a `serverOverrides` key mapping a built-in server `id` (`"rust"`, `"nix"`, …) to an `initializationOptions` object that is deep-merged onto the server's built-in defaults at spawn time (user wins on conflicts; arrays replaced, not merged). Brings pi-lens diagnostics in line with a user's editor LSP setup (e.g. rust-analyzer `check.command: "clippy"`, nixd options expressions) without forking. Contributed by @vkarasen.
- `lens_diagnostics` (and MCP `pilens_diagnostics`) accept `paths` to scope any mode to an explicit file/directory list — enables wrappers like "check exactly the git-staged files" (#461).

### Changed

- perf: cascade diagnostics now run concurrently after each edit instead of blocking the write pipeline (~26% median per-edit latency reduction); settled at turn_end with a bounded wait (#450)
- perf: write-path micro batch — ESLint autofix runs a single `--fix` spawn (was dry-run + fix, double cold-start), LSP quick-fix lookups for blocking diagnostics run in parallel, and the lsp runner reuses its already-read file content for nosemgrep suppression (#453)
- perf/refactor: the eight NDJSON debug loggers (latency, cascade, read-guard, tree-sitter, dead-code, actionable-warnings, ast-grep-tool, diagnostic) now share one buffered async writer (clients/ndjson-logger.ts) — no more synchronous appendFileSync on the per-edit hot path; best-effort sync flush at process exit (#454)
- perf: the review-graph freshness check now uses RuntimeCoordinator sequence state to skip the per-build O(project) walk+stat sweep when only pi-observed edits occurred (seq fast path; periodic full re-verify every 20 builds/5 min catches external changes; PI_LENS_GRAPH_SEQ_FASTPATH=0 disables) (#451)
- perf: skip the reverse-dependency index rebuild (O(graph edges)) and its project-snapshot disk write on cascade runs where the review graph didn't actually change — the index is a pure function of the graph, so a cache-hit graph build (or a seq-fastpath build that found nothing graph-relevant to re-parse) now reuses the last-built index instead of redoing both. Freshness keys on a new `ReviewGraph.buildGeneration` stamp that travels with the returned graph instance (`mode` alone can't distinguish a true seq-fastpath no-op from one that re-parsed files, and the global build-info slot can be clobbered by overlapping deferred cascades); per-workspace cache, `PI_LENS_REVERSE_DEPS_REUSE=0` disables (#459)

### Fixed

- **`no-init-return` no longer flags factory functions** (#439) — the ast-grep rule matched `return` inside any `function_definition` whose *body text* regex-contained `def __init__`, so a factory that returns a class with an `__init__` (and its sibling methods' returns) tripped it. It now matches a `function_definition` whose **name field** is `__init__` (`has: field: name`), so only real `__init__` returns are flagged. Regression fixtures added (factory + sibling-method cases).
- **`python-assert-production` no longer fires in test files** (#440) — `assert` is the idiomatic test assertion, so flagging every `assert` in `tests/**` was pure noise that trained users to ignore the rule. Tree-sitter rules gain an opt-in `skip_test_files` field (the runner otherwise runs on test files, since structural issues matter there); `python-assert-production` sets it, so production `assert` (the `-O` strip risk) is still flagged while test asserts are skipped. Exercised through the real runner (prod fires, `tests/` skips).
- **The opengrep/Semgrep runner now honors `# nosemgrep` suppression** (#441) — the canonical Semgrep inline suppression (`# nosemgrep` and `# nosemgrep: <rule-id>[,<rule-id>]`, also `//`) was ignored, leaving only `.pi-lens.json` path globs or code restructuring as escapes. The auxiliary-LSP runner now drops opengrep findings suppressed by a `nosemgrep` comment on the finding's own line (inline) or a standalone comment on the line above — matching Semgrep's placement semantics (an inline comment doesn't leak to the next line).
- **`lens_diagnostics mode=full` now honors inline `# pi-lens-ignore` comments** (#442) — inline suppression (`// pi-lens-ignore: rule` / `# pi-lens-ignore: rule`) was applied only in the per-edit dispatch path (`mode=all`), so a site cleanly suppressed there reappeared as **blocking** in the project-wide `mode=full` sweep — making `mode=full` unusable as a "clean" gate for any project with a legitimate suppression. The suppression filter is now shared (`clients/dispatch/inline-suppressions.ts`) and applied to the merged `mode=full` summaries too: each flagged file is read and its inline ignores honored (fail-safe — a read error never hides a finding), and counts are re-summarized so a fully-suppressed file reports clean. Rule matching also normalizes `ast-grep:<id>` / `<id>-js` forms, so a bare `pi-lens-ignore: <id>` suppresses the finding in both modes. (The reporter's secondary ask — per-rule enable/disable in `.pi-lens.json` — is tracked separately as a follow-up.)
- **The review-graph snapshot is now stamped with git HEAD + worktree root, and the read-substitute path drops it on mismatch** (#300) — `git worktree remove` followed by `add` at the same path for a different branch reuses the cwd-derived data-dir slug, so a `module_report`/blast-radius read fired before the first rebuild could return the previous branch's symbols/edges. The persisted snapshot now carries an optional git stamp (HEAD commit + worktree top-level path), resolved purely by reading `.git`/`HEAD`/`refs` files — no `git` subprocess, since the persist path includes the synchronous flush-on-exit handler and spawning at teardown crashes libuv on Windows (#234). The blind read path (`getCachedReviewGraph`, which trusts disk with no other verification) drops a stamped snapshot that mismatches the current repo; the build path deliberately keeps loading it unverified, because its signature/content-hash confirm (#202) already proves file-level freshness — so a plain `git commit` (HEAD moves, files unchanged) still cold-starts as a cheap "cached" reuse, never a full rebuild. An absent stamp (older snapshot, or a non-git cwd) behaves exactly as before. Separately, a cwd that isn't the git worktree top-level is now logged once per process (observability only, no hard-fail) — the review graph's cross-worktree isolation has always rested on that assumption, and it was previously invisible.

## [3.8.66] - 2026-07-07

### Added

- **`lens_diagnostics mode=full` now surfaces the heavyweight project analyzers via an extractor registry** — previously only knip crossed from the heavyweight analyzers into the diagnostic surface; the rest reached the agent only via next-turn context injection. New `project-diagnostics/extractors.ts` registry maps each analyzer's **cached** result to per-file `ProjectDiagnostic`s through pure `runner-adapters/*` functions (mirroring `knip.ts`): **jscpd** copy-paste (a clone → a diagnostic on **both** ends, each naming the other span), **madge** circular deps (a cycle → one on **each** participating file), **gitleaks** secrets (→ **blocking**), **govulncheck** reachable Go CVEs (anchored at the first traced source frame), **trivy** dependency CVEs (anchored at the manifest), and **dead-code** (vulture/Python — unused symbols; unlisted deps → **blocking**). **Cache-only, never re-launched:** `mode=full` reads each analyzer's session-start cache and folds the results in — it never spawns a scan, so it can't relaunch or contend with the background runs (which share a global abort signal). Adding a new analyzer is now one adapter + one registry row. Included when `refreshRunners` is `cached`/`cheap`/`all`.
- **madge now runs whole-project at session-start and caches its result** — bringing it in line with knip/jscpd/gitleaks/govulncheck (it was the lone analyzer running per-edited-file at turn-end and discarding its output). `lens_diagnostics mode=full` reads the new `madge` cache via the extractor registry, giving whole-project circular-dependency coverage.

### Changed

- **Dropped the `typescript` compiler from runtime dependencies** (#402) — it's now a **devDependency** (the `tsc` build/type-check tool), so it no longer ships to users (`npm install --omit=dev`), saving ~8.4 MB bundle / ~23 MB `node_modules`. The last runtime consumers were the dispatch fact-rules: the low-signal style smells were **dropped** (commented-out-code, duplicate-string-literal, max-switch-cases, no-magic-numbers, no-boolean-params, no-complex-conditionals), two rules were already **dead** (no-magic-numbers, high-entropy-string — never registered; secret detection is covered by the gitleaks extractor), dynamic-regexp/ReDoS was dropped in favor of the existing pattern-based ast-grep `redos-nested-quantifier` + tree-sitter `unsafe-regex` rules, and function-in-loop moves to a follow-up ast-grep rule (#428). The three survivors — **cors-wildcard**, **no-commented-credentials** (regex, multi-language), and **high-import-coupling** (fact-based) — are now individual files under `dispatch/rules/` (matching the one-rule-per-file convention) and register **eagerly** in `integration.ts` like every other rule. That unifies fact-rule registration (#421) and removes the now-obsolete lazy `ensureTypeScriptDispatchUnits` degrade indirection from `fact-runner`: with no `typescript` in the graph and `web-tree-sitter` loaded via a dynamic import, the providers are safe to register eagerly. No user-facing behavior change beyond the dropped rules.
- **Complexity metrics are now language-agnostic and tree-sitter-based** (#402) — `ComplexityClient` no longer uses the `typescript` compiler; it computes cyclomatic + cognitive complexity, nesting depth, function metrics, LOC/comments, code entropy, and AI-slop indicators over the shared tree-sitter client via a per-language node table. Beyond JS/TS it now also analyzes **Python, Go, and Rust** (adding a language is one table entry). Halstead volume is dropped (its maintainability-index term is replaced with the Halstead-free variant `171 − 0.23·CC − 16.2·ln(LOC)` + comment bonus); the dead `formatMetrics`/`checkThresholds` methods are removed. These are silent session-summary baselines, so the metric surface is unchanged aside from the dropped `halsteadVolume` field. `analyzeFile` is now async (grammar parse). Only the sonar/quality **rules** still import `typescript` — the last #402 Phase-2 step before the dependency is dropped.
- **The comment / try-catch / function fact extractors now parse via tree-sitter instead of the TypeScript compiler** (#402, Phase 2) — `commentFactProvider`, `tryCatchFactProvider`, and `functionFactProvider` are ported off `ts.createSourceFile` onto the shared tree-sitter client via a new shared `facts/tree-sitter-facts.ts` (parse boilerplate + node-walk helpers, which `import-facts` also now uses). Output is unchanged — the fact-consuming rules (`high-complexity`, `high-fan-out`, `async-noise`, `error-swallowing`, `pass-through-wrappers`, `placeholder-comments`, …) and the review graph read the same `file.comments`/`file.tryCatchSummaries`/`file.functionSummaries`, and these rules still surface as the same code-quality warnings. Parity is locked by the existing try-catch suite + new comment/function-fact tests (names, async/await, pass-through/boundary wrappers, cyclomatic complexity, nesting depth, outgoing calls). Providers are now async; the review-graph caller awaits `functionFactProvider` too. Only `complexity-client` + the sonar/quality rules still use `typescript` (#402 Phase 2 remainder).
- **`import-facts` now extracts imports via tree-sitter instead of the TypeScript compiler** (#402, Phase 2 pilot) — the `importFactProvider` (static/default/namespace/side-effect imports, dynamic `import()`/`require()`, named + star re-exports, esm/cjs/unknown module-type) is ported off `ts.createSourceFile` onto the shared, cached tree-sitter client (#416). Output (`file.imports`/`file.reexports`, consumed by the `high-import-coupling` rule and the review graph) is unchanged — parity is locked by the existing suite plus new cases (aliased/combined imports, `.tsx`-grammar smoke). The provider's `run()` is now async (grammar parse); the one non-awaited caller (`review-graph/builder.ts`) is fixed to await it. First of the syntactic TS-AST consumers to move off the `typescript` dependency.
- **Unified tree-sitter parsing on a single shared client** (refs #402) — the dispatch tree-sitter runner, project scanner, `module-report`, and review-graph each held their **own** `TreeSitterClient`, so a file written on the hot path was parsed multiple times with no shared tree cache, and each subsystem re-loaded grammars independently. They now share one process-wide client via `clients/tree-sitter-shared.ts` (`getSharedTreeSitterClient` + a single `resolveTreeSitterLanguage` ext→grammar map), so a file parsed by one subsystem is served from the shared tree cache for the others (one parse per write). This also fixes a latent gap: web-tree-sitter's WASM runtime is module-level (one per process), so an Emscripten `abort()` corrupts it for **everyone** — previously only the runner tracked the poison flag while the scanner/module-report/review-graph kept calling the dead runtime; `markTreeSitterWasmAborted()` now makes every consumer skip. Foundation for porting the syntactic TS-AST consumers (fact providers, complexity, rules) off the `typescript` dependency onto tree-sitter.
- **CLI tool resolution now finds binaries installed by any package manager, not just npm/PATH** (#375) — the shared `resolveLocalFirstAsync` helper (the runner fleet's "local `.bin` → global → `npx --no`" resolver) plus the per-tool resolvers for **biome** (`biome-client.ts`, `dispatch/runners/biome.ts`, `formatters.ts`), **prettier** (`formatters.ts`), **type-coverage**, **jscpd**, **madge** (`dependency-checker.ts`), **ast-grep** (`sg-runner.ts` + the shared `isSgAvailableAsync`), and the **test runners** (`test-runner-client.ts`) now check every installed manager's global bin dir (npm/pnpm/yarn/bun) by direct file lookup before falling back to `npx`. This finds tools installed via `pnpm add -g` / `bun add -g` (whose bin dirs are often off PATH) and survives PATH-cache staleness right after an `install -g`. Each site's existing `npx` fallback is unchanged — the lookup is purely additive, so no user ever gets a surprise `dlx` download. New `findGlobalBinary` / `findNodeToolBinary` helpers in `package-manager.ts`, which also de-duplicate the identical private lookup in `lsp/launch.ts`.
- **`lens_diagnostics mode=full` wall-clock ceiling raised from 3 min to 5 min** — large monorepos with many cold language servers were hitting the 180s cap and returning partial results before the sweep finished. The default is now 300s (still env-tunable via `PI_LENS_LENS_DIAGNOSTICS_FULL_TIMEOUT_MS`).
- **In-flight LSP nav requests now cancel when the turn is abandoned** (#238 Item 1) — `navRequest`/`safeSendRequest` (`clients/lsp/client.ts`) thread the ambient abort signal into a vscode-jsonrpc `CancellationToken`, so aborting a turn (Escape) sends an LSP `$/cancelRequest` and the server stops computing a definition/references/hover/etc. result the agent has already walked away from — reclaiming clangd/pyright/tsserver CPU on the hot path instead of running the request to completion and discarding it. An already-aborted signal skips the send entirely; the resulting cancellation rejection (`RequestCancelled`/`ServerCancelled`) is treated as "no result." Defaults to the ambient signal, so all ~12 nav call sites get it with no signature change.

### Removed

- **Removed the deprecated built-in TypeScript type-checker fallback** (#402, Phase 1) — the `ts-lsp` dispatch runner and its `TypeScriptClient` (`typescript-client.ts`) are deleted, along with the dead `TypeScriptService` (`ts-service.ts`, which had no consumers). TS type-checking is now **LSP-only** (tsserver via the unified `lsp` runner, which is default-on and was already the primary path — `ts-lsp` merely deferred to it). **Behaviour change:** running with `--no-lsp` no longer provides TS *type* diagnostics; the write-path linters (eslint/oxlint/biome) and structural analysis (tree-sitter, ast-grep, fact-rules) are unaffected. This removes the only `ts.createProgram`/`createLanguageService`/`TypeChecker` usage in the codebase — the first step toward dropping the heavyweight `typescript` dependency entirely (remaining usage is purely syntactic AST parsing, portable to tree-sitter).
- **The `/lens-booboo` command is gone** — its full-codebase review (design smells, complexity, dead code, duplicates, circular deps, secrets, vulns) is now available through the normal diagnostic surface: **`lens_diagnostics mode=full refreshRunners=all`**, which folds in the same heavyweight analyzers via the extractor registry. Also removed the dormant `TypeCoverageClient` (its only caller was `/lens-booboo`; it was never run on the normal path and is TS-only, redundant with LSP strict-mode + biome). The `--lens-guard` commit-block message and `/lens-tdi` now point to `lens_diagnostics` instead. (`FULL_LINT_PLANS`/`fullOnlyGroups` in `dispatch/plan.ts` were orphaned by this removal and are deleted in the follow-up below.)
- **Removed the orphaned full-project lint plan machinery** (#399, refs #398) — `FULL_LINT_PLANS` + `toFullPlan()` and the `fullOnlyGroups` field/entries in `dispatch/plan.ts` are gone now that `/lens-booboo` (their only consumer) is removed; `TOOL_PLANS` (the per-write plans) is the sole plan surface. The two full-plan-only runners this orphaned — **`biome-lint`** (`dispatch/runners/biome.ts`) and **`python-slop`** (`dispatch/runners/python-slop.ts`, `PRIORITY.PYTHON_SLOP`) — are deregistered and deleted. The `python-slop` **ast-grep rules** (`rules/python-slop-rules/`, ~45 warning-severity Python "slop" patterns) are **kept in the tree** pending a decision to migrate them into the shipped `ast-grep-rules` corpus or delete them (tracked in #400); they were never loaded by the ast-grep LSP or the `ast-grep-napi` runner.

### Fixed

- **`pi install git:…` (GitHub install) no longer fails on a clean machine** (#437) — pi builds the GitHub install by cloning master HEAD and running `npm install --omit=dev` in-place, which triggers `prepare` → `build:dist` → `tsc`. Since #402 made `typescript` a **devDependency**, `--omit=dev` omits it, so on a machine without a cached/global `tsc` the from-source build had no compiler and the install failed (`npm run build:dist … exit 1`). The npm-registry install path was unaffected (it ships prebuilt `dist/`; `prepare` never runs). `build:dist` now resolves the compiler on demand via `npx --yes -p typescript@6 tsc …` — it uses the local devDep when present (dev/publish) and fetches it transiently only for a clean `--omit=dev` source build. `typescript` stays a devDependency (nothing extra ships to registry users, no build output committed). The `prod-install-build` CI job is hardened to remove the globally-installed `typescript` first (so the from-source build can no longer pass by leaking `tsc` off the runner's PATH — which is how the regression slipped through), and now also runs a faithful `pi install git:…` simulation: a clean clone + `npm install --omit=dev` that exercises the whole `prepare` (npx build + grammar download) from a pristine tree.
- **Lua symbols + imports no longer silently break in multi-language repos** (#255) — the aggregator's `tree-sitter-lua.wasm` (`tree-sitter-wasms@0.1.13`) parsed lua correctly only as the **sole** grammar: the moment any second grammar loaded into web-tree-sitter's process-global WASM `Module`, every subsequent lua parse became an `ERROR` tree, so `SYMBOL_QUERIES.lua`/`IMPORT_QUERIES.lua` extracted nothing — lua symbol search, symbol-level impact, and `module_report` outlines were silently empty in essentially every real repo (this is why the #249 lua import query couldn't ship). Root cause is that specific stale wasm, not the runtime (bash/ruby/python/go/js are all fine after a 2nd grammar). Fixed with a new **per-grammar source override** (`GRAMMAR_SOURCE_OVERRIDES`): lua now downloads from the maintained **`@tree-sitter-grammars/tree-sitter-lua@0.4.1`** prebuilt wasm instead of the frozen aggregator, which parses cleanly in a multi-grammar process. The lua defs/refs/import queries are rewritten for that grammar's node types (`function_declaration`/`function_call`/`dot_index_expression`), and lua is now covered by the symbol + import smokes plus a dedicated shared-Module regression test.
- **The bundled YAML grammar now actually loads** (#427) — the aggregator's `tree-sitter-yaml.wasm` (`tree-sitter-wasms@0.1.13`) is ABI-incompatible with the pinned `web-tree-sitter@0.25` and fails `Language.load` outright, so YAML parsing silently returned nothing despite the grammar shipping in the bundled CORE set (dead weight in the tarball, and the lone grammar the grammar-health sweep reported as "unavailable"). Uses the same new `GRAMMAR_SOURCE_OVERRIDES` mechanism to pull the maintained **`@tree-sitter-grammars/tree-sitter-yaml@0.7.1`** prebuilt wasm, which loads + parses cleanly. Covered by a load regression test.
- **Swift files no longer crash `pi` on Node 24** (#423, #432) — the prebuilt `tree-sitter-swift.wasm` (from `tree-sitter-wasms@0.1.13`) triggers a **fatal, uncatchable V8 crash** (`Fatal process out of memory: Zone`, in the background Turboshaft-WASM optimizer) the first time a `.swift` file is analyzed on **Node 24, every OS** — taking down the whole agent. The crash is a process **abort**, so it can't be caught or degraded in-process, and rebuilding the grammar from source does **not** reliably dodge it (proven by the grammar-health nightly: the from-source wasm crashes on Node 24.18 identically to the prebuilt). pi-lens now **refuses to load the grammar at the point of use on the affected runtime** (`BLOCKED_GRAMMARS` / `grammarBlockReason`, gated on V8 + Node major ≥ 24): a `.swift` file simply gets no tree-sitter structural symbols (graceful degrade) instead of crashing the session. **bun (JavaScriptCore) and Node ≤ 22 are unaffected** and keep full Swift support via the normal CDN grammar download. Membership of the blocklist is **guard-driven**: the **`npm run check:grammar-load`** guard (loads each grammar in an isolated child process, skipping blocked ones — a hard gate for any *new* crasher) plus the **nightly cross-OS grammar-health workflow** watch (via a force-load probe) for when a future Node/V8 makes it safe to lift. Supersedes the earlier from-source **vendoring** approach (#426), which added a committed-wasm + provenance mechanism without actually dodging the crash — now removed.
- **Tree-sitter no longer leaks WASM heap memory across a session** (#417) — web-tree-sitter `Tree` objects live in the WASM heap, which JS GC does **not** reclaim (0.25 has no auto-free); the tree cache dropped evicted/invalidated/overwritten trees with `Map.delete()` and never called `tree.delete()`, so every removed tree leaked. The cache bounded entry count (50) but not the heap, so it grew unbounded over a long editing session. `TreeCache` now frees the WASM tree on every removal path — eviction, same-file re-parse (same-key overwrite), on-disk change/deletion invalidations, `invalidate()`, and `clear()` — via a guarded `freeTree()` (best-effort; tolerates a dead/aborted runtime). The retained-for-incremental path (content changed, tree kept) is deliberately not freed. Safe because every consumer uses a parsed tree transiently (parse → extract → discard) and eviction only ever targets the oldest entry, never a just-parsed tree still in use.
- **`lsp_diagnostics` now stops opening files in the language server once the turn is abandoned** (#343) — the batch and directory scans thread the tool-call + turn (`ctx.signal`) abort signal into their concurrency fan-out, so an Escape/abort mid-scan stops scheduling new files (each in-flight file stays bounded by `waitMs`) and returns partial results, instead of grinding the whole capped batch into the server after the agent has moved on.
- **LSP nav requests retry once on `ContentModified` instead of returning empty** (#238 Item 2) — when a file changes under an in-flight `definition`/`references`/`hover`/etc. request the server rejects with `ContentModified` (-32801); `safeSendRequest` now does a single safe retry against the fresh state (correctness-under-edit is the hot path), returning empty only if it still can't answer. `RequestCancelled`/`ServerCancelled` are surfaced as "no result" (no retry) and `RequestFailed` (-32803) is treated as permanent — the JSON-RPC error code is now discriminated rather than blanket-rethrown.

## [3.8.65] - 2026-07-04

### Added

### Changed

### Fixed

- **Full-scan progress bar now actually renders** — the progress bar added in 3.8.64 was computed and streamed to the tool's `onUpdate`, but never displayed: `lens_diagnostics`/`lsp_diagnostics` define a custom `renderResult` (`compactRenderResult`), and the pi host renders a partial update through that renderer, which drove its summary off structured `details` and ignored the progress text in `content`. The summarizers now detect a streaming progress partial (`details.phase === "scanning"`, via the new `scanningSummaryLine` helper) and show the bar (`Scanning… [████░░░░░░] 62/123 (50%)`) during the scan, falling back to the normal diagnostic summary on completion. Also ran `npm pkg fix` to drop the `./` prefix from the `bin` paths, silencing an npm publish auto-correct warning (the published bins were already correct).

## [3.8.64] - 2026-07-04

### Added

- **Opt-in `workspace/diagnostic` pull for the full scan — one request per server instead of N file opens (#387 Item 2)** — where a language server advertises `workspace/diagnostic` (e.g. TypeScript), `lens_diagnostics mode=full` can now issue a single project-wide pull per server instead of opening every file, detected via a new `workspaceDiagnostics` capability flag (distinct from per-document pull) and a `requestWorkspaceDiagnostics` client method. Gated behind `PI_LENS_LSP_WORKSPACE_PULL=1` (default off) and used per server-group only when the server advertises it and no file in the group has an auxiliary scanner; **any** miss (unsupported / dead / timeout / auxiliary present) falls back to the per-server-serial per-file path from #387 Item 1. Off by default because a **cold** server can answer a workspace pull with an empty/partial report that would read as a false "all clean", and the pull covers only the primary server — so it stays opt-in pending real-server validation before becoming the default. Completes the capability side of #387 (Item 1 shipped in #388).
- **Progress bar for the long full-mode diagnostic scans (`lens_diagnostics mode=full`, `lsp_diagnostics` batch/directory)** — these scans can run for seconds to minutes and were previously opaque until they returned. They now stream a throttled progress bar (`Scanning… [████░░░░░░] 45/123 (37%)`) to the tool's `onUpdate` callback — at most ~4×/s plus a guaranteed final tick — so the agent/user sees movement. The data already existed (the sweep's per-file completion count); this just surfaces it. Shared `tools/scan-progress.ts` (`renderScanProgress` + `makeProgressReporter`); `runWorkspaceDiagnostics` and `mapWithConcurrency` gained an optional `onProgress(completed, total)`.

- **Tree-sitter grammar provenance — sha256 sidecars + a committed manifest + a CI guard (#177)** — the grammar downloader now verifies every fetched `.wasm` against a committed provenance manifest (`scripts/grammars.lock.json`: package, version, per-grammar sha256) and writes a `<grammar>.wasm.json` sidecar recording what was installed. Two integrity gaps close: (1) fetched bytes are checked against the pinned hash — a grammar whose bytes don't match is never written (guards CDN corruption/tampering); (2) the old **skip-if-exists** behavior is replaced by **skip-if-verified** — on a version bump or hash mismatch the stale grammar is re-downloaded instead of silently persisting, so an ABI-mismatched `.wasm` can't survive against the deliberately pinned `web-tree-sitter`. A new `npm run check:grammars` (`scripts/check-grammar-provenance.mjs`, wired into CI like `check:lockfile`) re-hashes the installed grammars' bytes and fails on any drift from the manifest. Regenerate on a deliberate tree-sitter-wasms bump with `node scripts/download-grammars.js --write-manifest`.
- **Package-manager detection — pi-lens no longer hardcodes npm/npx (#374)** — new `clients/package-manager.ts` is the single source of truth for *which* Node package manager to use (npm/pnpm/yarn/bun) and *how* to spell each command (run-script / install / global-install / exec / global-bin). Resolution: the project's lockfile or corepack `packageManager` field when that manager is installed, else the first installed by preference (npm → pnpm → yarn → bun), else npm. Routed through it: tool auto-install and global-bin discovery (`clients/installer`), LSP global binary lookup (`clients/lsp/launch.ts`), the interactive LSP-server global install (`clients/lsp/interactive-install.ts`), the MCP `pilens_rebuild` (`runRebuild` reports which manager it used), and project run-command hints. This makes pi-lens work on hosts that ship bun/pnpm/yarn instead of npm.

### Changed

- **Tree-sitter grammars: bundle a core set + fix cross-manager install** — grammars were fetched only by the `postinstall` script, which **npm** runs but **pnpm/bun block by default** (and **yarn** couldn't install at all — see below), so non-npm users depended entirely on a runtime CDN fetch and got *no* tree-sitter offline. Now the 12 core grammars (ts, tsx, js, python, go, rust, json, yaml, bash, html, css, java) are **downloaded at `prepare` time and shipped in the tarball** (`grammars/`, in `files[]`; ~+1 MB to the `.tgz` since wasm gzips well, +9 MB unpacked), so the common languages parse **offline on every package manager**. The long-tail grammars still lazy-fetch on first use, and a failed fetch now emits a **visible, actionable warning** instead of a silent debug line. Also removed the `tree-sitter-wasms: "npm:null@^0.11.0"` optional-dependency sentinel — npm/pnpm/bun skipped it as a failing optional but **yarn classic hard-errored** on it, so `yarn add pi-lens` failed outright; it's now installable under yarn.

### Fixed

- **The LSP notify write is now bounded at the source, so a wedged server can't ride an edit to the 30 s dispatcher timeout** — the pre-dispatch sync fix bounded one call site, but *every* `touchFile` caller (the dispatch LSP runner and the workspace sweep too) `await`ed `notify.open`'s `didChange`/`didOpen` write, which backpressures indefinitely on a server whose stdin isn't drained. So edits on a wedged server still took ~31 s — the dispatch LSP runner hung on that write until the coarse 30 s per-runner ceiling killed it (`Runner lsp timed out after 30000ms`). The write is now bounded inside `touchFile` itself (`PI_LENS_LSP_NOTIFY_BUDGET_MS`, default 2 s): on a wedged server it degrades to "no fresh diagnostics" (logged as `lsp_notify_timeout` / `notifyWriteTimedOut`) instead of hanging, for **all** callers.
- **`lens_diagnostics`/`lsp_diagnostics` full sweep no longer floods a single-threaded server (#387)** — the sweep ran a flat 8-wide worker pool that was server-agnostic, so on a single-language repo all 8 concurrent touches hit one `tsserver`. It's single-threaded per project: they don't parallelize, they queue — inflating the working set (each `didOpen` can force a project recheck) and **cascading per-file-budget timeouts by queue position** (observed: 51 of 123 files "timed out" purely from being behind others, the count climbing 0→51 as the queue deepened). The sweep now **groups files by their primary server, serializes touches within a server (one in flight), and parallelizes across distinct servers** — real parallelism in a mixed TS+Python repo, no flooding in a single-language one. This uses the universal per-file `didOpen` path, so it works for every server regardless of `workspace/diagnostic` support (a single-request workspace pull for servers that advertise it remains the #387 follow-up).
- **An edit could hang indefinitely (and ignore Escape) on a wedged language server — the pre-dispatch LSP sync was unbounded** — after an edit, pi-lens syncs the new content to the language server (a `didChange`/`didOpen`) before dispatching lint. Client *acquisition* was capped, but the notify *write* was not: when the server's stdin isn't being drained (a CPU-bound/wedged server — e.g. TypeScript mid-recheck), that write backpressures forever, hanging the whole edit with **no per-call bound and no log at all** (the first instrumented phase, `read_file`, sits *after* the sync, so the stall left zero trace). Observed live: an edit wedged 8+ minutes with the server timing out on every request. Now the sync is raced against a hard budget (`PI_LENS_LSP_SYNC_BUDGET_MS`, default 3 s) **and** the turn's abort signal, so Escape cancels it and a slow server can't park the pipeline — the edit proceeds (the dispatch LSP runner, with its own 30 s cap, still tries). Escape now reaches this path because the turn signal is exposed for in-process LSP awaits (`getAmbientAbortSignal`), not just child-process spawns. Two observability gaps closed too: an abandoned sync logs an `lsp_sync_abandoned` phase (timeout vs aborted), and a new `tool_result_received` marker fires the instant pi-lens receives an edit — so a future stall is localizable (present-then-silent = in-pipeline; absent = upstream) instead of invisible.
- **Log retention was silently broken — rotated backups never got deleted, and three logs never rotated** — the 10 MB rotation worked, but the 7-day retention sweep that's supposed to reap the backups used a pattern (`/\.log\./`) that only matched the *legacy* `name.log.<ts>` shape, never the *current* `name.<ts>.log` that rotation actually produces — so every backup ever rotated accumulated forever (observed: ~200 MB of `~/.pi-lens` backups dating back 2.5 months against a 7-day policy). Separately, three of the eight global logs — `actionable-warnings.log`, `ast-grep-tools.log`, `dead-code.log` — were absent from the rotation list and grew unbounded. Both are fixed: the deletion pattern now matches both backup shapes (and never an active log), and all eight logs are managed from one shared `MANAGED_LOG_FILES` list so rotation and the storage summary can't drift apart again. Because `runLogCleanup` already runs unconditionally on every session start, the corrected sweep **self-heals existing backlogs** — the next launch after upgrade reaps each user's accumulated `>7 day` backups with no migration step.
- **`lens_diagnostics mode=full` could hang indefinitely and didn't cancel on Escape** — an unattended session was observed wedged for ~8 hours on a small repo. Two root causes, both fixed: **(1) Escape didn't cancel** — the tool honored only the tool-call `signal` positional, not `ctx.signal` (the turn-wired abort Escape fires for a registered extension tool), so the sweep ran on. It now combines both (`combineAbortSignals`, shared in `deadline-utils`) and threads the result through the LSP sweep and project-runner scan. **(2) It could hang forever** — per-file diagnostic waits were bounded, but *client acquisition* in the sweep was not, so a language server hanging on spawn/`initialize` parked a worker permanently. Now (a) each file gets a per-file wall-clock budget (`PI_LENS_LSP_WORKSPACE_PER_FILE_MS`, default 15 s) so a worker always returns to its abort check, and (b) the whole scan has a hard wall-clock ceiling (`PI_LENS_LENS_DIAGNOSTICS_FULL_TIMEOUT_MS`, default 3 min) that aborts it to partial results rather than never returning. The sweep also now emits **start + periodic heartbeat** latency logs (with `completed/total`, `timedOutFiles`) so a future stall is debuggable instead of silent, and the `scripts/analyze-pi-lens-logs.mjs` smell report now consumes them — surfacing sweeps that started but never completed (the hang/kill signature, with the last heartbeat's `completed X/Y`), sweeps whose files hit the per-file budget, and any `*_timeout` phases. The same pass repairs the analyzer's `slow-background-tasks` detector, whose regex had drifted from the runtime's `session_start task … success runMs=<n>` format and was silently matching **zero** of ~2k rows — it now flags the real multi-second startup tasks (`call-graph`, `knip`, `project-index`) again, and accepts both the current `runMs=` and the older `(<n>ms)` shapes. The same `ctx.signal` cancellation gap is fixed in the sibling tools `lsp_diagnostics` (batch/directory scans) and `ast_grep_search` (which was already abort-aware but checked the wrong signal).

## [3.8.63] - 2026-07-01

### Added

### Changed

- **Consolidated the timeout-race helpers into one shared `clients/deadline-utils.ts` (#366)** — the "race a promise against a timer" pattern had drifted into three near-identical copies (`withTimeout` in the LSP client, `withBudget` in read-expansion, `withinRemaining` in module-report). They're now thin adapters over one `withDeadline` core, which also fixes two latent bugs the copies carried: `withBudget` didn't suppress the loser promise's late rejection (an unhandled rejection when the timer won first), and `withinRemaining` never cleared its timer. Behaviour at every call site is unchanged (covered by the consumer suites); the core's semantics are locked by dedicated tests including an explicit late-rejection-suppression probe.

### Fixed

- **Bounded the remaining unbounded LSP requests — `workspace/symbol`, `textDocument/codeAction`, `workspace/executeCommand` (#365)** — like the pull-diagnostics fix (#364), these were sent via `safeSendRequest` with no `withTimeout` ceiling, so a language server that accepts a request but never replies (alive but hung) would make the `await` never resolve — hanging symbol search (`pilens_symbol_search`), code-action lookups, and server-command execution. `workspace/symbol` and `textDocument/codeAction` now route through the shared `navRequest` helper (its `withTimeout` ceiling + single-file stale-drop), timing out to `[]`. `workspace/executeCommand` — which is mutating and legitimately long-running — gets a separate, generous 30s anti-deadlock backstop (`PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS`) that returns an honest `executed:false, reason:"…may still be applying server-side"` rather than truncating valid work or pretending it ran; its allowlist-by-advertisement and server-edit-window hardening are preserved. Real (non-timeout) errors still propagate throughout.
- **Autofix project-snapshot walk no longer freezes the TUI on large repos (#368)** — `snapshotProjectFiles` (the `tool_result` autofix side-effect detection that snapshots the project tree before/after a formatter or fixer runs, to catch files it changed as a side effect) was a fully synchronous `readdirSync`/`statSync` walk bounded only by a 5,000-file cap — a ~130ms event-loop block at the cap (2–4× that under load), stalling keystrokes on large projects while autofix ran. It now walks asynchronously and yields to the event loop every 500 files, so it holds the loop only for a short chunk. The scan cap and directory-exclusion/confinement behavior are unchanged; a cap-scale (~5k-file) event-loop occupancy guard asserts the walk keeps yielding.
- **Bounded LSP pull-diagnostics request — a hung server can no longer hang `lens_diagnostics` (#349, #364)** — the pull path in `clientWaitForDiagnostics` awaited a `textDocument/diagnostic` request via `safeSendRequest`, which only settles on a reply or a *destroyed* stream. A pull-mode server that is alive but hung (accepts the request, never replies) made that `await` never resolve, hanging the diagnostics wait → the `dispatch_lint` pipeline phase → `flushDebouncedToolResults` → `lens_diagnostics`, forever (and `safeSpawnAsync`'s 30s cap doesn't apply — it's a pipe request, not a spawn). Unlike the navigation/init/shutdown callers, this request had no `withTimeout` ceiling; the `timeoutMs` passed into `clientWaitForDiagnostics` only bounded the push backstop and the pull *retry interval*, never the individual request. The request is now wrapped in the existing `withTimeout` helper, bounded by `min(PULL_REQUEST_TIMEOUT_MS, remaining caller budget)` (env `PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS`, default 10s, mirroring `NAV_REQUEST_TIMEOUT_MS`). On timeout the request is caught as `unavailable`, which per #240 is not read as clean and falls through to the already-bounded push backstop. Explains the intermittent repro — it only fires for pull-mode servers (rust-analyzer being the classic) stalling mid-analysis. Regression test: a pull server whose `sendRequest` never resolves now resolves within the caller's budget instead of hanging.
- **POSIX LSP teardown now cleans up the whole process tree, not just the direct child (#362, #363)** — on POSIX, LSP servers launched through wrappers (npm shims, shell/node launchers) could leave descendants alive after pi-lens reset or shut down an LSP service; observed most visibly as `vscode-html-language-server` processes accumulating across long-lived zellij sessions and pressuring memory. LSP servers are now spawned detached into their own process group and teardown signals the group (`process.kill(-pid, ...)`) before falling back to the direct child, bringing POSIX cleanup in line with the existing Windows `taskkill /T` behavior. Windows teardown is unchanged (`taskkill /T` mid-session, handle-only kill for `processExiting`). Guarded by the `pid <= 0` check so a group signal can never degrade into `process.kill(-0)` against pi-lens's own process group.

## [3.8.62] - 2026-06-28

### Added

- **Compact, blue-branded tool-result rendering (refs #345)** — the navigable/structural/diagnostic tools (`module_report`, `read_symbol`, `read_enclosing`, `ast_grep_search`, `ast_grep_replace`, `ast_grep_dump`/`ast_dump`, `ast_grep_outline`, `lsp_navigation`, `lsp_diagnostics`, `lens_diagnostics`) no longer flood the terminal with their full body. Each now defines a `renderResult` that shows a one-line summary by default — in pi-lens **blue characters** (bold blue text on the default tool-shell background) built from the tool's structured `details` (semantic counts/ranges, not blind truncation) — while the **model still receives the untouched full `content`**; the full output is one keystroke away via expand. Errors stay theme-red. The pi host only dumps a tool's `content` verbatim when it defines no `renderResult`, so supplying one decouples the model payload from the terminal view. Coexists with global renderer extensions (pi-tool-display / pi-claude-style-tools), which default to respecting a tool's own renderer. Shared helper `tools/render-compact.ts`, with unit tests; `pi-tui` `Text` routed through the `clients/deps/*` accessor (dep-centralization seam #285/#335).
- **Cross-file dead-code detection for non-JS/TS languages — Phase 1: Python via vulture (#127)** — Knip gives JS/TS projects project-wide unused exports/files/deps at session-start, but per-file dispatch linters can't catch "this exported symbol is unused anywhere in the project" for other languages. New `DeadCodeClient` interface (`clients/dead-code-client.ts`) parallels Knip's lifecycle (detect → ensureAvailable → analyze, cached at session-start, surfaced as a turn-end advisory), with a Python implementation backed by [`vulture`](https://github.com/jendrikseipp/vulture). Detection gates on a Python marker (`pyproject.toml`/`setup.py`/`requirements.txt`/…) with the same home-dir + VCS-boundary containment as Knip, so a scan launched from a bare cwd can't recurse `$HOME`. **Presence-gated, never auto-installed** — vulture is a pure-Python package with no standalone binary, so auto-installing would mean mutating the user's active Python environment (wrong for uv/poetry/conda/pipx); pi-lens uses it only when already present, probing both the `vulture` script and `python -m vulture` (mirrors `govulncheck`'s no-install gating). Its text output (`path:line: unused <kind> '<name>' (NN% confidence)`) is parsed into the uniform `DeadCodeResult` buckets. The turn-end advisory reads the cached session-start scan (project-wide scans are slow — no per-turn re-scan) and merges across languages for polyglot repos; advisory-only, never a blocker. Telemetry: one NDJSON event per scan to `~/.pi-lens/dead-code.log`. Future phases add Go/Rust/etc. by implementing the interface. Guards: parser unit tests against captured real vulture output + a guarded real-binary integration test.

### Changed

- **Round-2 agent-tool ergonomics (#345): validation, summary tiers, and high-volume caps** — `ast_grep_search` gains `validateOnly` (compile a pattern/rule against a throwaway snippet to distinguish a bad pattern from a real no-match), `maxMatches` (per-call cap, default 50 / max 200; also the pagination step for `skip`), and `groupByFile` (compact one-line-per-file `L<line>:<col>` distribution instead of full match bodies — for high-volume searches; per-match read slices stay in `details.matchLocations`). The pattern/rule validator uses a per-language temp snippet so the throwaway file parses under the requested lang, rejects NUL/oversized inputs before spawning, and treats only line-anchored `error:` stderr as failure (not warnings like "contains ERROR node"). `module_report` gains a `summary` view tier (top-level read handles + `recommendedReads`; heavy callback/usedBy/blast-radius payloads omitted) and section-level `provenance` (`syntax` / `cached-review-graph` / `heuristic` / `none`); the unimplemented `deep` view tier was dropped. Unit tests cover each. (Block-unit selection from the same plan was deliberately deferred — per-language tree-sitter block queries across ~15 grammars are high-risk/low-marginal-value over the existing `read_enclosing onOversize=slice`.)
- **Agent-tool ergonomics for ast-grep search/debug flows** — `ast_grep_search` results now include `details.matchLocations[]` with ready `readSlice` handles so agents can jump from a structural hit to bounded context without manually computing offsets. Zero-match results now point at `ast_grep_dump` and include a bounded `suggestedDump` hint instead of leaving agents to guess node kinds. Added `ast_grep_dump` as the preferred AST dump tool name while keeping `ast_dump` as a compatibility alias; the ast-grep skill now includes lifecycle/callback search recipes. New `ast_grep_outline` exposes `ast-grep outline` as a syntax-only structure tool (symbols/imports/exports/members for files or directories, with `items`/`view`/`type`/`match`/`pubMembers`/`globs` and ready `read` handles) — fast, local, no index/LSP; `module_report` stays the pi-lens-aware default.

- **`module_report` now surfaces callback/closure handles, with per-language semantics** — reports include a `callbacks[]` section for high-signal inline executable nodes that normal symbol outlines miss: event handlers (`pi.on`/`*.on`), timers, promise callbacks, object/dict function properties, and assigned closures/lambdas/function literals. Each entry has stable synthetic `name`, flags such as `captures ctx.ui` / `detached timer`, and ready `read` args. `read_symbol` now accepts those handles, returns the exact body, and records read-guard coverage just like a named symbol. New `read_enclosing` bridges search/diagnostic line hits to the smallest enclosing symbol/callback body, also with read-guard coverage. `module_report.focus` can optionally rank existing symbols/callbacks in `recommendedReads` without expanding scope, building the graph, or calling LSP. The inline-executable *node kinds* are language-uniform over the tree-sitter WASMs, but the callback *semantics* are per-language via a `CALLBACK_RULES` table keyed like `SYMBOL_QUERIES`: JS/TS-tuned rules are the default, plus language slices for Go (goroutine/`defer` closures), Python (scheduler/future lambdas — `call_later`/`call_soon`/`Timer`/`add_done_callback`), Rust (`spawn` and `move` closures), Swift (strong-vs-`[weak self]` capture — the retain-cycle signal), C++ (`[&]` by-reference capture + `std::thread`/`std::async` launches), Kotlin (coroutine builders — `launch`/`async`/`withContext`/…), Java (`new Thread`, executor `submit`/`execute`/`schedule`, UI/event listeners), and C# (`Task.Run`/`StartNew` + `event += handler` subscriptions) that surface lifecycle callbacks the generic rules previously dropped. The report's `callbackSupport: "tuned" | "generic"` flag tells callers whether language-specific rules applied, so the list isn't over-trusted for untuned languages. (Named-symbol navigation — `module_report` outline, `read_symbol`, `read_enclosing` — already spans all ~19 tree-sitter `SYMBOL_QUERIES` languages.) Each symbol/member entry now also carries `decorators[]` — the declaration's decorators/attributes/annotations in source order (`@app.get("/x")`, `#[tokio::main]`, `@Override`), surfacing a symbol's role (route/test/fixture/entrypoint) without reading its body. Extracted structurally from the declaration node (preceding-sibling / own-child / `modifiers`-nested shapes), spanning Python/Rust/TS/Java/Kotlin/C# including nested method members. Async/suspend functions and methods now carry an `async` flag (structurally detected — `async` keyword node or `async`/`suspend` in a modifiers container), marking concurrency boundaries.

- **Fuller, more correct utilization of knip + madge (tool-utilization audit)** — an audit of our whole-project analyzers (validated by running them on this repo) found gaps and silent-failure modes. (1) **knip** now requests `enumMembers` in `--include` — finer-grained dead code (unused enum members) than file-level exports, advisory-only. *(The audit also caught a bug: knip 6.x has **no `classMembers` issue type** — requesting it makes knip exit 2 with zero output, silently disabling the scan. Verified against knip 6.20.)* (2) **madge** now passes `--ts-config <tsconfig.json>` when one exists, so TypeScript `paths` aliases (`@/foo`) resolve — previously alias-routed imports were silently unresolved and **cycles through an alias were missed**. (3) **madge** `--extensions` gained `mjs,cjs`. (4) **madge** now runs with `--warning` and we parse its stderr for **skipped (unresolvable) files** — previously `--json` mode hid these, so a skipped *local* file could silently drop an internal edge and hide a cycle; local skips are now logged (external package skips are expected and ignored). jscpd was already broad (≈20 languages, unrestricted scan since #126) — no gap there. Guards: knip member-type parse test, `buildMadgeArgs` + `parseMadgeSkips` unit tests.
- **Turn-end injects only this-turn, high-confidence findings — not the full project-wide warning set** — measured on this repo, the whole-project analyzers emit hundreds of findings (knip 390, jscpd 136 clones in `clients/` alone), most pre-existing and noisy. Injecting that wall every turn would drown the genuine blockers and burn context. So the turn-end knip advisory now surfaces only the **delta attributable to the agent's edits** (symbols in files it just touched that became unused) — low-volume and actionable — instead of the whole project. The full picture remains available on demand via `lens_diagnostics`, and the delta still feeds the session-slop record. (madge already operated in this blockers-only mode.)
- **Read-guard: every blocking verdict ends with a concrete next-action line (#328)** — an LLM recovers best when each blocking/retryable verdict tells it exactly what to do next. An audit found the read-guard verdicts were *almost* uniform — `read-guard.ts` (zero-read / file-modified / out-of-range / range-stale) and the oldText-not-found path all already end with a recovery instruction — except the `unsupported_hashline_edit_target` block (malformed/unsupported hashline anchors), which listed the errors with no next step. It now ends with a single concrete next-action ("Re-read `<file>` to get current #line anchors, then retry with `set_line` / `replace_lines` — or use a native ranged edit"). Message-only; a guard test asserts the next-action line is present.

### Fixed

- **LSP idle reset no longer touches stale pi contexts after session replacement (#338)** — the detached 240s idle timer now captures any footer repaint callback while the `turn_end` event context is still active, skips resets from superseded session generations, and swallows timer-only cleanup errors so `ctx.newSession()` / `ctx.fork()` / `ctx.switchSession()` / `ctx.reload()` cannot crash later when the old `ctx.ui` getter becomes stale.

## [3.8.61] - 2026-06-25

### Added

- **Release notes now come from CHANGELOG.md (single source of truth) + per-language rule catalogs** — the GitHub release body is now the curated `## [VERSION]` CHANGELOG section instead of an auto-generated PR-title list, condensed to a scannable summary (bold titles grouped by Added/Changed/Fixed) by `scripts/changelog-extract.mjs --summary`; `release.yml` posts it via `gh release create --notes-file`. New helpers: `scripts/lib/changelog.mjs` (pure section parser), `changelog-release.mjs` (`npm run changelog:release` promotes `[Unreleased]` → a dated version section at bump time), and `backfill-github-releases.mjs` (retroactively set existing release bodies; all 35 v3.8.x releases were backfilled). Also added two generated docs — `docs/ast-grep_rules_catalog.md` and `docs/tree-sitter_rules_catalog.md` (rules listed per language via `npm run docs:rule-catalogs`, kept in sync by a `--check` test).
- **Trivy security suite — four scan modes (#131)** — integrated [Trivy](https://github.com/aquasecurity/trivy) as the consolidated dependency/secret/IaC scanner that the removed built-in regex scanner and the scattered overlapping paths pointed toward. **Mode 1 — dependency CVEs** (#313): a session-scan client that resolves the project's lockfiles and surfaces known-vulnerable dependencies once per session (not per-edit). **Mode 3 — secret scan** (#314): edit/write-path secret detection with cross-source dedup so a secret already flagged by gitleaks or an ast-grep `*-hardcoded-secret-*` rule isn't reported twice. **Mode 2 — IaC misconfiguration** (#316): a per-edit runner for Terraform/Kubernetes/Dockerfile/etc. misconfigurations. **Mode 4 — dependency license risk** (#318): flags dependencies whose licenses fall outside an allow/deny policy. Trivy auto-installs on demand; each mode is independently gated.
- **typos spell-checker as a cross-cutting auxiliary LSP (#283)** — [`typos-lsp`](https://github.com/tekumara/typos-lsp) (wrapping `crate-ci/typos`) attaches as a `role:"auxiliary"` diagnostic server alongside the file's primary language server, surfacing source-code and Markdown misspellings warm (the Opengrep/ast-grep auxiliary-LSP template). Allow-list based — it only flags *known* misspellings against a compiled-in dictionary, so the false-positive rate on code is low. Default-on when the binary is available (`--no-typos` to disable); a repo-local `typos.toml`/`_typos.toml`/`.typos.toml` opts in to blocking. Validated end-to-end via a tool-smoke fixture.
- **ast-grep project scan via the bundled napi engine (#308)** — the project-wide ast-grep pass now runs in-process through `@ast-grep/napi` (#309) instead of shelling out, and de-dups its findings against the warm `ast-grep` LSP so a rule that fires in both surfaces is reported once (#311).
- **37 SonarCloud Python BLOCKER rules as ast-grep rules (#317)** — ported SonarCloud's Python BLOCKER-severity checks to bundled ast-grep rules, with style-consistency passes and false-positive fixes. The same batch also added a set of Python **security** detectors — Flask/Jinja2 autoescape-off, XXE-vulnerable XML parsers, hardcoded secrets/passwords, AWS S3 public-access / API-Gateway no-auth misconfig, `requests` without timeout / `verify=False`, SQL string concatenation, wildcard server binds — for ~55 new rule files total. **6 more SonarCloud Python BLOCKER rules as tree-sitter queries (#319)** for checks that need structural matching ast-grep patterns can't express.
- **ast-grep flag-argument + Law-of-Demeter rules (#305)** — new detector rules for boolean flag arguments and long message chains (Law of Demeter), with behavioural fixtures (#326). Plus a behavioural-fixture harness covering the rule catalog with 15 accompanying rule fixes (#310).
- **Go concurrency / correctness / GORM ast-grep rules** — a batch of Go idiom detectors: **concurrency** — `loop-var-capture` (loop var captured by a goroutine closure), `mutex-unlock-mismatch` / `unlock-in-loop` (unpaired `Lock`/`Unlock`), `waitgroup-done-scope` (`WaitGroup.Done()` outside the goroutine that called `Add`); **correctness** — `nil-map-assignment` (assignment to a nil map panics), `defer-in-loop` + `go-defer-func-call-antipattern` (defer semantics inside loops / eager arg evaluation); **performance** — `string-concat-in-loop` (prefer `strings.Builder`); **GORM** — `gorm-find-without-where` (unbounded full-table `.Find()`), `gorm-n-plus-one` (DB call inside a loop), plus a `go-test-functions` naming-convention detector. `gorm-unbounded-preload` ships disabled (`rules-disabled/`). Each rule has a positive/negative fixture pair. (Landed alongside a `ruby-detect-path-traversal` security rule in the same batch.)
- **`module_report` cross-file blast-radius (#304)** — `module_report` gained an opt-in `blastRadius` section: transitive dependents of the file, aggregated per-file and ranked, surfaced as read-only `read` args over the cached review graph (cold-omitted). This replaces the standalone `pilens_impact` tool (removed — see below). Plus cold-cache import resolution made language-uniform with member nesting (#301) and C/C++ `#include` support (#302) so the outline is populated even on a cold start.
- **Contributor guide + issue/PR templates** — added `CONTRIBUTING.md` with step-by-step wiring checklists for new dispatch runners, language servers (primary and auxiliary), formatters, ast-grep rules, and tree-sitter rules; added GitHub issue templates for bug reports, feature proposals, and enhancements; added a pull request template. Also added `docs/audit1.md` documenting the centralization gaps and stale docs found during the write-up.
- **License, Code of Conduct, security policy, and all-contributors** — added an MIT `LICENSE` file, `CODE_OF_CONDUCT.md` adapted from the Contributor Covenant 2.1, `SECURITY.md` for private vulnerability reporting, and an `.all-contributorsrc` plus generated contributor table in `README.md` covering code contributors and resolved-issue reporters.
- **Issue/PR automation** — added `stale.yml` workflow to mark and close stale issues/PRs, `greetings.yml` to welcome first-time contributors, `.github/labels.yml` plus a label-sync workflow, and `.github/release.yml` to categorize generated GitHub release notes.
- **GitHub Actions hardening** — pinned workflow actions to full commit SHAs, disabled persisted checkout credentials where push credentials are unnecessary, replaced the release action with `gh release create`, and removed `pull_request_target` from the greetings workflow.
- **README split into a landing page + docs** — trimmed README to install, docs links, contributing/security/license, and contributors; moved detailed sections into `docs/features.md`, `docs/tools.md`, `docs/globalconfig.md`, `docs/env_variables.md`, `docs/language-coverage.md`, `docs/dependencies.md`, and `docs/usage.md`. Fixed the generated contributor table markup so GitHub renders rows instead of showing raw `<tr>` fragments.

### Changed

- **Tool schemas aligned with the pi SDK house style** — compared pi-lens's registered tools against pi's built-ins (`read`/`write`/`edit`/`grep`/`find`/`ls`) and closed two consistency gaps. (1) **`promptSnippet` phrasing**: ours restated the tool name ("Use module_report to…"), which the SDK renders as `- module_report: Use module_report to…` (the name doubled); rewrote the six non-`lens_diagnostics` snippets to bare imperatives matching the built-ins (e.g. "Navigable file outline — a cheap substitute for reading a whole file"). (2) **Input param `filePath` → `path`** (and `filePaths` → `paths`): every pi built-in file tool uses `path`, so `module_report`, `read_symbol`, `lsp_navigation`, and `lsp_diagnostics` now take `path`/`paths` — schema keys, impl, and the user-facing error/hint strings, leaving result-object `filePath` output fields and internal LSP-service args untouched. **Note for hardcoded callers**: agents read the tool schema each session and adapt automatically, but any script/hook that invokes these tools with `filePath:` must switch to `path:`.

- **Session-start guidance now surfaces `module_report` + `read_symbol`, and is leaner** — the session-start orientation advertised `lens_diagnostics`/`lsp_*`/`ast_grep` but never the #245 read-substitute tools, so agents rarely reached for them. Replaced the ~300-token block (which re-documented each tool's args — already in their registered descriptions) with a ~130-token nudge that names the high-value tools, adds `module_report` + `read_symbol`, and keeps only the one non-obvious behaviour (`lens_diagnostics mode=all` resurfaces stale blocking errors dropped from turn context).

### Removed

- **Standalone `pilens_impact` tool — folded into `module_report` (#304)** — the separate transitive-impact MCP tool was removed; the same blast-radius analysis is now an opt-in `blastRadius` section on `module_report` (see Added), so there's one navigable read-substitute surface instead of two. The now-unused `symbolImpact` lens-engine seam was removed as a follow-up (#324).
- **Built-in regex secrets scanner (`clients/secrets-scanner.ts`)** — the hand-rolled, always-on content scanner that regex-matched a handful of secret shapes (Stripe/OpenAI `sk-*`, GitHub tokens, AWS `AKIA*`, Slack `xox*`, private keys, generic api-key/password) on the edit/write path and blocked the pipeline. It's now redundant: the bundled CodeRabbit ast-grep ruleset ships dozens of language-specific `*-hardcoded-secret-*` rules and gitleaks covers repo-level entropy/history scanning, so three overlapping paths produced duplicate, noisy blocks. Removed the scanner, its dedicated pipeline stage + import, and its tests; the `"secrets"` defect class and taxonomy hints remain (now served by the ast-grep rules and gitleaks). Trivy is slated as the consolidated secret/vuln/IaC scanner in a later slice.

### Fixed

- **Full-suite "Worker exited unexpectedly" flake (#283)** — the LSP teardown's Windows tree-kill (`taskkill /F /T /PID`) force-killed a PID's whole process tree, but once a child LSP process had exited its PID can be OS-recycled, so the tree-kill could land on an unrelated process — under `vitest` that was a sibling worker fork (bare worker-exit, no Node crash dump), and in production it was a latent hazard against any recycled PID. Both kill paths (`killWindowsTree` in `launch.ts`, `killProcessTree` in `client.ts`) now early-return when the tracked process has already exited (`exitCode`/`signalCode` set) unless the session itself is tearing down. Also gave the spawn-heavy `lifecycle.test.ts` cases an explicit 20s timeout. (Vitest 4 config note: `execArgv` moved to a direct `test` field — the v3 `poolOptions.forks.execArgv` nesting is silently ignored.)
- **Read-guard no longer false-blocks edits the host would apply** (#257) — the guard gates the host's edit tool but resolved `oldText` → line range with a *weaker* normalizer than the host applies it with, so an edit whose `oldText` carried a smart quote, em-dash, NBSP, BOM, lone `\r`, or any NFKC-equivalent form matched on the host side but not in the guard, surfacing as a spurious `RETRYABLE — edit target not found` for a valid edit. Vendored the host's fuzzy-match normalization ladder (`normalizeForFuzzyMatch` + `normalizeToLF`/`stripBom`, from `@earendil-works/pi-coding-agent` `core/tools/edit-diff`) into a new `clients/host-edit-normalize.ts` and routed all three guard match-space normalizers through it, so the gate and the host now agree by construction. The partial-apply self-write path additionally adopts the host's first-occurrence-wins `detectLineEnding`/`restoreLineEndings` (was "any CRLF present"). A host-pin sync test re-reads the SDK source from devDeps and fails if the host's normalization set drifts. The SDK stays a type-only dependency — the ~50 lines are deliberately vendored, not imported.
- **Edit shapes pinned to the host's `EditToolInput` type** (#257 follow-on) — the read-guard's edit-input parser (`getTouchedLinesForGuard`) and the partial-apply edit element (`PartiallyApplicableEdit`, previously declared twice) now derive their `oldText`/`newText` fields from the SDK's exported `EditToolInput` instead of re-declaring them as bare `string`. A host edit-schema rename is now a compile error at the lint gate rather than a silent fall-through to `unknown_edit_schema`. Type-only (`import type`, fully erased) — no runtime SDK coupling.
- **Cached project diagnostics no longer replay stale findings** (#298) — `lens_diagnostics mode=full refreshRunners=cached` reads a persisted, cross-session snapshot (`project-diagnostics.json`), but `loadProjectDiagnosticsSnapshot` validated only the cache *version* — it never checked whether the underlying files had changed. So a diagnostic recorded for a file the agent later fixed (or deleted) was replayed verbatim on the next `mode=full` call, which is the "the cache needs to be cleaned before running diagnostics because it became stale" symptom in the report (reproduced: a snapshot entry survives an edit that bumps the file's mtime past `scannedAt`, and survives outright deletion). Added `reconcileProjectDiagnosticsSnapshot` — the snapshot analogue of `reconcileStaleWidgetFiles` for the in-memory widget — which drops any diagnostic whose file's `mtimeMs > scannedAt` (+1ms tolerance) or no longer exists, applied at the cached-full-mode consumer so `loadProjectDiagnosticsSnapshot` stays a pure reader. Fail-safe on an unparseable `scannedAt` (keeps everything rather than risk dropping live findings). Guards: 4 reconcile unit tests (edited-after-scan, deleted, unchanged no-op, unparseable-timestamp). Note: the `ignore`/`rules` parts of #298 were already addressed by #279 (mode=full/all cache-ignore filter) and #297 (cascade); this closes the remaining staleness leg.
- **`lsp_diagnostics` directory scans reuse the canonical exclusions and honor project `ignore`** (PR #299, originally by @StartupBros) — the tool's directory walk (`collectFiles` in `tools/lsp-diagnostics.ts`) filtered subdirectories through its own small local `SKIP_DIRS` set (10 build/dep names), so it both diverged from the shared `isExcludedDirName` list (missing agent/runtime + vendored dirs like `.claude`, `.codex`, `.pi`, `.agents`, `.worktrees`, `.pi-lens`, `vendor`, `third_party`) and ignored the project's `.pi-lens.json`/`.gitignore` patterns entirely — the same private-skip-list divergence #243 fixed for the workspace-diagnostics walk. Swapped `SKIP_DIRS` for `isExcludedDirName` (a strict superset, case-insensitive, glob-aware) and additionally threaded a fail-open `getProjectIgnoreMatcher` predicate through the walk so a directory scan now also suppresses user-ignored paths — bringing this surface in line with the workspace walk and `lens_diagnostics` (#243/#297/#298). Explicitly targeting an excluded/ignored path still scans it (exclusion is checked on recursion children, not the root). Guards: the contributor's canonical-exclusion tests on both surfaces, plus a `.pi-lens.json` ignore-honored directory-scan test and `.git`-anchored, global-config-isolated fixtures for determinism.
- **Cascade no longer surfaces diagnostics from ignored files** (#297) — when an edit's blast radius reached a file the project ignores (e.g. a `*.test.ts` glob in `.pi-lens.json`), the cascade neighbour analysis still surfaced that file's LSP errors at turn-end. This produced false positives in exactly the case the reporter hit: editing `reader.ts` to add an export made its `reader.test.ts` importer a cascade neighbour, and the TypeScript server's *partial* view (which hadn't re-indexed the new export the way a full `tsc` does) flagged the import as unresolved — a phantom blocker on a file the user had deliberately excluded. Cascade was the last diagnostic surface that filtered neighbours only by vendor/`node_modules` (`isExternalOrVendorFile`) and not by the project ignore config; both neighbour-collection sites in `computeCascadeForFile` (the primary `sortedNeighbors` walk and the passive `appendFallbackNeighbors` fallback) now also route through the shared `getProjectIgnoreMatcher`, the same matcher the project walk, LSP workspace scan, and `lens_diagnostics` (#279) already use. Fail-open: an ignore-config probe error never drops a neighbour. Guards: 2 cascade-compute regression tests (snapshot + fallback paths). Note: this also closes the remaining ignore-leak called out in #298 — its `mode=full`/`all` cache-leak was already fixed in #279 (which landed after #298 was filed); the cascade path was the one surface #279 didn't cover.

## [3.8.60] - 2026-06-21

### Added

- **ast-grep catalog port + upstream playground cross-validator** — 11 detector-only rules from the [official ast-grep catalog](https://ast-grep.github.io/catalog) were ported into `rules/ast-grep-rules/rules/` (filling real gaps: Go `unmarshal-tag-is-dash` CWE-639, Rust `redundant-unsafe-function` / `avoid-duplicate-export` / `rust-2024-let-chain-candidate`, TS `no-console-except-error` / `missing-component-decorator` / `unnecessary-react-hook` / `find-import-file-without-extension` / `redundant-usestate-type`, plus a Cpp format-string detector that's vendored from CodeRabbit to avoid a duplicate). The 4 rules with a mechanical `fix:` re-export the upstream rewrite (the LSP surfaces it as a codeAction; the napi runner surfaces it as a text `fixSuggestion`); the other 7 are detection-only (manual refactor hints in the `note:`). Validated end-to-end by `tests/clients/dispatch/runners/ast-grep-catalog-rules.test.ts` — each rule gets a positive/negative fixture pair run through the real `ast-grep scan -r` CLI, and the 4 fix-carrying rules additionally get the `ast-grep scan --json=compact` `replacement` field checked end-to-end so a typo in the `fix:` string can't slip through. New `scripts/playground-verify-rule.mjs` cross-validates any rule against the **upstream web playground** (a headless-CDP tool that loads the rule into <https://ast-grep.github.io/playground.html>, scrapes the `Found N match(es)` / `No match found` text, and reports the match count the upstream engine produces — useful as a second opinion against the local CLI test to catch pattern-level drift between the version of `ast-grep` pinned in `package.json` and the version the upstream binary ships; the playground uses a fixed source, so this is a pattern-level smoke test, not a source-level one; see `docs/astplayground.md`). The verifier bundles its own minimal CDP driver + Chrome lifecycle (port 9224, isolated profile at `<tmpdir>/pilens-playground-profile/`, hard-exit after each command to avoid the Windows close-handshake hang) — adapted from [GreedySearch-pi's `bin/cdp.mjs`](https://github.com/apmantza/GreedySearch-pi) and `bin/launch.mjs` with the port/profile changed. Auto-installs Chrome (PATH auto-detect; `PILENS_PLAYGROUND_CHROME` for non-standard installs). First-run ~11s (cold start + page paint), reuse mode ~1.5s. Run `npm run audit:playground -- <rule.yml>` or `node scripts/playground-verify-rule.mjs <rule.yml> --keep-chrome --expected N` for assertions. Test suite auto-skips when Chrome is unavailable.

### Fixed

- **Stale schema/docs said the napi runner didn't support `inside` / `stopBy` / `field` / `constraints`** — the runner has used napi's native engine (#206) since it landed, which supports the full ast-grep rule grammar; the schema and `docs/custom-rules.md` claimed otherwise, which would have scared catalog-port authors off the very features their rules need. Updated the schema descriptions and the docs table to reflect what the engine actually accepts. Same latent bug surfaced in the runner itself: `isOverlyBroadPattern(pattern)` called `.trim()` on the rich-pattern form `{context, selector, ...}` — now guards on `typeof !== "string"` (rich patterns are never single-metavar traps). `isStructuredRule` also recognises the rich form as structure so a rule whose only top-level structure is `{context, selector}` isn't dropped by the runner's safety net. `YamlRuleCondition.pattern` is now typed as `string | YamlRichPattern` (new exported `YamlRichPattern` type). Guards: 2 new unit tests in `tests/clients/dispatch/runners/yaml-rule-parser.test.ts`.

- **Java LSP Lombok support (refs #244)** — JDT LS launches with `JDTLS_JVM_ARGS=-javaagent:<lombok.jar>` when a Java project declares Lombok (`lombok.config`, Maven, or Gradle) and pi-lens can resolve a jar. Resolution order: explicit `PI_LENS_LOMBOK_JAR` / `LOMBOK_JAR`, project-local `lombok.jar` / `.lombok/lombok.jar` / `lib(s)/lombok.jar`, then Maven/Gradle caches. Existing `JDTLS_JVM_ARGS` are preserved, an existing Lombok javaagent is not duplicated, and `PI_LENS_JAVA_LOMBOK=0` disables the integration. Added unit coverage plus a live LSP smoke fixture (`node scripts/smoke-tools.mjs --lsp java-lombok`) that downloads Lombok into the temp workspace and verifies JDT LS no longer reports Lombok-generated getters as unresolved when `jdtls` is available.

- **Project-level `.pi-lens.json` config now honored (`ignore` + `rules`)** — pi-lens already walked up to find a `.pi-lens.json` for LSP server config (`lsp.json` schema), but the `ignore` and `rules` fields on that same file were parsed and discarded. Originally contributed by @greg-hass in #246; this change wires them in: a new `clients/project-lens-config.ts` loader (discovery+parse cached, JSON-parse-fault-tolerant with one-shot warnings, reusing the shared `walkUpDirs` walk-up helper like other project-root probes) is plumbed into two places. (1) **Scanner exclusion** — `getProjectIgnoreMatcher` now takes the loaded `ignore` patterns as `extraPatterns` to the existing `createProjectIgnoreMatcher` extension point, so any matching path is skipped by every diagnostic scan (LSP walk, fact-rules, tree-sitter, jscpd, knip, review graph, source-filter). The cache is invalidated by the actual inherited `.pi-lens.json`/`pi-lens.json` path + mtime as well as `.gitignore` mtime, so editing the file takes effect on the next scan without a session restart. (2) **Rule threshold overrides** — `high-complexity` (cyclomatic complexity) and `high-fan-out` (distinct function calls) had hardcoded `const` thresholds (`15` / `20`); converted to `let` with positive-finite guarded `setHighComplexityThresholds(cc, depth)` / `setHighFanOutThreshold(n)` setters, plus `reset…` helpers for tests. Dispatch contexts now carry the loaded project config, and the two fact rules read thresholds from that per-dispatch context instead of mutating process-global rule state; `applyProjectLensConfig(cwd)` remains as a thin loader integration seam, and `resetDispatchBaselines` can invoke it to warm the cache. The runtime wiring: `runtime-session.ts`'s `handleSessionStart` and the MCP adapter `mcp/session.ts`'s `runSessionStart` both pass `cwd` through, so the config is applied on every session start. The depth sub-threshold of `high-complexity` is intentionally not exposed (keeps the schema tight). Unknown top-level keys and unknown rule ids are ignored for forward-compat; a malformed JSON file is logged once and treated as "no config" so a syntax error in your own file never blocks diagnostics. End-to-end: write `.pi-lens.json` with `ignore: ["fixtures/**"]` and a `fixtures/noise.ts`, then `collectSourceFiles` (and every consumer) skips it; set `rules["high-complexity"].threshold: 5` and a function with `cc=7` that the default `15` would have ignored now triggers a warning. Guards: 5 new test files (loader unit, ignore integration via sync+async `collectSourceFiles`, threshold-setter units, and context-scoped config integration through `createDispatchContext`) plus regression coverage for inherited-config mtime invalidation, gitignore negation, invalid thresholds, config removal, and cross-project bleed; the existing source-filter / file-utils / dispatch-rules suites remain green; full suite 1999/2001 (the 2 pre-existing timing-sensitive failures in `runner-timeout.test.ts` are unrelated to this change). Documented in README under **Project Config** alongside **Global Config**.

### Changed

- **Replaced Semgrep with Opengrep, integrated as an auxiliary diagnostic LSP + introduced the auxiliary-LSP capability (closes #111)** — [Opengrep](https://github.com/opengrep/opengrep) is an open, login-free fork of Semgrep (same rule format, semgrep-compatible CLI) that ships as a **single standalone binary** with **no account, token, or telemetry**, so pi-lens **auto-installs it on demand** via the `github` strategy (Semgrep was never auto-installable). Rather than a per-file CLI runner (~8s/file: the rule set recompiles on every invocation), Opengrep now runs as a **warm LSP server** (`opengrep lsp`) that compiles its rules **once per session** → **~1–2s per file warm** (measured: 1.4–1.7s on edits to a large file).

  This is delivered via a new, reusable **auxiliary-diagnostic-LSP capability**: a `role:"auxiliary"` tag on `LSPServerInfo` marks cross-cutting, diagnostic-only servers that attach *alongside* the file's primary language server (never selected as primary) and are collected on a new `with-auxiliary` `touchFile` scope; the aggregation layer merges/dedups their diagnostics. A profile registry (`clients/dispatch/auxiliary-lsp.ts`) maps each one's LSP `source` → pi-lens `tool` + semantic policy + enablement gate (Opengrep's `source:"Semgrep"` → `tool:"opengrep"`). **Blocking policy:** the LSP diagnostic carries severity + rule id but **not confidence** (the CLI's `metadata.confidence` is stripped), and Opengrep's `auto` Community set is uniformly ERROR/LOW-confidence audit-tier — so a naive "ERROR → blocking" would block ~15 findings on a single normal file. Instead a profile declares `allowBlocking(cwd)`: Opengrep blocks ERROR findings **only when the repo supplies its own curated rules** (`.opengrep.yml`/`.semgrep.yml` — the author's deliberate severity); the `auto` set is **advisory** (warning) regardless. Either way all findings surface in `lens_diagnostics` (via widget-state `recordDiagnostics`). Future cross-cutting scanners (spelling, secrets, …) plug in by registration. Per-server `reopenOnResync` was added because **Opengrep re-scans only on a fresh `didOpen`** (it ignores `didChange`) — auxiliaries with this flag are re-synced via `didClose`+`didOpen` so edits actually trigger a re-scan (without it, warm edits silently returned zero).

  **Default-on** (a registered LSP server) when the binary is available; disable with `--no-opengrep`. Rules: a repo `.opengrep.yml`/`.semgrep.yml` if present, else the login-free `auto` Community ruleset. **Removed** the interim CLI `opengrep` dispatch runner, `withOpengrepGroup`, the `lens-opengrep`/`lens-opengrep-config` flags, and the persisted `.pi-lens/opengrep.json` — superseded by the LSP integration. **Validated end-to-end on the dev box** (cold scan delivers findings; warm edits return correct, content-scaled findings at ~1.4–1.7s) with **no regression across 227 LSP + dispatch tests** on the shared notify/collection hot path. Guards: `auxiliary-lsp.test.ts` (enablement kill-switch + source routing + semantic policy), the auto-deriving `lsp-registry-consistency` guard, and a **generic auxiliary layer in the tool-smoke harness** (`scripts/smoke-tools.mjs --lsp`): a fixture declaring `auxiliaryServerIds` drives the real `with-auxiliary` `touchFile` and asserts the cross-cutting server produced a finding (matched by LSP `source`) — verified end-to-end (opengrep auto-installs, spawns, scans `eval(userInput)`, and its `Semgrep`-sourced diagnostic returns alongside the TypeScript primary). New cross-cutting adopters get harness coverage by adding one fixture entry. *(typos-lsp is the validating second adopter; ast-grep's full-engine LSP a noted strategic migration. FindSecBugs/PMD remain unrelated JVM follow-ups.)*

- **Removed the dormant ESLint language-server definition (37 LSP servers now)** — `ESLintServer` was registered for `.js/.jsx/.svelte/.vue`, but `getClientForFile` is first-match and the TypeScript server claims all of `jsts` ahead of it, so the ESLint LSP only ever activated for `.svelte/.vue` and **never `.js/.jsx`** — the case its own config targeted. ESLint coverage is and remains the config-gated **`eslint` CLI runner**, which works across ESLint v8/v9/**v10**. A latency probe confirmed the warm-LSP path isn't worth reviving today: cold `eslint` v10 is **~400 ms** (not the 1–3 s of the old eslintrc era), and while a warm `vscode-eslint-language-server` validates in **~3–4 ms/edit** on ESLint v9, the current 4.10 server is **incompatible with ESLint v10** — it still calls the removed `FlatESLint` API and silently returns **zero diagnostics**. Removing the dead server (plus its `EslintRoot` helper, the now-orphaned `vscode-langservers-extracted` installer entry, and the `ESLintServer.root` tests) eliminates a misleading, version-fragile half-wiring; the json/css/html members of the same npm package are unaffected (separate tool ids). ESLint-as-auxiliary can be revisited once the language server supports ESLint v10.

### Added

- **ast-grep LSP as a cross-cutting auxiliary diagnostic server (sgconfig-gated) — Phase 1 of #239** — pi-lens now honors a project's OWN `ast-grep` rules: when a repo has an `sgconfig.y[a]ml`, the `ast-grep lsp` server attaches as a `role:"auxiliary"` scanner alongside the file's primary language server (the Opengrep auxiliary-LSP template), surfacing the team's curated structural rules warm, full-engine, with codeAction fixes. Doubly gated so it never over-reaches: (1) the root detector keys on `sgconfig.y[a]ml`, so **no sgconfig ⇒ it never attaches and the existing napi ast-grep runner stays the path** (this is purely additive — the runner is untouched); (2) it only attaches to files whose extension is in ast-grep's supported-language set (`AST_GREP_EXTENSIONS`, ~15 languages). Blocking-eligible by construction (an sgconfig is the team's deliberately-authored ruleset — mirrors Opengrep's curated-config gate); the auxiliary-lsp profile routes `source:"ast-grep"` → `tool:"ast-grep"` with severity→semantic policy. Validated end-to-end via a new `scripts/smoke-tools.mjs --lsp` fixture (sgconfig + a rule + a violating file → install → spawn → compile rules → scan → `ast-grep`-sourced diagnostic alongside the primary). Latency (Gate A, #239): cold ~3.5s (spawn + rule compile), warm **~0.9s/edit and file-size-independent** (a 1 KiB and a 105 KiB file both ~0.9s — the cost is fixed re-sync overhead, not scan), in the same range as the shipped Opengrep auxiliary. *(Phase 2 — consolidating the no-sgconfig baseline onto the LSP via `--config` + shipped rules and retiring the napi runner — remains gated on that warm-latency floor vs napi's in-process ~40ms; tracked in #239.)*

- **LSP server command support — capability/command caching + hardened `workspace/executeCommand`** — pi-lens now captures each server's advertised commands and can run them, closing the "we don't know or use server commands" gap. **Discovery:** at `initialize` we already cached the operation-provider flags but discarded the rest of `ServerCapabilities`; now `detectExecuteCommands` also retains `executeCommandProvider.commands` into a per-client allowlist (`state.advertisedCommands`), and `client/registerCapability` merges any dynamically-registered `registerOptions.commands` (the dynamic path previously kept only `id→method`). Surfaced via `getAdvertisedCommands()` on the client, the `LSPCapabilitySnapshot`, and the `capabilities` nav op (which now lists the advertised commands). **Execution** (`executeCommand` op on `pilens_lsp_navigation` / `lsp_navigation`) is deliberately hardened: (1) **allowlist-by-advertisement** — a command is refused without being sent unless the server itself advertised it (enforced in the client, the authoritative chokepoint, *and* pre-checked in the tool — defense in depth); (2) **dry-run by default** — the op only reports whether a command is advertised; mutation requires explicit `apply:true`; (3) **gated server-initiated edits** — a new `workspace/applyEdit` handler honors server-pushed edits *only* while an opted-in `executeCommand` is in flight (`serverEditsAllowed` counter), so a server cannot push edits to disk unsolicited, and those edits route through the same `applyWorkspaceEdit` path as every other edit. Guards: real-wire integration tests (extended fake LSP server advertises commands, runs one, refuses an unadvertised one, and applies a solicited `workspace/applyEdit` to a temp file end-to-end) + tool-level tests (dry-run default, apply executes, unadvertised refused) + capability-snapshot coverage. *(Origin: the "are we OK on capabilities/commands?" audit — capability negotiation was already solid; command discovery/execution was the real gap.)*

- **`typeDefinition` + `declaration` LSP navigation operations** — `pilens_lsp_navigation` / the `lsp_navigation` tool gained two position operations that round out the LSP "go-to" family: `typeDefinition` (jump to the definition of a symbol's **type** — e.g. the class/interface behind a variable, which `definition` alone never gives you) and `declaration` (jump to a symbol's declaration, distinct from its definition for languages with a decl/def split, e.g. C/C++ externs or ambient TS declarations). Both reuse the existing `navRequest` location pattern (mirroring `implementation`): wired through `client.ts` (method + `LSPOperationSupport.{typeDefinition,declaration}` + static `typeDefinitionProvider`/`declarationProvider` capability detection + dynamic-registration map), `index.ts` service delegation, and the tool's operation list, position handling, empty-result retry, capability table, and per-location `searchReads` registration (so their results feed the read-guard like `definition`/`references` do). Guards: two new `lsp-navigation` tests (typeDefinition resolves + attaches location searchReads; empty declaration reports the no-results reason) plus the existing capability-snapshot tests updated for the two new keys. *(Adopted from the LSP 3.18 feature-gap audit; the heavier `workspace/diagnostic` bulk-pull gap is tracked separately.)*

- **Alternate-primary LSP reachability — static guard + live harness coverage (refs #111)** — the ESLint removal exposed a blind spot: nothing verified that a registered non-auxiliary server is actually *selectable* as primary. `getClientForFile` is first-match by availability, so a server can be permanently shadowed (ESLint), and the live `--lsp` harness only ever exercised the *selected* server — so a shadowed/alternate server was never tested either. Two layers now close this: (1) **`lsp-primary-reachability.test.ts`** (per-PR, deterministic) asserts every non-auxiliary server is either the default first-match winner for ≥1 extension it claims **or** a declared alternate (`deno`↔typescript, `python-jedi`↔pyright, `omnisharp`↔csharp — the registry's actual zero-default-win set), and that each alternate is wired behind its default and becomes the next pick when predecessors drop out; a server that is neither fails with guidance to mark it `role:"auxiliary"` or declare it (the exact ESLint-class catch). (2) A **live alternate layer in the tool-smoke harness** (`scripts/smoke-tools.mjs --lsp`) drives the real selection fallthrough: it writes a `.pi-lens/lsp.json` disabling the default into the temp workspace (the genuine user-facing mechanism) so `getClientForFile` falls through to the alternate, then asserts the alternate spawns + handshakes + diagnoses by fingerprinting the diagnostic `source`. **Validated end-to-end on the dev box** for **deno** (`deno-ts` type error) and **python-jedi** (`compile` syntax error). Alternates are PATH-only (no installer entry — see Fixed), so the layer prechecks the binary and skips cleanly when absent. *(omnisharp left for nightly — heavier toolchain.)*

- **SpotBugs bytecode bug-pattern analyzer for Java + Kotlin (closes #133)** — Java's pipeline was `javac`-only (compile errors, no static analysis); SpotBugs (Apache-2.0) adds 400+ bytecode-level bug patterns (null derefs, resource leaks, thread-safety, performance, bad-practice), and since it analyzes JVM **bytecode**, Kotlin projects get it for free. **Opt-in** behind the `lens-spotbugs` flag (it's heavyweight — JVM cold start + whole-tree analysis), wired via a `withSpotbugsGroup` dispatch group that only activates when the flag is set **and** a Java build descriptor (`pom.xml`/`build.gradle{.kts}`/`settings.gradle{.kts}`) **and** a compiled-classes dir (`target/classes`, `build/classes`, `out/production`, `bin/main`) are present. The runner operates on the compiled tree (not the edited source) and **mtime-caches** — it only re-invokes SpotBugs after a rebuild changes the `.class` files, returning cached findings otherwise. `<BugInstance>` XML (`-xml:withMessages`) is parsed by a bounded, zero-dep, ReDoS-safe reader: priority→severity (1=error/2=warning/3=info), category→defect-class (`CORRECTNESS`/`MT_CORRECTNESS`→correctness, `SECURITY`→safety, `PERFORMANCE`/`BAD_PRACTICE`/`STYLE`/`I18N`/`EXPERIMENTAL`→style), `type`→rule, the primary `<SourceLine>`→location, and the `<LongMessage>` first sentence→`fixSuggestion`; bugs stay advisory (`semantic: warning`). Auto-installs via the archive strategy. **Validated end-to-end on the dev box**: a `NP_ALWAYS_NULL` null-deref in a compiled Maven fixture surfaced as `error`/`correctness` at the right line, through the real dispatch path (coexisting with javac). Guards: `spotbugs-parser.test.ts` (severity×category mapping + primary-line selection + drop-no-source), `spotbugs-runner.test.ts` (scan/cache/rebuild-reinvoke/skip-when-unbuilt), and detection-helper + dispatch-coverage/smoke-coverage exemptions. *(FindSecBugs security plugin + PMD are noted follow-ups.)*

- **Archive-extraction install strategy (refs #133)** — the installer gained an `archive` strategy alongside npm/pip/gem/github/maven, for JVM tools that ship as a **distribution archive** (a `lib/` of many JARs + `bin/` launchers) rather than a single runnable binary or fat JAR. It downloads the `.tgz`/`.zip`, extracts it (top-level dir stripped via `--strip-components=1`) into `~/.pi-lens/tools/<id>/`, and writes a thin launcher shim into the managed bin so the tool resolves like any other via `findGitHubToolPath`. Extraction shells out to `tar` (present on Windows 10+ as bsdtar, which also reads `.zip`); the spawn uses `cwd` + **relative** paths so no argument carries a drive-letter colon — GNU tar (MSYS) otherwise misreads `C:\…` as an rsync `host:path` (avoids the GNU-only `--force-local`, which bsdtar rejects). First consumer registered: **SpotBugs** (`spotbugs-4.10.2.tgz`) — verified end-to-end on the dev box (`ensureTool("spotbugs")` → shim → `spotbugs -version` → `4.10.2`). This is the prerequisite the SpotBugs runner (#133) needs; #133's premise that SpotBugs uses #129's maven fat-JAR path was incorrect — SpotBugs has no runnable standalone JAR on Maven Central, only the distribution archive. Guard: an `archive`-strategy install-contract case in `tool-registry-consistency.test.ts`.

### Fixed

- **Installer security hardening — tighter tool perms + PATH-safe extraction (SonarCloud S2612 + S4036, new code on `master`)** — the installer's executable-perm sites set `0o755` (read/execute for *others*), but managed tools live in user-scoped `~/.pi-lens/` and are only ever run by the installing user. Aligned all six `0o755` binary/launcher/shim modes to **`0o750`** (no "other" access — matching the installer's existing `0o750` chmod sites), clearing the S2612 vulnerability. Also resolved the archive-extraction `tar` spawn to an absolute path on Windows (`%SystemRoot%\System32\tar.exe`, the bundled bsdtar) so it can't be hijacked via a writable `PATH` entry — the same hardening already applied to the `taskkill` spawn; POSIX keeps bare `tar` (a trusted coreutil whose path varies by distro).

- **Windows libuv abort on `pi update` (`Assertion !(handle->flags & UV_HANDLE_CLOSING)`, `src\win\async.c`) (closes #234)** — `pi update` tears the session down (→ `session_shutdown`) to reload the updated extension, and pi-lens's shutdown killed every LSP server by **spawning `taskkill /F /T`** child processes. Spawning a child while the event loop is already closing makes libuv call `uv_async_send` on the closing loop-wakeup handle → hard abort (a native crash, uncatchable in JS). Fixed by adding a `processExiting` shutdown flag (set only on `session_shutdown`): in that state `killProcessTree` kills via the process handle it already holds (`TerminateProcess`, synchronous, no new async handle) instead of spawning. Mid-session teardowns (subagent/turn boundaries, idle shutdown) where the host keeps running still use the `taskkill /T` tree-kill to avoid zombie accumulation. Guard: `kill-process-tree.test.ts` (mocks `child_process`, forces win32 — asserts no spawn when `processExiting`, tree-kill spawn otherwise). *(Also noticed `stopLSP` in `launch.ts` — which carried a second teardown `taskkill` spawn — is dead code with no callers; left untouched.)*

- **Alternate LSP servers `deno` + `python-jedi` now auto-install (refs #111)** — previously both resolved straight off PATH (no installer entry, no `managedToolId`), so unlike `typescript`/`pyright` they never auto-installed and pi-lens couldn't offer them when absent. Added installer entries — **deno** via the `github` strategy (per-platform `.zip` containing the `deno` binary, extracted like rust-analyzer's; added to `GITHUB_TOOLS`) and **jedi-language-server** via `pip` — and rewired `DenoServer`/`PythonJediServer` `spawn` to `resolveAndLaunch({ candidates, managedToolId }, allowInstall)` (the opengrep pattern: try PATH, else install on demand). **Validated end-to-end on the dev box**: with `deno` absent from PATH, `ensureTool("deno")` downloaded it to the managed bin, then the server spawned, handshook, and returned a `deno-ts` diagnostic; jedi resolved + diagnosed (`compile`) via the pip strategy. Guards: github full-matrix + pip install-contract in `tool-registry-consistency.test.ts`; both ids in `managed-tool-ids.test.ts`. *(omnisharp — the C# alternate — still doesn't auto-install: it ships as per-platform archive **trees**, which the single-URL `archive` strategy can't express; needs a per-platform-URL archive extension, tracked separately.)*

- **Jedi LSP returned zero diagnostics on cold start (refs #111)** — surfaced immediately by the new alternate-primary harness layer. `jedi-language-server` is push-only and its first (complete) `publishDiagnostics` lands ~1011 ms after `didOpen` on cold start (Python/parso import), but the `python-jedi` diagnostic strategy capped the aggregate wait at **1000 ms** — so pi-lens stopped listening ~11 ms too early and surfaced nothing, despite the server working (verified via a raw JSON-RPC handshake). Bumped `python-jedi` `aggregateWaitMs` 1000 → **3000** for cold-start headroom (still seeds on the first push; warm path unaffected).

## [3.8.53] - 2026-06-16

### Added

- **ktfmt wired as a config-gated Kotlin formatter + safe autofix (closes #129)** — projects that use [ktfmt](https://github.com/facebook/ktfmt) (Facebook's opinionated, gofmt-style Kotlin formatter) now get real formatting support. ktfmt is a *pure formatter* (no lint rules), so it's wired only where that fits: as a **formatter** (`getFormattersForFile` → `ktfmtFormatter`, in-place) and a **safe pipeline autofix** (`runAutofix` → `tryKtfmtFix`), **not** as a lint runner — a "not formatted" nag would be redundant with the autofix pass (unlike shfmt, which has no autofix). Both are **config-first**: ktfmt activates only when the project opts in (a `.ktfmt`/`.ktfmt.kts` marker or the ktfmt gradle plugin in `build.gradle{.kts}`, via `hasKtfmtConfig`). When opted in, ktfmt **replaces ktlint** for formatting (the lint policy drops ktlint from `preferredRunners` so its style suggestions don't conflict with ktfmt's output); detekt's *semantic* lint is unaffected. Installs via the new maven-JAR strategy. Validated end-to-end on the dev box through the harness `--format` and `--autofix` layers (ktfmt reformats + applies a fix). Guards: `formatters.test.ts` (ktfmt wins over the ktlint default when opted in), `tool-policy.test.ts` (lint suppresses ktlint / autofix selects ktfmt + `hasKtfmtConfig` detection), and the `autofix-policy-consistency` gate-match. *(Follow-up filed: re-evaluate ktlint's default lint runner now that ktlint is itself a safe autofix — the same redundancy question applies to pure-formatter-linters generally.)*

- **Maven-JAR auto-install strategy (refs #129)** — the installer gained a `maven` strategy alongside npm/pip/gem/github: it downloads a runnable fat JAR from Maven Central into the managed bin and writes a `java -jar` launcher next to it, so the tool resolves like any managed binary (gated on a JRE). First consumer registered: **ktfmt** (`com.facebook:ktfmt:0.63:with-dependencies`) — verified end-to-end on the dev box (`ensureTool("ktfmt")` → launcher → `ktfmt --version`). Unblocks JVM-ecosystem tools that ship only as Maven JARs (ktfmt, google-java-format, SpotBugs). Guard: a `maven`-strategy install-contract case in `tool-registry-consistency.test.ts`.

- **Upgrade `vscode-jsonrpc` 8 → 9 (the LSP JSON-RPC transport)** — v9 introduced an `exports` map exposing the Node entry as the `./node` subpath, so the old `vscode-jsonrpc/node.js` file-path import no longer resolves (TS2307). Migrated the one import in `clients/lsp/client.ts` to `vscode-jsonrpc/node`; the API (`createMessageConnection`/`StreamMessageReader`/`StreamMessageWriter`/`MessageConnection`) and the internal `lib/node/ril.js` the error-classifier heuristic checks are unchanged. Verified with a live LSP initialize handshake. Supersedes the lockfile-only dependabot bump, which couldn't carry the required code change (closes #183).

- **Pipeline safe-autofix expanded to golangci-lint, detekt, markdownlint, oxlint (refs #209)** — four more fixable linters now apply their safe `--fix`/`--auto-correct` in the pipeline's autofix phase, each gated to **match its lint-policy strategy**: golangci-lint (Go, config-first — closes the gap where Go had no pipeline autofix), detekt (Kotlin, config-first — an alternative to the Windows-broken ktlint #218), markdownlint (smart-default), and oxlint (JS/TS, config-gated, mirroring the eslint→oxlint→biome lint precedence). Added to `AUTOFIX_CAPABILITIES` + `getAutofixPolicyForFile`. A new guard, `autofix-policy-consistency.test.ts`, locks the three hand-coded policy maps together — every autofix-selectable tool must be capability-declared and reachable, and each language's autofix gate must match its lint gate (catching config-first↔smart-default drift; it already caught an oxlint mismatch).

- **Tool-smoke harness gained an `--autofix` layer covering the pipeline's safe-autofix phase (refs #209)** — the safe-autofix phase (`runAutofix`, what `runPipeline` invokes) applies fixable linters in fix mode gated by the autofix policy. It **mutates files**, yet was exercised by neither the lint layer (lint-only) nor `--format` (formatters) — the highest-stakes path with no live coverage. `node scripts/smoke-tools.mjs --autofix` drives that exact phase per fixture (a safely-autofixable violation) and asserts the expected tool was policy-selected and applied a fix (`fixedCount > 0`, file changed). Validated end-to-end on the dev box for 11 tools: ruff (F401), biome (useConst), rubocop (spacing), sqlfluff (LT01), rust-clippy (needless_return), dart-analyze (prefer_const_declarations), stylelint (color-hex-length), eslint (semi), golangci-lint (gofmt), markdownlint (MD009), oxlint (no-var). ktlint is blocked by the Windows install bug (#218); detekt is wired + consistency-tested but live-validation needs the detekt CLI + formatting plugin (CI-deferred). `runAutofix` is now exported; the harness git-inits each autofix workspace so VCS-gated fixers (cargo fix) run as they would in a real repo.

- **Tool-smoke harness gained a `--format` layer covering the formatter pipeline (refs #209)** — formatters are a wholly separate subsystem (`getFormattersForFile` → `formatFile`, what `runFormatPhase` drives) that the lint-dispatch path the harness exercised never touched, so the formatters had zero live coverage despite mutating files in place (a silently-broken formatter is higher-stakes than a missed lint). `node scripts/smoke-tools.mjs --format` drives that exact entry per fixture: it asserts the expected formatter is **selected** for the file (config-gated formatters ship the config their `detect()` needs — `.prettierrc` / `gleam.toml` / `Gemfile` / `pyproject.toml [tool.black]` / `.cmake-format.yaml`) and that running it actually **reformats** a deliberately mis-formatted-but-valid fixture (`changed === true`). Now covers **28 of the 31 supported formatters** across 32 fixtures, all validated end-to-end on the dev box: biome, prettier, ruff, black, taplo, shfmt, gofmt, rustfmt, dart, zig, mix, gleam, rubocop, standardrb, sqlfluff, csharpier, terraform, fantomas, psscriptanalyzer-format, cmake-format, oxfmt, stylua, ormolu, cljfmt, php-cs-fixer, google-java-format, clang-format (+ ktlint, which the layer caught broken on Windows → #218). Config-gated formatters ship the config their `detect()` requires (stylua.toml / .cljfmt.edn / .php-cs-fixer.php / .editorconfig / Gemfile / pyproject.toml). The remaining 3 — nixfmt, ocamlformat, swiftformat — have no usable Windows toolchain (Nix/opam/Swift) and are left for nightly-CI. Wired into the nightly workflow alongside the tool and `--lsp` layers. (Note: the nightly run exercises whichever formatter tools it can install on the runner; standalone-binary formatters not auto-installed by pi-lens report ⚠ until a setup step is added.)

- **Tool-smoke harness now covers eight more toolchain-gated languages (refs #209)** — added live fixtures + harness entries for `zig` (zig-check), `java` (javac), `dart` (dart-analyze), `php` (php-lint), `ruby` (rubocop), `kotlin` (ktlint), `gleam` (gleam-check), and `elixir` (elixir-check), all **validated end-to-end on the Windows dev box** after installing the toolchains (JDK 21, Dart, Ruby 3.4 + MSYS2 devkit, Gleam, Zig, PHP 8.4, Erlang/OTP 29 + Elixir 1.20.1). Each produced a parseable diagnostic on its fixture's known defect. The gleam fixture is a minimal package (`gleam.toml` + `src/`) since `gleam check` compiles the whole project. This batch surfaced two genuinely-broken runners (see Fixed: #215, #216) — exactly the regression class the harness exists to catch.

- **Tool-smoke harness language expansion + LSP-install gap fix (refs #209)** — the dispatch tool-smoke fixtures now also install each kind's LSP server (not just the linter), so the lsp runner no longer spuriously `server_error`s for want of an uninstalled server. Added fixtures: `terraform` (tflint tool + terraform-ls LSP, both standalone); toolchain-gated `go` (go-vet), `powershell` (PSScriptAnalyzer), `rust` (rust-clippy tool + rust-analyzer LSP), and `csharp` (dotnet-build) — all four verified end-to-end on this box (Go/Rust/.NET/PowerShell toolchains present; rust-clippy → clippy::len_zero, dotnet-build → CS0029); plus LSP-handshake fixtures for `prisma` (@prisma/language-server — 2 diagnostics) and `php` (intelephense). Confirms the fallback→all fix end-to-end: terraform runs `lsp + tflint` together. The go fixture surfaced #214 (go-vet returns 0 diagnostics in dispatch though `go vet` reports them manually). Harness `--verbose` now prints each failed runner's `failureKind`/message so found-errors aren't misread as crashes.

- **LSP handshake layer in the tool-smoke harness (refs #209)** — `scripts/smoke-tools.mjs --lsp` drives the **same production entry the lsp runner uses** (`LSPService.touchFile`, with a generous cold-spawn budget) for each LSP fixture, so a pass means the real server installed, spawned, completed the JSON-RPC initialize handshake, and replied — not a hand-rolled handshake (the trap that false-failed typescript in the dropped smoke-lsp). Verified end-to-end for typescript-language-server, pyright, yaml-language-server, vscode-json-language-server, and bash-language-server (all handshook; yaml/json returned diagnostics). Shares the harness's startup temp-sweep and tears down spawned servers via `LSPService.shutdown()`.

### Fixed

- **CI: `tool-discovery.test.ts` is now hermetic — no real GitHub-API fetch** — the `ensureTool force-reinstall` tests asserted on a post-download spawn, which required `installTool`'s real `node:https` GitHub-release fetch; in restricted CI (notably **dependabot PRs**) that fetch fails → 0 spawns → flaky red. `node:https` is now mocked (records the fetch, fails deterministically) and the test asserts the fetch was *attempted* (proves installTool was reached) rather than a network-dependent spawn.

- **LSP launch no longer logs scary "candidate failed / npm shim failed / Run npm install" lines when a later candidate succeeds** — `resolveAndLaunch` tries candidates in order (local `node_modules/.bin` → global PATH → managed install); each failure was logged immediately, so the common "no local install, fall back to global" path flooded the logs with failure lines that read as an LSP-availability smell even though the server launched fine. Failures are now **deferred** and surfaced only when *all* candidates fail (the all-failed case stays fully diagnosable). Guard: `resolve-and-launch-fallback.test.ts`.

- **ktlint now works on Windows — installer fetches the jar alongside ktlint.bat (closes #218)** — ktlint's Windows asset is `ktlint.bat`, a wrapper that runs `java -jar %~dp0ktlint`; the installer fetched only the `.bat`, so every invocation failed `Unable to access jarfile` (and the lint runner masked it — the error text became a fallback diagnostic that looked like a finding). The github install strategy gained an optional `extraAssets` hook; ktlint declares `["ktlint"]` (the jar) on win32, so both files now land in the managed bin. Verified end-to-end on Windows: ktlint lint emits real diagnostics and ktlint format (`-F`) reformats. Guards in `tool-registry-consistency.test.ts`: any win32 `.bat`/`.cmd` wrapper asset must declare `extraAssets`, plus a ktlint-specific check.

- **shfmt no longer nags on every unformatted shell write (closes #211)** — the shfmt runner reported a "not formatted" warning against shfmt's built-in defaults on every `.sh` write, even when the project never opted into shfmt formatting. The format-diff *warning* is now gated on a `.editorconfig` (shfmt's only config source) — out of the box shfmt reports only genuine **parse errors** (always-on, blocking); the formatting warning appears once a project opts in via `.editorconfig`. Guard: `shfmt.test.ts`.

- **shellcheck now surfaces `info`-level findings like SC2086 (closes #213)** — with no `.shellcheckrc`, the runner forced `--severity warning`, which dropped `info` findings entirely — including SC2086 (double-quote-to-prevent-globbing), a high-value, commonly-relevant check. Default is now `--severity info` (surfaces SC2086-class findings, mapped non-blocking) while still excluding pure `style` rules to limit noise; projects opt into `style` via `.shellcheckrc`. Guard updated in `shellcheck.test.ts`.

- **markdownlint produced 0 diagnostics — parser didn't match modern markdownlint-cli2 output (closes #212)** — `parseMarkdownlintOutput`'s regex expected the rule code immediately after `line[:col]`, but markdownlint-cli2 now emits a **severity token** (`error`/`warning`) in between (`file:1:1 error MD018/… msg`), and some rules carry **multiple** slash-separated names (`MD041/first-line-heading/first-line-h1`). Both made the regex miss every line → silent "succeeded, 0 diagnostics". The severity token is now optional (older/relative output still parses) and multi-segment rule codes are handled. (The issue's original "Windows abs-path glob" diagnosis was wrong — the file lints fine; the parser was the culprit, on every platform with current cli2.) Guard: a markdownlint-cli2-format case in `markdownlint-fixable.test.ts`.

- **ESLint autofix never applied fixes — keyed on `fixableErrorCount` from `--fix-dry-run` (closes #220)** — the pipeline's safe-autofix phase (`tryEslintFix`) ran `eslint --fix-dry-run --format json` and decided whether to apply fixes by summing `fixableErrorCount` + `fixableWarningCount`. But `--fix-dry-run` reports the **post-fix** state: when every problem is auto-fixable (the common case), ESLint clears `messages`, sets `fixableErrorCount: 0`, and puts the fixed source in the **`output`** field — so the count was 0 and eslint fixes were **never applied**. Now also treats a dry-run `output` field as a fix signal (apply `--fix` when `fixableCount > 0` *or* any result carries `output`). Guard: `pipeline-eslint-autofix.test.ts`. Found by the #209 `--autofix` layer (eslint v10.5.0).

- **`zig-check` never ran: availability probe used `zig --version`, which zig rejects (closes #215)** — the shared `createAvailabilityChecker` hard-coded a `--version` probe, but zig's version subcommand is `zig version` (`zig --version` → `error: unknown command: --version`, exit 1). So the probe always failed and zig-check silently skipped on **every** machine with zig installed. `createAvailabilityChecker` now takes an optional `versionArgs` (default `["--version"]`); zig-check passes `["version"]`. Guard: `runner-helpers.test.ts` asserts the override reaches the spawn. Found by the #209 harness (zig 0.16.0 reported `skipped` despite being on PATH).

- **`elixir-check` silently dropped all diagnostics on modern Elixir (closes #216)** — `parseElixirOutput` only understood the legacy diagnostic format, so on Elixir 1.16+ the runner was a no-op. Two bugs: (1) Elixir 1.16+ emits a multi-line "code snippet" format with the location on a trailing `└─ path:line:col` line, several lines after the `error:`/`warning:` header — the parser now forward-scans to that footer while keeping legacy support; (2) `elixirc` reports paths **relative to its cwd**, but the parser resolved them against `process.cwd()` instead of the runner cwd (and compared case-sensitively, breaking on Windows' lowercased drive letter) — `parseElixirOutput` now takes `cwd`, resolves against it, and matches case-insensitively on win32. Guard: `elixir-parser.test.ts` (modern error/warning, cwd-relative paths, legacy format, win32 drive-case). Found by the #209 harness (Elixir 1.20.1/OTP 29 ran clean but produced 0 diagnostics on a known compile error).

- **Windows: tools whose path contains a space now run (closes #214)** — `safeSpawnAsync`'s Windows `shell:true` path built the cmd.exe string by escaping only the **args**, not the command, so a tool resolved under a spaced path — e.g. Go at `C:\Program Files\Go\bin\go.exe` — made cmd.exe parse `C:\Program` as the command and fail with `'C:\Program' is not recognized`. This silently broke **any** such tool on Windows (npm/.pi-lens tool paths have no spaces, so it stayed hidden; the #209 harness exposed it via go-vet returning 0 diagnostics). The command is now escaped like the args (`buildWindowsShellCommand`, extracted + unit-tested); `cmdEscapeArg` is a no-op for space-free commands so the previously-working paths are unchanged. Found by the #209 tool-smoke harness; go-vet now reports diagnostics through dispatch.

- **`smart-default` linters no longer suppressed by the LSP in fallback dispatch groups (refs #209)** — the primary dispatch group for css/yaml/html/docker/toml/ruby/kotlin paired the `lsp` runner with the language's dedicated linter under `mode:"fallback"`, where the first success wins. Once the language server installed and handshook (now reliable), the LSP succeeded and the linter was **silently suppressed** — dropping rules the generic LSP never emits (yamllint style, stylelint, hadolint best-practices, htmlhint, ktlint, rubocop, taplo). Those linters are classified `smart-default` in tool-policy (designed to run with built-in defaults), and `shell`/`fish`/`powershell`/`prisma` already pair LSP+linter via `mode:"all"` — so this was an inconsistency, not intent. All seven groups are now `mode:"all"`; LSP↔linter duplicate diagnostics remain handled by `suppressLintOverlapsWithLsp` + dedup. A new guard (`tests/clients/dispatch/lsp-linter-coverage.test.ts`) fails if any `smart-default` linter ever sits behind the `lsp` in a fallback group again. Type-checker/compiler fallbacks (jsts lsp+ts-lsp, python lsp+pyright, csharp lsp+dotnet-build, …) are intentionally left as fallback. Tool-smoke harness gained css/html/toml/sql/dockerfile fixtures (+ css/html/docker/toml LSP fixtures) confirming each linter now executes alongside its LSP.

- **Wire `markdownlint` and `shfmt` into their dispatch plans — they were registered but never ran (refs #209)** — a new deterministic per-PR guard (`tests/clients/dispatch/dispatch-coverage.test.ts`) cross-checks every registered runner against the static plans (`TOOL_PLANS` ∪ `FULL_LINT_PLANS`) and fails if any runner is wired into no plan (the "markdownlint class": registered + installs + tested, but silently never dispatched) or if a plan references a phantom runner id. It immediately caught `markdownlint` (markdown's write group was only `["spellcheck","vale"]`, though its linter policy already preferred it) and `shfmt` (shell's group omitted it). Both are now in their plans, so `.md` writes get markdownlint structural lint and `.sh` writes get shfmt format-diff + parse-error checks (shfmt is check-only — never auto-applies). The live tool-smoke harness gained a `shell` fixture and confirms all three (markdownlint/shellcheck/shfmt) now execute through the real dispatch path.

## [3.8.52] - 2026-06-14

### Fixed

- **read-guard: canonicalize path map keys — stops false `zero_read` blocks (closes #210)** — `ReadGuard` keyed its `reads`/`edits`/`exemptions` maps on the **raw** file-path string, relying on the read-path and edit-path strings being byte-for-byte identical. `resolveToolCallFilePath` returns absolute paths verbatim, so the key was whatever separator/casing the model emitted — and models freely mix `/` and `\` on Windows. The regression trigger: read-guard started recording reads from new sources that produce a *different* path form than the Edit tool — `ast_grep_search` matches (#169, slash-normalized from ast-grep output) and LSP-expanded synthetic reads (URI → forward slash). On Windows a file read via search/LSP got a `C:/…` key while the follow-up edit arrived `C:\…` → key miss → false `zero_read` ("Edit without read") despite the file having been read, repeatedly, in a real session (`pi-free`: reads logged `C:/…`, the blocking edit `C:\…`). Every map access now keys through `normalizeFilePath` (folds separators + Windows casing), so record and lookup always agree. **Why it slipped:** every read-guard test used the *same* POSIX path on both `recordRead` and `checkEdit`, so the raw keys always matched — no test exercised cross-separator/cross-source agreement. Closed by `tests/clients/read-guard-path-normalization.test.ts` (forward↔back-slash both directions, Windows case-folding, exemption parity, and a negative: a genuinely-unread file still blocks).

### Added

- **Live tool-smoke harness driving the real dispatch path (refs #209, layer 2)** — `scripts/smoke-tools.mjs` installs (via the real `ensureTool` auto-install) and runs each supported tool against a minimal real project per language (`tests/fixtures/tool-smoke/<lang>/`), driving pi-lens's **real** dispatch path so a smoke pass means the actual runner→spawn code worked (not a hand-rolled stand-in). Step 1 (default) asserts each target tool spawns and exits cleanly (no `timeout`/`exception`/`server_error`); Step 2 (`--step2`) additionally asserts a parseable diagnostic on the fixture's known defect. Per-runner truth comes from a new optional `onRunnerResult` sink threaded through `dispatchForFile`→`runGroup` (fires per executed runner with its exact `RunnerResult` incl. `failureKind`) exposed via `dispatchLintDetailed` — no duplication of dispatch's selection/gating. Opt-in/nightly (installs + spawns real tools), never a per-PR gate; not shipped in the npm tarball. Already surfaced a real wiring gap: `markdownlint` is registered (priority 30) and installs, but the markdown write-dispatch group is `["spellcheck","vale"]`, so it never runs on markdown writes.

- **Deterministic auto-install registry-consistency guard (refs #209, layer 1)** — the live install→run net for every supported tool is expensive and environment-dependent (deferred to layer 2); this catches the cheap-to-catch class per-PR. A new `tests/clients/installer/tool-registry-consistency.test.ts` exports the previously-private `TOOLS` array and locks the **install contract** that `installTool` silently depends on: each `npm` entry declares `packageName`+`binaryName`, each `pip`/`gem` entry declares `packageName`, each `github` entry declares an `owner/repo` + `assetMatch` + `binaryName` and no `packageName` — a half-wired entry compiles fine today but just `return false`s at install time, so it "looks registered" while never installing. It also asserts ids are globally unique, `checkCommand`/`binaryName` are clean executable tokens, and every `github` tool's `assetMatch` is total/safe (never throws across the platform×arch matrix incl. unsupported platforms, resolves at least one combo, rejects freebsd/sunos/aix). **Fixed a coverage drift it surfaced:** `GITHUB_TOOLS` (the curated list the asset-matrix value-test iterates) had drifted to 9 of the 14 actual `github`-strategy tools, leaving `hadolint`, `gitleaks`, `taplo`, and `vale` asset selection **completely untested** — they're now in `GITHUB_TOOLS` (so the full matrix test covers them), and a bidirectional sync assertion keeps the list ≡ "github tools with full cross-platform coverage" going forward (`swiftlint` is intentionally excluded — no Windows asset).

## [3.8.51] - 2026-06-14

### Added

- **Interrupting the agent (Esc) now cancels in-flight linter/formatter/type-check child processes (refs #197)** — pi-lens runs its dispatch tools via `safeSpawnAsync`, which already supported an `AbortSignal`, but nothing was feeding pi's per-turn `ctx.signal` into it, so an interrupted turn left its linters running until they hit their own timeout (up to 10–15s of wasted work, and on Windows orphaned process trees). The lifecycle handlers (`tool_result`, `agent_end`, `turn_end`) now publish the turn's `ctx.signal` as an ambient default that every `safeSpawnAsync` falls back to (`setAmbientAbortSignal`, cleared in each handler's `finally`), so Esc/abort tears down the in-flight children — process-tree kill on Windows. Threading the signal through every dispatch→runner→spawn call site would have been invasive; the ambient default captures the signal at spawn time, so clearing it after a handler returns only affects future spawns, never work already in flight. An explicit `options.signal` still takes precedence. Guarded by `tests/clients/safe-spawn-ambient-signal.test.ts`.

- **Resumed sessions rehydrate their diagnostics instead of starting empty (#190 Phase 1)** — quitting and resuming a session (`pi --session <id>`) made `lens_diagnostics` return nothing: pi-lens kept widget/diagnostic state in-memory only and reset on every `session_start`, treating resume as new. Root cause: it took the session id from the `session_start` event (which has none) and fell back to a fresh **per-process random id**, so nothing could be keyed across a resume. Now pi-lens reads pi's **stable** session id via `ctx.sessionManager.getSessionId()` and the `session_start.reason` (`new`/`resume`/`fork`/`reload`/`startup`): it persists the per-file widget diagnostics to disk at each `turn_end` (under `getProjectDataDir(cwd)/sessions/<id>.json`, atomic write, best-effort) and **rehydrates** that session's snapshot when one exists so `lens_diagnostics mode=all` and the widget show the prior findings; `reload` keeps in-memory state; an explicit `new` session starts clean. The rehydrate trigger is *"a persisted snapshot exists for this stable id"*, **not** `reason === "resume"` — a `pi --session <id>` launch fires `reason: "startup"` (only an in-process `switchSession` is `"resume"`), so gating on `"resume"` alone missed the common resume path; the reason→action mapping is now a unit-tested pure function (`sessionStartMode`). A brand-new session at startup has a fresh id with no snapshot → clean. Process-bound `lspServers` are deliberately not persisted (they re-spawn fresh).

  **Phase 2** adds: (a) **fork branching** — `session_before_fork` stashes the source session's diagnostics in-memory and the forked session's `session_start` (reason="fork") adopts them, then persists under the new session id, so a `/fork` starts from the fork point's findings instead of empty (in-memory hand-off avoids deriving the source id from a file path, since pi stores the id in the session-file header, not the filename); (b) **freshness reconciliation (#180)** — on resume, files whose on-disk mtime is newer than the snapshot (edited between sessions) or that no longer exist are dropped before rehydration, so a resume never surfaces stale diagnostics; dropped files re-scan on their next edit. Still deferred on #190: `delta`-mode rehydration (gated by the `projectSeq`-reset freshness check, intertwined with #180's seq semantics) and tree-navigation (`/tree` doesn't change files on disk). Guarded by `tests/clients/session-state-store.test.ts` (export/import, save/load, end-to-end resume, fork hand-off, `dropStaleFiles`) and `tests/clients/runtime-session-lifecycle.test.ts` (stable-id pinning). Investigation closed the other two transitions as no-ops: `delta` mode is current-turn-scoped and its caches already persist per-project (no rehydration belongs there), and `/tree` navigation doesn't change files on disk. As discoverability for the turn-scoped default, `lens_diagnostics mode=delta` now appends a one-line hint when it's empty but the session-wide view has carried-over findings (e.g. just after a resume): "N findings across M files carried over — use mode=all".

- **`/lens-health` surfaces event-loop occupancy (#192)** — pi-lens now monitors event-loop delay in production (Node's native `monitorEventLoopDelay`, enabled at extension load, no per-event overhead) and `/lens-health` reports the worst synchronous block, p99, and mean for the session — flagging a >100ms block that can stutter the TUI. This is the dimension our duration-only logs were blind to (the one that let the ~1.5s scan freeze through, #188/#191). `latency.log` also records a `loop_block` entry for each new worst freeze, attributed to its turn, so blocks are queryable across sessions. Paired with the at-scale occupancy **test** harness (`tests/support/perf-harness.ts` — `measureMaxSyncBlockMs` + `generateSourceTree`) and CI budget guards. A dedicated `/lens-perf` view remains (#192).

- **Extension-wiring test harness + mock consolidation (closes #171)** — a single dependency-free mock of the host `ExtensionAPI` (`tests/support/pi-mock.ts`) that records everything `index.ts` registers (flags/commands/tools/lifecycle hooks) and lets a test drive a hook (`emit`) or command (`runCommand`) through the *real* entry, with `makeCtx()` capturing `ui.notify`/`setStatus`/`setWidget`. New `index-wiring` tests assert the full registration contract and that `context` injection is gated by `--no-lens-context` and flipped by `/lens-context-toggle` — glue that was previously untested and that the dist-packaging breakage showed we need. Consolidated the three parallel pi mocks onto this one: migrated `lens-toggle-command.test.ts` (template) and `index-integration.test.ts`, removed the duplicate `tests/support/mock-pi.ts`, and deleted `extension-hooks.test.ts` (its assertions never invoked the real entry — they registered on the mock and asserted the mock, so they were tautological and used stale flag names; the real registration contract is now covered by `index-wiring`). Dispatch-runner `RunnerContext` tests are a separate harness concern, out of scope here.

- **Startup-time logging (makes the #182 win measurable)** — pi-lens now records how long pi took to load it: `performance.now()` captured as the first statement in the extension entry (after all imports = full jiti transpile paid) gives ms from pi's process start to pi-lens load-complete. Emitted once per load as a human line in `sessionstart.log` (`pi-lens loaded: <ms>ms after process start (from dist|source)`) and a structured `latency.log` entry (`phase: "extension_loaded"`, `metadata.loadedFrom`). The `loadedFrom` tag distinguishes the precompiled `dist/` path from `source`/jiti, so the transpile-on-startup cost is now quantified rather than guessed (`clients/startup-timing.ts`).

- **Runner failures carry a `failureKind`, and the log-smell analyzer tells breakage from found-errors (refs #207)** — the dispatch latency log recorded `status:"failed"` both when a runner genuinely broke *and* when it simply found blocking diagnostics (the LSP runner reports `failed` for a file with type errors), so `scripts/analyze-pi-lens-logs.mjs` counted all of them as crashes — a false "98 runner failures" alarm over 24h where the real infra-failure count was **zero**. `RunnerResult` now carries `failureKind`/`failureMessage`: the LSP runner tags `server_error` (spawn/exit/JSON-RPC) vs `blocking_diagnostics` (found type errors — not a fault), and the central `runRunner` catch tags `timeout` vs `exception` (covering every runner's crash path); the dispatcher logs `metadata.failureKind` on the runner line. The analyzer reclassifies accordingly — only genuine breakage (`timeout`/`exception`/`server_error`) counts as the `runner-failures` smell, found-errors go to a separate per-runner tally, and legacy logs without the field fall back to a "failed + has diagnostics = found-errors" heuristic. It also now reads two live logs it was previously blind to — `actionable-warnings*.log` (advisory inject/suppress pipeline) and `ast-grep-tools*.log` (MCP search/replace telemetry) — with new `ast-grep-tool-errors` / `actionable-warning-errors` smells and per-source report sections. Guarded by `tests/scripts/analyze-pi-lens-logs.test.ts` (fixture-driven subprocess run: source discovery, the infra-vs-found-errors split, advisory aggregation, the ast-grep error smell).

- **ast-grep `search`/`replace` surface a remediation hint to the agent on error (refs #207)** — the tools already classified each failure (`classifyAstGrepError`) for telemetry, but only the two highest-frequency categories (`multiple_ast_nodes`, `cannot_parse_query`, curated by `sg-runner.ts`) reached the agent with guidance; the other four (`timeout`, `tool_not_found`, `json_parse_failed`, `other`) came back as raw stderr, and the rich `getPatternHint()` self-correction only fired on the *zero-matches* path, never on a hard error. `astGrepRemediationHint(kind)` now reuses that same classification to append a one-line fix on the error path (returns `null` for the already-curated categories so it never doubles up) — e.g. an empty `--rewrite` (previously raw clap CLI noise) now gets "verify the pattern is a single valid AST node … or fall back to grep". Guarded by `ast-grep-tool-logger.test.ts` (hint map incl. the real empty-`--rewrite` log case + a wrapped multiple-nodes error → no extra hint) and error-path tests in both tool suites (hint appended for raw errors, *not* for curated ones).

### Fixed

- **Read-guard autopatch recovers Unicode-punctuation drift (Tier C)** — the autopatch ladder (`tryCorrectIndentationMismatchFromContent`) gained a tier that tolerates the punctuation models routinely swap: smart quotes ↔ straight (`“”‘’` ↔ `"'`), em/en-dash ↔ hyphen, and non-breaking / typographic spaces ↔ a regular space (common when `oldText` is pasted from rendered Markdown or the model "tidies" punctuation). Previously such an edit failed the exact + whitespace tiers and was blocked; now the tier matches on a Unicode-folded, whitespace-collapsed signature and — like Tiers A/B — **recovers and returns the verbatim file span** (the file's real characters), so the applied edit stays exact. Same safety contract: the folded signature must match exactly once, anchored on ≥2 non-blank lines. Borrowed from the fuzzy matcher in mitsuhiko/agent-stuff's multi-edit, but kept to pi-lens's verify-don't-guess discipline (no blind "closest match" fallthrough). Guarded by `read-guard-tool-lines.test.ts` (smart-quote / em-dash / NBSP recovery + single-line, ambiguous, and absent-content negatives).

- **Skills now load from the compiled `dist/` build (closes #205, reported by @feoh)** — the `resources_discover` handler resolved the skills directory relative to the module's own location (`path.dirname(import.meta.url) + "/skills"`). Under the `dist/` layout (#182) the module is `dist/index.js`, so that landed on the nonexistent `dist/skills/` and pi logged `skill path does not exist` while silently loading none of pi-lens's skills (ast-grep, lsp-navigation, write-ast-grep-rule, write-tree-sitter-rule). Now it uses `resolvePackagePath(import.meta.url, "skills")`, which walks up to the nearest `package.json` and lands on `<pkg>/skills/` in both the source and dist layouts. (The issue's suggested `path.join(extensionDir, "..", "skills")` would have fixed dist but broken source, where the module already sits at the package root.) Guarded by an `index-wiring` test that invokes the handler and asserts the path exists, ends in `skills`, and is not `dist/skills`.

- **SonarCloud security/reliability fixes (new-code period)** — (1) **`S5850` reliability bug** in `clients/word-index.ts`: the `TEST_VENDOR_RE` regex mixed anchors with a top-level `|`, leaving operator precedence ambiguous; wrapped the two alternatives in explicit non-capturing groups (behaviour verified identical, capture groups unchanged). (2) **`S4790` weak-hash** ×2: `clients/mcp/ipc.ts` (IPC socket/pipe name) and `clients/review-graph/builder.ts` (content fingerprint for change detection) used `sha1` for non-security hashing — switched to `sha256` (functionally equivalent here, silences the flag). (3) **`S7637`**: pinned the third-party `softprops/action-gh-release` GitHub Action to a full commit SHA (`b430933…` # v3) in `release.yml`. The remaining 48 `S5852` (ReDoS) and 4 `S4036` (PATH lookup) hotspots were reviewed and are safe by context — every flagged regex is single-quantifier or polynomial-at-worst over bounded, trusted input (source lines / tool output), with zero nested-quantifier (`(x+)+`) patterns in the codebase; PATH-based tool resolution is core to how pi-lens finds the user's installed linters/LSPs. These are review hotspots to mark *Safe* in SonarCloud, not code defects.

- **`audit:rule-catalog` no longer fails on duplicate rule_ids** — three catalog entries violated the registry's globally-unique-`rule_id` invariant (which the `-java`/`-js`/`-cobol` suffix convention exists to maintain). `infinite-loop` had genuine java *and* typescript rule files sharing one id, so the java variant was renamed to `infinite-loop-java` (id + file, matching the `unnecessary-bit-ops-java` precedent); `no-octal-values` and `short-circuit-logic` had phantom `typescript` catalog entries with no backing rule file (TS octal coverage already exists via the ast-grep `no-octal-literal` rule), so those were removed. The audit now reports 0 errors.

- **ast-grep-napi runner migrated to napi's native rule engine; hand-rolled interpreter deleted (closes #206)** — the runner used a ~240-line hand-rolled rule interpreter (`nodeMatchesCondition`/`findMatchingNodes`/`findByKind`/`legacyRuleMatches`) over a hand-rolled YAML parser (`parseSimpleYaml`). That parser could not faithfully serialize the ast-grep grammar — it flattened nested `any`/`has`, kept quotes inside `kind: "true"`, and dropped the metavariable key from `constraints` — so relational/`field`/`constraints` rules were silently skipped, and the interpreter's `has` both recursed AND matched the node itself (a self-referential `kind: X` has `kind: X` flagged **every** X: `nested-ternary` reported 720 of which ~678 were false). Now: (1) `parseSimpleYaml` is a thin `js-yaml` wrapper — the full grammar survives intact and is fed straight to `root.findAll({rule, constraints})`; one malformed document skips only itself, not its whole file; (2) the runner always uses napi (the `ast-grep-native-rules` flag and the entire legacy interpreter are gone); a rule napi rejects is skipped, never partially evaluated. Corpus changes to land the migration cleanly: quoted three rule `message:` scalars that began with `!!`/contained `:` (js-yaml threw on them, silently dropping the rules); rewrote five rules that used non-existent tree-sitter kinds (`element_access_expression`→`subscript_expression`, `property_access_expression`→`member_expression`, `block`→`statement_block`, `for_of_statement`→pattern) — they had been dead in both engines; added `stopBy: end` to `switch-without-default` and `nested-ternary`(+js) (their `has` targets a non-direct descendant — `switch_default` lives under `switch_body`); left direct-child `has` rules (`no-throw-string`, `no-discarded-error`, `else-return`, `redundant-state`/`follows`) at napi's neighbour default so they don't over-report. Earlier de-risking (this batch): `no-constant-condition`(+js) rewritten to a flat pattern any-list; `constructor-super`(+js)/`no-process-env`/`no-hardcoded-secrets`(+js)/`unchecked-sync-fs`(+js) moved to `rules-disabled/` (constraint/relational rules that were never actually running; secrets entries marked `deprecated` in the catalog). Net on this repo: 233 files, 0 errors, ~632 diagnostics with the `nested-ternary` false-positive bomb gone and previously-dead relational rules now correctly active. Guarded by `ast-grep-sonar-rules.test.ts` (native `has`/`stopBy` semantics: nested vs single ternary, switch with/without default, rewritten relational rules) and `yaml-rule-parser.test.ts` (faithful nested-`any`/`has` + `constraints` survive the parse; malformed doc returns null).

- **LSP registry-consistency guard (follow-up to #208)** — #208 was a server pi-lens wires that never actually came up, so we added a deterministic per-PR guard: `tests/clients/lsp/lsp-registry-consistency.test.ts` validates every `LSP_SERVERS` entry is well-formed — globally-unique ids, required `spawn`/`root`/`extensions`, clean extension tokens, sane optional timeouts — catching half-wired or duplicated entries cheaply. The complementary *live* end-to-end install→launch→`initialize` smoke (across all install strategies) is tracked in #209: a first cut produced false failures on Windows (a hand-rolled handshake that bypassed `vscode-jsonrpc` framing missed the flagship typescript server, which actually responds in ~120 ms), so it's being reworked to drive pi-lens's real LSP client before it lands as a nightly job.

- **LSP auto-install no longer rejects stdio servers that fail `--version` (closes #208, reported by a Fedora Silverblue user)** — `verifyToolBinary` confirmed a freshly-installed binary by running `<bin> --version` and requiring exit 0. Servers built on `vscode-languageserver-node` — the `vscode-langservers-extracted` family (JSON/CSS/HTML/ESLint) — reject a bare `--version`: `createConnection()` throws `Connection input stream is not set … '--node-ipc', '--stdio' or '--socket={number}'` and exits 1. So every install "verified as broken," got cleaned up, and those LSPs were never available (lost diagnostics/hover/format, plus repeated wasted install attempts at startup). Verification now treats that specific transport-required error as success — it is positive proof the binary loaded and is a working LSP server that simply needs `--stdio` to run. A genuinely broken install still fails, because it errors with a *different* message (`ERR_MODULE_NOT_FOUND`, `SyntaxError`, …) that doesn't match the pattern, so the broken-install guard is preserved. **Smoke-tested against the real `vscode-langservers-extracted@4.10.0` binaries**: JSON/CSS/HTML/ESLint all emit the transport error and now verify — these are exactly the four LSP servers pi-lens wires (`clients/lsp/server.ts`, each spawned with `--stdio` and auto-installed via `managedToolId`). pi-lens does **not** configure a Markdown LSP, so the package's Markdown binary is irrelevant here; it additionally fails to load under Node ≥24 (unrelated upstream `vscode-uri` ESM-interop `SyntaxError`, before the transport check), which verification correctly continues to reject — so it is neither used by pi-lens nor force-verified. The check is a small exported predicate (`isLspTransportRequiredError`) guarded by `tests/clients/installer/lsp-transport-verify.test.ts` (the exact vscode transport error → pass; the real Markdown `vscode-uri` crash / module-not-found / syntax-error / unknown-flag → still fail).

- **LSP last-known diagnostics cache is content-hash guarded — no more stale actionable-warnings (refs #207)** — the actionable-warnings turn_end read reused `getLastKnownDiagnostics` (keyed by path only) on the premise that dispatch's `touchFile` primed it that turn — but `touchFile` never wrote `lastKnownDiagnostics` (only the service-level `getDiagnostics` did, called by the *fresh* branch and the agent lsp tools). So once an earlier turn's fresh branch cached diagnostics, later turns on the same file served those **prior-turn** results as `"cache"` with no content guard — genuine staleness, worst when LSP is cold (the entry can't be refreshed that turn). This was the ~40% `lspSource:cache` seen in the log review. Now `touchFile` primes `lastKnownDiagnostics` together with a sha256 of the synced content (gated on `collectDiagnostics`), `getLastKnownDiagnostics(path, expectedContentHash)` returns the entry only on a hash match (the content-less service merge clears the hash, so those entries never pose as current; the unguarded widget read still gets last-known for display), and actionable-warnings hashes the on-disk bytes and passes them: match → verified-current reuse, mismatch/absent → fresh open+wait. `lspSource:"cache"` now means **verified-current reuse**. Guarded by `actionable-warnings-lsp-cache.test.ts` (passes the correct hash; reuses on match with no fresh read; rejects a stale entry on hash mismatch → forces a fresh read).

### Changed

- **Rust/Go/type-coverage availability probes are now async (refs #197)** — `RustClient.findCargoPath`/`isAvailable`, `GoClient.findGoPath`/`isGoAvailable`, and `TypeCoverageClient.isAvailable`/`scan` were sync `safeSpawn` `--version`/path probes that blocked the event loop on first use; they're now `findCargoPathAsync`/`isAvailableAsync` etc. on `safeSpawnAsync`, with their callers (the `rust-clippy`/`go-vet` dispatch runners, the `session_start` active-tools list, and `/lens-booboo`) awaiting them. The unused `GoClient.isGoplsAvailable` was deleted outright. One intentionally-sync probe remains: `TestRunnerClient.detectRunner`'s `which pytest` check — it's cached per (cwd, runner) and only fires once for a Python project with no config-file runner, and converting it would ripple async through five methods into the per-edit turn path for no real gain.

- **Dispatch availability probes are now async-only (refs #197)** — the runner availability layer carried parallel sync/async probes; the sync ones blocked the event loop on first use. `createAvailabilityChecker` now exposes only `isAvailableAsync` (the never-taken sync `isAvailable` fallback is gone, and all ~25 runners + `resolveAvailableOrInstall` use the async path directly), the ast-grep availability chain collapsed to its async form (`AstGrepClient.runTempScanAsync` now `await`s `ensureAvailable()`, retiring the dead sync `AstGrepClient.isAvailable` → `SgRunner.isAvailable` → `isSgAvailable` → `probeAstGrepCommand` cascade), and the unused sync `isCommandAvailable` in `dispatch/runners/utils.ts` was deleted. No remaining sync spawn in the dispatch availability layer; behaviour unchanged (full suite green).

- **Tool installs and formatter probes no longer block the event loop (refs #197)** — converted the last event-loop-reachable synchronous spawns to `safeSpawnAsync`: the LSP runtime-install actions (`tryGoInstallGopls` `go install`, `tryDotnetToolInstall` `dotnet tool install`/`update`, `tryGemInstall` `gem install` — previously raw `spawnSync` that could freeze the TUI for the whole install, and `go install` had *no* timeout at all), and every formatter probe/install in `formatters.ts` (`gem install rubocop`, `rustup component add rustfmt`, `which`, `go env GOROOT`, `dotnet csharpier --version`, the PSScriptAnalyzer check). On Windows this also fixes a latent bug — `gem`/`dotnet` are often `.cmd` shims that bare `spawnSync(…, { shell:false })` can't launch, whereas `safeSpawnAsync` uses shell mode. Installs pass a new `ignoreAmbientSignal` option so they run to completion even if the agent turn is interrupted (matching the old uncancellable sync behaviour — an Esc can't strand a half-finished `gem install`); the quick probes stay cancellable. Equivalence-tested in `tests/clients/install-actions.test.ts` (same command/args, same success-on-exit-0 semantics, the dotnet NuGet-missing and update-fallback branches, the gem PATH update, and the formatter lazy-install dedupe guard) plus a `safeSpawnAsync` `ignoreAmbientSignal` unit test.

### Performance

- **Collapsed the redundant post-edit LSP double-push that discarded in-flight diagnostics (#203)** — on every edit pi-lens pushed the final post-format content to the language server twice: once in the pipeline `lsp_sync` phase (via `resyncLspFile` → `LSPService.openFile`) and again ~80ms later in the `dispatch-lsp-runner`. `openFile` never registered the push in the touch-debounce map (`markTouched`), so the dispatch runner's `shouldSkipNotify` always returned false and its `didChange` **cleared the diagnostics the first push had just set the server computing**, forcing a from-scratch recompute and a multi-second wait. Latency-log evidence (`~/.pi-lens/latency.log`, ~18k events): the notify-skip dedup fired on just 2 of ~465 dispatch touches, and ~280 of ~700 document-diagnostics waits timed out — **136 of 142 on TypeScript** (`typescript-language-server` is push-only, so the timeouts were us throwing away a push that did arrive, not waiting on one that never came). `resyncLspFile` now routes through `touchFile({ diagnostics: "none", source: "lsp_sync", clientScope: "primary" })`, so the sync push registers via `markTouched`; the dispatch touch moments later then hits `shouldSkipNotify=true`, reuses those diagnostics instead of re-clearing, and `waitForDiagnostics` fast-paths. Expected `dispatch_lint` p50 ~3.1s → ~2.2s on every LSP edit, with the `.ts` timeout population largely eliminated. The old `formatChanged`/`preserveDiagnostics` branch is dropped — `didChange` triggers a server recompute regardless, so letting the cache clear yields fresh, correctly-positioned diagnostics rather than stale pre-edit ones. Regression-tested in `tests/clients/pipeline.test.ts` (the sync routes through `touchFile` with the registering options, not `openFile`); the touch→touch dedup itself is already covered by `service-touch-collect.test.ts` (#116).

- **Per-server diagnostics-wait budget on the LSP hot path (#203)** — `touchFile` resolved its diagnostics-wait timeout from a flat default (the dispatch runner's 2500ms / a 1200ms floor), ignoring the per-server budgets already defined in `server-strategies.ts`. On the single-server primary path it now uses that server's `aggregateWaitMs` (TypeScript 1000ms, rust-analyzer 3000ms, python 1500ms, …), bounded by any caller ceiling — so a fast server isn't held to a flat multi-second wait while a slow one still gets the time it needs. Env override (`PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`) still wins, and the multi-server `full`/cascade path keeps its flat resolution. Covered by `tests/clients/lsp/service-touch-collect.test.ts`.

- **Auto-warm the dominant language's LSP at `session_start` (#203)** — first-edit-of-session cold-spawn stalls (`lsp_client_wait_timeout`, observed up to 5s on TypeScript/Deno) happened because servers only pre-warmed when a project explicitly listed `warmFiles`. When none are configured, pi-lens now uses the language detection it already does to pre-spawn just the **dominant** language's server (highest source-file count) by opening one representative file — backgrounded off the interactive path. Only one server is warmed by design: launching every detected language's server at once (rust-analyzer + gopls + tsserver …) would spike the event loop at startup, working against the latency it protects. The scan is directory-reads-only (`inspectGeneratedHeaders:false`, no per-file opens). Covered by `tests/clients/runtime-session-warm.test.ts`.

- **Document-version-coherent diagnostics freshness (#203)** — `waitForDiagnostics` judged freshness off a monotonic push counter, so a stale `publishDiagnostics` for a superseded document version could satisfy a wait for the current one (a latent correctness gap exposed once the double-push above stops clearing the cache pre-wait). The client now records the LSP document version each push was computed against (`publishDiagnostics.version`) and rejects cached results that lag the latest `didChange`. Servers that omit a version are treated as current, so version-less servers are unaffected and the timeout remains the backstop. Covered by `tests/clients/lsp/client-internals.test.ts`.

### Removed

- **Deleted the dead synchronous linter/formatter methods from `BiomeClient`/`RuffClient` (refs #197)** — both clients carried a full legacy *sync* surface (`checkFile`, `checkFormatting`, `fixFile`, `fixFiles`, `formatFile`, `formatDiagnostics`, biome's `getFormatDiff`/`withValidatedPath`/`spawnBiome`, plus the now-orphaned private `parseDiagnostics`/`computeDiff`) built on the event-loop-blocking sync `safeSpawn`. An audit of the live dispatch path confirmed **none of these had any caller** — every per-edit path already runs async: the dispatch runners (`biome-check.ts`, `ruff.ts`) use `safeSpawnAsync`, autofix-on-write uses `fixFileAsync` (`pipeline.ts`), and format-on-write uses the async `formatService`. The audit that flagged "autofix-on-write blocks the loop" had conflated this dead sync code with the live `fixFileAsync` sitting next to it. Removing it deletes the most alarming sync-spawn call sites outright (-719 lines; biome-client 657→233, ruff-client 511→218) with zero behavior change (full suite green). The remaining sync `safeSpawn` sites are the cached availability probes and one-shot install actions tracked in #197.

- **Deleted the remaining dead legacy-sync methods + an unused module (refs #197)** — continuing the sync-`safeSpawn` cleanup: removed `TestRunnerClient.runTestFile` (sync; the live per-write path uses `runTestFileAsync`), `AstGrepClient.scanFile` → `SgRunner.execSync` → `SgRunner.tempScan`/`scanWithRule` (a fully dead sync ast-grep scan cascade; the ast-grep tools and temp-scans all use the async `exec`/`tempScanAsync` paths), and the entire `clients/subprocess-client.ts` (a 101-line abstract `SubprocessClient` base with **zero** importers). Orphaned helpers/imports went with them (`mapSeverity`, the `AstGrepParser` import, `sg-runner`'s now-unused sync `safeSpawn` import) and the obsolete `execSync` tests were removed (the co-located `formatMatches` tests were re-parented, not lost). ~360 fewer lines, zero behavior change (full suite green). Kept by design: `findCargoPath`/`findGoPath` and the `detectRunner` pytest probe (bounded, cached) and the `booboo` command-path probes (user-invoked).

- **Deleted the dead synchronous check methods from `RustClient`/`GoClient` (refs #197)** — same legacy pattern as the Biome/Ruff cleanup: the per-edit Rust/Go diagnostics already run through the async dispatch runners (`rust-clippy.ts`/`go-vet.ts`, which call `findCargoPath`/`findGoPath` + their own `safeSpawnAsync`), so the clients' sync `checkFile`/`clippyCheck`/`buildCheck`/`formatDiagnostics` methods (built on blocking `safeSpawn`) and their now-orphaned private `parseJsonOutput`/`parseOutput` + `CargoMessage` type had **no callers**. Removed (rust-client 270→107, go-client 242→126). The still-live probes are intentionally kept: `findCargoPath`/`findGoPath` (a bounded, cached, one-time `--version` fallback only hit when the tool isn't at a standard absolute path) and the status-list `isAvailable`/`isGoAvailable` (command/`runtime-session` path) — tracked as the residual in #197.

- **Deleted two more dead sync modules/functions (refs #197)** — the entire `clients/tool-availability.ts` module (a 251-line cached tool-availability layer — `isToolAvailable`/`getToolVersion`/`ToolAvailabilityChecker`/`TOOL_REGISTRY`) had **zero importers** anywhere in source or tests, and the sync `resolveLocalFirst()` in `runner-helpers.ts` was superseded by its live async twin `resolveLocalFirstAsync()` and likewise had no callers. Both built on sync `safeSpawn`; deleting them removes three more event-loop-blocking probe sites at zero risk. The genuinely *live* remaining sync probes — `createAvailabilityChecker`'s sync `isAvailable` fallback and `isSgAvailable()` (reached via the clients' legacy sync `isAvailable()` methods, e.g. `ast-grep-client`/`rust-client`/`type-coverage-client`) — are a cross-client availability-contract change tracked as the remaining (B) work in #197, not a deletion.

### Security

- **Patched a moderate ReDoS-class advisory in a transitive dep and added a CI audit gate** — `brace-expansion` (pulled via our direct `minimatch@^10`) resolved to a version under GHSA-jxxr-4gwj-5jf2 (a large numeric range defeats its documented `max` DoS protection). Bumped to `5.0.6` (lockfile-only; `npm audit` clean for both prod and full trees). It slipped through because Dependabot's weekly *version* updates only bump direct deps, and `minimatch@^10` was already satisfied — nothing was watching the transitive tree. CI now runs `npm audit --omit=dev --audit-level=high` in the lint job, so a known-vulnerable **production** dependency (what ships to users via `--omit=dev`) fails the build at PR time instead of being noticed by chance; the gate is scoped to high/critical to avoid blocking on fix-less moderate advisories, which Dependabot security updates can handle separately.

### Fixed

- **Production install no longer fails to build `dist/` under `npm install --omit=dev` (#193, thanks @feoh; guarded by #194)** — `prepare`/`build:dist` inherited `types: ["node"]` from the base tsconfig, so under pi's `--omit=dev` git install (dev-only `@types/node` absent) `tsc` failed with TS2688 *before* type-checking — `--noCheck` doesn't suppress a program-construction error, contrary to what #182 assumed. `tsconfig.dist.json` now sets `types: []` (the transpile-only dist build needs no ambient node types). A new CI job (`prod-install-build`) installs `--omit=dev` and builds `dist/` from source, so this can't regress — the tarball-based install-test never re-ran the build under `--omit=dev` (#194).

- **Faster startup: ship precompiled JS instead of transpiling on every launch (closes #182)** — pi-lens was distributed as TypeScript source (`main: index.ts`, `pi.extensions: ["./index.ts"]`), so pi's jiti loader transpiled ~215 `.ts` files on every cold start (including `/new`), adding ~3.5s. The package now ships a precompiled `dist/` and points `main` + `pi.extensions` at `./dist/index.js`, which pi loads directly (~1.5s). A `prepare` step (`tsconfig.dist.json` → `dist/`, transpile-only via `--noCheck`) builds it **on install — including `git:` installs, which run `npm install` not `npm pack` — and before publish**, so both install paths get the compiled output with no rebuild script. pi-lens's own asset resolution is unaffected: `rules/`, `config/`, and grammars resolve via `getPackageRoot()` (walks up to `package.json`), not module depth. Guarded by `tests/packaging.test.ts` (entry/`files` contract), an upgraded `scripts/check-extensions.mjs` (validates compiled `.js` imports resolve), and CI install-test steps that verify the tarball ships `dist/index.js` with no `.ts` source and that the compiled entry loads. The dev/test loop still uses the in-place `npm run build`.

- **Skills now actually load under the moved entry (closes #199)** — pi resolves each `pi.skills` entry relative to the extension entry's **file path** (`path.resolve(entryFile, skillEntry)`), not its directory. Once the entry moved to `./dist/index.js` (#182), `pi.skills: ["../skills"]` resolved to `<root>/dist/skills` — the `../` only cancels `index.js` and stays in `dist/` — which doesn't exist, so pi-lens's skills silently stopped loading and pi warned `[Skill conflicts] … skill path does not exist`. Reaching the real root `skills/` from `dist/index.js` needs to climb **two** levels, so `pi.skills` is now `["../../skills"]`. The earlier value was off by one and the CI/tarball check never caught it (it only verifies `skills/` *ships*, not that pi *resolves* it); `tests/packaging.test.ts` now statically replicates `resolve(entryFile, skillEntry)` and asserts it lands on the package's own root `skills/`.

- **`ast_grep_replace` apply no longer falsely reports "no matches" on a successful replacement (closes #178)** — the apply path counted matches *after* writing the fix, so any content-changing replacement reported `[APPLIED] No changes made (no matches found)` despite succeeding, misleading agents into thinking the edit failed. Both replace paths (pattern and rule) now report the **pre-apply** match count, and the apply-zero display message is unambiguous (`[NOT APPLIED] No matches found …`).

- **Reliable alphabetical sort of project-diagnostic sources** — `[...sources].sort()` relied on default UTF-16 ordering, which SonarCloud flags as unreliable (`typescript:S2871`); now uses an explicit `String.localeCompare` comparator.

- **Multi-line diagnostic messages no longer break TUI rendering (closes #189)** — diagnostics with multi-line messages (e.g. TS2769 "no overload matches this call") spilled across several widget rows and broke the layout (and the `L<line>: <message>` inline-blocker format), because `fitLine` clips by visible width but embedded newlines survive. `recordDiagnostics` now collapses whitespace runs to a single space at storage, so the widget, `lens_diagnostics`, and summaries all get single-line messages.

- **`session_start` no longer freezes TUI input on cold boot / `/new` (closes #188)** — the synchronous `session_start` walks (scan-context, language profile, todo / call-graph scheduling) ran O(N) without yielding, starving the stdin macrotask queue for 3–6s on large projects. Fixed with an `ignoreMatcher` path-memo (mtime-invalidated), process-lifetime memos for scan-context and language-profile, async chunked-yield walk variants, background scans deferred past the typing window, a per-file chunked todo scan, and a cold-start forced-quick + delayed-warmup. `session_start` total drops from 3000–6000ms to ~3ms on a 1832-file project. Env knobs: `PI_LENS_COLD_START_QUICK`, `PI_LENS_WARMUP_DELAY_MS`, `PI_LENS_STARTUP_MODE`. Together with #182 this fixes both halves of startup latency (jiti transpile + scan). Thanks @amit-gshe.

- **Source-file enumeration no longer blocks the event loop (perf hardening, follows #188)** — the file walk under the deferred todo / project-diagnostics scans (`collectSourceFiles`) was still a single ~1.5s synchronous burst on a 2k-file project (≈70% of it the per-file 4 KB generated-header read), blocking TUI input even though #188 had made the *callers* yield. Added a chunked-yield `collectSourceFilesAsync` (shares the filter logic with the sync collector via an extracted `classifyEntry`, so results are identical), memoized the generated-header verdict (keyed on path+mtime+size, self-invalidating on edit), and routed the background callers (todo, project-diagnostics) to the async path. Longest synchronous block during enumeration: **~1576ms → ≤38ms cold / 5.9ms warm**; returned file set asserted identical. Guarded by `tests/clients/source-filter-async.test.ts`; remaining (riskier) source-walk hardening tracked in #191.

- **Per-edit cascade graph rebuild no longer freezes the TUI (perf hardening, follows #188/#191)** — `buildOrUpdateGraph` runs on **every** write/edit (via `computeCascadeForFile`), and even on a pure cache hit it re-derived the workspace source-file list (sync tree walk + per-file 4 KB generated-header read) and re-statted every project file just to compute the cache-validity signature — the same sync-FS-over-all-files class #188 fixed for startup, here on the path that runs on every keystroke-triggered edit. Made the walk (`getGraphSourceFiles`) and the signature/stat loop (`sourceSignatureMapAsync`) async + chunked-yield, reusing the existing `collectSourceFilesAsync` (byte-identical file list via the shared `classifyEntry`) and producing the identical `file → "size:mtimeMs"` signature; `_doBuildGraph` already awaited the builder so the call contract is unchanged. Longest synchronous block on a 1,200-file project: **warm cache-hit ~770ms → ~47ms; cold full derivation ~2,215ms → ~46ms** (total FS work unchanged — the loop now yields instead of freezing). Verified behavior-preserving: the async sections touch only local accumulation (no shared cache/fact mutation), and concurrent different-file builds were already interleavable at the existing `await` points, so no new race. Guarded by `tests/clients/cascade-graph-occupancy.test.ts`. The walk still runs each edit (now yielding), but we deliberately stop here: the expensive work (tree-sitter parse + graph construction) is already cached, so this walk is only the cache-*validation* step, and memoizing it would trade always-fresh impact analysis for tens of ms of yielded work on an accuracy-critical path — with no FS watcher to catch out-of-band file changes. Closed #196 won't-do with that rationale.

- **`lens_diagnostics` no longer lists findings the agent already fixed this session (read-your-writes, closes #180)** — `mode=all` reads the widget's per-file diagnostic state, which only refreshes a file when that file is re-dispatched. Because per-edit dispatches are **debounced** (flushed at `turn_end`), an agent that fixed files and then queried `lens_diagnostics` in the same turn saw the **pre-fix** diagnostics still pending in the debounce window. Now the tool **flushes pending dispatches before reporting** (`flushDebouncedToolResults`, injected) so just-fixed files are re-dispatched and reflected, and then **reconciles the live widget against the filesystem** (`reconcileStaleWidgetFiles`): entries whose file changed on disk after their diagnostics were recorded (`mtime > touchedAt`, e.g. an external edit) or that were deleted are dropped — and `mode=all` notes how many were omitted ("N changed files omitted as stale — use mode=full to rescan") so a changed-but-unscanned file reads as *stale*, not falsely clean. Cross-file staleness (a neighbor whose own content is unchanged but whose diagnostic an edit elsewhere invalidated) is a separate follow-up. Guarded by `tests/tools/lens-diagnostics.test.ts` (flush invoked, stale note) and `tests/clients/session-state-store.test.ts` (`reconcileStaleWidgetFiles` drops edited/deleted, keeps unchanged).

- **`rust-analyzer` no longer spawns one process per directory while scaffolding (closes #201 for Rust)** — `RustServer.root` was `RootWithFallback(RustWorkspaceRoot())`, whose default fallback is `FileDirRoot` (the file's own directory). Before a `Cargo.toml` exists, `RustWorkspaceRoot()` returns `undefined`, so every `.rs` file fell back to its own directory as the root — and since LSP clients dedup by `` `${serverId}:${root}` ``, each directory spawned a **separate `rust-analyzer`** (the active-LSP count climbed one-per-file during project creation, and each server was rooted at a manifest-less dir where rust-analyzer can't function). Dropped the fallback for Rust: no `Cargo.toml` ⇒ `undefined` ⇒ the server is skipped (no spawn) until a manifest gives a stable, shared crate root, after which all files share one server. The with-manifest behavior is unchanged. (C# `csharp-ls` has the same fallback trap but a compounding bug — `createRootDetector` matches markers by exact filename, so `.csproj` never matches a real `Foo.csproj` and C# currently depends on the fallback entirely; fixing it needs extension/glob marker support, tracked on #201.)

- **Read-guard autopatch now tolerates mid-block blank-line drift in `oldText` (Tier A of #200)** — when an agent's `Edit` `oldText` differed from the file only by a blank line added/removed *inside* the block, the autopatch's fixed-length window matchers couldn't bridge it (any interior blank-line delta breaks 1:1 line alignment), so the edit failed `oldtext_not_found` and the agent had to re-read/retry. A new blank-line-insensitive matcher (`findBlankLineInsensitiveCandidate`) matches the `oldText`'s non-blank lines (indentation-insensitive) against consecutive content, skipping interior blanks, and — critically — **recovers and returns the real file span verbatim** so the applied `oldText` is actual file bytes. Safety-gated: anchored on ≥2 non-blank lines, requires the signature to match **exactly once** (refuses on 0 or ≥2), and inherits the caller's existing `correctedMatchCount === 1` check — it prefers a no-patch over ever patching the wrong span. Internal-whitespace tolerance (string-literal-sensitive, riskier) remains tracked as Tier B on #200.

- **Tests can no longer silently run against a stale in-place build (closes #198)** — `npm run build` emits compiled `.js` next to each `.ts`, and vitest resolves a test's `.js` import specifier to that literal compiled file. Editing a source `.ts` and running the suite without rebuilding therefore exercised the *previous* build — the change was silently untested while `npm run lint` (which type-checks the `.ts`) stayed green. A vitest `globalSetup` (`tests/support/check-build-freshness.ts`) now fails fast — for any launch, including a direct `npx vitest run` that a `pretest` hook would miss — when a compiled-source `.ts` under `clients/`/`commands/`/`tools/` (or root `index.ts`/`i18n.ts`) is newer than its `.js` or has none, with an actionable `⛔ Stale build … run npm run build` message. The detection logic is unit-tested against a temp fixture (`tests/build-freshness-guard.test.ts`). This is the guard for the gotcha that nearly mis-calibrated the cascade occupancy test.

- **LSP workspace-diagnostics + warm-path FS calls no longer block the event loop (perf hardening, follows #188/#191)** — four synchronous filesystem calls on LSP hot paths were converted to their async equivalents, all behavior-preserving: (1) `collectWorkspaceDiagnosticFiles` (the `lsp_diagnostics` project-wide enumeration) walked the tree with a non-yielding `readdirSync` recursion — **~44.5ms → 0.7ms** longest sync block at ~1,400 files, scaling linearly on monorepos — now an `fs.promises.readdir` yielding walk; (2) its per-file `readFileSync` worker reads → `await readFile`; (3) `handleNotifyOpen`'s document-open existence probe `existsSync` → `await access` (the `didChangeWatchedFiles` Created/Changed type is unchanged); (4) `isOnPath` (the runtime-install gate on the spawn fall-through) `spawnSync("where"/"which")` → the shared `isCommandAvailableAsync` (`safeSpawnAsync`, 5s timeout, same `status === 0` semantics) so a stalled finder can't freeze the loop. The spawn-dedup invariant (one in-flight launch per `serverId:root`) was verified already correct and left untouched. Guarded by `tests/clients/lsp/workspace-diagnostics-occupancy.test.ts`.

- **`lsp_diagnostics` cascade cleanup no longer stats files synchronously (perf hardening, partial #197)** — `LSPService.getAllDiagnostics` (the cascade-checking path) pruned tracked diagnostics with a blocking `existsSync` per file *inside* the prune predicate, holding the event loop across every tracked file. Existence is now resolved in an async pre-pass (`fs.promises.access`, concurrent) and pruning stays a synchronous in-memory map operation — same semantics (a file is pruned iff it's missing **or** older than the cascade TTL), via a new `client.getTrackedDiagnosticPaths()`. Guarded by `tests/clients/lsp/get-all-diagnostics-prune.test.ts`. The remaining sync calls under #197 (the `go`/`dotnet`/`gem` install `spawnSync` and the single-shot `launch.ts`/root-detection stats) are deliberately left: they run once per tool/launch, off the typing window, and the install conversion needs equivalence testing of real install side-effects + reconciling `safeSpawnAsync`'s forced `shell` / timeout against the install commands' `shell:false`.

## [3.8.50] - 2026-06-07

### Added

- **Function-level call graph + impact analysis (closes #154)** — a cross-file call graph is built at session-start (ref→def resolution, bidirectional callers/callees, in-degree centrality, ambiguity-discounted edges); at turn-end the symbols a modified file touches surface a `WillBreak`/`MayBreak`/`Review` impact advisory. Backed by `import-facts` extended to JS/JSX/MJS/CJS with dynamic imports, module-type detection and re-export edges, and a `review-graph` whose `MAIN_KINDS`/language mapping spans every WASM-backed grammar.

- **Internal codebase mental model (closes #155)** — a compact structural summary ranked by call-graph in-degree, cached to `<project-data>/cache/codebase-model.json`. Internal-only (a session-start debug line) until validated across real sessions; agent exposure + hybrid ranking are tracked in #162.

- **`lens_diagnostics` tool (closes #159)** — queries pi-lens's cached diagnostic state with no LSP/dispatch re-run. `mode=delta` = the current turn's fixable + code-quality warnings; `mode=all` = every file edited this session.

- **`ast_grep_search` results register as reads so a follow-up edit isn't blocked (refs #169)** — the search→edit flow (find where something must change, then edit those lines) was blocked by the read-guard because the search didn't count as a read. `ast_grep_search` now attaches the shown match locations to its result (`details.searchReads`), and the tool_result handler registers each as a read **± 2 lines** of context via the new `clients/search-read-registration.ts`. Only the shown lines are registered — never the whole file — so editing an unseen region is still guarded. (`lsp_navigation` and bash `grep` are the remaining parts of #169.)

- **Disable automatic context injection without disabling pi-lens (closes #165)** — a narrow opt-out for the prompt-cache cost of prepending automatic findings. `--no-lens-context` flag, `contextInjection.enabled: false` in `~/.pi-lens/config.json`, `PI_LENS_NO_CONTEXT_INJECTION=1` env, and a runtime `/lens-context-toggle` command. When off, the `context` hook stops prepending session-start guidance / turn-end findings / test findings, but everything else keeps running — tools, LSP, read-guard, formatting, inline tool-result feedback — and findings are still cached so `lens_diagnostics` and `/lens-health` work. Precedence: env → CLI flag → config.

### Fixed

- **Read-guard tracks non-Read file access (closes #168, refs #169)** — bash file views (`cat`/`head`/`tail`/`sed -n`) register as reads with their exact line ranges; bash writes (`>`/`>>`/`tee`/`sed -i`/`cp`/`mv`/`touch`) register as authored-by-agent like the Write tool; search-tool matches register the shown lines ±2 context. So a follow-up edit to something the agent viewed, wrote, or searched is no longer falsely blocked. `grep`/`find`/`ls` are not treated as content reads.

- **Bash-written files are re-analyzed (no more stale diagnostics after `git checkout`/`git restore`)** — a bash command that rewrites working-tree content (redirects, `tee`, `sed -i`, `cp`/`mv`, `touch`, and now `git checkout -- <file>` / `git restore <file>`) never went through the edit-tool pipeline, so its diagnostics, `fileSeq`, and change-log stayed frozen at the pre-write state — e.g. restoring a file would keep reporting the old broken-state warnings on every later `lens_diagnostics` call. Each in-project file a bash command writes/restores is now re-run through the dispatch pipeline (via a synthetic write) so its analysis refreshes. Whole-tree git ops (`reset --hard`, `stash pop`, `revert`, branch switches) don't name files and aren't covered.

- **`LSP Inactive` footer status no longer rendered in red (closes #167)** — having no LSP server running for the current file (or after the idle timer releases them) is a passive state, not a fault, but it was painted in the `error` (red) color, implying something was broken. It now uses the neutral `dim` (grey) color; `LSP Active (n)` stays green. Surfacing genuine LSP *failures* in red is tracked in #170.

- **Extension load no longer requires the host coding-agent package in `node_modules`** — `index.ts` and `clients/read-guard-tool-lines.ts` imported a *runtime* value (`isToolCallEventType`) from `@earendil-works/pi-coding-agent`. pi installs extension deps with `npm install --omit=dev`, so that package isn't present at runtime; and pulling it in drags a huge transitive tree (LLM provider SDKs) whose deeply nested paths exceed Windows' `MAX_PATH`, breaking `git clean -fdx` on `pi update` (→ a half-deleted `node_modules` → `Cannot find module 'vscode-jsonrpc/node.js'`). The one-line discriminant is now inlined in `clients/tool-event.ts`, so every `@earendil-works/pi-coding-agent` import is type-only (erased at runtime) — matching the established pi-extension pattern (e.g. `nicobailon/pi-subagents`).

- **`js-yaml` moved from `devDependencies` to `dependencies`** — `clients/ast-grep-yaml-synth.ts` imports it at runtime, but it was declared dev-only, so a production (`--omit=dev`) install left it missing and the extension failed to load with `Cannot find package 'js-yaml'`. (`@types/js-yaml` stays dev-only.) The CI install-test (production tarball install + `tsx` load) now exercises this path so misplaced runtime deps are caught before release.

- **Lockfile kept committed and guarded against drift** — `package-lock.json` had silently drifted from `package.json` (the exact `web-tree-sitter` pin was recorded as `^0.25.10` in the lock), which makes `npm ci` delete `node_modules` then hard-fail. The lock is now regenerated in sync, and a new `npm run check:lockfile` guard (run in CI) fails the build if any declared dependency spec diverges from the lock — so the drift that started this can't recur. CI/release also switched from `npm ci` to `npm install` so a future desync degrades (self-heals) instead of hard-failing.

### Changed

- **`lens_diagnostics` mode=all now shows the actual diagnostics, not just counts, and is no longer limited by the TUI's display cap** — previously it printed `file.ts  3W` with no indication of *what* the warnings were. It now lists each diagnostic in the same `L<line>: <message> [rule]` shape as the inline blocker output (blockers first, 🔴-marked), honouring the `severity` filter. The widget state keeps a separate **uncapped** per-file diagnostic list for the tool (the TUI still uses its 12-entry render cap), so `getFileDiagnosticSummaries()` exposes the **full** set instead of just the 12 the widget retained for rendering. The tool applies its own generous 50-per-file budget with an accurate `… N more in this file (showing 50 of N)` note (the old note double-counted via `blocking + errors + warnings`).

### Added

- **Six new structural rules covering SonarCloud BLOCKER/CRITICAL TS gaps** — pure-AST checks (no taint analysis required), each with tests run through the production runner. ast-grep: `no-sort-without-comparator` (S2871 — `.sort()`/`.toSorted()` with no compare function), `no-octal-literal` (S1314 — legacy leading-zero octals), `no-mutable-export` (S6861 — exported `let`/`var`), `switch-without-default` (S131 — `switch` with no `default` clause). tree-sitter: `no-equality-in-for-condition` (S888 — `==`/`!=` as a `for`-loop exit test), `no-jump-in-finally` (S1143 — `return`/`break`/`continue`/`throw` written directly in a `finally` block). All `warning` severity.

- **`redos-nested-quantifier` ast-grep rule — flags catastrophic-backtracking (ReDoS) regex literals** — detects an unbounded quantifier nested inside an unbounded-quantified group (`(a+)+`, `(a*)*`, `([a-z]+)*`, `(\d+){2,}`, `(a{2,})+`), the classic CWE-1333 / S5852 exponential case. Fires only when both inner and outer quantifiers are unbounded (`+`, `*`, `{n,}`); bounded quantifiers like `{2,3}` are intentionally not flagged. Runs in the NAPI runner via `kind: regex_pattern` + a linear detector regex (no self-ReDoS). `warning` severity with fix guidance (bounded quantifier, atomic-group emulation, negated character class, or RE2/node-re2 for untrusted input).

- Extended oxfmt formatter to CSS, SCSS, Less, HTML, JSON, YAML, Markdown, MDX, GraphQL, TOML, Vue files. Updated tool-policy entries and added unit tests.

- **`ast_grep_search` / `ast_grep_replace` structural-intent parameters — `insideKind`, `hasKind`, `follows`, `precedes` (closes #125 Phase 3)** — agents can now express cross-context queries without writing YAML. `insideKind: "function_declaration"` restricts matches to nodes inside that ancestor kind (searches all ancestors via `stopBy: end`); `hasKind` restricts to nodes containing a descendant; `follows`/`precedes` restrict by sibling pattern. Parameters synthesize a YAML rule via `clients/ast-grep-yaml-synth.ts` and route through `sg scan --config`. For `ast_grep_replace`, a `fix:` field is added to the synthesized rule so `sg scan --update-all` applies the rewrite. When `rule:` (Phase 4) is also provided, it takes precedence. 22 new tests covering synthesizer output, constraint combinations, language canonicalisation, routing, and YAML content assertions.

- **`ast_grep_search` raw YAML rule passthrough — `rule` parameter (closes #125 Phase 4)** — passing a complete ast-grep YAML rule bypasses `sg run -p` entirely and routes through `sg scan --config`, unlocking `all`/`any`/`not`, `nthChild`, `regex`, field constraints, and multi-pattern rules. Each path is scanned independently and results are merged. Pagination (`skip`) works the same as the pattern path.

- **`ast_grep_search` and `ast_grep_replace` metavariable captures in output (refs #125)** — named captures (`$VAR`, `$$$ARGS`) from `sg --json=compact` appear below each match. Language field (`[TypeScript]`) surfaced per match.
- **SgRunner binary resolution extended with platform package and Homebrew fallback (refs #153)** — probes `@ast-grep/cli-{os}-{arch}` npm packages (walking up 5 directory levels) and Homebrew (`brew --prefix ast-grep`) before falling back to auto-install.

- **Read expansion ancestry chain (refs #153)** — `ExpandedRead` now includes `ancestry?: AncestorSymbol[]` (outermost first) so the full structural path is available (e.g. `ReviewManager → runSynthesis`). The session-start debug log now shows the full path instead of just the immediate enclosing symbol.

### Fixed

- **Windows subprocess encoding (garbled tool output)** — `safeSpawnAsync` prefixes Windows shell commands with `chcp 65001 >nul 2>&1 &&` to force UTF-8 code page, eliminating garbled characters in `sg`/`biome`/`ruff` error messages.

- **Thrashing warning scoped to same tool+file pair** — consecutive counter resets when either the tool name or the file path changes; editing different files no longer triggers the warning.

- **Regex S5852 backtracking eliminated** — replaced `(.*?)` with `([^(]*)` and `/\r?\n/` with `/\r\n|\n/` in ast-grep-client and lsp-navigation.

- **`@earendil-works/pi-coding-agent` declared as optional peer dependency** — `devDependencies` retains the explicit version for local dev; install test updated to exclude host-provided peer from the `ERR_MODULE_NOT_FOUND` gate.

### Performance

- **Read expansion limit raised from 60 to 100 lines** — expansion now fires for reads up to 100 lines, making it useful for the typical 80-100 line agent reads that previously fell outside the threshold.

## [3.8.48] - 2026-06-05

### Added

- **`ast_dump` tool — expose tree-sitter AST structure for pattern debugging (closes #156)** — new `ast_dump` tool parses a source snippet with `sg --debug-query=ast|cst` and returns an indented AST tree with 1-indexed line:col positions and source snippets per node. Named nodes only by default; `includeAnonymous: true` shows all CST nodes including punctuation. Use this when `ast_grep_search` returns zero matches and the correct node kind or field name is unknown. Invalid language returns a clear error; partial/error trees are returned as-is so syntax errors are visible.

- **`lsp_navigation` `rename_file` operation — LSP-aware source file rename (closes #148)** — new `rename_file` operation sends `workspace/willRenameFiles` to all active LSP servers, collects and deduplicates returned workspace edits (primary type-checker server wins on range conflicts), renames the file on disk, sends `workspace/didRenameFiles`, then re-syncs touched files in LSP. Preview mode (`apply: false`) shows the merged workspace edits without touching disk. Overlap detection across server edit sets throws a descriptive error rather than producing corrupted output.

- **`lsp_navigation` `capabilities` operation — cached server feature map (closes #149)** — new operation reads `serverCapabilities` from the post-`initialize` cached state and renders a per-server table of which `lsp_navigation` operations are actually supported (definition, references, hover, rename, codeAction, workspaceSymbol, implementation, signatureHelp, callHierarchy, workspaceDiagnostics, rename_file). No LSP round-trip. Scoped to a specific file or all active servers when `filePath` is omitted.

- **`lsp_navigation` symbol-to-column resolution (closes #147)** — omitting `character` and supplying `symbol` resolves the correct column automatically by scanning the target line. Full fallback chain: word-boundary regex match → same with `#N` occurrence selector (`symbol: "foo#2"` = second occurrence) → case-insensitive match → first non-whitespace character. Eliminates the dominant class of position-mismatch retries where the agent knew the line but guessed the column wrong.

- **`ast_grep_replace` stale-preview detection, `ast_grep_search` pagination, and strictness parameter (closes #151)** — three improvements to the ast-grep tools. (1) Before applying (`apply: true`), a dry-run re-validates that the pattern still matches; if files changed since the preview, returns a `stalePreview` error rather than applying against wrong content. (2) `ast_grep_search` accepts `skip: N` to offset into large result sets; truncated results include a "Use skip=50 for the next page" hint. (3) Both tools accept `strictness: "smart" | "relaxed" | "ast" | "cst" | "signature" | "template"` passed to `sg --strictness`; `"relaxed"` is the most useful for patterns that miss matches due to optional trailing commas or semicolons.

- **`ast_grep_search` and `ast_grep_replace` surface metavariable captures (refs #125)** — named captures (`$VAR`, `$$$ARGS`) from `sg --json=compact` output are now shown below each match: `$VAR=x  $VALUE=foo(a, b, c)` and `$$$ARGS=a,b,c`. Unnamed wildcards (`$$$` without a name) produce no extra line. Both `SgMatch` and `AstGrepMatch` interfaces include the full `metaVariables` payload for downstream consumers.

- **tree-sitter WASM coverage expanded from 13 to 26 languages (refs #152)** — `scripts/download-grammars.ts` now downloads bash, c_sharp, css, html, json, lua, ocaml, php, swift, toml, vue, yaml, zig from `tree-sitter-wasms` at install time. All 13 new grammars registered in `TreeSitterClient.LANG_MAP`.

- **C#, PHP, and CSS tree-sitter dispatch rules now active (refs #152)** — the three languages had existing `.scm` rule files that silently never fired because no WASM was loaded and they were absent from the rules runner's `EXT_TO_LANG` / `appliesTo`. Both gaps closed. PL/SQL (9 rules), ABAP (1 rule), and COBOL (2 rules) moved to `-disabled/` subdirectories — no standard tree-sitter WASM exists for these grammars so the rules could not execute.

- **Read expansion and symbol extraction extended to 9 more languages (refs #152)** — `clients/read-expansion.ts` `EXT_TO_LANG` / `ENCLOSING_TYPES` and `clients/tree-sitter-symbol-extractor.ts` `SYMBOL_QUERIES` wired for Java, Kotlin, Dart, Elixir, C, C++ (read expansion + symbols) and C#, PHP, Swift, Lua, OCaml, Zig, Bash (symbols). All use WASMs already downloaded by the grammar expansion above. Node-type names verified against each language's `node-types.json` before use.

- **Tool registration collision guard (closes #106)** — all four `pi.registerTool()` calls in `index.ts` are now wrapped in try/catch. When another extension (e.g. `@narumitw/pi-lsp`) has already registered the same tool name, the collision is caught silently instead of aborting pi-lens extension load.

- **gitleaks runner for cross-language committed-secret detection (closes #130)** — new `clients/gitleaks-client.ts` runs `gitleaks detect --no-git --source <root> --report-format json` at session_start when the project root has any opt-in signal: `.gitleaks.toml` / `.gitleaks.yaml` / `.gitleaks.yml` / `.gitleaksignore`, a `gitleaks`-substring dependency in `package.json`, or a `.husky/` or `.git/hooks/` pre-commit hook referencing gitleaks. Cross-language by design (operates on bytes via regex + entropy, not AST), so a single binary covers every repo we support. Auto-installs from GitHub releases via the existing installer pattern (same shape as `actionlint` / `hadolint` / `tflint` — registered entry at `clients/installer/index.ts`). At turn_end, the cached findings surface as a **blocker** (not advisory) — committed credentials are real production risk and need rotation before merge; the block lists up to 5 findings as `path:line — RULE-ID: description`. Parser handles gitleaks's standard JSON-array report shape with 19 unit tests covering all six opt-in signals, malformed JSON tolerance, missing-required-field skipping (rather than crashing), and lenient coercion of stringified `StartLine` values. Client lifecycle mirrors `KnipClient` / `JscpdClient` / `GovulncheckClient` (in-flight dedupe, off-main-thread session_start invocation via the existing `runTask(setImmediate)` wrapper). Per-edit re-scan is intentionally NOT wired — secrets either are or aren't in a file; the session_start cache is the authoritative source.

- **govulncheck runner for reachable Go CVE detection (closes #132)** — new `clients/govulncheck-client.ts` runs `govulncheck -mode=source -format=json ./...` at session_start when the analysis root contains a `go.mod`. Caches results by project root via `cacheManager.writeCache("govulncheck", ...)`. The advisory surfaces at turn_end via a single `🛡️ Go CVEs reachable from this code` block listing up to 5 findings with `OSV-ID (file:line) — upgrade to vX.Y.Z`, complementary to (not redundant with) trivy: govulncheck reports only CVEs whose vulnerable function is actually called from the build graph, dramatically lower false-positive rate vs. flat dep-CVE scanning. **Auto-installs via `go install golang.org/x/vuln/cmd/govulncheck@latest`** when missing — the `hasGoModule(analysisRoot)` gate guarantees the Go toolchain is available, so leaning on `go install` is honest (same pattern as how rust-clippy works on cargo projects). Falls back to `$GOBIN` / `$GOPATH/bin` / `~/go/bin` lookup when the installed binary isn't on `PATH`. Parser handles govulncheck's informal JSON stream (newline-delimited dominant case, concatenated multi-object lines, malformed-prefix tolerance) with 7 unit tests; client lifecycle mirrors `KnipClient` / `JscpdClient` (in-flight dedupe, off-main-thread session_start invocation via the existing `runTask(setImmediate)` wrapper).

- **Rolling actionable-warnings history** — every actionable warning surfaced at `turn_end` is now appended to `<project-data>/actionable-warnings.jsonl`, parallel to the existing `code-quality-warnings.jsonl`. Captures the fields `worklog.jsonl` drops: stable `aw:<hash>` ID for cross-turn correlation, suppression state, LSP code-action enrichment counts, and origin (dispatch / lsp / merged). Empty reports skip the write. Closes the symmetry gap where code-quality warnings persisted across turns/sessions but actionable warnings did not.
- **NDJSON telemetry for `ast_grep_search` / `ast_grep_replace`** — every invocation of the two agent-facing ast-grep tools now writes a record to `~/.pi-lens/ast-grep-tools.log` capturing pattern (truncated to 500 chars), `patternLineCount` (so single-line vs multi-line analyses are trivial), lang, outcome (`success` / `no_matches` / `error`), and a classified `errorKind` (`multiple_ast_nodes`, `cannot_parse_query`, `tool_not_found`, `timeout`, `json_parse_failed`, `other`). Rotates at 1 MiB. `classifyAstGrepError` recognises both sg-runner's friendly wrappers and the raw underlying stderr, case-insensitive. The data answers: how often do agents hit multi-statement failures? Which language emits which error most? Do retries succeed after the skill is read?

### Performance

- **Actionable-warnings turn-end report reuses dispatch-primed LSP diagnostics** — `buildActionableWarningsReport` was running its own LSP `openFile` + `getDiagnostics` loop per modified file, even though the dispatch pipeline had already run `touchFile` (open + diagnostics-wait + merge) for every modified file earlier in the same turn. The LSP service caches in `lastKnownDiagnostics`, but `getDiagnostics` ignored the cache and always re-spawned clients. New `LspService.getLastKnownDiagnostics(filePath)` returns the cached value without a re-fetch, distinguishing `[]` (cache-hit empty) from `undefined` (cache miss). actionable-warnings checks the cache first and falls through to the slow path only on a true miss. Latency log analysis showed reports >2 s on zero-warning turns dropping from common (63 of 733 in one rotation) to the sub-100 ms floor. `lsp_file_checked` NDJSON gains a `lspSource: "cache" | "fresh"` field so the cache-hit ratio is observable.

### Fixed

- **`oldtext_not_found` messages distinguish content-drift from indentation mismatch (refs #144)** — when the first line of `oldText` is found in the file but the surrounding block no longer matches, the error now explicitly states this is a content-drift failure (not an indentation issue) and that indentation autopatch already ran. Previously both cases produced a generic re-read message; agents wasted retries changing tabs to spaces when the real problem was a 60-line content drift from earlier edits in the same session.

- **LSP diagnostics version guard prevents stale results (refs #150)** — `waitForDiagnostics` now captures a `diagnosticsVersion` baseline immediately before `refreshFile`. Only accepts results when `diagnosticsVersion > baseline`, ensuring a fresh `publishDiagnostics` arrived after the sync. Eliminates false-clean results after rapid sequential edits where the server was still processing an earlier file state.

- **Lazy `codeAction/resolve` before applying code actions (refs #150)** — many LSP servers (rust-analyzer, typescript-language-server) return lightweight code action objects with no `edit` field, only populating it on an explicit `codeAction/resolve` request. Pi-lens now resolves lightweight actions before applying; falls back silently if the server does not support `resolveSupport`.

- **Workspace symbol deduplication (refs #150)** — workspace symbol results deduplicated by `name:containerName:kind:uri:startLine:startCol` before returning. Prevents duplicate entries when multiple LSP servers are active for the same file.

- **Diagnostic noise stripping (refs #150)** — "for further information visit `<url>`" lines and bare URL-only lines stripped from LSP diagnostic messages before they surface in dispatch output. Reduces noise from rust-analyzer/clippy and other servers that embed documentation URLs inline.

- **Workspace edit ordering and overlap detection (refs #150)** — `applyWorkspaceEdit` now flushes all text edits to disk before processing resource operations (create/rename/delete), preventing a rename from moving a file before its content is updated. Overlapping text edit ranges within a single server's response now throw a descriptive error (`"overlapping LSP edits: X conflicts with Y"`) rather than producing corrupted output.

- **README `PILENS_DATA_DIR` description corrected (closes #142)** — the previous description stated the default write location was `<cwd>/.pi-lens/`, which is only true for legacy projects that already have that directory. New installs have always defaulted to `~/.pi-lens/projects/<slug>/`. Added a callout for local model server users (llama.cpp, Ollama) noting that cache-file churn inside the workspace disrupts model context scoring and `PILENS_DATA_DIR` is the fix.

- **ast-grep SKILL.md documents `Multiple AST nodes are detected` failure modes (refs #125 Phase 1)** — added a new gotcha entry covering the two distinct shapes: (1) sequence-in-block — wrap in `{ }` to make it one AST node; (2) cross-context (module-level + block-level in the same pattern) — wrapping is invalid, use two scoped searches or a YAML `inside:`/`has:` rule instead.

- **Widget stop warning storm churn (PR #146)** — `widget-state.ts` now tracks whether each file has received a final diagnostics snapshot (`hasFinalDiagnosticsSnapshot`). The `✓ clean` header is suppressed while any file is pending, and pending files are excluded from the file row list until diagnostics land. Prevents the transient `✓ clean` flash observed on warning-heavy analysis passes in C++ and other multi-runner languages. Stored diagnostics per file capped at 12 while preserving full warning counts in `diagnosticCounts`.

- **jscpd clone detection now runs on non-JS/TS projects, and excludes compiled `dist/` from TS-project scans (closes #126)** — the source-file gate at `JscpdClient.hasSourceFilesRecursive` accepted only JS/TS extensions (commit 8b5d588), making pi-lens's jscpd integration effectively JS/TS-only even though jscpd's underlying tokenizer covers 15+ languages. Pure-Python, pure-Go, pure-Rust, pure-Java, etc. repos got zero clone detection. The gate now recognises every language jscpd tokenizes well: Python, Java, Go, Rust, Ruby, PHP, Swift, Kotlin, Dart, Lua, Scala, C/C++, C#, plus the existing JS/TS set. Gleam / Zig / Fish stay excluded — jscpd has no tokenizer for them. Separately, the session_start call site now auto-detects `isTsProject` via the presence of `tsconfig.json` and passes it to `scan()`, so TS projects with a `dist/` directory of compiled `.js` artifacts no longer flag them as duplicates of their `.ts` sources. The cache scanner key varies by this flag (`"jscpd"` vs `"jscpd-ts"`) so a stale pre-#126 cache invalidates on first read instead of masking the fix.

  *Behaviour note*: a previously-skipped pure-Python / Go / Rust / Java repo now runs a real jscpd scan at session_start (seconds, scaling with file count). The scan is off the main thread via the existing `setImmediate` runTask wrapper, so the TUI is not blocked, and the result caches for subsequent sessions.

- **Read-guard autopatch now registers a synthetic read for the matched line range** — a successful unique-match indent or trailing-ws autopatch (`oldtext_indent_autopatched` / `oldtext_trailing_ws_autopatched`) proves the agent's `oldText` reflects real content at a unique span. Two systems used to disagree about this: the autopatch successfully matched, and 4–5 ms later the read-guard fired `zero_read` because no Read tool event existed for that file. Now the autopatch path registers a synthetic read covering the matched range via `runtime.readGuard.recordRead`, so the downstream guard check has the evidence it needs. Doesn't bypass `file_modified` (orthogonal) or widen coverage beyond the matched span. Fixes the observed pattern of autopatch-then-block on `model-selector.{ts,test.ts}` and any similar future cases.

### Removed

- **Deleted the regex-based `type-safety` runner** — three regex heuristics on raw source text (switch exhaustiveness without `default`, missing `return` in functions with non-void return type, `: any` / `as any`). All three checks are covered better — with real type information — by tools already in the dispatch pipeline: TypeScript LSP catches missing returns with proper control-flow analysis; Biome `noExplicitAny` and ESLint `@typescript-eslint/no-explicit-any` catch `any` usage; ESLint `@typescript-eslint/switch-exhaustiveness-check` is discriminant-type-aware. The regex `:\s*any\b` also matched identifiers like `anything`, `Many`, `Company`, comments, and strings — producing the dominant `type-safety:no-any-type` rule (244 of 404 entries in pi-drykiss's rolling history) with mostly false positives. Other typed languages need no equivalent: we already run their actual compilers / analyzers (pyright + mypy, go-vet + golangci-lint, rust-clippy, javac, cpp-check, dotnet-build, dart-analyze, phpstan, detekt, swiftlint, etc.). The orphan `clients/type-safety-client.ts` (a separate AST-based implementation with zero callers) was deleted alongside.
- **Deleted the state-matrix similarity infrastructure** — the 57×72 AST-kind transition matrix algorithm (`clients/state-matrix.ts`, `clients/amain-types.ts`, `clients/project-index.ts`) and all three of its consumers: the dispatch `similarity` runner, lens-booboo's "Runner 3: semantic similarity (Amain)" all-pairs comparison, and the `index.ts` Phase 7b pre-write inline check. The algorithm captured AST-kind shape distribution — not identifiers, control-flow ordering, data flow, function size, or imports. Two functions with the same kind distribution (e.g. all test functions, all map/filter chains, all early-return guards) scored ~1.0 cosine similarity despite doing completely different things. At the 0.98 threshold all three consumers produced zero observable output across 567 history entries in three active projects; at lower thresholds (~0.95) the same algorithm produced false-positive floods on idiom-shaped code. Refs #128 for the design intent of the eventual rewrite as AST-subtree fingerprinting with review-graph import-overlap gating. booboo's other similarity flow via `clients.astGrep.findSimilarFunctions` is preserved. Session-start cost drops by ~395 ms run + 212 ms queued (the index build/load task is gone).
- **Session_start `project-index` task** — built or loaded the now-deleted state-matrix index on every session start. Pure dead cost without the algorithm; removed.

## [3.8.47] - 2026-06-01

### Added

- **Actionable-warnings ecosystem expansion (closes #112)** — six dispatch runners now propagate `fixable` + a `fixSuggestion` so the actionable-warnings advisory can surface them instead of dropping them into code-quality. rust-clippy and golangci-lint read the structured replacement metadata each tool already publishes (`suggested_replacement` / `Replacement`); sqlfluff, detekt, swiftlint, and dart-analyze use curated allowlists of rules their respective `--fix` / `--auto-correct` / `dart fix --apply` commands rewrite deterministically. oxlint, stylelint, and markdownlint received the same treatment earlier in the cycle. Each slice ships parser-level unit tests against the runner's real output shape.
- **Framework / convention detector foundation (#118 Phases 1 + 2)** — new `clients/project-conventions.ts` exports `detectProjectConventions(cwd)` returning detected `frameworks` (react / next / vite / vitest in the first cut, each with confidence + signals), `testRunners`, `buildTools`, and `agentDocs`. Detection is purely deterministic — no LLM, no spawn — from `package.json` deps, canonical config files, and directory shape. `ProjectSnapshot` gained an optional `conventions` field with explicit-arg → previously-saved → fresh-detect precedence so a snapshot rewrite without conventions inherits rather than blanks.
- **Per-runner timeoutMs overrides the global 30 s default (#107)** — each `RunnerDefinition` may now declare its own `timeoutMs`; the dispatch harness honours it instead of the shared `RUNNER_TIMEOUT_FLOOR_MS`. The floor is also configurable via `pi-lens.runnerTimeoutFloorMs` config and `PI_LENS_RUNNER_TIMEOUT_FLOOR_MS` env, guarded against NaN, and lazy-resolved so tests can reset it.
- **LSP diagnostics-wait cap with env override (#117)** — dispatch LSP wait is now capped at 2.5 s by default to prevent slow language servers from holding edit feedback; tunable via `PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS`. A new `lsp_diagnostics_timeout` phase event and a `diagnosticsTimedOut` flag in the success log surface when the cap fires.
- **Tool-result debounce window (#115)** — `PI_LENS_TOOL_RESULT_DEBOUNCE_MS` (default 0, max 1 s) coalesces sequential tool_results for the same file so burst edits no longer rerun the full pipeline on every keystroke. Off by default; opt-in via env.
- **Custom rules guide + JSON schemas** — new docs and JSON schemas for tree-sitter and ast-grep custom rule authoring, plus tightened agent skill docs for write-ast-grep-rule, write-tree-sitter-rule, ast-grep, and lsp-navigation.
- **Read-guard `oldtext_duplicate` disambiguation** — the first `oldtext_not_found` and every `oldtext_duplicate` now include surrounding line context so the agent can pick the right occurrence without rereading the whole file.

### Performance

- **In-flight dedupe on RuffClient and BiomeClient (#120)** — concurrent first-time callers to `ensureAvailable()` now share a single probe + auto-install promise via `ensureInFlight`, mirroring the pattern that closed #113 for SgRunner. Previously two parallel session-start tasks (one Python, one JS/TS) could each race the `ensureTool()` auto-install branch and produce partial state in `~/.pi-lens/tools`.
- **SgRunner in-flight dedupe (#113)** — concurrent ast-grep `ensureAvailable()` callers now share one probe; the auto-install branch runs at most once across a session.
- **Centralized `~/.pi-lens` and `walkUpDirs` helpers** — every `~/.pi-lens` computation now routes through `getGlobalPiLensDir()` (#122), and the parent-dir walk is consolidated as a `walkUpDirs` generator + `findNearestContaining` helper in `path-utils.ts`. Same behaviour, fewer ad-hoc walks.

### Fixed

- **Cascade reverse-dependency neighbors now use the in-memory index** — the cascade builder was building a reverse-dep index from the review graph, saving it to the project snapshot, then immediately reloading it from disk to compute affected-file neighbors. The reload almost always returned `null` during active editing because the project sequence had advanced past the snapshot sequence, silently discarding the freshly computed data every time. Affected-file queries now run directly against the in-memory index built from the just-completed graph.
- **Tree-sitter rule cache preserves `has_fix` across the roundtrip** — `has_fix` was set on first load but dropped on the cache rehydration path, so cached runs never marked tree-sitter findings as fixable. Restored — the cache now roundtrips the flag end to end.
- **TypeScript LSP starts for pi-extension files when only `~/.pi/agent/package.json` exists (#123)** — root detection now performs a bounded walk to the extension boundary; if no marker is found inside that scope, it falls back to `FileDirRoot` provided the agent-level `package.json` exists, instead of silently giving up.
- **BiomeClient resolves binaries per project cwd, not `process.cwd()` (#121)** — `getBiomeBinary` now accepts a per-call cwd and caches resolved binaries keyed by cwd, so monorepos with sub-package biome installs reach the right binary even when pi-lens was invoked from a different directory.
- **Skip redundant `notify.open` on `touchFile` when content was already pushed within the debounce window (#116)** — split `shouldSkipTouch` from `shouldSkipNotify`; the latter avoids re-opening but still waits for diagnostics so cache invalidation isn't lost. A `notifySkipped` flag in the latency log records when the optimization fires.
- **dispatch runner `--version` probes flow through `createAvailabilityChecker`** — cpp-check is now cwd-keyed and dedupes concurrent first-time callers, eliminating one of the hottest uncached spawn paths in the audit.
- **PILENS_DATA_DIR compliance in actionable-warnings, review-graph, semgrep-config** — these paths now route through `getProjectDataDir(cwd)` instead of hardcoding `.pi-lens/` under cwd, so the data dir override is respected end to end.
- **Read-guard tracks session writes in an explicit Set** — unreliable mtime checks could let a Write→Edit sequence be blocked by a zero-read violation; an explicit per-session write set is the new authoritative signal.
- **Read-guard partial apply routes through post-edit analysis** — when only some `oldText` edits resolve, partial application performs exact replacements and then invokes the normal `handleToolResult` pipeline so staleness stamps, modified ranges, deferred formatting, dispatch diagnostics, cascade, and warning collection stay in sync with disk.
- **Read-guard staleness escalation fires across inter-turn gaps** — `REPEAT_FAILURE_TTL_MS` raised from 30 s to 300 s so repeated stale `oldText` attempts 2–3 minutes apart are still counted as the same streak; at ≥ 2 failures the preflight error is upgraded from `🔄 RETRYABLE` to `🛑 RE-READ REQUIRED`.
- **dart-analyze / detekt drop the dead sync `isAvailable` fallback** — both runners now use only the async availability check, eliminating a dead code path that masked test-mock mismatches.
- **ReDoS hotspots in oxlint rule extraction and cors-wildcard patterns** — bounded the affected regexes; the oxlint fix also backfills `defectClass` on five runners that had been missing it.
- **5 runners that were missing `defectClass`** — backfilled correctness/style/etc. classifications so downstream taxonomy + advisory routing work consistently.

### Widget

- **Quieter widget glyphs and tighter horizontal layout** — warning glyph swapped from triangle to exclamation mark, dispatch findings pack into a single horizontal row at normal widths, the red dot now reflects blocking semantics (not just severity), and the divider/filename header / non-blocking fillers in horizontal mode were dropped.

## [3.8.46] - 2026-05-27

### Added

- **actionlint runner for GitHub Actions workflows** — actionlint is now a dispatch runner for `.github/workflows/*.yml` and `.yaml` files. It runs as its own independently-gated group alongside the existing YAML pipeline (lsp + yamllint fallback), so non-workflow YAML behaviour is unchanged. Auto-installed from GitHub releases with full platform/arch coverage (linux/darwin/win32, amd64 + arm64). Diagnostics map to `blocking`/`correctness` severity with structured IDs. JSON and NDJSON output formats are both handled, with a plain-text fallback diagnostic on non-zero exit.

- **Inter-extension events for lens findings** — pi-lens now emits structured, versioned payloads on the shared `pi.events` bus so companion extensions can react to diagnostics without scraping rendered text or log files. New events include `pi-lens/analysis-complete` for every file analysis, `pi-lens/findings` when diagnostics/fixes are present, and `pi-lens/turn-findings` for aggregated turn-end blockers/advisories. Payloads include telemetry/session metadata, affected files, blockers/warnings/fixed diagnostics, and bounded/truncated text fields.
- **Actionable warning reports (global-config gated)** — experimental `actionableWarnings` config writes `.pi-lens/cache/actionable-warnings.json` at `turn_end` for fixable warnings introduced by the current turn, using stable `aw:<hash>` warning IDs plus `.pi-lens/cache/actionable-warning-state.json` for suppression state. The report merges dispatch `fixable` warnings with optional LSP warning code actions, records auto-fix eligibility/skip reasons, and injects a concise advisory instead of blocker language. `actionableWarnings.autoFix.enabled` can optionally apply conservative preferred edit-only LSP warning quickfixes at `agent_end`; all options default off except `deltaOnly: true`.
- **LSP rename application** — `lsp_navigation` rename now supports `apply: true`, applying returned workspace edits to disk via a shared LSP edit applier. Preview remains the default; applied edits are coalesced per file, executed bottom-up against one snapshot, and overlapping edits are rejected.
- **Code-quality warning reports** — turn-end now writes `.pi-lens/cache/code-quality-warnings.json` for non-fixable code-quality warnings introduced or touched in modified ranges, separate from actionable/autofixable warnings. A concise advisory points agents at the JSON without treating the findings as blockers, and an append-only project history is preserved in `code-quality-warnings.jsonl`.
- **Project change sequencing foundation** — pi-lens now tracks monotonic project/file sequence numbers for observed mutations and appends them to `<project-data-dir>/change-log.jsonl`. Agent writes/edits, partial applies, deferred formatting, and conservative autofixes record their source, session/turn metadata, file sequence, and optional changed range; actionable and code-quality warning reports now include project/file sequence metadata for future stale-report detection.
- **Project intelligence snapshot foundation** — session start now loads a versioned `.pi-lens/cache/project-snapshot.json` when it matches the current project sequence, hydrating cached exports and project rule scan state before background scans finish. Startup scans refresh the snapshot as project rules, ast-grep exports, and project-index metadata become available, creating a shared seq-stamped cache for future reverse-dependency and hot-file features.
- **Reverse-dependency cache/query foundation** — new internal reverse-dependency helpers build `file -> imports` and `file -> importedBy` indexes from the existing review graph, persist them into the project snapshot, reload fresh snapshot-backed indexes, and answer bounded affected-file queries. Cascade graph builds now refresh the snapshot reverse-dependency section, log refresh/load/merge details to `~/.pi-lens/cascade.log`, and merge fresh cached reverse-dependency neighbors into cascade selection.
- **Session-start snapshot telemetry** — session startup now logs project snapshot probe paths, miss reasons, loaded snapshot contents, seeded file-sequence counts, scan-context/profile cache sources, and split queued/run timings for deferred startup tasks so snapshot and startup-cache behavior can be debugged from `~/.pi-lens/sessionstart.log`.

### Performance

- **Deferred format runs concurrently across files at agent_end** — `handleAgentEnd` now dispatches all formatter subprocesses in parallel via `Promise.all` before sequentially flushing results (sequence bumps, cache mutations, LSP resyncs). Sessions with multiple queued files no longer pay N × ~400 ms; all formatters run simultaneously instead of back-to-back.
- **Session startup avoids repeated cold filesystem walks** — project snapshots now persist `startupScan` and `languageProfile` data keyed by project sequence, so `/new` can reuse the prior scan-context and language-profile results instead of re-running two recursive `readdirSync` walks over the same project tree. Startup background scan bodies are also deferred with `setImmediate`, so synchronous tasks such as TODO scanning cannot inflate the interactive `session_start` path before control returns to the TUI.
- **LSP child handles are unreferenced after launch** — LSP subprocess and stdio handles are unref'd once startup succeeds, complementing fast session shutdown so live language servers do not keep Node/Pi alive during Ctrl+C or session replacement flows.
- **Fast LSP shutdown skips the protocol handshake** — `client.shutdown({ fast: true })` now bypasses the `shutdown` request and `exit` notification entirely, disposing the JSON-RPC connection and moving straight to process-tree termination so background teardown does not spend up to one second per client waiting for unresponsive servers. Session-start LSP resets and pipeline-crash recovery now also use fast teardown because both discard old clients rather than preserving graceful LSP state.
- **Debounced disk-flush timers no longer keep Node alive** — probe-cache and metrics-history debounce timers now call `.unref()` like the LSP idle reset timer, so short-lived/teardown paths are not held open just to flush best-effort background history.

### Fixed

- **Cascade reverse-dependency neighbors now use the in-memory index** — the cascade builder was building a reverse-dependency index from the review graph, saving it to the project snapshot, then immediately reloading it from disk to compute affected-file neighbors. The reload almost always returned `null` during active editing because the project sequence had advanced past the snapshot sequence, silently discarding the freshly computed data every time. Affected-file queries now run directly against the in-memory index built from the just-completed graph.

- **Monorepo turn-state bookkeeping uses the workspace root** — write/edit tool results now keep language-specific dispatch cwd separate from workspace-scoped turn-state/change-log cwd, so nested Go/Rust/etc. modules still generate actionable/code-quality warning reports at turn_end. Deferred-format bookkeeping also records project changes and modified ranges under the workspace root rather than the nested language root.

- **Actionable-warning autofix rejects stale reports** — agent-end conservative LSP quickfix application now requires the cached `.pi-lens/cache/actionable-warnings.json` report to match the current project sequence, and also verifies any recorded per-file sequence before applying edits. Stale or pre-sequence reports are skipped with a debug reason instead of applying cached quickfixes against shifted diagnostics.
- **Project snapshots use a consistent root for load and save** — startup snapshot refreshes from project rules, ast-grep exports, and project-index scans are now written to the same resolved snapshot root used for session-start reads, avoiding silent cache misses when the analysis root differs from the initial cwd.

- **Session shutdown no longer waits on graceful LSP teardown** — `/new`, `/resume`, and Ctrl+C now call `resetLSPService({ fast: true })`, which disposes clients, signals LSP processes, and unreferences kill timers/child handles instead of keeping the TUI alive while graceful shutdown or SIGTERM→SIGKILL escalation completes. This targets the common lifecycle path shared by both `/new` and process exit; deferred agent-end formatting remains parallelized for multi-file turns but is not the primary shutdown path. Relates to #103.

- **TypeScript LSP no longer blocks the edit pipeline on loose Pi extension files** — dispatch LSP diagnostics now use the bounded `touchFile` document path instead of opening the file and then waiting on unbounded aggregate diagnostics, preventing cold TypeScript server startup from holding the TUI until the generic 30s runner timeout. TypeScript LSP root detection also skips loose files under `.pi/agent/extensions` unless a real JS/TS project marker exists inside that extension tree, avoiding tsserver walks through global Pi/npm dependency paths for tiny extension edits. Fixes #104.

- **Read-guard downgrades `out_of_range` to warning when `oldText` resolved** — when the model's `oldText` was found in the current file (content-verified), an edit touching lines outside the recorded read ranges is now warned rather than blocked. Line drift from earlier edits in the same session is the most common cause; the model demonstrably knew the content it was replacing, so a hard block is a false positive. The `oldTextResolved` flag is surfaced in verdict telemetry for observability.

- **Read-guard Pass 1 autopatch now also strips trailing empty lines from `oldText`** — the model sometimes includes the indentation of the next line at the end of `oldText` (e.g. `}) as any,\n\t\t\t\t`). After per-line `trimEnd` that trailing indentation became an empty line, so the joined string still ended with `\n` and failed to match. The fix pops any trailing empty lines from the split array before rejoining. Pass 1 is now guarded by exact raw matching: it only patches when the original raw `oldText` does not match and the stripped raw candidate matches exactly once. When trailing empty lines are removed from `oldText`, the equivalent suffix is removed from `newText` so the replacement span is preserved.

- **Actionable-warnings pipeline now emits structured NDJSON telemetry** — a new `actionable-warnings-logger.ts` writes NDJSON events to `~/.pi-lens/actionable-warnings.log` (rotating at 1 MiB) covering the full advisory pipeline: `report_started` (files/warnings in scope), `lsp_file_checked` per file (diag counts, delta-filter counts, enriched counts), `lsp_file_skipped` for unsupported or erroring files, `report_complete` (final summary), `advisory_injected` / `advisory_skipped` (whether the advisory actually reached model context). Test mode suppresses all writes.

- **Read-guard partial apply now routes through post-edit analysis** — when only some oldText edits resolve, partial application now performs exact replacements only and then invokes the normal `handleToolResult` pipeline/bookkeeping path. This keeps read-guard staleness stamps, modified ranges, deferred formatting, dispatch diagnostics, cascade, and warning collection in sync with the disk mutation.

- **Read-guard stale-oldText escalation now fires across inter-turn gaps** — `REPEAT_FAILURE_TTL_MS` raised from 30 s to 300 s so repeated stale `oldText` attempts made 2–3 minutes apart are still counted as the same failure streak. At ≥ 2 failures the preflight error is upgraded from `🔄 RETRYABLE` to `🛑 RE-READ REQUIRED` with an explicit instruction not to retry from memory.

- **Workspace edit partial-application now surfaces a clear error** — `applyWorkspaceEdit` applies file edits and file-system operations sequentially; if one fails mid-way, previously written files are not rolled back. The error now lists every file already written before the failure so callers can diagnose the inconsistency. When no files had been written yet, the original error is re-thrown unchanged.
- **Actionable-warnings autofix logs when its cache is absent** — `agent_end` now emits a debug message when `actionableAutofixEnabled` is true but the `actionable-warnings` cache entry is missing or expired, instead of silently skipping fixes.

- **Read-guard no longer blocks edits to files the agent just created** — when a `write` tool creates a new file, pi-lens now registers a synthetic read covering the full written content, so an immediately following `edit` on the same file is not blocked by a zero-read violation. The agent authored the content, so the guard invariant holds. The pre-write `isNewFile` check gates the synthetic read to genuinely new files only.
- **Trailing whitespace in `oldText` is auto-patched before the edit lands** — editors and formatters strip trailing whitespace on save; if the model copies content that had it, the edit tool can fail to match. pi-lens now strips trailing whitespace from each line of `oldText` (and updates `event.input` in-place) when the stripped version matches exactly one location. Runs as a first pass before indentation correction so both normalizations compose cleanly.
- **Read snapshot hash coverage raised from 1 000 to 3 000 lines** — reads larger than the old cap produced `unavailable` snapshot status, downgrading validation to range-only. The FNV-1a hash cost for 3 000 lines is sub-millisecond; the limit remains overridable via `PI_LENS_READ_GUARD_HASH_MAX_LINES`.

- **Indentation autopatch no longer produces mixed indentation in nested `newText`** — `retargetReplacementIndentation` now extends the indentation map to cover deeper nesting levels not present in `oldText` by resolving any indent as `n × baseUnit → n × correctedUnit`. Previously, lines at depths beyond what appeared in `oldText` were left with the agent's original (wrong) style while shallower lines were remapped, producing mixed indentation in replaced blocks that introduced new conditional or loop nesting. If any non-blank line's indentation cannot be resolved as a multiple of the base unit, retargeting is now aborted entirely rather than applied partially.
- **Indentation correction reads the file once instead of three times** — the autopatch path previously called `readFileSync` three times per `oldText` entry (once in `tryCorrectIndentationMismatch`, twice in `countOldTextMatches`). A single read now derives both the CRLF-normalised form (used by the correction logic) and the trailing-whitespace-trimmed form (used by occurrence counting). `resolveOldTextEdits` in `read-guard-tool-lines.ts` also no longer re-reads a file it already holds.

- **Read-guard snapshot validation now blocks stale covered ranges** — touched edit ranges with hash-checkable prior reads are rejected when the current file lines no longer match what the agent saw. Hash-unavailable cases still fall back to existing range coverage to avoid false blocks, while unrelated line changes outside the edited range no longer cause file-modified false positives.
- **Read-guard preflight blocks now emit structured telemetry** — unresolved native `edit` targets now log `edit_preflight_blocked` with `reasonKind`, failed edit indexes, resolution counts, and oldText previews, making exact-text failures distinguishable from later read-range verdicts.
- **Safe indentation-only edit retries preserve replacement indentation** — when pi-lens auto-patches an `edit` call's tab/space-only `oldText` mismatch, it now also retargets leading whitespace in the paired `newText` using the same indentation mapping. Successful tab-vs-space retries no longer introduce mixed indentation in the edited block.
- **Read-guard snapshot telemetry no longer mixes candidate states** — snapshot-validation events now clear stale `missingLines` when a later candidate produces a real mismatch, so `mismatch` telemetry no longer reports lines as both missing and mismatched.
- **Safe indentation-only edit retries are auto-patched** — when an `edit` call's `oldText` differs only by leading tabs/spaces and the corrected text matches exactly one location, pi-lens now mutates the tool input before execution instead of blocking with a visually lossy retry instruction. Ambiguous or non-indentation-only corrections still block and require a re-read.
- **Read-guard snapshot validation and retry guidance** — edit preflight now validates captured `oldText` snapshots against current file content, reports structured snapshot-validation events, and gives clearer retryable indentation-mismatch guidance with corrected `oldText` candidates. This reduces false blocks from stale reads while steering agents to retry exact tab/space corrections instead of improvising.
- **Path normalization avoids regex hotspots** — slash normalization in ignore/path matching no longer relies on regex replacement patterns that static analyzers flagged as potential hotspots.
- **Project scans now respect `.gitignore` and generated artifacts** — centralized project ignore matching now supports rooted patterns (`/profiles/`), globbed trees (`profiles/**`), nested `.gitignore` files, and negations, and is shared by source collection, startup counting, jscpd, tree-sitter collection, review-graph workspace module scans, autofix snapshots, ast-grep temp scans, `/lens-booboo` ast-grep scan globs, and write/read hook paths. pi-lens now skips gitignored files before LSP warming or dispatching the pipeline, and generated/artifact detection is centralized for common codegen dirs, protobuf/sqlc/OpenAPI outputs, minified/bundled files, declaration stubs, and generated-file headers. Also avoids source-scanning `$HOME` during session start when startup gating has already classified the cwd as `home-dir`. Refs #91.
- **Review graph has hard safety caps for large projects** — review-graph construction now goes through the shared project scan policy, skips files above the configured size limit, and bails out with a logged `too_many_files` skip instead of parsing thousands of files on the hook path. Defaults are 1,000 source files and 1 MiB per file, with `PI_LENS_REVIEW_GRAPH_MAX_FILES` / `PI_LENS_REVIEW_GRAPH_MAX_FILE_BYTES` overrides for exceptional projects.

## [3.8.45] - 2026-05-21

### Added

- **Markdown section read expansion** — `tryExpandRead` now expands partial reads in `.md` and `.mdx` files to the full enclosing heading section (from the `## Heading` at or before the read anchor to the next heading of same or higher level). No tree-sitter is needed; expansion is synchronous and stays within the existing `EXPANDED_SIZE_CAP_LINES` (300) and `EXPANSION_LIMIT_LINES` (60) guards. Populates `enclosingSymbol` with `kind: "markdown_section"` and the heading text as the symbol name, giving the read guard precise section-level coverage instead of the previous blanket `.md` exemption.

- **pi-lens log smell analyzer** — new `npm run logs:smells` script scans pi-lens telemetry across all projects where the extension was active (`latency.log`, `sessionstart.log`, `cascade.log`, `read-guard.log`, `tree-sitter.log`, and daily diagnostic JSONL logs), grouping operational smells such as slow hook paths, runner failures, LSP availability noise, cascade fallback/slowness, and read-guard friction.
- **LSP batch diagnostics and document symbol search** — `lsp_diagnostics` now accepts explicit `filePaths` batches with bounded concurrency (`concurrency`, default 8/max 16) and optional `waitMs`, so agents can validate exactly the files they touched without scanning a directory. `lsp_navigation` adds `operation: "findSymbol"` for filtered document-symbol lookup by `query`, `kinds`, `exactMatch`, `topLevelOnly`, and `maxResults`.
- **Review-graph feature hints and source grouping helpers** — review graph file/symbol metadata now includes deterministic `featureKind` and `trustBoundaries` hints derived from names/paths, and `source-groups.ts` can partition large source sets into stable labeled groups for context planning.
- **Global user config at `~/.pi-lens/config.json`** — pi-lens now reads persistent user preferences from the same global directory used for logs/probe state. Initial settings cover `widget.visible` (hide the diagnostics widget by default; fixes #84) and `format.enabled` / `format.mode` (`"immediate"` to format after each write/edit instead of waiting for `agent_end`; fixes #61). CLI flags still override global config.
- **10 new C blocker tree-sitter rules** — implements SonarCloud C blocker rules via AST queries:
  - `memset-sensitive-data` (S5798) — `memset` on passwords/secrets (optimized away by compilers)
  - `noreturn-returns` (S5267) — `return` inside `__attribute__((noreturn))` functions
  - `no-octal-literals` (S1314) — octal literals like `010`
  - `no-reserved-identifiers` (S978) — `_Upper` or `__` identifiers
  - `no-stdlib-name-as-id` (S6936) — shadowing `malloc`, `printf`, etc.
  - `no-bit-fields` (S2806) — `int x : 4;` bit-field declarations
  - `no-redundant-pointer-ops` (S3491) — `*&x` and `&*p` no-ops
  - `no-pointer-arithmetic-array-access` (S3729) — `*(arr + i)` instead of `arr[i]`
  - `c-hardcoded-secrets` (S6418) — hard-coded API keys/passwords in strings
  - `non-case-label-in-switch` (S1219) — regular labels inside `switch` bodies
- **5 new C post-filters** — `c_memset_sensitive_arg`, `c_stdlib_name`, `c_octal_literal`, `c_noreturn_attr`, `c_label_in_switch` added to `applyPostFilter` in `tree-sitter-client.ts`.
- **C tree-sitter tests** — `tests/clients/tree-sitter-c-rules.test.ts` with 10 passing tests.
- **C/C++ tree-sitter runner and cascade support** ([#83](https://github.com/apmantza/pi-lens/pull/83)) — `cxx` files (`.c`, `.h`, `.cpp`, `.cc`, `.hpp`, etc.) are now fully wired through the dispatch pipeline: tree-sitter structural analysis, review-graph construction with `#include` edge extraction, blast-radius entity snapshots, and cascade neighbor propagation. `cpp-check` runner enhanced with `clang-tidy` support. `language-profile.ts` adds C/C++-specific complexity baselines.
- **Vale prose linter runner** — new `vale` dispatch runner for Markdown files. Config-gated (requires `.vale.ini`); auto-install disabled (uses PATH). Parses `--output=JSON` into pi-lens diagnostics with severity mapping. Covers prose/style quality alongside `spellcheck` and `markdownlint`.
- **SwiftLint runner** — new `swiftlint` dispatch runner for Swift files. Runs out of the box with built-in defaults (no config required). Auto-installs via GitHub release (macOS portable zip, Linux amd64/arm64). Uses `--reporter json` output. Swift dispatch now has LSP + SwiftLint + swiftformat.

### Changed

- **`.md` / `.mdx` no longer auto-format with prettier defaults when the project has no prettier config.** Closes [#89](https://github.com/apmantza/pi-lens/issues/89) via [#90](https://github.com/apmantza/pi-lens/pull/90). Prettier's defaults reflow lines, normalize emphasis markers (`*` → `_`), and restyle lists, producing noisy diffs on doc-only writes. The smart-default gate still runs prettier when an explicit project config (`.prettierrc`, `prettier` field in `package.json`, etc.) is present — flip is on the no-config path only. To restore prior behaviour, add an empty `.prettierrc` (or any explicit prettier config) to the project root.
- **README accuracy fixes** — corrected Python LSP label (pyright/basedpyright + jedi), bumped formatter count 26→27→32 (added oxfmt, fish_indent, google-java-format, cljfmt, cmake-format, psscriptanalyzer-format), fixed read-guard markdown exemption text, added `/lens-allow-edit` to key commands, bumped language coverage 35→36+ (added Fish, Svelte, Vue rows), added `tree-sitter` to C/C++ dispatch, added `detekt` to Kotlin dispatch, added formatters to Java/Clojure/CMake/PowerShell rows, added `vale` to Markdown row, added `swiftlint` to Swift row.

- **`.md` read-guard exemption tightened from `allow` to `warn`** — markdown files are no longer silently exempt from the read-before-edit guard. With the new markdown-section expansion providing precise heading-level coverage, edits outside the expanded read range trigger a warning instead of passing unchecked. Plain-text (`.txt`) and log (`.log`) files remain exempt.

- **Module-level dependency graph for monorepo cascade** — `buildModuleGraph` (new `clients/review-graph/workspace-modules.ts`) scans workspace manifests (`pnpm-workspace.yaml`, `package.json` workspaces, `Cargo.toml` `[workspace]`, `go.work`) and builds a module dependency graph with transitive downstream BFS. `computeImpactCascade` now expands the blast radius to include source files from downstream dependent packages when an edited file belongs to a workspace module. Cache cleared on `resetDispatchBaselines`.

- **LSP `references` for symbol-level blast radius** — when `changedSymbols` are detected in a file, `computeCascadeForFile` now calls LSP `references` for up to 3 changed symbols (with a 750ms timeout per symbol, 1200ms hard ceiling) to find the true call-site blast radius. Reference files are merged into `impact.neighborFiles`, giving cascade precision beyond coarse file-level import edges. Falls back silently to import-graph neighbors on timeout or LSP error.

- **Test suggestions for cascade neighbors** — `TestRunnerClient` gained `suggestTestFiles()` and `handleTurnEnd` now appends a "Likely tests for affected neighbors" section to the cascade output when cascade neighbors have diagnostics. Extends the existing test-discovery patterns (basename, `__tests__`, `tests/`, import-scan fallback) to affected neighbor files, capped at 5 suggestions.

- **Content-hash staleness detection for ReadGuard** — read records now capture per-line content hashes for the effective read range (capped by `PI_LENS_READ_GUARD_HASH_MAX_LINES`, default 1000). When file mtime changes but the relevant read lines still hash-match, ReadGuard treats the context as fresh and avoids false `file_modified` blocks from no-op formatting/touching. Semantic line changes still block and require a re-read.

### Fixed

- **ESLint LSP activation is config-gated for JS packages** — ESLint language-server startup now requires a real ESLint signal (config file, `eslintConfig`, or an `eslint` package dependency) instead of treating any `package.json` as enough. Plain JS packages without ESLint no longer spend the LSP timeout trying to start `vscode-eslint-language-server`, and nested packages without ESLint no longer inherit a parent repo ESLint config by accident. Closes #86.

- **SonarCloud regex hotspot in workspace scanner** — replaced `workspace-modules.ts` multi-line manifest regexes with linear line scanners for `pnpm-workspace.yaml` and Cargo TOML sections/arrays, avoiding super-linear regex hotspot reports while preserving monorepo module detection.

- **Agent guidance now promotes active LSP diagnostics and ast-grep retries** — session-start guidance and shipped skills now direct agents to use `lsp_diagnostics` for proactive file/folder/batch validation, keep `lsp_navigation` for code intelligence, and retry `ast_grep_search` once with a simpler valid AST pattern before falling back to grep. `ast_grep_search` tool docs now describe `selector` correctly as a node-kind filter rather than an extraction mechanism.
- **Startup language detection avoids fixture/tooling false positives** — plain Git repositories no longer count as configured C/C++ projects just because `.git` exists, and Ruby startup tooling now requires real Ruby project markers (`Gemfile`/`Rakefile`) before preinstalling RuboCop. This avoids noisy C++/RuboCop probes in JS/TS projects and fixture-only repos.
- **Missing direct LSP commands are negatively cached** — direct language-server commands such as `clangd` are now skipped for a short TTL after a clear command-missing failure, preventing repeated spawn attempts across multiple roots/files while still allowing later installs to be picked up.
- **Review graph cache supports incremental changed-file updates** — cascade graph construction now persists per-file signatures and updates the cached graph when only the edited file changed, instead of rebuilding the entire project graph on every write. Cascade remains synchronous in the existing lifecycle; the fix reduces hot-path cost without moving work to `turn_end`.
- **Generated files are skipped by dispatch** — dispatch context now classifies file roles from path/content prefixes and bypasses runners for generated files, avoiding noisy lint/security findings on protobuf/sqlc/generated artifacts. Generated-file detection covers common Go/Python outputs such as `.pb.go`, `_sqlc.go`, `_pb2.py`, and `_pb2_grpc.py`.
- **Disabled tree-sitter rules leaked into production dispatch/cache** — disabled query directories are now keyed under their base language for test access but filtered from production dispatch with cross-platform path-segment checks. Rule-cache entries now preserve `filePath`, cached disabled rules are defensively filtered, and the tree-sitter rule-cache version was bumped to invalidate stale `ts-path-traversal` cache entries from `typescript-disabled/`.
- **Knip scans bounded to real project roots** ([#81](https://github.com/apmantza/pi-lens/pull/81)) — Knip was running against arbitrary working directories (including `/tmp` or parent dirs without `package.json`), producing nonsensical unused-export reports or crashing on missing configs. `KnipClient` now validates the project root with `findProjectRoot()` before scanning, and `turn_end` Knip delta analysis bails early when the root lacks a recognizable package manifest. Prevents false-positive unused-export noise and config-not-found errors.
- **ReDoS in C/C++ include parsing** — `review-graph/builder.ts` used a regex with `[^>]*` to parse `#include <...>` directives, which SonarCloud flagged as S5852 (polynomial backtracking on malicious input). Replaced with a linear manual parser that scans character-by-character.
- **3 existing C rule post-filters were broken** — `case-range-multiple-values`, `goto-into-block`, and `goto-label-order` referenced post-filters (`case_range_single_value`, `goto_targets_inner_block`, `goto_jumps_backward`) that didn't exist in `applyPostFilter`, causing them to silently pass all matches. All three are now implemented. The `case-range-multiple-values` rule was moved to `c-disabled/` because the C grammar lacks `range_expression`.

- **LSP unavailable states are now explicit instead of false-clean** — `lsp_diagnostics` reports when no language-server client is ready (including candidate server IDs and stale-diagnostic state) rather than returning "No diagnostics found". C/C++ startup failures now point users at `clangd`/LLVM instead of the bogus `cpp-language-server` npm hint. Repeatedly failing server/root pairs are truly session-disabled after the permanent-failure threshold, client wait timeouts only log on real timeouts, and read-warm logs distinguish successful warms from no-client unavailability.

- **Entity snapshot extended for Rust and Ruby** — Rust now tracks `trait_item` (critical: changing a trait breaks all implementors and should always trigger blast-radius) and `type_item` (type aliases). Ruby now tracks `singleton_method` (`def self.foo` class-level methods were silently missed). Go and Python had no critical gaps. Inspired by repomix tree-sitter query coverage.

- **Entity snapshot now tracks arrow functions, interfaces, type aliases, and enums for blast-radius triggering** — `ENTITY_QUERIES` previously only detected `function_declaration`, `class_declaration`, and `method_definition`. In modern TypeScript/JavaScript codebases most "functions" are arrow functions (`const foo = () => {}`), so edits to them never triggered blast-radius analysis. Added `entity-jsts-arrow` (covers both arrow functions and function expressions), `entity-ts-interface`, `entity-ts-type`, and `entity-ts-enum` to complete the picture. Shared TS/JS queries factored into `JSTS_SHARED_ENTITY_QUERIES` and TypeScript-only structural types into `TS_STRUCTURAL_ENTITY_QUERIES` — class declaration remains the only language-specific entry (TS uses `type_identifier`, JS uses `identifier`). Blast-radius mechanism unchanged; it operates on language-agnostic `kind:name` keys. Inspired by repomix tree-sitter query coverage.

- **Runner diagnostics now captured in latency log** — each `type: "runner"` entry now includes a `diagnostics` array (rule, message truncated to 120 chars, line, semantic) when the runner produces findings. Previously only `diagnosticCount` was logged, making it impossible to trace which runner+rule produced a specific diagnostic (e.g. a false-positive blocker) without a live debugger. Relates to #78.

- **`isSgAvailableAsync()` replaces sync `isSgAvailable()` in dispatch hot path** — `python-slop` runner was calling `isSgAvailable()` on every invocation, which on first call runs multiple `safeSpawn` probes (local bins, PATH, npx) blocking the event loop. Added `probeAstGrepCommandAsync` and `isSgAvailableAsync` with an in-flight deduplication guard; `python-slop` now awaits the async version. Shared module-level cache (`sgAvailable`, `sgCmd`, `sgCmdArgs`) means subsequent calls return immediately regardless of which path ran first. Sync `isSgAvailable` retained for `SgRunner.isAvailable()` legacy compat.

- **`SgRunner.tempScan` is now async (`tempScanAsync`)** — the live production path `scanExports` → `runTempScan` → `tempScan` was blocking the Node event loop during background session startup scans. Added `tempScanAsync` using `safeSpawnAsync` and wired it through `AstGrepClient.runTempScanAsync` and `scanExports`/`findSimilarFunctions`. Sync `tempScan` retained for test compatibility per AGENTS.md legacy-cleanup contract.

- **`rust-clippy` and `go-vet` runners now use platform-aware binary resolution** — both runners were calling `"cargo"` / `"go"` as bare command names, relying on PATH. On Windows, `cargo` lives in `~/.cargo/bin/cargo.exe` and `go` in `C:\Program Files\Go\bin\go.exe` — locations not always on the shell PATH when pi-lens launches from an IDE. The runners now use `RustClient.findCargoPath()` and `GoClient.findGoPath()` respectively, which probe known install locations before falling back to PATH. Both path-finder methods are made public. `GoClient` and `RustClient` module-level singletons are shared across runner invocations so the path is resolved and cached once per session.

### Changed

- **Pyright / basedpyright reinstated as default Python LSP** — `PythonServer` re-added to `LSP_SERVERS` before `PythonJediServer` (jedi remains as fallback). The 5–14 s cold-start that caused the original removal is fixed by passing `openFilesOnly: true` in LSP initialization options, switching pyright to lazy per-file analysis rather than full workspace analysis on startup. `basedpyright-langserver` added as a candidate alongside `pyright-langserver` — same `--stdio` protocol, drop-in compatible. Deep type checking via standalone pyright CLI and mypy runners is unchanged. Strategy key renamed from orphaned `"pyright"` to `"python"` to match `PythonServer.id`. Closes #80; shipped via [#82](https://github.com/apmantza/pi-lens/pull/82).

## [3.8.44] - 2026-05-13

### Added

- **`fish` FileKind with `fish_indent` formatter runner** — `.fish` files are now a first-class `"fish"` kind rather than being bucketed under `"shell"`. A new `fish-indent` runner wraps `fish_indent --check` (fish ≥ 3.6), reporting a formatting warning with a `fish_indent -w` fix hint on exit 1 and a blocking parse-error diagnostic when stderr is non-empty. Formatter and linter policy entries added for `.fish` in `tool-policy.ts`; fish dispatch group `[lsp, fish-indent]` wired in `language-policy.ts`. Closes #74.

### Fixed

- **Linux `sg` command no longer breaks `ast_grep_search` / `ast_grep_replace`** — ast-grep resolution now prefers the canonical `ast-grep` binary and only accepts `sg` when `--version` proves it is ast-grep, avoiding the util-linux `/usr/bin/sg` group-switch command. The installer, probe cache, tool availability, sync runner helpers, and Python slop scan now share the corrected command shape and `npx --no -- ast-grep` fallback. Closes #75.
- **`return-in-generator` no longer flags normal `async def` coroutine returns** — added a Python tree-sitter post-filter that keeps only synchronous functions containing `yield`, skips `async def`, and rejects non-generator functions. Added regression tests for valued generator returns, coroutine returns, and normal functions. Closes #76.
- **`python-sql-injection` no longer flags safe SQLAlchemy expression execution** — the rule now captures the call receiver and the post-filter skips likely SQLAlchemy ORM session receivers (`session.execute(stmt)`) plus expression-builder calls such as `conn.execute(select(...).where(...))`, while still flagging raw `cursor.execute(sql)` and composed SQL strings. Closes #77.
- **Formatter tests no longer depend on a real global Ruff install** — the Ruff global fallback test now uses an isolated PATH shim, making it deterministic on machines without Ruff installed.

- **`psscriptanalyzer` runner could hang indefinitely** — `spawnPs` had no timeout; if `pwsh` or `Invoke-ScriptAnalyzer` stalled on a large file the turn would block forever. Added a 30s timeout with SIGTERM → 1s → SIGKILL escalation. `shell: false` means `child.pid` is the actual `pwsh` process so `child.kill()` hits the right target directly (no `taskkill` needed).

- **`turn_end` hangs ~40–50s on Windows when knip times out** — `safeSpawnAsync` used `child.kill("SIGTERM/SIGKILL")` to terminate timed-out processes. On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper; killing it orphans the actual subprocess (e.g. knip/npx node process) which then runs unsupervised until it naturally exits. Replaced with `taskkill /F /T /PID` on Windows, which kills the full process tree rooted at `cmd.exe`, matching the approach already used in `lsp/client.ts`.

- **`fish` missing from `LANGUAGE_CAPABILITY_MATRIX` and `LintRunnerName`** — adding the `"fish"` FileKind required two exhaustiveness fixes: a `fish` entry in `plan.ts`'s `Record<FileKind, CapabilityMatrixEntry>` and `"fish-indent"` in the `LintRunnerName` union in `tool-policy.ts`; both caused build/type-check failures on CI.
- **shellcheck and shfmt no longer fire on `.fish` files** — `.fish` was classified as `"shell"`, causing both runners (which use `appliesTo: ["shell"]`) to process fish scripts with `--shell bash`, producing false-positive SC1073/SC1064 parse errors. Moving `.fish` to the new `"fish"` kind fixes the routing with no special-case logic in either runner. Closes #74.

- **`lsp_diagnostics` tool** — proactive LSP error checking for files and directories. The agent can now run `lsp_diagnostics({ filePath: "src/" })` before builds to catch issues without making edits. Directory mode walks the tree (skipping node_modules/.git/target), auto-detects the language extension, opens each file in the LSP client, and aggregates diagnostics. Supports severity filtering (`error`/`warning`/`information`/`hint`/`all`), caps at 50 files and 200 diagnostics. Returns structured details with `totalDiagnostics`, `truncated`, and per-diagnostic `file`/`line`/`severity`/`message`/`source`/`code`. Adapted from `code-yeongyu/pi-lsp-client`.
- **LSP process stderr capture and health check** — the LSP client now maintains a rolling 100-line stderr buffer from server startup through shutdown. Three new client methods exposed: `processExited()` (true if the server process died), `recentStderr(n)` (last N lines for diagnostics), and `checkAlive()` (pre-request health check returning error string with exit code + stderr tail if dead). Previously, stderr was only captured during initialization and discarded afterward.
- **SIGTERM → 1.5s → SIGKILL escalation in `killProcessTree`** — on Unix, process cleanup now sends SIGTERM first, waits 1.5 seconds, then sends SIGKILL if the process is still alive. Prevents zombie server processes that survive a standard kill. Windows already uses `taskkill /F /T` (force kill tree).
- **LSP force-reinstall when PATH-resolved tool is broken** — when an LSP server's PATH candidate fails to launch (e.g. broken symlink, missing runtime, corrupted binary) AND the managed install returns the same broken PATH entry, pi-lens now clears the probe cache, downloads a managed copy from the registry (npm/GitHub/pip), and retries the launch. Previously, broken PATH tools triggered exponential backoff and were permanently disabled after 5 failures. The retry only fires when the `ensureTool` path is a bare command name (no `/` or `\` separators) — absolute paths from prior managed installs are not force-reinstalled to avoid redundant download loops. `ensureTool` gained an optional `forceReinstall` flag that bypasses both the in-memory `resolvedPathCache` and the persistent probe cache.
- **`getToolPath` prefers managed installs over PATH for github-strategy tools** — github-strategy tools (`rust-analyzer`, `shellcheck`, `shfmt`, `golangci-lint`) now check `~/.pi-lens/bin/` before falling through to PATH lookup. This ensures force-reinstall flows find the newly downloaded binary, and pi-lens-managed copies take priority over potentially stale or broken PATH entries. Non-github tools (npm, pip) are unaffected.
- **Pattern hints for `ast_grep_search` zero-match results** — when a search returns no matches, the tool now appends a hint suggesting likely pattern mistakes: regex misuse (`\w`, `\d`, `[a-z]`, `.*`, `.+`, `|` alternation), language-specific mistakes (Python trailing colons, incomplete JS/Go/Rust function patterns). Adapted from `code-yeongyu/pi-ast-grep`.
- **Truncation metadata in ast-grep tool results** — `SgResult` now carries `totalMatches` and `truncated` fields, threaded through `SgRunner` → `AstGrepClient` → both `ast_grep_search` and `ast_grep_replace` tool `details`. The agent can now distinguish "50 shown of 500 total" from "50 total".

### Changed

- **Runner process execution is async/non-blocking across hook paths** — jscpd scans, Madge dependency checks, formatter execution, and dispatch runners that previously used sync `safeSpawn()` now use `safeSpawnAsync()` in write/session/turn hooks. Added in-flight guards for jscpd and Madge project/file scans, async availability checks in runner helpers, and Knip availability dedupe + project-root bail before install/probe.
- **`isCommandAvailable` replaced `which`/`where` spawn with PATH walk + `statSync` size validation** — instead of spawning `which`/`where` (~50 ms + timeout risk), the installer now walks `$PATH` entries synchronously and checks `statSync(path).isFile() && stat.size > 0` for each candidate. This catches broken symlinks (stat throws `ENOENT` or returns size 0) at ~μs per candidate with zero process spawns. On Windows, `.exe`, `.cmd`, and `.bat` extensions are probed.

### Fixed

- **SonarCloud security hotspots resolved** — replaced the .NET build diagnostic regex with a linear manual parser to avoid ReDoS risk (S5852), and switched jscpd temporary directory creation from a `Math.random()` suffix to `fs.mkdtempSync()` to avoid weak PRNG use (S2245).
- **ast-grep tool language list aligned with ast-grep CLI** — dropped phantom `dart` and `sql` (not supported by ast-grep binary), added missing `bash`, `nix`, `solidity`. The `LANGUAGES` constant in `tools/shared.ts` now matches ast-grep v0.41's official 25-language list.
- **Graph-cache test: disk cache leaked across test runs** — `buildOrUpdateGraph` persists to `cwd/.pi-lens/cache/review-graph.json`. All tests used hardcoded `"/cwd"`, causing the first test run's disk cache to contaminate subsequent runs. Switched to `fs.mkdtempSync` temp directories with `afterEach` cleanup.
- **Disabled tree-sitter rules leaked into production** — `parseQueryFile` uses the YAML's `language:` field over the directory name, so rules in `typescript-disabled/` with `language: typescript` were loaded as active TypeScript rules and appeared in the diagnostics widget. Added `!d.name.endsWith("-disabled")` filter to `loadQueries` directory enumeration.

## [3.8.43] - 2026-05-10

### Added

- **Unresolved inline blocker re-surfacing at turn_end** — when the agent ignores a blocking diagnostic shown during a write/edit and moves to the next turn without fixing it, the blocker now reappears in the turn_end injection framed as `"Unresolved from this turn — <file>: 🔴 STOP…"`. Previously, unresolved inline blockers were silently lost until cascade happened to re-touch the same file via an importer. `RuntimeCoordinator` tracks the last-seen blocking output per file (`_pendingInlineBlockers`); a subsequent write that produces no blockers clears the entry, so only genuinely unresolved issues resurface. The map is cleared at `beginTurn` to prevent cross-turn contamination.
- **S1219 (switch non-case labels) and S2970 (incomplete assertions) blocking tree-sitter rules** — S1219 detects labeled statements inside switch cases in TypeScript (SonarCloud S1219); S2970 detects Jest/Vitest `expect()` chains that are never called (e.g. `expect(x).toBe(y)` without `await`), with Chai property assertion exclusion. S2083 (path traversal) moved to disabled — regex heuristics on tree-sitter syntax are the wrong layer; needs taint/data-flow analysis. Adds `parent?` field to `TreeSitterNode` interface.
- **Inline code snippets in blocker output** — each 🔴 STOP diagnostic now includes the exact source line the agent wrote that caused the violation, so the agent can identify and fix the issue without re-reading the file. `fixSuggestion` is also surfaced inline when present. Snippet capped at 120 chars.
- **AST node type and matched text in blocker output** — tree-sitter diagnostics now carry `matchedText` (the exact matched node, more precise than the full source line) and `astNodeType` (e.g. `call_expression`, `template_string`). The agent sees: `L12: SQL query built with string interpolation (template_string) → db.query(...)`.
- **Persist review graph to disk** — `_workspaceGraphCache` is now backed by `.pi-lens/cache/review-graph.json`. On cold start, if source file signatures match the stored cache, the full 2–4 s tree-sitter + import-fact build is skipped (~20 ms JSON parse + `rebuildIndexes` instead). Write is fire-and-forget, never blocks dispatch.
- **Preserve last known LSP diagnostics when LSP goes inactive** — when no live clients are available (dead client respawning, circuit-breaker cooldown), `getDiagnostics` now returns the last non-empty result for that file instead of `[]`. The widget keeps showing the last known issues rather than going blank mid-session. Live clients returning `[]` clears the stale entry. Stale hits are logged as `failureKind: "no_clients_stale"`.

### Fixed

- **Read-guard false-positive block on files outside the project root** — edits to files outside `projectRoot` (e.g. `C:/llama/*.bat`, scripts in arbitrary directories) were always blocked with `zero_read` because reads for external files are intentionally not recorded (`isExternalOrVendor` gate in the read handler), but the `checkEdit` call had no matching guard. Added `!isExternalOrVendor` to the `checkEdit` condition so external files bypass the read-guard entirely, consistent with how reads are handled.

### Changed

- **Replace pyright-langserver and pylsp with jedi-language-server for Python LSP** — `PythonServer` (pyright-langserver) and `PythonPylspServer` (pylsp) removed from `LSP_SERVERS`; replaced by `PythonJediServer` which spawns `jedi-language-server`. pyright-langserver was causing 5–14 s cold-start delays on large Python projects (e.g. tinygrad) because it performs full workspace analysis on startup; jedi starts in ~200–500 ms via lazy per-file analysis. pylsp was removed because it consistently returned 0 diagnostics (no venv → jedi can't resolve imports; 1500 ms aggregate timeout hit on warm runs). Deep type checking is unaffected — the standalone `pyright` CLI runner and `mypy` runner continue to run in parallel. Added `"python-jedi"` strategy entry (`seedFirstPush: true`, `aggregateWaitMs: 1000`). Wall-clock gate for Python dispatch shifts from LSP (~5–14 s) to mypy (~3.5 s).

## [3.8.42] - 2026-05-08

### Added

- **Fact-rules wired into all language dispatch plans** — the `fact-rules` runner was registered but never listed in any `RunnerGroup`; 20 TypeScript FactRule instances (`corsWildcardRule`, `jwtWithoutVerifyRule`, `dynamicRegexpRule`, `errorObscuringRule`, `highComplexityRule`, etc.) were never executing. Added `mode:all fact-rules` group to jsts, python, go, rust, ruby, cmake, and shell write plans.
- **3 fact-rules promoted to blocking (inline at write time):** `cors-wildcard` (CORS `*` origin — no ast-grep/tree-sitter equivalent), `error-swallowing` (empty catch — smarter than the disabled tree-sitter `empty-catch`, skips fs-boundary and documented fallbacks), `no-commented-credentials` (credentials in commented code — complementary to ast-grep which covers live code). `high-entropy-string` was already blocking.
- **Fact-rule false-positive reductions:** `no-boolean-params` now exempts names with `*Only`/`*Enabled`/`*Disabled` suffixes, `allow*`/`skip*`/`needs*`/`auto*` prefixes, and `_`-prefixed params. `duplicate-string-literal` SKIP_STRINGS expanded with DSL discriminators (`types`, `fallback`, `direct`, `all`, `mode`, `source`) and infrastructure strings (`github`, `rubocop`, `arm64`). `high-import-coupling` threshold raised 10→15 and exempts `index.ts`/`integration.ts` registry/hub files. `no-commented-credentials` exempts scanner/fixture files.
- **Severity alignment for 3 existing TS tree-sitter blocking rules** — `ts-command-injection`, `ts-ssrf`, `unsafe-regex` had `inline_tier: blocking` but `severity: warning`, producing `semantic: "warning"` which is never shown inline. Fixed to `severity: error` → `semantic: "blocking"` → actually surfaces to the agent.
- **Fixed `inline_tier: error` typo** on `ts-hallucinated-react-import` and `python-hallucinated-import` (→ `blocking`).
- **13 new high-confidence blocking promotions across 5 languages** (all `severity: error`, `inline_tier: blocking`):
  - *TypeScript:* `ts-weak-hash` (`createHash("md5"/"sha1")` — confidence: high)
  - *Python:* `python-command-injection`, `python-sql-injection`, `python-insecure-deserialization`, `python-weak-hash`
  - *Go:* `go-command-injection`, `go-sql-injection`, `go-shared-map-write-goroutine`, `go-weak-hash`
  - *Ruby:* `ruby-weak-hash`
  - *Rust:* `rust-lock-held-across-await`
- **4 new blocking tree-sitter rules (SonarCloud BLOCKER equivalents)**:
  - `ts-xss-dom-sink` (S5696) — flags dynamic values assigned to `innerHTML`/`outerHTML` or passed to `document.write()` / `document.writeln()`
  - `ts-dynamic-require` (S5335) — flags `require()` called with a non-string-literal argument (arbitrary module loading)
  - `ts-open-redirect` (S6105) — flags `res.redirect(variable)` / `response.redirect` / `ctx.redirect` with dynamic URL, and `window.location.href = variable`
  - `ts-nosql-injection` (S5147) — flags any MongoDB `$where` key (JS-execution sink, dangerous regardless of value)
- **2 existing security rules promoted to `inline_tier: blocking`** — `ts-command-injection` (maps to SonarCloud S2076) and `ts-ssrf` (maps to S5146) were previously `warning`; now block the agent turn on detection.

### Fixed

- **`fact-rules` `RuleCache` blind to built-in rule changes** — the cache hash only covered project-local rule files; for any project with no local `rules/` directory the hash was a constant, so new pi-lens built-in rules were silently ignored after the first run. Fixed by including both project-local files and `resolvePackagePath()`-resolved built-in files in the hash, with a `Set` to deduplicate when pi-lens analyzes itself.

### Changed

- **`max-switch-cases` threshold raised 30→40** — `applyPostFilter` dispatch table now has 31 cases and is expected to grow; the old threshold triggered a false positive on pi-lens itself.
- **Package scope migration** — all `@mariozechner/*` import references updated to `@earendil-works/*` following the repo move to `earendil-works/pi-mono`. `@earendil-works/pi-tui` dependency bumped to `^0.74.0`.
- **Startup: `lsp-config` phase is now fully fire-and-forget** — `loadLSPConfig` and `igniteWarmFiles` no longer block the interactive path, removing ~1s from session start on Windows (previously dominated by sequential ENOENT `readFile` calls walking the directory tree to find a config file).
- **Startup: persistent tool probe cache** — `ensureTool` now checks `~/.pi-lens/probe-cache.json` before falling back to the full `verifyToolBinary` process spawn. Cache entries are validated with `fs.access` + mtime check and expire after 24 h; stale or missing entries fall through to the full probe and update the cache on success.

### Added

- **Startup observability** — `checkProbeCache` now logs the reason for each cache miss (`ttl expired`, `gone`, `mtime changed`); the lsp-config fire-and-forget callback logs how many warm files were configured once the config resolves asynchronously.

### Added

- **Test runner: import-based fallback discovery** — when basename pattern lookup finds no test file for a modified source file (e.g. `cline.test.ts` for `cline-auth.ts`), the runner now scans `tests/`, `__tests__/`, and the source file's own directory for any `*.test.*` file whose content references the source basename in an import path. Fixes the silent `no test file found` for files whose test is named after a module rather than the source file.
- **Test runner: prefer local `node_modules/.bin` binary over `npx`** — `vitest` and `jest` now resolve the project-local binary (`node_modules/.bin/vitest.cmd` on Windows, `node_modules/.bin/vitest` on Unix) before falling back to `npx`, saving ~150ms of startup overhead per test run.
- **Turn-end test runner logging** — `turn_end` now logs the outcome of every test run: `turn_end: test vitest util.test.ts → PASS 8p/0f (412ms)` or `FAIL 2p/8f (930ms)`. Stale results (turn advanced while tests ran) are logged with a `[stale]` prefix instead of being silently discarded. All-pass turns are no longer silent.
- **Per-file test target logging** — `turn_end` now logs which test file was resolved for each modified source file, or `no test file found` when none matched. Previously silent; impossible to distinguish "runner disabled" from "no test found".
- **Session-scoped turn-end dedup** — `turn-end-findings-last` now stores the current session ID alongside the content signature. Identical findings from a previous session are no longer suppressed — each new session sees its blockers fresh. Same-session dedup continues to work as before.
- **Cross-session turn state eviction** — turn state (modified file ranges) now carries the session ID set at first edit. If `turn_end` reads a turn state written by a different session, it evicts it immediately and logs `turn_end: evicting stale turn state (session X ≠ current Y)`, preventing stale cross-session file lists from triggering jscpd, madge, or test runs.

### Changed

- **Context injections framed as automated checks** — all three `consume*` injections (`turn-end findings`, `test findings`, `session guidance`) now prefix their content with `[pi-lens automated check — not a user request]` so the agent cannot mistake a hook-injected message for a direct user command. Advisory sections additionally carry `ℹ️ Advisory — no action required this turn:` before their content; blockers (🔴) continue to require action.

- **`/lens-widget-toggle` command** — toggles the pi-lens diagnostics widget below the editor on/off for the current session, so users can reclaim footer/editor space without disabling pi-lens analysis.

### Changed

- **Removed per-turn jscpd scans** — jscpd remains in the session-start project scan, but no longer runs unconditionally at `turn_end`; inline structural-similarity checks cover the high-value duplicate-code signal during active edits without the repeated multi-second clone scan.
- **Cascade avoids low-value work** — unsupported graph kinds now skip review-graph construction and go straight to passive LSP fallback diagnostics, and neighbor files that recently returned clean can skip repeated active LSP touches for a few turns unless the passive snapshot already contains fresh errors.
- **Knip now surfaces unused-export regressions** — newly unused exports in modified files are shown as advisory end-of-turn findings when they were absent from the previous Knip cache.

### Fixed

- **Knip latency log now includes result metadata** — the `turn_end` Knip phase previously logged only duration with empty `metadata: {}`, making it impossible to distinguish a clean run from a silent failure. It now logs `success`, `totalIssues`, `newIssues`, `blockerIssues`, and `skipped` when the startup scan is still in flight.

- **LSP timeout log now includes `serverIds`** — `lsp_client_wait_timeout` previously only recorded `maxWaitMs`, making it impossible to identify which server consistently failed to respond within the budget. The event now includes the array of server IDs that were being waited on.

- **Vendor/third-party files excluded from cascade neighbor analysis** — `isExternalOrVendorFile()` previously only checked `node_modules`; it now checks every path segment against `vendor`, `vendors`, `third_party`, and `third-party` as well. Cascade neighbor discovery and fallback neighbor injection both skip files inside these directories, preventing vendored dependency diagnostics from surfacing in cascade output.

- **`lens-booboo` hangs on repos with large vendored trees (fixes #57)** — `collectSourceFiles` and the `sg scan` runner in `lens-booboo` now exclude `vendor/`, `third_party/`, `third-party/`, and `vendors/` by default (added to `EXCLUDED_DIRS`). Additionally, `readGitignoreDirs()` reads the root `.gitignore` and extracts simple directory-name entries (bare names and `name/` patterns — no wildcards, negations, or internal slashes), merging them into the exclusion list for `collectSourceFiles` and the `sg scan` glob arguments. This covers project-specific large dirs (e.g. `my-upstream/`) without requiring full gitignore-spec compliance.

## [3.8.41] - 2026-05-05

### Fixed

- **tree-sitter wasm abort loop and memory leak (fixes #56)** — when the emscripten wasm runtime aborts (OOM or assertion failure on large workspaces), the module-level heap is permanently corrupted. pi-lens was re-invoking the dead runtime on every subsequent file write, printing `Aborted()` to stderr on each query and leaking memory on each retry. Added a module-level `_wasmAborted` flag: the first abort detected in the query catch loop poisons the singleton and prevents any further tree-sitter calls for the session. The runner skips cleanly with `reason: wasm_aborted_fatal` logged to `tree-sitter.log`.
- **`turn_end` phases now instrumented in latency log** — `handleTurnEnd` previously had no `logLatency` calls; all timing data was buried in plain-text `dbg()` lines in `sessionstart.log`. Added per-phase latency entries for `cascade_merge`, `jscpd`, `knip`, and `madge`, plus a `tool_result` total with `fileCount` and `blockerSections`. This gives a baseline for measuring the cost of future turn_end additions (e.g. LSP re-query).
- **Cascade ran graph build on non-code files** — markdown, YAML, JSON, and other files without a dispatchable kind were reaching `buildOrUpdateGraph`, causing cold graph builds that took up to 3–4 seconds per write with zero useful output. `computeCascadeForFile` now exits immediately with `cascade_skip / non_code_file` when `detectFileKind` returns `undefined`, consistent with the existing `shouldDispatch` gate used by the lint pipeline.

### Added

- **Per-server LSP diagnostic strategies** — new `clients/lsp/server-strategies.ts` codifies known server behavior (TypeScript, rust-analyzer, pyright, ESLint) so timing decisions are automatic rather than one-size-fits-all. Strategies control first-push seeding, debounce window, pull retry budget, aggregate wait timeout, and whether a server benefits from a semantic second pull pass. Env var overrides (`PI_LENS_LSP_*`) take precedence. Unknown servers get a conservative default.
- **Result-aware diagnostic racing (`raceToCompletion`)** — new `clients/lsp/aggregation.ts` replaces the simple `Promise.race` + grace window pattern with a result-quality-aware aggregator. The grace window only triggers when at least one client has returned non-empty diagnostics, preventing premature resolution when the fastest client returns empty (e.g., TypeScript's syntactic pass). Document mode uses 0ms grace; full mode keeps the 400ms default.
- **`seedFirstPush` early-exit for clean files** — `raceToCompletion`'s completion predicate now also fires when a `seedFirstPush` server (TypeScript, ESLint) returns any result, even an empty one. These servers' first push is authoritative — waiting further yields nothing. Cuts clean-file diagnostic latency from ~1000ms to ~450ms in full mode and to near-zero in document mode (cascade neighbor touches).

- **`/lens-toggle` session switch** — added a single command to toggle pi-lens on/off at runtime without restarting pi. When off, write/edit analysis, read-guard, formatting, cascade, turn-end checks, and context injection are paused; running `/lens-toggle` again resumes them. `--no-lens` starts a session in the disabled state. Closes #49.
- **Experimental Semgrep CLI dispatch integration** — added a config-gated `semgrep` dispatch runner that normalizes Semgrep JSON findings into pi-lens diagnostics. The runner never auto-installs Semgrep and only runs when a local `.semgrep.yml`/`.semgrep.yaml`/`semgrep.yml`/`semgrep.yaml` is discovered or when explicitly configured with `--lens-semgrep --lens-semgrep-config <auto|p/pack|path>` / `/lens-semgrep enable --config <...>`. Dispatch scans pass `--metrics=off`; local rule scans do not require a Semgrep token, while Semgrep AppSec/Pro/managed configs may require `semgrep login` or `SEMGREP_APP_TOKEN`.
- **`/lens-semgrep` command** — new project command for managing Semgrep dispatch: `status` shows CLI/config/effective state, `init` writes a starter `.semgrep.yml` and enables dispatch, `enable [--config <auto|p/pack|path>]` persists activation in `.pi-lens/semgrep.json`, `disable` persists opt-out, and `clear` removes the pi-lens Semgrep config to return to local-config auto-discovery.
- **Semgrep severity policy metadata** — Semgrep rules can opt into pi-lens blocking semantics with metadata such as `metadata.pi-lens.semantic: blocking` and `metadata.pi-lens.defect_class: injection`. Otherwise, pi-lens promotes only high-signal Semgrep `ERROR` findings in security defect classes (`injection`, `secrets`, `safety`) to blockers and leaves other findings as warnings.
- **Experimental terminal dashboard** — `--lens-dashboard` / `PI_LENS_DASHBOARD=1` streams redacted session telemetry to a per-session JSONL file (`~/.pi-lens/dashboard-events/{sessionId}.jsonl`) and opens a live terminal dashboard. The dashboard shows the working folder, detected languages, formatter/linter activity, LSP servers spawned, diagnostics grouped by file with OSC-8 clickable links, and a session-start summary of languages, tools, configs, and autoinstalls. Each session gets its own event file; old files are pruned after 7 days (configurable via `PI_LENS_DASHBOARD_RETENTION_DAYS`). Use `PI_LENS_DASHBOARD_LOG_ONLY=1` to emit JSONL without opening a terminal. The viewer auto-scrolls to the latest content on each render.

### Changed

- **LSP diagnostic pipeline latency optimization** — six targeted refactors reduce per-file diagnostic wait times by 50–900ms depending on the language server: first-push seeding skips the debounce timer for TypeScript and ESLint (~150–200ms saved); adaptive debounce computes remaining wait from `pushDiagnosticTimestamps` (50–140ms saved); per-server aggregate wait times (1000ms for TypeScript, 3000ms for rust-analyzer, 1500ms default); semantic settle pass gated to rust-analyzer only; pull retry budget zeroed for TypeScript/ESLint. Global constants `DIAGNOSTICS_DEBOUNCE_MS`, `PULL_DIAGNOSTICS_RETRY_BUDGET_MS`, and `DIAGNOSTICS_AGGREGATE_WAIT_MS` replaced by per-server strategy values from the new `server-strategies.ts`.

### Fixed

- **Cascade neighbor touch cache ignores `writeSeq` on hit** — the A5 neighbor touch cache checked only `turnSeq` on cache hits, so a neighbor diagnosed at writeSeq=1 was served stale results when a second file write (writeSeq=2) cascaded to the same neighbor in the same turn. Fixed by requiring both `turnSeq` and `writeSeq` to match before using the cached entry.
- **Cascade fallback neighbors include other primary files** — `appendFallbackNeighbors` (the degraded-LSP path) excluded only the current primary file from the passive diagnostic snapshot sweep, but not other files edited as primary this turn. Those files could appear as cascade neighbors even though their own pipeline run is the authoritative diagnostic source. Fixed by adding a `primaryFilesThisTurn` check consistent with the B10 filter in the main neighbor path.

- **Semgrep dispatch plan regression** — kept the experimental Semgrep runner out of static `TOOL_PLANS` exposure and appends it only at runtime when Semgrep is actually configured. Fixes CI regressions in plan-shape tests while preserving config-gated Semgrep dispatch.
- **Widget theme method binding crash** — `renderWidget` now calls `theme.fg(...)` directly instead of destructuring `fg`, preserving the `this` binding required by pi's `Theme` class. Fixes the `Cannot read properties of undefined (reading 'fgColors')` widget render crash. Closes #53.
- **Read-guard follow-up edits after own writes** — tuned `file_modified` handling so a file changed by the agent's own prior allowed edit, immediate format, autofix, or deferred `agent_end` formatting does not force a redundant re-read when the next edit is still within already-read ranges. The guard still blocks zero-read and out-of-range edits, and external/stale changes outside the own-edit grace window remain protected. `PI_LENS_READ_GUARD_OWN_EDIT_GRACE_MS` controls the default 120s grace window.
- **Read-guard log noise and growth** — `~/.pi-lens/read-guard.log` now defaults to block/warn/anomaly events instead of logging every read and allowed edit. Verbose logging is available with `PI_LENS_READ_GUARD_VERBOSE=1` or `PI_LENS_READ_GUARD_LOG=verbose`; allowed-edit logging can be restored with `PI_LENS_READ_GUARD_LOG_ALLOWS=1`. The log now rotates at 1MB by default (`PI_LENS_READ_GUARD_MAX_BYTES`).
- **Pipelines skipped for external and vendor files** — agents reading dependency source (global npm packages, project-local `node_modules`) previously triggered LSP server spawns, tree-sitter read-range expansion, read-guard recording, and complexity baseline capture on those files — all noise with no diagnostic value. Added `isExternalOrVendorFile()` (built on the existing `isUnderDir` helper for correct Windows case handling) and gated all five pipeline paths: LSP auto-touch, tree-sitter expansion, read-guard recording, complexity baseline, and the full dispatch pipeline on write/edit.
- **Security: absolute paths for `cmd.exe` and `osascript` spawn calls** — dashboard terminal launch now resolves both executables via `process.env.SystemRoot` / absolute macOS path instead of relying on `PATH`, eliminating the SonarCloud S4036 PATH-injection finding.
- **Security: installed binary permissions tightened** — `chmod` calls on downloaded tool binaries changed from `0o755` to `0o750`, removing world-execute permission (SonarCloud S2612). GitHub Actions `contents: write` permission moved from workflow level to the `release` job only (S8233).
- **Agent messages: full-file-read options removed** — read-guard block messages no longer offer "read the full file" as an alternative. The out-of-range block now presents only the pre-computed targeted `offset`/`limit`; the zero-read block gives a single imperative directive. "Re-read the file" fallback text in ambiguous-edit messages replaced with "Re-read the relevant section" throughout.
- **Agent messages: indentation-mismatch RETRYABLE made explicitly directive** — the block now opens with "Retry the same edit call immediately with the corrected oldText shown below — copy it exactly as-is" and labels each corrected entry with "do not shorten, do not change newText", preventing agents from improvising instead of copying the corrected text verbatim.
- **SonarCloud reliability fixes** — five `.sort()` calls on string arrays given explicit `localeCompare` comparators (S2871); three identical-branch conditionals collapsed (S3923 in `knip-client.ts`, `shellcheck.ts`, `production-readiness.ts`); emoji character class converted to alternation to handle multi-codepoint variation-selector emojis (S5868); regex alternation precedence made explicit with non-capturing groups (S5850); `| 0` in hash function annotated as intentional 32-bit truncation (S7767).
- **CI: build step added before tests** — Vitest's native ESM resolver requires compiled `.js` output when `vi.resetModules()` is used; without a prior `tsc` build, imports of newly-added exports resolved as `undefined` in CI.
- **Widget: diagnostic rows exceeded terminal width** — the custom `truncate()` helper stripped ANSI sequences to measure length but sliced the raw string, losing OSC-8 hyperlinks and SGR sequences from the count. Replaced with pi-tui's `truncateToWidth()` / `visibleWidth()` which correctly account for all escape sequences. All widget lines (header, file rows, separators, diagnostic detail, LSP status) are now clamped. Closes #54.
- **Widget: file list capped at 5 entries, basename deduplication** — reduced max file rows from 6 to 5 to keep the widget compact. Added basename deduplication (last write wins) so that different files with the same name (e.g. `pi-lens/index.ts` and `pi-webaio/index.ts`) show as a single merged entry instead of flooding the widget with near-identical labels.

## [3.8.40] - 2026-05-04

### Added

- **60+ SonarCloud BLOCKER tree-sitter rules** — comprehensive BLOCKER severity rules across 13 languages:
  - **Java (11 rules)**: no-exit-methods, no-threads-in-constructors, switch-fall-through, no-wait-notify-on-thread, no-double-checked-locking, no-future-keywords, no-field-shadowing, junit-call-super, no-octal-values, short-circuit-logic, infinite-loop, infinite-recursion, name-capitalization-conflict, mockito-initialized, resources-closed, unnecessary-bit-ops-java
  - **TypeScript (5 rules)**: infinite-loop, self-assignment, duplicate-function-arg, empty-switch-case, default-not-last, switch-case-termination
  - **JavaScript (1 rule)**: switch-case-termination-js (replaces switch-fall-through-js)
  - **PL/SQL (7 rules)**: forallsave-exceptions, not-null-initialization, end-loop-semicolon, raise-application-error-codes, no-synchronize, lock-table, nchar-nvarchar2-bytes, delete-update-where, fetch-bulk-collect-limit
  - **Python (8 rules)**: send-file-mimetype, no-super-torchscript, return-in-init, yield-return-outside-function, notimplemented-boolean-context, exit-signature-check, return-in-generator, iter-return-iterator, in-operator-unsupported
  - **C++ (5 rules)**: unnecessary-bit-ops, noexcept-functions, no-auto-ptr, no-memset-sensitive-data, no-scoped-lock-without-args, no-confused-move-forward
  - **PHP (2 rules)**: this-in-static-context, no-exit-die
  - **C (3 rules)**: case-range-multiple-values, goto-label-order, goto-into-block
  - **C# (5 rules)**: is-with-this, no-operator-eq-reference, no-dangerous-get-handle, no-thread-resume-suspend, async-await-identifiers
  - **Kotlin (1 rule)**: prepared-statement-indices
  - **ABAP (1 rule)**: delete-where
  - **COBOL (2 rules)**: alter-statement, lock-table-cobol
  - **CSS (1 rule)**: calc-spacing
- **rule-catalog.json** updated with all 60+ new rule registrations

### Fixed

- **Read-guard: false `file_modified` blocks after own edits** — `ReadGuard` was blocking the second edit to a file because the model's first write changed the file's mtime, making `FileTime.hasChanged()` return `true` on the next `checkEdit`. Added `recordWritten(filePath)` to `ReadGuard` and wired it into the `tool_result` handler (post-write, file already on disk), so the FileTime stamp stays in sync with the model's own writes. Eliminates the spurious `file_modified` blocks that appeared on every multi-edit file in a session.

- **LSP: parallel-turn root-resolution timeouts** — `NearestRoot` performed a fresh `fs.stat` directory walk on every call with no caching. When Claude Code edited multiple files simultaneously (e.g. a 4-file turn), all pipelines raced `NearestRoot` concurrently, saturating Windows filesystem I/O and triggering the 750ms `lsp_client_wait_timeout` on all but the first. `NearestRoot` now maintains per-instance result and in-flight caches keyed by resolved directory: successful roots are cached for the session lifetime; concurrent calls for the same directory share one walk promise. Only successful roots are cached so a `package.json` created mid-session is still detected on the next call.

- **Memory: `lastAnalyzedStateByFile` cleared each turn** — module-level Map in `runtime-tool-result.ts` accumulated dead entries across turns (entries from previous turns can never match the new `turnIndex`). Now cleared at `turn_start` alongside `runtime.beginTurn()`, keeping the map bounded to files touched in the current turn only. (refs #50)
- **Memory: `recentTouches` stale entry eviction** — `LSPService.recentTouches` grew unboundedly across a session with one entry per unique file path. Entries older than `TOUCH_DEBOUNCE_MS` are already ignored by `shouldSkipTouch`; a threshold-based sweep (triggered when size > 200) now removes them. (refs #50)
- **Memory: orphaned LSP child processes on Windows** — `clientShutdown` only called `process.kill()` which on Windows terminates the direct child but leaves grandchildren (e.g. `tsserver.js`) as orphaned OS processes each holding 300–600MB. Both the normal shutdown and crash paths now go through a shared `killProcessTree` helper: on Windows it runs `taskkill /F /T` via absolute `SystemRoot` path and awaits completion before returning; on other platforms it sends `SIGTERM`. The SIGKILL fallback timer is also skipped on Windows since `taskkill /F` already force-terminates. (refs #50)
- **Memory: file-time session state not cleared on session reset** — `clearAllSessions()` from `file-time.ts` is now called during `handleSessionStart`, clearing stale file timestamp state that previously accumulated across session switches. (refs #50)
- **Memory: pending ast-grep warn timers not cancelled on session reset** — `resetDispatchBaselines()` left active `astGrepWarnDebounceTimers` running into a cleared session context. Now explicitly cancelled and cleared on reset. (refs #50)
- **Security: `taskkill` spawned via absolute path** — both the normal shutdown and crash paths now resolve `taskkill.exe` through `process.env.SystemRoot` instead of relying on PATH, eliminating the SonarCloud PATH-injection hotspot.
- **LSP: shutdown cannot hang indefinitely** — `client.shutdown()` now bounds the graceful `shutdown` request and proceeds to `exit`/process-tree kill if a server stops responding.
- **LSP: test cleanup stop helper hardened on Windows** — `stopLSP()` now uses the absolute `taskkill.exe` path, handles already-exited processes, and avoids orphaning grandchildren by killing the process tree before the direct child on Windows.

- **booboo project root detection** — `resolveProjectRoot` now walks up to the nearest ancestor with a root marker (`package.json`, `tsconfig.json`, `.git`, etc.), then falls back to walking down one level if exactly one immediate subdirectory has a root marker. Fixes scans running against the wrong directory in nested-project layouts (e.g. `pi-models/pi-models/`).

- **Switch-case false positives eliminated** — replaced naive `switch-fall-through` rules with `switch-case-termination` rules that properly recognize `return`, `throw`, and `continue` as valid case terminators. Reduced false positive hits from 174 to 0.
- **Self-assignment false positives fixed** — changed from `post_filter: same_identifier` to inline `#eq?` predicate so `wave = nextWave` is no longer flagged as self-assignment

## [3.8.39] - 2026-05-02

### Fixed

- **Context injection now prepends guidance before the user prompt** — pi-lens previously appended session guidance after the user's message; provider bridges that treat the last message as the active user action would demote the real request. Guidance is now prepended so the user's prompt stays last. (PR #48 by @tifandotme)
- **jscpd no longer runs on YAML/JSON/Markdown files** — `getFilesForJscpd` now filters to source code extensions only, preventing multi-second delays at `turn_end` when editing rule YAMLs or config files.
- **ReDoS S5852 final (gleam/zig parsers)** — rewrote `gleamRe` and `zigRe` as line-by-line parsers, eliminating the multiline flag that SonarCloud continued to flag despite `[ \t]*` substitution.
- **SonarCloud MAJOR code smells (batch 1 & 2)** — `readonly` members, `void` operator removals, nested ternaries, nested template literals, optional chains, duplicate branches, and redundant type alias across 15+ files.
- **Type-narrow `severityMap` for `Diagnostic.severity` union** — properly satisfies the union type for diagnostic severity mapping.
- **9 tree-sitter query bugs in new rule files** — predicate outside outermost parens (`cpp/no-auto-ptr`); false-positive `post_filter` gate added (`cpp/no-confused-move-forward`); leaf-node child match removed (`php/this-in-static-context`); invalid node name `class_hereditary` replaced (`java/no-field-shadowing`); field order corrected (`java/no-wait-notify-on-thread`); duplicate `modifiers` blocks merged (`java/spring-session-attributes-setcomplete`); invalid anonymous-node field label removed (`csharp/is-with-this`); inline alternation replaced with two patterns (`python/in-operator-unsupported`); adjacent sibling requirement removed, delegated to `post_filter` (`python/return-in-generator`).

## [3.8.38] - 2026-05-02

### Added

- **`RuleCache` respects `PILENS_DATA_DIR`** — tree-sitter rule cache files are now stored under `getProjectDataDir(rootDir)` instead of `<cwd>/.pi-lens/cache`, consistent with all other pi-lens data files. Projects using `PILENS_DATA_DIR` no longer get a stray `.pi-lens` directory created in the project root. (PR #47 by @tifandotme)

### Fixed

- **ReDoS: `gleamRe` and `zigRe` compiler parsers** — residual `\s*` quantifiers (which match `\n` in JS) replaced with `[ \t]*` to eliminate cross-line backtracking. Completes the SonarCloud S5852 remediation started in 3.8.37.
- **Test env leak in `file-utils.test.ts`** — `PILENS_DATA_DIR` is now saved and restored in a `finally` block so it doesn't bleed into subsequent tests in the suite.

## [3.8.37] - 2026-05-02

### Fixed

- **ReDoS: 3 compiler output parsers in `/lens-booboo`** — `csRe` trailing optional group `(?:\s+\[[^\]]+\])?` dropped (message capture already stops at `[`); `gleamRe` narrowed `[^:]+` → `[^:\n]+` to prevent cross-line backtracking; `zigRe` replaced `(.+)$` with `([^\n]+)` and dropped the redundant end anchor. All three flagged by SonarCloud S5852.

## [3.8.36] - 2026-05-02

### Changed

- **`agent_end` deferred format notification now lists filenames** — the notification now reads `pi-lens deferred format applied to N file(s): foo.ts, bar.ts` instead of just the count, making it immediately clear which files were reformatted without needing to check logs.

### Added

- **Deferred formatting by default** — files touched by `write` and `edit` are now queued and formatted once at `agent_end` instead of immediately after each edit. This prevents mid-task formatting mutations from invalidating read-guard context and interrupting multi-edit flows. Formatting still runs in real time when `--immediate-format` is passed.
- **`agent_end` lifecycle handler** — new `clients/runtime-agent-end.ts` drains the deferred format queue at the end of each agent turn, runs the formatter once per file, syncs formatted content to LSP, and emits a concise notification.
- **`--immediate-format` flag** — opt-in flag to restore the legacy per-edit formatting behavior.
- **`/lens-health` session timestamp** — output now opens with `Session started: HH:MM (Xh Ym ago)` so all session-scoped counters have clear time context.
- **`/lens-health` LSP status section** — shows each currently running language server with a `✓`/`✗` connected indicator and workspace root. Makes dead servers immediately visible to the agent without needing to check logs. Also fixes `LSPService.getStatus()` which previously hardcoded `connected: true` instead of calling `isAlive()`.
- **`/lens-health` cascade summary** — shows session-total cascade runs, diagnostics surfaced, and cold-snapshot touches (the new active-touch fallback for TypeScript neighbors with no snapshot).
- **`/lens-health` i18n** — localizes status labels with English fallback; es, fr, and pt-BR strings included (PR #45 by @jerryfan).
- **`/lens-booboo` language gates** — Knip (dead code), Madge (circular deps), and type coverage now skip on non-JS/TS projects. Compiler checks extended with Java (mvn/gradle), C# (dotnet build), Dart, Gleam, Zig, and Elixir alongside the existing TypeScript, Go, Rust, Ruby, and Python checks.
- **`project-metadata` detects 8 new languages** — Java, Kotlin, C#, Dart, Gleam, Zig, Elixir, and C++ are now detected from their project markers (pom.xml, build.gradle.kts, \*.sln, pubspec.yaml, gleam.toml, build.zig, mix.exs, CMakeLists.txt). All runners and booboo language gates now work correctly for these languages.
- **4 new formatters** — `google-java-format` (config-gated via `.editorconfig` or `.google-java-format`), `cljfmt` (config-gated via `.cljfmt.edn`), `cmake-format` (config-gated via `.cmake-format`), and `PSScriptAnalyzer` formatter for PowerShell (smart-default when PSScriptAnalyzer module is available).
- **Startup pre-install defaults for shell, Ruby, Kotlin, TOML** — `shellcheck`, `rubocop`, `ktlint`, and `taplo` are now pre-installed fire-and-forget at session start for matching projects, consistent with the existing pattern for `typescript-language-server`, `biome`, `pyright`, `ruff`, `yamllint`, and `sqlfluff`. No latency impact — all installs are fire-and-forget and no-ops when already cached.

### Fixed

- **Installer race condition** — coalesced the entire `ensureTool()` operation (not just the install phase) to prevent duplicate concurrent "auto-install ensure X: start" probes when multiple tools race to resolve the same binary.
- **Read-expansion union bug** — tree-sitter read expansion now returns the union of the requested range and the enclosing symbol range, instead of silently dropping originally requested prefix/suffix lines. Fixes false "Edit outside read range" blocks when an agent reads a partial range inside a large symbol.
- **Startup probe deduplication** — removed broad eager probes for biome, ast-grep, ruff, knip, jscpd, and madge at session start. Replaced with `scheduleDeferredToolProbes()` which only probes tools not already covered by preinstall or startup scans, scoped to the project's actual language profile.
- **ReDoS-safe compiler output parsers in `/lens-booboo`** — five regex patterns in the compiler checks (Maven, Gradle, .NET, Gleam, Elixir) flagged by SonarCloud as vulnerable to super-linear backtracking (S5852). Fixed: `mvnRe` and `gradleRe` replaced greedy `(.+)$` with `([^\n]+)` and dropped the end anchor; `csRe` replaced lazy `([^[]+?)` with greedy `([^[]+)`; `gleamRe` replaced `(.+?)` with `([^:]+)`; `elixirRe` replaced the multiline regex entirely with a line-by-line parser to eliminate the flagged pattern.
- **Cascade diagnostics now surface for TypeScript neighbors on cold sessions** — previously cascade silently returned zero diagnostics for TypeScript/Deno neighbors when no passive snapshot existed (i.e. the agent had not yet opened the file). Cold-snapshot neighbors now fall through into the parallel `touchFile` pool with a 1000ms budget (tighter than the 2000ms used for non-jsts neighbors, since the TypeScript server is expected to be warm). Valid snapshots still use the fast read path with no touch. New `coldSnapshot: true` field on `neighbor_touch` log entries tracks these in `cascade.log`.

### Improved

- **`ast-grep` skill clarifies string literal behaviour** — exact string literals in patterns (e.g. `from "./utils"`) work correctly; only metavariables inside string literals (e.g. `from "$PATH"`) are not supported and should use grep instead. Previously the skill incorrectly implied import path matching was unsupported entirely, causing unnecessary grep fallbacks.

## [3.8.35] - 2026-05-02

### Fixed

- **Startup hang for all users fixed (issue #46)** — `igniteWarmFiles` was previously `await`ed unconditionally on the session-start path, causing every session to pay the cost of a full directory walk looking for `lsp.json` (checking 3 config paths at every ancestor up to the filesystem root) before returning. This caused the 20–30s startup delay reported in 3.8.34 regardless of whether `warmFiles` was configured. The `loadLSPConfig` call now runs with `await` at the call site; if `warmFiles` is absent or empty, `igniteWarmFiles` is skipped entirely. When warm files are configured, the per-file LSP `touchFile` loop runs fire-and-forget so it never blocks session completion.

## [3.8.34] - 2026-05-01

### Added

- **LSP config `warmFiles` option** — added `warmFiles` to the LSP config schema. Accepts an array of relative or absolute file paths that pi-lens opens at full session startup to seed language servers that perform lazy translation-unit indexing (e.g. clangd). Without this, a short-lived `workspaceSymbol` query may return empty results for symbols in TUs clangd has not yet built an AST for, and background indexing timing is unreliable at LLVM scale. Specify entry-point files that transitively cover most of the project. The feature is general — any LSP that indexes lazily benefits.
- **TypeScript tsconfig split into build and lint configs** — `tsconfig.build.json` now drives `npm run build` (emits, excludes tests), while `tsconfig.json` drives `npm run lint` (no-emit, includes tests, `allowImportingTsExtensions`, `noUnusedLocals`, `noUnusedParameters`). CI lint step consolidated to `npm run lint`. Surfaced and fixed several latent type errors: unused imports removed, `error: null → undefined` alignment, `_ctx` unused-param rename, `void resolveSlowWait` for intentional float.
- **`GITHUB_TOOLS` const array and `GitHubToolId` type exported from installer** — the set of tools resolved via GitHub releases is now an exported `as const` array with a derived type, eliminating the duplicate definition that previously lived only in the test file.
- **`startupFailureWindowMs` option on `launchLSP`** — callers can now override the startup-failure detection window per-launch instead of relying solely on the Windows/non-Windows heuristic. Used by the LSP lifecycle test to avoid the full `WINDOWS_NAV_STARTUP_FAILURE_WINDOW_MS` delay in CI.
- **Test log pollution fix for read-guard** — `read-guard.test.ts` now mocks `read-guard-logger` unconditionally, so test events never reach `~/.pi-lens/read-guard.log` regardless of how the test suite is invoked.
- **Tab/space indentation mismatch correction in the edit hook** — some models output spaces in `oldText` when the file uses tabs (or vice versa), causing edits to fail with a cryptic "not found" error. The `tool_call` hook now detects this before execution by trying tabs↔2-spaces and tabs↔4-spaces conversions against the actual file. On mismatch it blocks with a `🔄 RETRYABLE` message containing the corrected `oldText` verbatim, so the model retries successfully on the next attempt at zero cost when `oldText` already matches.
- **Global project-data storage is now the default for new projects** — project-scoped pi-lens artifacts (turn state, worklog, metrics history, index, install choices, runner scratch data) now default to `~/.pi-lens/projects/<project-slug>/` instead of creating `<project>/.pi-lens/`. Existing projects that already have `<project>/.pi-lens/` continue to reuse it unless `PILENS_DATA_DIR` is explicitly set. This closes issue #40 while preserving backward compatibility.
- **`PILENS_DATA_DIR` and `PI_LENS_STARTUP_MODE` documented in README** — both env vars are now listed under a dedicated *Environment Variables* section between `## Run` and `## Key Commands`.
- **Tree-sitter read expansion for the read-before-edit guard** — partial reads (requested `limit ≤ 60` lines) are now automatically expanded to cover the full enclosing function, method, or class using the tree-sitter AST. The agent receives the full symbol as context, and the read guard records symbol-level coverage so edits anywhere within the symbol pass without requiring the agent to have read every line. Supports TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, and Ruby. Runs within a 200 ms budget; falls back silently on parse failure or unsupported extension. Replaces the dead LSP-based expansion (which required `limit = 1` and a warm server — zero production hits).
- **`read_pattern` structured log on every read** — `~/.pi-lens/read-guard.log` now records a `read_pattern` JSONL event for each read tool call: `offset`, `limit`, `totalLines`, `fractionRead`, `isPartial`, `fileKind`, and `expandedByTs`. Enables analysis of actual agent read behaviour across sessions.
- **`prettier.config.ts` and `eslint.config.ts` added to config detection arrays** — both config filenames are now recognised by `hasPrettierConfig` and `hasEslintConfig` respectively. Previously only `.js`/`.cjs`/`.mjs` variants were listed, so TypeScript-based configs were silently ignored.
- **Walk-up boundary stops at nearest `package.json`** — all 8 config-detection walk-up functions (`hasEslintConfig`, `getBiomeConfigPath`, `hasOxlintConfig`, `hasMypyConfig`, `hasDetektConfig`, `hasBlackConfig`, `hasRuffConfig`, `hasPrettierConfig`) now stop ascending once they reach the directory containing the nearest `package.json` instead of walking all the way to the filesystem root. This prevents cross-project config bleed in monorepos where an unrelated project higher up the tree happens to have a config file. A shared `walkUpDirsUntilPackageJson` helper encapsulates the boundary logic.
- **Formatter and linter selection logged to `latency.log`** — `getFormattersForFile` now emits a `formatter_selected` phase entry recording the chosen formatter name, selection reason (`explicit-config`, `smart-default`, `detect`, or `none`), and `cwd`. `getLinterPolicyForCwd` emits a `linter_selected` phase entry recording the chosen runner, gate, `cwd`, and the full detection-context flags. Both events are skipped in test mode.

### Fixed

- **Config detection walks up the directory tree for all competing tools** — `hasEslintConfig`, `hasBiomeConfig` / `getBiomeConfigPath`, `hasOxlintConfig`, `hasMypyConfig`, `hasDetektConfig`, `hasBlackConfig`, and `hasRuffConfig` now all walk up to the filesystem root (matching the `findNearestPackageJsonPath` pattern) instead of only checking `cwd`. In monorepos where pi-lens passes a subdirectory as `cwd`, configs at the project root are now found correctly. Prevents wrong smart-default selection (e.g. oxlint firing instead of eslint, ruff firing instead of black) and restores optional runners (mypy, detekt) that were silently dropped when their configs lived above `cwd`. Functions with no competing smart-default (stylelint, sqlfluff, rubocop, golangci-lint, etc.) are unchanged.
- **Biome smart-default no longer overrides explicit Prettier config** — `getFormattersForFile` now only activates the Biome smart-default when no candidate formatter has explicit project config. Previously, a project with `.prettierrc` but no `biome.json` would still have Biome auto-installed and selected. `hasPrettierConfig` also now walks up the directory tree (matching the `findUp` pattern used elsewhere) so a Prettier config in a parent directory is detected even when pi-lens passes a subdirectory as `cwd`. The inline `package.json#prettier` field check uses `Object.prototype.hasOwnProperty` instead of truthiness, correctly handling `"prettier": false` and `"prettier": null`.
- **Duplicate `oldText` in edit calls now blocked early** — the read guard pre-flight check (`resolveOldTextEdits`) returns a `🔴 BLOCKED` error before the edit tool executes when `oldText` matches more than one location in the file, with per-match line numbers so the model can tighten its context.
- **Read-guard `oldText` inference hardened** — unresolved `oldText` targets no longer degrade into permissive `no_line_info` allows. Missing matches now return a blocking preflight error, partial multi-edit resolution blocks the whole edit, and indentation-correctable `oldText` is recognized during touched-line derivation as well as in the retryable pipeline guard.
- **Cascade diagnostics unified through review graph + LSP touch flow** — cascade results now accumulate as structured `CascadeResult` values across the turn, merge/deduplicate by dependent file at turn end, use review-graph references for broader neighbor discovery, respect TypeScript/Deno auto-propagation capabilities, and fall back to passive LSP snapshots when no trustworthy neighbor LSP data is produced.
- **Cascade LSP diagnostics now use shared conversion/tracking** — cascade diagnostics are converted through the shared LSP→dispatch diagnostic utility, participate in `DiagnosticTracker`, use separate cascade delta baselines (`session.baseline.cascade.*`), and share centralized cascade formatting.
- **`touchFile({ collectDiagnostics: true })`** — LSP touch can now return merged diagnostics from the clients it opened/synced, allowing cascade to collect diagnostics from the same silently touched clients without a second aggregate `getDiagnostics()` call.
- **Review graph workspace cache** — cascade graph builds now reuse the parsed review graph across pipeline invocations when source file mtimes/sizes are unchanged, while still applying per-write changed-symbol state. Cascade logs now record whether the graph was reused and the build mode.
- **`PILENS_DATA_DIR` env var for external project data storage** — when set, all project-generated data (caches, index, worklog, LSP install choices, elixir outputs, metrics history) is written to `$PILENS_DATA_DIR/<project-slug>/`. Slug is derived from the project's absolute path using the existing cross-platform `normalizeFilePath` utility.

### Fixed

- **Cascade silent LSP opens no longer broadcast file-watch changes** — cascade neighbor reads now open documents with `silent: true`, suppressing `workspace/didChangeWatchedFiles` so TypeScript/Python servers do not schedule project-wide rechecks for every dependent file touched.
- **Cascade cache/fallback correctness** — per-turn cascade caches are scoped by turn/write sequence, empty cascade results are suppressed, no-LSP neighbors are treated as no signal, and degraded fallback now triggers when no neighbor produced LSP data rather than only when the graph returned zero neighbors.
- **LSP touch `no_clients` latency diagnostics** — `lsp_touch_file` no-client records now include attempted server count, source, and wait budget so slow no-client outcomes can be distinguished from unsupported-file fast paths.
- **Misleading LSP error when `filePath` is a directory** — `lsp_navigation` now stat-checks the resolved path before server lookup. Passing a directory (e.g. `.`) to `workspaceDiagnostics` falls through to workspace-scoped mode; file-scoped operations return a clear `filepath_is_directory` error instead of the previous "No LSP server available … Check that the language server is installed" message, which incorrectly implied an install problem.
- **LSP `didChangeWatchedFiles` sends correct change type** — `handleNotifyOpen` now uses `type: 2` (Changed) for existing files instead of unconditionally sending `type: 1` (Created). File-watching LSPs no longer treat every open as a newly created file, which could invalidate caches differently than intended.
- **`getAllDiagnostics()` deduplicates across multiple LSP clients** — when TypeScript + ESLint both report an error on the same line, the fallback/snapshot path now merges and deduplicates instead of showing both. Prevents duplicates from pushing out unique diagnostics under the `MAX_PER_FILE` cap.
- **`formatImpactCascade` respects configurable `cascadeMaxFiles`** — removed hardcoded `MAX_FILES = 4` in `format.ts`; the display cap now matches `RUNTIME_CONFIG.pipeline.cascadeMaxFiles` (default 8), so the impact header and truncation hint are consistent with actual analysis.
- **Turn-end cascade merge preserves impact context** — previously `runtime-turn.ts` rebuilt output from raw `neighbors`, discarding impact headers, changed symbols, risk flags, and truncation hints. It now uses the pre-built `CascadeResult.formatted` field (deduplicated by primary file), so the agent sees causal context ("Changed symbols: X", "Direct importers: Y", "Risk: Z") alongside diagnostics.
- **Neighbor touch cache is turn-scoped** — `neighborTouchCache` previously invalidated on every `writeIndex` bump, so reading a file then editing it would re-touch the same neighbor. The cache now keys on `turnSeq` only, so neighbors are touched once per turn regardless of how many files are edited.
- **Dead opportunistic LSP read expansion removed** — the `findSymbolAtLine` / `withTimeout` / `LSP_READ_EXPANSION_BUDGET_MS` code path was never triggered in production (zero `lsp_range_expanded` events outside tests) and added complexity/latency to every read tool call. Removed entirely. Read guard records now use `peekWriteIndex()` instead of `nextWriteIndex()`, fixing the cascade cache invalidation bug where reads incremented the write counter.
- **Test-mode guards for all loggers** — every logger that writes to `~/.pi-lens/` now skips disk I/O when `PI_LENS_TEST_MODE === "1"` or when running under `VITEST` (unless explicitly opted out with `PI_LENS_TEST_MODE=0`). Eliminates test pollution in `cascade.log`, `read-guard.log`, `latency.log`, `sessionstart.log`, `tree-sitter.log`, and diagnostic JSONL. The `dbg()` function already had this guard; it is now applied consistently across `logCascade`, `logReadGuardEvent`, `logLatency`, `logTreeSitter`, `logSessionStart`, and `DiagnosticLogger.log`.
- **`read-guard.log` included in automatic cleanup** — `runLogCleanup()` now covers `read-guard.log` alongside the existing `sessionstart.log`, `tree-sitter.log`, and `cascade.log`.

- **oxfmt `.oxfmtrc.json` detection** — `hasOxfmtConfig` now treats `.oxfmtrc.json` as an activation signal alongside `oxfmt.toml` and `@oxc-project/oxfmt` in package.json.

## [3.8.33] - 2026-04-27

### Fixed

- **JSON/JSONC autofix skipped without biome config** — `getAutofixPolicyForFile` now returns `undefined` for `.json`/`.jsonc` files when no `biome.json`/`biome.jsonc` is present, matching the format policy's `defaultWhenUnconfigured: false` gate. Previously biome was always invoked for JSON edits (~688ms) even when it had no config and fixed nothing. `hasBiomeConfig` added to `AutofixPolicyContext` and wired into the autofix context in `runAutofix`.

### Added

- **Early-unblock diagnostic aggregation** — `getDiagnostics()` now races `Promise.all` against a first-client-done + grace window (`PI_LENS_LSP_EARLY_UNBLOCK_GRACE_MS`, default 400ms). Once the fastest client delivers results, remaining clients have the grace window before the call returns with whatever is ready. Eliminates the previous worst case where a slow push-only server forced the full 1500ms aggregate wait even when a faster server already had errors. `earlyUnblockedCount` is logged in `lsp_diagnostics_aggregate` latency records.
- **Dynamic LSP capability registration tracking** — `client/registerCapability` and `client/unregisterCapability` handlers now record live registrations (`id → method`) in `dynamicRegistrations`. `applyDynamicCapabilities()` upgrades `workspaceDiagnosticsSupport` to pull mode when `textDocument/diagnostic` or `workspace/diagnostic` is dynamically registered, and reverts when the last such registration is removed (unless statically advertised). Operation support flags are also upgraded for dynamically-registered nav methods. Servers that defer capability advertisement past `initialize` are now treated correctly.
- **Deno/TypeScript server disambiguation** — `TypeScriptServer.root` now returns `undefined` for any file with a `deno.json` or `deno.jsonc` ancestor, preventing TypeScript LSP from being spawned alongside Deno LSP for the same file. Eliminates false diagnostics for Deno-specific APIs and removes the wasted parallel spawn.
- **`CONDA_PREFIX` support in Python venv detection** — conda environments do not set `VIRTUAL_ENV`; venv detection now checks `CONDA_PREFIX` as a fallback between `VIRTUAL_ENV` and the local `.venv`/`venv` directories.
- **pylsp venv initialization** — `PythonPylspServer.spawn` now passes `{ pylsp: { plugins: { jedi: { environment: pythonPath } } } }` when a virtual environment is detected. Previously pylsp always used the system Python, so completions and diagnostics resolved against the wrong package set in virtualenv projects.

### Changed

- **Push/pull LSP diagnostic caches split** — `LSPClientState` now maintains separate `pushDiagnostics` and `documentPullDiagnostics` maps with independent timestamps. Public API (`getDiagnostics`, `getAllDiagnostics`, `pruneDiagnostics`) operates on a merged, deduplicated view. Clears and prunes invalidate both sources independently. Makes diagnostic freshness and source attribution inspectable without changing caller behavior.
- **Explicit LSP touch diagnostics modes** — `touchFile()` now takes `{ diagnostics: "none" | "document" | "full", clientScope: "primary" | "all", source, maxClientWaitMs }` instead of a boolean `waitForDiagnostics` flag. Read/tool-call warming uses `"none"`; write validation uses `"document"`. Latency records include `diagnosticsMode`, `clientScope`, and `source`.
- **Pipeline reordered around final content** — format → refresh → autofix → refresh → LSP sync once with final content → dispatch. LSP diagnostics and dispatch runners now always operate on the final post-format/post-fix on-disk state. Removed previously-dead `supportsAutofix` / deferred sync logic.
- **Python venv detection deduplicated** — `PythonServer.spawn` previously ran identical 20-line venv detection blocks in both the direct and managed code paths. Both now call the shared `detectPythonVenv(root)` helper.

### Fixed

- **Formatter failures now visible in output** — formatter crashes (missing binary, timeout, I/O error) now append `⚠️ Auto-format failed: <reason>` to pipeline output instead of silently writing to debug logs. Prevents misleading all-clear output when a required format phase failed.
- **Same-file same-turn pipeline dedupe keyed on content hash** — previously any later pipeline for a file already reported in the same turn was skipped by file path alone, suppressing legitimate second edits. Dedupe is now keyed on post-write content hash: concurrent duplicate events for the same final content are collapsed, but a later edit with changed content runs the full pipeline again.
- **Autofix side-effect files tracked in turn state** — `runAutofix()` now returns `changedFiles[]`. File-scoped fixers (ruff, biome, eslint, stylelint, sqlfluff, rubocop, ktlint) record the target file on a successful fix; project-wide fixers (cargo clippy --fix, dart fix --apply) snapshot the project tree before and after to detect side-effect changes. Non-target changed files are added to turn state via `cacheManager.addModifiedRange()` so cascade and read-guard see the full mutation set.

### Changed

- **Linter dispatch runners promoted to always-on for 11 languages** — runners that previously fired only when LSP failed (`mode: "fallback"`) now run alongside LSP unconditionally (`mode: "all"`): `pyright` (Python), `rust-clippy` (Rust), `go-vet` (Go), `shellcheck` (Shell), `tflint` (Terraform), `elixir-check` + `credo` (Elixir), `cpp-check` (C/C++), `dart-analyze` (Dart), `gleam-check` (Gleam), `psscriptanalyzer` (PowerShell), `prisma-validate` (Prisma). These tools provide orthogonal signal to the LSP that was previously invisible on healthy sessions.

### Added

- **Linter policy entries for 9 languages** — `getLinterPolicyForFile` now covers Rust (rust-clippy, smart-default), Shell (shellcheck, smart-default), Terraform (tflint, smart-default), Elixir (credo, smart-default), C/C++ (cpp-check, smart-default), Dart (dart-analyze, smart-default), Gleam (gleam-check, smart-default), PowerShell (psscriptanalyzer, smart-default), and Prisma (prisma-validate, smart-default). These linters now participate in the full policy layer rather than being dispatch-only.
- **`cargo clippy --fix` autofix for Rust** — `rust-clippy` is now a safe pipeline autofix tool for `.rs` files. After each edit, `cargo clippy --fix --allow-dirty --allow-staged` runs in the nearest `Cargo.toml` directory before dispatch lint, applying machine-fixable clippy suggestions. Gated `smart-default`; skips silently if `cargo` is unavailable or no `Cargo.toml` is found.
- **`dart fix --apply` autofix for Dart** — `dart-analyze` is now a safe pipeline autofix tool for `.dart` files. After each edit, `dart fix --apply` runs in the nearest `pubspec.yaml` directory before dispatch lint. Gated `smart-default`; skips silently if `dart` is unavailable or no `pubspec.yaml` is found.

### Fixed

- **Unknown/support files no longer trigger opportunistic LSP auto-touch** — `tool_call` LSP warming now defaults unknown file kinds to non-LSP-capable and explicitly skips internal/support artifacts such as `.pi-lens/*`, `.harness/*`, `stdout.jsonl`, `stderr.txt`, `prompt.txt`, and harness `case.json` files. This removes pointless `lsp_touch_file` `no_clients` waits on logs, prompts, and turn-state sidecars.
- **Spawn-heavy LSP capability checks removed from hot paths** — added a pure `supportsLSP(filePath)` check and a lightweight `hasWarmLSP(filePath)` helper so hot write/read paths no longer use `hasLSP()` merely to ask whether a file type is supported. `pipeline` sync/resync, the unified LSP runner, and `lsp_navigation` unsupported-file messaging now avoid accidental client spawns during simple capability checks.
- **`ktlint` autofix case missing `continue`** — the `ktlint` branch in `runAutofix` lacked a `continue` guard, causing fall-through into the next tool match on every ktlint run.

## [Unreleased — mypy + detekt]

### Added

- **`mypy` wired into Python dispatch** — runner already existed but was never included in the dispatch plan or linter policy. Added to Python `writeGroups` in `plan.ts` and to `getLinterPolicyForFile` for `.py`/`.pyi`. When `mypy.ini` or `[tool.mypy]` is present, mypy is appended to `preferredRunners` alongside ruff-lint (gate: `mixed`); unconfigured projects are unaffected.
- **`detekt` runner for Kotlin** — new runner (`detekt.ts`) that runs `detekt --input <file> --config <config>` for static analysis of `.kt`/`.kts` files. Config-first: activates only when `detekt.yml`, `.detekt.yml`, `config/detekt/detekt.yml`, or `detekt/detekt.yml` is found. Added `hasDetektConfig` helper, `"detekt"` to `LintRunnerName`, `hasDetektConfig` to `LinterPolicyContext`, and detekt to Kotlin's linter policy (appended to `preferredRunners` alongside ktlint when configured). Kotlin `plan.ts` `writeGroups` updated to include detekt.

## [3.8.32] - 2026-04-26

### Fixed

- **`lspExpansionsHelped` counter undercounted in `/lens-health`** — `getSummary` used `reads.find(r => r.timestamp <= record.precedingReads[0]?.timestamp)` which always selected the first ever read for the file, so only sessions where the very first read used LSP expansion were counted. Fixed to `record.precedingReads.some(r => r.expandedByLsp)`, correctly checking all reads that preceded the specific edit.
- **`preserveDiagnostics` incorrectly set when autofix also ran** — when a formatter and an autofix tool both modified a file, the LSP resync was still called with `preserveDiagnostics: true` because `formatChanged` was set, even though autofix changes can affect code semantics. Fixed by gating on `formatChanged && fixedCount === 0`, ensuring semantics-changing autofix always triggers a fresh diagnostics cycle.
- **Empty-result message for `workspaceSymbol` had dangling "at"** — `"No results for workspaceSymbol at "` was produced when no `filePath` was given (workspace-scoped query with no file). Fixed by guarding the `" at <filename>"` segment on `filePath` being non-empty.

### Fixed

- **TypeScript LSP 5-second pipeline stall on every edit to clean files** — after biome or another formatter rewrote a file, `resyncLspFile` called `lsp.openFile` which deleted the diagnostics cache and sent `textDocument/didChange`. `waitForDiagnostics` then waited the full 5000ms timeout for TypeScript to re-publish what it already knew (formatting doesn't change semantics, so the error set is identical). Added `preserveDiagnostics` option to `openFile`/`handleNotifyOpen`: format-only resyncs no longer clear the cache, so `waitForDiagnostics` fast-paths immediately. For pi-free provider files this cuts per-edit pipeline time from ~12s to ~3-4s.
- **`ktlint` formatter silently inactive when installed by the linter runner** — `ktlint` is both a smart-default formatter (`.kt`/`.kts`) and a smart-default linter with a managed GitHub-release install. The formatter's `detect()` used only `which("ktlint")`, never `getToolPath("ktlint")`, and the formatter was absent from `AUTO_INSTALLABLE_DEFAULT_FORMATTERS`. When the linter runner auto-installed `ktlint` to `~/.pi-lens/bin/`, the formatter was blind to it — Kotlin files got linted but never formatted. Fixed by adding `ktlint` to `AUTO_INSTALLABLE_DEFAULT_FORMATTERS`, adding `resolveCommand` that calls `ensureTool`, and making `detect` check `getToolPath` as fallback.
- **Subagent process hangs indefinitely after completing work (issue #22)** — `scheduleLSPIdleReset` created a 240-second `setTimeout` without `.unref()`. Every `turn_end` with no file edits scheduled this timer, keeping the Node.js event loop alive for 4 full minutes. pi-subagents killed the child at the 5-second drain deadline and reported `exit code 1` / SIGTERM even though all work completed successfully. Confirmed: `--no-lsp` exited cleanly because the timer is gated on LSP being enabled. Fixed by calling `.unref()` on the timer (lets the process exit naturally if there is no other pending work) and by registering a `session_shutdown` handler that cancels the timer explicitly and calls `resetLSPService()`.
- **Read-guard false-blocks multi-chunk reads** — `checkCoverage` checked each `ReadRecord` independently, so reading a 200-line file as two 100-line chunks and then Writing it was falsely blocked because neither chunk alone covered `[1, 200]`. Fixed by adding a second-pass union-merge of all read intervals: overlapping/adjacent ranges are merged in sorted order, and coverage is satisfied if any merged interval contains the edit range.
- **`requestedLimit` field recorded as `effectiveReadLimit` instead of the agent's actual requested limit** — `ReadRecord.requestedLimit` was always the computed effective limit, not what the agent asked for. Fixed to record the raw requested limit (falling back to effective when not provided).
- **Read-guard blocks legitimate full-file writes** — `write` tool calls were assigned the range `[1, Number.MAX_SAFE_INTEGER]`, which can never be covered by any prior read, so every full-file write on an existing file was incorrectly blocked with "Edit outside read range … lines 1–9007199254740991". Fixed by passing the file path into `getTouchedLinesForGuard` and using the actual on-disk line count (`countFileLines`) as the end of the write range. An agent that read all N lines of a file can now rewrite it without a false block.
- **Read-guard false-blocks text replacement edits without explicit line ranges** — `edit` calls using `oldText` / `newText` matching but no `range` metadata were previously inferred as touching line `1`, producing bogus `"🔴 BLOCKED — Edit outside read range"` failures even when the agent had read the correct target region. Fixed touched-line inference so range-less replacement edits return `undefined` instead of defaulting to `1-1`, avoiding fabricated line-1 violations.
- **`NEEDS_POSTINSTALL` broken for scoped npm packages** — `@biomejs/biome`, `@ast-grep/cli`, and `@ast-grep/napi` were incorrectly checked with `packageName.split("@")[0]` which always yields `""` for scoped packages; the nullish-coalescing fallback never fired. These packages always received `--ignore-scripts`, preventing native binary postinstall scripts from running and silently breaking their auto-installation. Fixed by checking the full package name directly.
- **Silent formatter failures in pipeline** — when a formatter crashed (binary missing, timeout, or I/O error) the post-write pipeline never emitted a debug log; only `anyChanged` triggered output. Formatter errors are now surfaced via `dbg()` so they appear in debug/latency logs.
- **`tryLazyInstallFormatterTool` failures logged** — lazy `gem install rubocop` and `rustup component add rustfmt` failures were silently swallowed with no log output anywhere. Both now emit a `[format] lazy-install <tool> failed: <reason>` message to stderr.
- **`getFormattersByName` broken for hyphenated formatter names** — constructing the export key as `` `${name}Formatter` `` produced `"php-cs-fixerFormatter"` and `"clang-formatFormatter"` instead of the real camelCase exports (`phpCsFixerFormatter`, `clangFormatFormatter`). These formatters were silently filtered out when selected by name via the explicit `options.formatters` API. Fixed by converting hyphenated names to camelCase before appending `Formatter`.
- **Read-before-edit guard correctness** — fixed `read.path` vs `read.filePath` mismatch, full-file read coverage tracking, read-guard range math, session reset leakage, and guard messaging so edit enforcement now correctly reflects actual reads
- **First-read LSP warmup behavior** — first `read` now triggers non-blocking async LSP warmup once per file/session window, with retry-safe state tracking and reset handling
- **Formatter selection bugs and drift** — formatter chooser now reliably selects exactly one formatter, no longer lets registry order accidentally block smart defaults, and keeps explicit config precedence over defaults
- **Ruby auto-install policy mismatch** — `rubocop` policy and installer behavior are now aligned through managed gem install support
- **Prettier dispatch redundancy** — removed `prettier-check` from the active dispatch path to avoid re-checking formatting after the authoritative autoformat pipeline has already run
- **LSP race condition in `initLSPConfig`** — `configInFlight` Map deduplicates concurrent initialization calls for the same workspace; parallel session starts no longer double-initialize and race on `workspaceConfigs`
- **`lsp_navigation` rejected accidentally quoted `operation` values at schema-validation time** — the tool previously declared `operation` as a `Type.Union` of string literals, so model outputs like `"workspaceDiagnostics"` were rejected before `execute()` ran, causing confusing retry loops with no recovery path. The tool now accepts a string, normalizes accidental surrounding quotes, validates against the allowed operation set inside `execute()`, and returns a clear error listing valid operations when the value is still invalid.
- **`LSPService` use-after-shutdown** — `isDestroyed` flag added; all public methods (`getClientForFile`, `openFile`, `updateFile`, `waitForDiagnostics`, `getDiagnostics`, `shutdown`) return early once the service has been shut down
- **`theme.fg` crash during session start** — `updateLspStatus` wraps theme calls in try/catch; theme may not be fully initialized during early session startup events
- **`isCommandAvailable` hangs on slow tools** — added 5s timeout with `proc.kill()` and a double-resolve guard; probe commands that stall no longer block session startup indefinitely
- **Tree-sitter `client_unavailable` log spam** — `TreeSitterClient.isAvailable()` now re-evaluates `grammarsDir` when the cached path goes missing, instead of caching an empty string forever. Added `resolveWebTreeSitterAsset()` helper with three strategies: (1) `createRequire` module resolution (hoisted installs — issue #20), (2) `resolvePackagePath(import.meta.url)` fallback (on-the-fly TS compilation by pi), (3) `process.cwd()` fallback. Fixes 108 skipped-runner log lines when the initial grammar probe failed transiently.
- **Pipeline test assertion drift** — updated `tests/clients/pipeline.test.ts` to match the current auto-format warning text (`File was modified by auto-format/fix...`)

### Added

- **Autofix decision/attempt logging** — the post-write pipeline now logs autofix policy selection, preferred tools, attempted tools, explicit skip reasons, and the important distinction between “autofix skipped” vs “autofix ran but applied 0 fixes.” This makes it much easier to understand whether TypeScript files chose Biome or ESLint autofix and why.
- **Dedicated read-guard trace log** — added `~/.pi-lens/read-guard.log` with structured events for read recording, LSP range expansion, touched-line derivation, edit checks, verdicts, and exemptions. This separates guard-policy debugging from the noisier general `latency.log` stream.
- **Centralized formatter policy layer** — added normalized per-extension formatter policy with explicit config detection, smart-default selection, and managed-vs-toolchain default handling
- **Centralized command spec / execution policy layer** — added shared tool command specs, execution policy, and resolver helpers used by dispatch runners and autofix paths
- **Centralized linter policy layer** — added policy selectors for dispatch lint runner choice so config-first and smart-default lint behavior is now encoded centrally instead of only in individual runners
- **Centralized autofix policy and capability metadata** — added policy selectors for safe pipeline autofix plus explicit capability metadata separating tool-level fix support from safe automatic post-write autofix
- **Expanded smart-default formatter coverage** — added smart defaults across web/content formats and additional language ecosystems, including managed smart-default support for `prettier`, `shfmt`, and `taplo`
- **LSP footer status indicator** — session start and turn end now show `LSP Active (N)` in green or `LSP Inactive` in red; count reflects alive (connected + initialized) clients via `getAliveClientCount()`
- **Rust monorepo workspace root detection** — `RustServer` walks up from the detected crate root checking parent `Cargo.toml` files for a `[workspace]` section; rust-analyzer now resolves correctly in Cargo workspaces
- **Opportunistic LSP read range expansion** — single-line `read` tool calls are silently expanded to the full enclosing symbol when a warm LSP client is available; best-effort, no-op if LSP is cold or the lookup doesn't resolve in time
- **`workspaceSymbol` result filtering and cap** — `lsp_navigation` now filters and caps workspace symbol results at 15 entries to avoid overwhelming the context window

### Performance

- **LSP pre-edit touch bounded and file-kind gated** — `edit` / `write` tool calls now skip opportunistic LSP pre-touch for non-LSP-capable files (for example Markdown) and cap the warm-client wait with `PI_LENS_TOOLCALL_TOUCH_MS` (default `750ms`). This avoids pointless `no_clients` touch attempts and reduces edit-path stalls.
- **Empty aggregate diagnostic waits shortened** — aggregate LSP diagnostics no longer wait the old hardcoded multi-second timeout just to confirm an empty result set. New settle/wait budgets (`PI_LENS_LSP_DIAGNOSTICS_AGGREGATE_WAIT_MS`, `PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS`, `PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS`) make clean-edit loops return faster.
- **Tool path resolution fast path** — `getToolPath` checks the local managed install (`~/.pi-lens/tools/node_modules/.bin/`) before global PATH probes, npm/pip/GitHub lookups; eliminates 2–5s overhead per tool on session start
- **`jscpd` availability fast path** — `ensureAvailable()` probes the local install with `fs.existsSync` before spawning a process, and deduplicates concurrent calls via `ensureInFlight`
- **Concurrent project indexing** — `buildProjectIndex` processes files in batches of 8 with `Promise.all` instead of sequentially; large projects index significantly faster
- **`buildFunctionMatrixFromNode` avoids re-parse** — walks the existing TypeScript AST directly instead of extracting function source text and creating a new `SourceFile`; removes per-function re-parse overhead from similarity indexing

### Removed

- **`prettier-check` runner fully removed** — the dead `clients/dispatch/runners/prettier-check.ts` file is now deleted entirely after its earlier removal from active dispatch plans; formatting remains owned by the autoformat pipeline instead of dispatch re-checks
- **Worthless `diagnostic-logger` tests** — deleted `tests/clients/diagnostic-logger.test.ts` (5 tests that only asserted mock objects equaled what was just assigned; zero behavior coverage)
- **Redundant circular-dependency regression tests** — removed 3 no-op import tests from `tests/clients/circular-deps-regression.test.ts` (`expect(module).toBeDefined()` after `await import(...)` adds no value; import failure throws before the assertion)

### Changed

- **Normal dispatch no longer runs `similarity` by default** — removed `similarity` from standard JS/TS write and full lint dispatch plans so targeted edits no longer pay its hot-path cost; similarity analysis remains available in explicit workflows like `/lens-booboo` and inline advisory logic.
- **Cascade diagnostics prune stale cache entries earlier** — LSP diagnostic merging now drops TTL-expired and non-existent file entries before cascade aggregation, reducing stale-path noise and improving cache hygiene during long sessions.
- **Autoformat policy normalized across supported languages** — formatter behavior is now: exactly one formatter runs, explicit config wins, otherwise smart default applies, and config-first file types do nothing when unconfigured
- **JS/TS lint fallback normalized** — no-config JavaScript/TypeScript dispatch now consistently prefers `oxlint` with `biome-check-json` fallback, while explicit ESLint/Oxlint/Biome config still wins
- **Safe autofix remains pipeline-owned** — autofix selection now flows through centralized policy and remains in the post-write pipeline, while dispatch runners stay diagnostics-only
- **Dispatch runner gating centralized** — major runners (`stylelint`, `yamllint`, `markdownlint`, `htmlhint`, `hadolint`, `sqlfluff`, `rubocop`, `ktlint`, `taplo`, `golangci-lint`, `phpstan`, `ruff`) now consult centralized lint policy before running
- **Kotlin safe autofix added** — `ktlint -F` is now treated as a safe pipeline autofix path for Kotlin files
- **Fixability semantics clarified** — dispatch diagnostics now distinguish generic fixability from safe pipeline autofix availability and expected fix mode (`pipeline`, `manual`, `suggestion`), including suggestion/manual-fix runners like LSP, TS-LSP, shellcheck, shfmt, spellcheck, tree-sitter, architect, and ast-grep-napi
- **Test runner moved to turn_end (non-blocking)** — previously fired inline on every write, blocking the pipeline for up to 60s mid-refactor and producing false failures while the codebase was in an inconsistent state. Tests now run once per turn after all edits complete: unique test targets are collected from modified files, fired concurrently as a fire-and-forget `Promise.allSettled`, and failures are written to cache for injection into the next turn's context. Results are discarded if the agent starts a new turn before tests finish, preventing stale failures from clobbering newer results.
- **Similarity runner skips small edits** — when `modifiedRanges` total lines is below `MIN_FUNCTION_LINES` (8), the similarity runner exits early; a new function can't fit in fewer lines than that, so the ~1100ms scan is wasted on targeted fixes
- **Stronger auto-format/fix re-read warning** — message now explicitly tells the agent it MUST re-read the file before any further edits, listing what may have changed (whitespace, indentation, quotes, code)
- **Turn-end findings cap tightened** — reduced `maxLines` from 24 → 20 and `maxChars` from 1600 → 1000 to stay conservative with context budget

### Tests

- **Read-guard touched-line regression tests** — added `tests/clients/read-guard-tool-lines.test.ts` covering full-file writes and range-less text replacement edits so read-guard line inference no longer regresses to bogus `1-1` edits.
- **Policy normalization regression coverage** — added and updated tests for read-guard fixes, runtime coordinator warm/reset behavior, formatter policy selection, command resolution, linter/autofix policy metadata, dispatch plan exposure, and runner status semantics across the formatter/linter/autofix normalization work
- **LSP integration tests** — added `tests/clients/lsp/integration.test.ts` with a fake JSON-RPC server (`tests/fixtures/fake-lsp-server.mjs`) covering LSP client lifecycle: initialize handshake, file open/change notifications, diagnostics, and graceful shutdown
- **Tree-sitter resolution regression tests** — added 3 tests to `tests/clients/tree-sitter-client-init.test.ts`:
  - `TreeSitterClient.isAvailable returns true when grammars are installed` (smoke test)
  - `falls back to resolvePackagePath when require.resolve fails` (on-the-fly compilation scenario)
  - `re-evaluates grammarsDir when isAvailable is called after initial miss` (prevents cached-empty-string bug)

## [3.8.31] - 2026-04-23

### Fixed

- **Duplicate inline feedback on edit arrays** — `tool_result` calls for the same file are now deduplicated within a turn using a `reportedThisTurn` set on `RuntimeCoordinator`, cleared on each `turn_start`; previously pi's sequential per-hunk `tool_result` firing caused the pipeline to re-run and feedback to repeat N times per edit array
- **Double latency logging on pipeline completion** — removed redundant `logLatency` call in `pipeline.ts`; `runtime-tool-result.ts` already logs the outer `tool_result completed` with full duration including format, autofix, and cascade phases
- **Modified range tracking broken for 3-digit+ line numbers** — `parseDiffRanges` regex changed from `\s+` to `\s*` to handle unpadded line numbers; the diff format right-pads to the file's max digit width so e.g. line 613 in a <1000-line file has no leading space and was silently dropped
- **Stale gleam grammar entries** — removed dead `LANGUAGE_TO_GRAMMAR` and `getExtensionsForLanguage` entries for gleam; `tree-sitter-gleam.wasm` was never published in `tree-sitter-wasms@0.1.13`

### Changed

- **TypeBox 0.34.x → 1.x migration** — updated `package.json` dependency from `@sinclair/typebox` to `typebox ^1.0.0` and updated imports in `tools/lsp-navigation.ts`, `tools/ast-grep-search.ts`, and `tools/ast-grep-replace.ts` to match pi-mono 0.69.0

## [3.8.30] - 2026-04-22

### Fixed

- **lsp_navigation permanently disabled** — removed stale `lens-lsp` flag check (flag was removed in 3.8.29) that caused every `lsp_navigation` call to short-circuit with `lsp_disabled`; tool now only gates on `--no-lsp`
- **ast_grep_search / ast_grep_replace auto-install** — switched availability check from sync `isAvailable()` to async `ensureAvailable()` so the auto-installer triggers when `sg` is missing
- **@ast-grep/cli postinstall skipped** — added `@ast-grep/cli` to `NEEDS_POSTINSTALL`; without it `--ignore-scripts` left ASCII stubs in place of `sg.exe` / `ast-grep.exe` on Windows
- **Windows .exe binary lookup** — `getToolPath` now also probes the `.exe` extension on Windows, covering packages (like `@ast-grep/cli`) that place a `.exe` directly without a `.cmd` wrapper
- **jscpd broken on Node 24** — pinned `jscpd` to `3.5.10`; v4 introduced a `reprism` dependency whose `lib/languages/` directory is absent from the published package
- **TypeScript LSP using home dir as workspace root** — wrapped `TypeScriptServer` and `ESLintServer` roots with `IgnoreHomeRoot` so a `package.json` / eslint config in `~` can no longer hijacks the workspace root; fallback is the file's own directory
- **CI npm publish runs without token** — gated `publish-npm` job and dry-run step on `NPM_TOKEN` secret being set
- **Stale compiled .js triggered test failures** — rebuilt project; `secrets-scanner.js` and `project-index.js` were from before the env-var-name false-positive fix and line-number capture fix respectively
- **ast_grep_search test mock** — updated test mock from `isAvailable` to `ensureAvailable` to match the new async availability check
- **Stale LSP diagnostics in cascade** — cascade diagnostics now skip entries older than 240s, preventing false positives from earlier test injections bleeding across turns
- **Biome check on Vue/Svelte** — biome-check-json was briefly skipped on `.vue`/`.svelte` but restored after confirming Biome 2.x has native support; the 3 blocking diagnostics were real lint findings, not parse errors
- **Vue/Svelte TypeScript SDK** — extracted `findTsserverPath` helper and wired it into `VueServer` and `SvelteServer` `initializationOptions` so Vue/Svelte LSP servers find the correct `typescript.tsdk`
- **Broken npm .cmd shims on Windows** — `launch.ts` now validates npm `.cmd` shims before spawning; if the target JS file doesn't exist the shim exits with code 1 after a 500ms startup window, pre-checking avoids the delay for all LSP servers on Windows
- **Tree-sitter WASM path in hoisted installs** — `tree-sitter-client.ts` now resolves `web-tree-sitter/tree-sitter.wasm` via `createRequire` so Node walks `node_modules` ancestors correctly; fixes `ENOENT` crash in pnpm/monorepo layouts where the wasm is not nested under pi-lens's own `node_modules`
- **Grammar directory lookups in hoisted installs** — `findGrammarsDir` uses the same `createRequire` fix to anchor `web-tree-sitter/grammars` and `tree-sitter-wasms/out` paths correctly in pnpm/monorepo layouts
- **tree-sitter-gleam download 404** — removed `tree-sitter-gleam.wasm` from grammar downloads; the file was never published in `tree-sitter-wasms@0.1.13`
- **Pipeline deduplication** — `handleToolResult` now deduplicates concurrent pipeline calls for the same file; the pi framework fires `tool_result` once per hunk in an Edit array, causing duplicate pipeline runs and doubled agent output

### Changed

- **Tuned false-positive thresholds across all runners** — reduced noise in `lens-booboo` and dispatch for all users:
  - Added `FACT_SEVERITY_FILTER` (`error`/`warning` only) and `MIN_TREE_SITTER_HITS_PER_RULE = 3`
  - Filtered entropy/AI-style warnings from complexity metrics
  - Aligned complexity markdown headers with actual thresholds (`MI < 20`, `cognitive > 80`, `nesting > 8`)
  - Raised `SEMANTIC_SIMILARITY_THRESHOLD` from `0.96` → `0.98` (aligned with dispatch similarity runner)
  - Raised duplicate-string-literal `MIN_DUPLICATES` from `4` → `10`
  - Unregistered `no-magic-numbers` and `high-entropy-string` fact rules globally

### Removed

- **Dead code across 32 files** — removed 51 sites of unused imports, locals, and parameters flagged by `tsc --noUnusedLocals --noUnusedParameters`:
  - `clients/architect-client.ts`, `ast-grep-client.ts`, `biome-client.ts`, `complexity-client.ts`, `go-client.ts`, `rust-client.ts`, `scan-utils.ts`, `secrets-scanner.ts`, `subprocess-client.ts`, `test-runner-client.ts`, `tool-availability.ts`, `tree-sitter-cache.ts`, `tree-sitter-client.ts`, `type-coverage-client.ts`, `type-safety-client.ts`
  - `clients/dispatch/dispatcher.ts`, `runners/ast-grep-napi.ts`, `runners/golangci-lint.ts`, `runners/index.ts`, `runners/python-slop.ts`, `runners/ts-lsp.ts`, `runners/utils/diagnostic-parsers.ts`
  - `clients/lsp/client.ts`, `config.ts`, `interactive-install.ts`, `launch.ts`, `server.ts`
  - `clients/pipeline.ts`, `review-graph/builder.ts`, `runner-tracker.ts`
  - `commands/booboo.ts`, `index.ts`

### Tests

- **Pipeline regression tests** — `tests/clients/pipeline.test.ts` (11 tests): secrets blocking, format modification, LSP sync, dispatch blockers, autofix output, test runner skip, all-clear output
- **Autofix helper tests** — `tests/clients/autofix-helpers.test.ts` (12 tests): config detection (eslint, stylelint, sqlfluff), malformed JSON handling, file change detection after command
- **LSP lifecycle tests** — `tests/clients/lsp/lifecycle.test.ts` (4 tests): missing binary error, process spawn, immediate exit detection, process kill
- **FormatService tests** — `tests/clients/format-service.test.ts` (11 tests): disabled/skip mode, no matching formatters, successful run with change detection, formatter failure, external modification detection, singleton behavior, state clearing, file tracking
- **Dispatch integration tests** — `tests/clients/dispatch/integration.test.ts` (11 tests): `dispatchLintWithResult` empty results, result propagation, warnings-only; `shouldDispatch` for supported/unsupported; `getAvailableRunners` for supported/unsupported
- **LSP client internals tests** — `tests/clients/lsp/client-internals.test.ts` (13 tests): `handleNotifyOpen` (first open, re-open, pending opens, clear diagnostics, skip when not alive), `handleNotifyChange` (didChange when open, fallback to didOpen, clear stale diagnostics, skip when not alive), `clientWaitForDiagnostics` (immediate resolve if cached, resolve via emitter, timeout, ignore other files)
- **Runtime event flow test fix** — added missing `gatherCascadeDiagnostics` mock export to `tests/clients/runtime-event-flow.test.ts`
- **LSP launch tests** — `tests/clients/lsp/launch.test.ts` (8 new tests): `isCmdShimValid` unit tests (target exists/missing, non-npm shim, unreadable file, `.mjs` extension), early `.cmd` shim rejection without spawning, `.ps1` bypass to `.cmd` sibling, `.ps1` fallback to direct `node <js>` execution
- **Tree-sitter hoisted-install tests** — `tests/clients/tree-sitter-client-init.test.ts` (3 tests): wasm resolution via `require.resolve`, `locateFile` directory derivation, `findGrammarsDir` external package resolution

### Refactored

- **Extract `detectFileChangedAfterCommand`** — moved from `clients/pipeline.ts` to `clients/file-utils.ts` and exported for reuse/testing; imported back into `pipeline.ts`; `tests/clients/autofix-helpers.test.ts` now imports the real function instead of reimplementing a copy
- **Export testable pipeline helpers** — exported `hasEslintConfig`, `hasStylelintConfig`, `hasSqlfluffConfig` from `clients/pipeline.ts` so config detection is testable
- **Export LSP client internals** — exported `clientWaitForDiagnostics`, `handleNotifyOpen`, `handleNotifyChange`, and `LSPClientState` from `clients/lsp/client.ts` for direct testing with mocks
- **Export `isCmdShimValid`** — exported from `clients/lsp/launch.ts` so the npm `.cmd` shim validator is unit-testable

### CI

- **Dead-code gate** — `lint-and-typecheck` job now runs `tsc --noUnusedLocals --noUnusedParameters --noEmit` alongside `--noEmit` so dead code regressions fail CI immediately

## [3.8.29] - 2026-04-21

### Added

- **New diagnostic commands** — added `/lens-tools` and `/lens-health` for system visibility:
  - `/lens-tools` — shows tool installation status: globally installed, pi-lens auto-installed, or npx fallback
  - `/lens-health` — shows runtime health: pipeline crashes, slow runners, diagnostic stats
  - Both provide actionable visibility into the pi-lens toolchain
- **Streamlined ast-grep skill** — reduced skill from 7,759 bytes to 2,313 bytes (~70% reduction):
  - Removed verbose CLI tips and YAML rule authoring sections (agent uses tools, not CLI)
  - Removed redundant testing documentation
  - Kept essential: Golden Rules, Quick Reference, Common Gotchas
- **Configurable log cleanup** — automatic retention and rotation for `~/.pi-lens/*.log` files:
  - Environment variable `PI_LENS_LOG_RETENTION_DAYS` (default: 7) — days to keep log files
  - Environment variable `PI_LENS_MAX_LOG_SIZE_MB` (default: 10) — max size before rotation
  - Runs automatically on session start, notifies when cleanup occurs
  - Rotated backups (`.log.*`) cleaned after retention period
  - Project-level logs (`{cwd}/.pi-lens/*`) intentionally excluded from cleanup

### Changed

- **`/lens-tools` output improved** — added explanatory note when GitHub-release tools are shown as missing: "GitHub-release tools auto-install when you open files of those languages"
- **Simplified agent prompts** — removed verbose prompt sections to reduce token burn:
  - Removed startup notes about project rules count (now just logged, not shown)
  - Removed tooling hints for missing language tools (Go/Rust/Ruby install suggestions)
  - Removed project rules section from system prompt (no longer injects `## Project Rules` block)
  - Updated core guidance to clarify: automated checks run on edits/writes, blocking errors shown inline must be fixed
- **Simplified CLI flags** — removed 16 flags to reduce surface area and cognitive load:
  - Removed per-tool disable flags: `--no-biome`, `--no-ast-grep`, `--no-shellcheck`, `--no-madge`, `--no-oxlint`, `--no-ruff`, `--no-go`, `--no-rust`
  - Removed per-tool autofix flags: `--no-autofix-biome`, `--no-autofix-ruff`
  - Removed feature flags: `--lens-verbose`, `--error-debt`, `--auto-install`, `--lens-eslint-core`
  - Removed redundant `--lens-lsp` flag (LSP is default-on; use `--no-lsp` to disable)
  - Removed internal dead flag: `--lens-blocking-only`
  - **Removed `--no-lsp-install` flag** — LSP servers now always auto-install when needed (no manual opt-out)
  - New minimal flag set: `--no-lsp`, `--no-autoformat`, `--no-autofix`, `--no-tests`, `--no-delta`, `--lens-guard`
- **Cross-platform line ending handling** — all `.split("\n")` changed to `.split(/\r?\n/)` for Windows CRLF compatibility (11 files updated)

### Fixed

- **Biome VCS/ignore file errors eliminated** — disabled VCS integration in biome config to prevent "ignore file not found" errors:
  - Changed `vcs.enabled: true` → `vcs.enabled: false` in `config/biome/core.jsonc`
  - Biome was searching for `.gitignore` files that don't exist when running on arbitrary projects via pi-lens
  - Eliminates biome:parse-error spam in logs when biome runs outside its config directory
- **LSP server thrashing eliminated** — added 240s idle timeout to prevent repeated LSP shutdown/startup cycles:
  - New `scheduleLSPIdleReset()` in `runtime-turn.ts` defers server reset when no files modified
  - Cancel pending reset when active editing resumes (avoids interrupting workflows)
  - Eliminates ~1-2s cold-start penalty during active development sessions
  - Debug logging added for scheduling and cancellation events
- **Biome check runner JSON parsing** — fixed error where biome's stderr warnings broke JSON parsing:
  - Changed from parsing `stdout || stderr` to parsing `stdout` only
  - Biome outputs text warnings (e.g., "couldn't find ignore file") to stderr which broke the JSON parser
  - Fixes biome-check-json runner failing with parse errors instead of providing lint diagnostics
- **Auto-install verification gap** — `getToolPath()` now verifies tool binaries actually work before using them:
  - Runs `--version` check on local npm tools (not just file existence)
  - Detects broken/corrupted installations (e.g., wrapper exists but package missing)
  - Triggers automatic reinstall when binary verification fails
  - Fixes case where `@biomejs/biome` package deleted but `.cmd` wrapper remained
- **Error swallowing in tool availability checks** — `runtime-session.ts` now logs errors when biome/ast-grep/ruff/knip/dep/jscpd availability checks fail (was silently returning `false`)
- **Biome check runner reliability** — fixed path resolution and configuration issues causing "skipped" status and parse errors:
  - Fixed biome flag: `--output-format=json` → `--reporter=json`
  - Fixed `findBiome()` to check `~/.pi-lens/tools/` directory (was falling back to bare "biome" not in PATH)
  - Fixed `findBiome()` to return `{cmd, argsPrefix}` object for proper npx fallback with `@biomejs/biome` prefix
  - Added `vcs.root: "."` to `config/biome/core.jsonc` to respect project `.gitignore`
- **LSP error messaging** — improved error messages for Windows .cmd shim failures to distinguish "npm .cmd shim failed (underlying binary not installed)" from "may be missing or corrupted"
- **Windows installer improvements** — multiple fixes for Windows tool discovery and LSP stability:
  - Prefer `.cmd` over extensionless in local TOOLS_DIR path lookup on Windows
  - Bypass PS1 hangs in LSP initialization with hard-kill on timeout
  - Remove `.ps1` from pyright managed candidates and ast-grep discovery on Windows
  - Use `SYSTEMDRIVE` env var instead of hardcoded `C:` for cargo fallback path
- **Rust LSP** — exponential backoff circuit breaker for failing LSP connections
- **Installer reliability** — remove `console.error` verbosity, route all events to `sessionstart.log`
- **Circular dependencies** — fixed circular dependencies identified in code review
- **Knip race condition** — fixed race condition in knip tool discovery
- **Non-blocking tool availability checks** — changed all `ensureAvailable()` methods to use async `safeSpawnAsync` instead of sync `safeSpawn`, completing the startup unblocking work:
  - `ruff-client.ts`, `biome-client.ts`, `sg-runner.ts` (first batch)
  - `knip-client.ts`, `dependency-checker.ts`, `jscpd-client.ts` (second batch)
  - `sg-runner.ts` — added missing `safeSpawnAsync` import
- **Secrets scanner false positives** — fixed incorrect flagging of environment variable name references (e.g., `"FIREWORKS_API_KEY"`, `"AWS_ACCESS_KEY_ID"`) as hardcoded secrets:
  - Added word boundaries to `hardcoded-secret` regex pattern
  - Added `looksLikeEnvVarName()` filter to skip UPPERCASE_SNAKE_CASE values
  - Prevents false positives when env var names are used as placeholder strings

### Changed

- **Biome check performance** — reduced lint latency from ~1.4s to ~100ms per file (92% improvement):
  - Removed redundant `--version` pre-check spawn (~200ms saved)
  - Switched from `biome check` to `biome lint` command (skip format validation)
  - Added binary path caching per cwd to avoid repeated fs checks
  - Benchmark: 107ms average vs 1400ms baseline
- **Tree-sitter performance** — reduced structural analysis latency by 30-50%:
  - Execute queries in parallel with concurrency limit of 6 (was sequential)
  - Skip entity snapshot extraction for changes under 5 lines (~500-800ms saved for trivial edits)
  - Reduces tree-sitter latency from ~3s to ~1-2s for typical files

## [3.8.28] - 2026-04-19

### Fixed

- **Session startup no longer blocks the Node event loop** — tool availability probes (biome, ast-grep, ruff, knip, jscpd, madge) now run via async `ensureAvailable()` in a fire-and-forget IIFE instead of `setImmediate` + `spawnSync`, eliminating ~8–10 s of main-thread freeze on startup.
- **Biome binary lookup extended** — `getBiomeBinary()` now checks `~/.pi-lens/tools/node_modules/.bin/biome` so the async probe finds the pre-installed binary without falling back to `npx`.
- **CSS roots and Windows LSP shims tightened** — improved root resolution for CSS language server on Windows.
- **Zig compile coverage kept active** — LSP availability check no longer incorrectly disables Zig compile diagnostics.
- **Ruby LSP startup budgets relaxed** — reduced false-negative LSP attach failures on slower machines.
- **Kotlin and Zig LSP availability improved** — more reliable server detection across platforms.
- **Standalone Python and Ruby LSP roots fixed** — correct workspace root used when opening files outside a project directory.

## [3.8.27] - 2026-04-19

### Added

- **Review graph impact cascade** — turn-end cascade now renders a review-graph impact view showing which files were affected and how diagnostics propagated.
- **Fact-rule pipeline in dispatch** — new `fact-rules` dispatch runner computes function-level facts (depth, cyclomatic complexity, call counts) and evaluates quality rules inline, replacing the bespoke tree-sitter booboo runner.
- **Function facts: depth / CC / calls** — tree-sitter extracts per-function cyclomatic complexity, nesting depth, and outgoing call count for fact-rule evaluation.
- **File role classification** — dispatch classifies files as `source`, `test`, `config`, or `vendor` and adjusts rule severity accordingly.
- **Inline suppression directives** — sources can suppress diagnostics with `// pi-lens-ignore` or `# pi-lens-ignore` comments; suppressed items are omitted from inline output.
- **High-complexity fact rule** — flags functions exceeding configurable cyclomatic complexity thresholds.
- **Unsafe-boundary fact rule** — detects dangerous boundary crossings (unvalidated user input → trusted context).
- **High-fan-out fact rule** — flags functions with excessive outgoing call count (default threshold 20).
- **`async-unnecessary-wrapper` ast-grep rule** — detects trivial async wrappers that just await and return.
- **`missing-error-propagation` ast-grep rule** — detects catch blocks that swallow errors without re-throwing or logging.
- **36 new ast-grep rules** — expanded coverage for security, correctness, and style across TypeScript, JavaScript, and Python.
- **5 quality fact rules** — structured quality checks driven by function-level metrics.
- **8 SonarJS-aligned rules** — try-catch enrichment and 8 rules ported from SonarJS patterns.
- **Slop-detection rules** — identifies low-signal / boilerplate-heavy code regions with observability log entries.
- **Dart-analyze dispatch runner** — runs `dart analyze` on `.dart` files.
- **Ktlint dispatch runner** — runs `ktlint` on `.kt` / `.kts` files.
- **TFLint dispatch runner** — runs `tflint` on `.tf` / `.tfvars` files.
- **Taplo dispatch runner + formatter** — runs `taplo` for TOML lint and format.
- **Credo dispatch runner** — runs `mix credo` on Elixir files (falls back to LSP).
- **Phpstan dispatch runner** — runs `phpstan` on PHP files (falls back to LSP).
- **Prettier-check dispatch runner** — runs `prettier --check` as a lint runner (not auto-fix, purely diagnostic).
- **PSScriptAnalyzer runner** — PowerShell linting via `Invoke-ScriptAnalyzer`, using temp `-File` instead of `-Command` to avoid cmd.exe mangling.
- **Hadolint dispatch runner** — Dockerfile lint with always-run dispatch gating.
- **Htmlhint dispatch runner** — HTML lint with tag-pair detection.
- **Docker / PHP / PowerShell / Prisma FileKind** — new language kind mappings enable LSP and dispatch for Dockerfile, `.php`, `.ps1`/`.psm1`, and `.prisma` files.
- **GitHub release downloader for installer** — `shellcheck`, `shfmt`, `rust-analyzer`, and `golangci-lint` are now auto-installed from GitHub releases with asset selection across platforms.
- **Auto-install gopls and ruby-lsp** — `gopls` installed via `go install`; `ruby-lsp` installed via `gem install` when not found.
- **Biome as default JS/TS linter** — when no ESLint or oxlint config exists, Biome runs as the default linter for write-path dispatch instead of silently skipping.
- **Bundled ruff config fallback** — Python projects without a `ruff.toml` / `pyproject.toml` ruff section now use a bundled safe-default config so ruff still produces useful findings.
- **Ruff autofix after diagnostics** — the ruff dispatch runner now applies safe autofixes after capturing diagnostics, mirroring Biome's write-path behavior.
- **Diagnostic history logging** — tree-sitter warnings and debounced ast-grep findings are now logged to session history for observability and `/lens-booboo` review.
- **Tree-sitter grammar downloads expanded** — additional grammars downloaded at install time for broader language coverage.
- **Java and C# fallback analysis** — dispatch includes fallback analysis paths for Java (`.java`) and C# (`.cs`) when LSP is unavailable.
- **CI: tsc type-check + vitest + install gate** — CI now runs `tsc --noEmit` and `vitest` as separate jobs; install-test is gated on both passing.
- **CI: tsx extension load check** — CI verifies that required extensions load correctly to catch missing dependency errors early.

### Changed

- **Promote LSP-backed languages into dispatch** — languages with active LSP servers now route through dispatch's standard pipeline instead of ad-hoc paths.
- **Dispatch language fallbacks aligned** — LSP-backed and fallback runner selection now uses consistent language-to-capability mapping.
- **CSS / HTML / TOML / Elixir fallback wiring** — dispatch fallbacks now include CSS (stylelint), HTML (htmlhint), TOML (taplo), and Elixir (credo).
- **Prettier-check and stylelint cwd handling** — both runners now resolve project root correctly instead of skipping when the working directory overshoots.
- **OS portability: vendor/bin and sg resolution** — `vendor/bin` tools resolve with multi-extension support (`.bat`/`.cmd`/no-ext); `sg` candidate list works across platforms.
- **LSP: live Windows registry PATH** — LSP spawn reads the live `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment\Path` at launch time so newly installed tools are immediately discoverable.
- **LSP: unified resolveAndLaunch** — four separate resolution mechanisms (local binary, global, npx, package manager) collapsed into a single `resolveAndLaunch` flow with clear fallback ordering.
- **LSP: telemetry and logging tightened** — init failures logged to `sessionstart.log`; terminal noise reduced; basename matching improved.
- **YAML LSP root fallback** — YAML language server uses `RootWithFallback` for seamless multi-root project support.
- **Dart / Terraform / TOML LSP: RootWithFallback** — same root-fallback pattern applied across these servers for reliable workspace detection.
- **Terraform-ls HashiCorp install fallback** — improved install path resolution for terraform-ls.
- **`empty-catch` and `unchecked-sync-fs` downgraded to warning** — too many false positives as errors; now `warning` severity.
- **High-fan-out threshold raised to 20** — reduced noise from earlier threshold of 10.
- **High-complexity and unsafe-boundary thresholds tightened** — reduced false positives at the default severity boundaries.
- **False-positive reduction: 8 rules + 3 error rules** — tuned OAuth/constants-related patterns, removed 3 error-level rules that flagged too broadly, and fixed `ts-ssrf` identifier argument matching.
- **Removed unused/noisy ast-grep rules** — culled rules that overlapped with tree-sitter coverage or produced excessive noise.
- **Moved duplicate TS tree-sitter rules** — overlapping rules relocated to `typescript-disabled/` to avoid double-reporting.
- **LSP crash diagnostics** — startup stderr captured and logged for faster root-cause analysis.
- **Tool PATH normalization** — cross-platform PATH resolution unified for LSP and dispatch tool spawning.
- **Cleaned up runtime dependencies** — moved `@ast-grep/napi` and `js-yaml` to `dependencies` (were `devDependencies`); removed unused deps.
- **Complexity reduction** — decomposed four highest-complexity functions (CC 75–153 → <20 each) for maintainability.

### Fixed

- **Windows LSP startup fallback** — hardened spawn logic for `.cmd` wrappers, PATH resolution, and process creation on Windows.
- **C# launch and secondary language fallbacks** — C# LSP and secondary language servers start reliably in more project layouts.
- **Prettier-check / stylelint cwd overshoot** — both runners now find the project root correctly instead of silently skipping.
- **Hadolint asset name case** — GitHub release downloader resolves case-sensitive asset names.
- **Htmlhint / hadolint always-run dispatch** — both runners fire correctly regardless of file presence heuristics.
- **Bash LSP re-spawn** — bash-language-server restarts cleanly after unexpected exit.
- **HTML dispatch + htmlhint tag-pair detection** — HTML file kind wired into dispatch; htmlhint catches missing closing tags.
- **Intelephense needs `scripts`** — PHP LSP installed with `--scripts` flag so its postinstall binary is available.
- **Rust-analyzer: RootWithFallback + Windows .zip asset** — both root detection and Windows asset extraction fixed.
- **Managed Pyright launch path** — pyright LSP binary resolves correctly when installed as a managed tool.
- **Terraform / Kotlin / coverage fallback handling** — all three dispatch paths handle missing tools or configs gracefully.
- **Shellcheck auto-install** — auto-installer works across platforms with GitHub release asset selection.
- **Ktlint asset names** — ktlint release assets resolved with correct URL patterns.
- **Coverage notice for mode:all linters** — mode:all linters that can't generate coverage now emit a notice instead of crashing.
- **npm install 120s timeout** — `ensureTool` npm installs have a hard 120s timeout to prevent indefinite hangs.
- **npm install ERESOLVE retry** — installer retries npm installs on ERESOLVE dependency conflicts.
- **Remove spawnSync from `unchecked-throwing-call` rule** — rule no longer flags `spawnSync` calls as unhandled throwing calls.
- **`flush()` drain before write-complete** — diagnostic history flush now drains pending entries before awaiting write completion, preventing data loss on session end.
- **Runner checks diagnostics-only** — dispatch runner checks are now diagnostics-only, avoiding stale LSP state mutations.
- **Biome-lsp server removed** — duplicate `biome-lsp` server entry removed; Biome LSP is accessed through the standard biome binary.
- **Size guards + path caching for ensureTool** — tool availability checks are cached and sized to avoid re-probing on every call.
- **Test assertions after runner wiring** — test expectations updated for new runner ordering and diagnostics pipeline.
- **OS path separator normalization** — path separators and map keys normalized for cross-platform compatibility in diagnostics and LSP.
- **Drop unnecessary async from `ensureAvailable`** — removed spurious `async` that added nothing and complicated error handling.
- **Tree-sitter rule false positives** — fixed query syntax, scan scripts, and architect glob patterns that produced incorrect findings.

### Performance

- **Startup: defer npm tool availability probes** — tool availability checks (Biome, ESLint, etc.) now run lazily out of the critical path, reducing session start latency.
- **Defer TypeScript loading in similarity runner** — similarity detection lazily imports the TypeScript parser, eliminating cold-start cost on first call.

### Refactored

- **LSP: collapse resolution into `resolveAndLaunch`** — unified four spawn mechanisms into one function with clear platform-aware fallbacks.
- **Booboo: replace bespoke tree-sitter runner** — `/lens-booboo` tree-sitter checks now use the same fact-rule pipeline as dispatch, eliminating code duplication.
- **Drop redundant async from LSP spawn** — removed unnecessary `async`/`await` from functions that already return Promises.

### Tests

- **GitHub release asset selection and PATH tests** — installer asset URL construction and PATH resolution covered by unit tests.
- **Rust-analyzer Windows .zip asset expectation** — test fixture updated for `.zip` extension on Windows.
- **Async-noise test multi-statement function** — test rule updated to match multi-statement function bodies.

## [3.8.26] - 2026-04-15

### Fixed

- **Silent crash on unhandled promise rejection** — the LSP crash guard's `unhandledRejection` handler was swallowing all non-ignorable rejections without rethrowing, causing silent process exits. The handler now rethrows so non-ignorable rejections surface as `uncaughtException` and are properly reported. Triggered most visibly when editing JSON files while Biome or another LSP server was active.

## [3.8.25] - 2026-04-13

### Changed

- **Go LSP PATH augmentation on Windows** — LSP subprocess PATH now includes common Go install directories (`C:\Program Files\Go\bin`, `C:\Go\bin`) to prevent `gopls` startup/runtime failures when `go` is not in inherited shell PATH.
- **Similarity runner cold-start behavior** — similarity now skips fast when no cached project index exists and for tiny/trivial files, reducing write/edit pipeline tail latency and eliminating frequent 30s timeout noise in scratch-file workflows.

### Fixed

- **Non-git workspace commit lookup noise** — metrics snapshot commit detection now pre-checks repository context before invoking Git, preventing `fatal: not a git repository` terminal noise in non-repo folders.

## [3.8.24] - 2026-04-12

### Changed

- **Lazy bootstrap client loading** — startup now defers heavy client initialization behind a shared bootstrap promise, reducing first-turn startup overhead while preserving tool behavior.
- **LSP config discovery scope** — `.pi-lens/lsp.json` (and related config paths) are now resolved from the current directory up through parent directories, improving nested-workspace support.
- **Ruby server fallback chain** — Ruby LSP startup now tries `ruby-lsp`, then `solargraph`, then `rubocop --lsp` for broader environment compatibility.

### Fixed

- **LSP config activation timing** — LSP server config initialization now runs reliably at `session_start` and before LSP-backed `tool_call` operations, so server enable/disable overrides apply in one-shot and interactive sessions.

## [3.8.23] - 2026-04-12

### Added

- **LSP auto-touch warm-up** — tool-call flow now proactively opens/syncs supported files (`read`/`write`/`edit`/`lsp_navigation`) so LSP clients warm up earlier and first semantic requests are less likely to return cold-start empties.

### Changed

- **Ruby LSP spawn resilience on Windows** — Ruby command discovery now tries `ruby-lsp`/`solargraph` from PATH plus common Ruby install locations before marking servers unavailable.
- **LSP diagnostics dedupe strategy** — multi-server diagnostics aggregation now dedupes using a simpler key (`line`, `character`, `message`) to better collapse equivalent findings across servers.
- **Windows LSP PATH fallback** — language-server spawns now augment PATH with common user-level tool locations (`.cargo\bin`, `go\bin`, common Ruby bin dirs) to improve server discovery on Windows shells.

### Fixed

- **LSP diagnostics key normalization** — publish diagnostics now store/update using normalized file-path keys, fixing Windows path mismatches that could hide diagnostics in some languages.
- **Pull diagnostics fallback path** — when a server advertises pull diagnostics, `textDocument/diagnostic` is now attempted before push-wait fallback.
- **Navigation diagnostics/health observability** — `lsp_navigation` and diagnostics aggregation now emit explicit `failureKind`/health metadata to latency logs and tool details for faster root-cause triage (`no_server`, `unsupported`, `empty_result`, `lsp_error`, etc.).
- **Scoped workspaceDiagnostics collection** — `workspaceDiagnostics` with `filePath` now forces file-level diagnostics collection (instead of only returning tracked snapshots), including pull-mode aggregation metadata.
- **Rust pull diagnostics cold-start handling** — pull diagnostics now retry briefly and then fall back to push-wait if pull responses remain empty, improving first-hit Rust diagnostic reliability.
- **Context injection message role validity** — session-start guidance is now injected as `user` context (valid `AgentMessage` role), preventing dropped context on providers that reject/ignore `system` in this path.

## [3.8.22] - 2026-04-09

### Changed

- **Quick startup path for one-shot print sessions** — `--print`/`-p` now auto-selects quick startup mode to skip heavy bootstrap work and reduce startup latency. Added `PI_LENS_STARTUP_MODE=full|minimal|quick` override for explicit control.

### Fixed

- **Cascade diagnostics formatting clarity** — turn-end cascade entries now render source location as `line <n>, col <m> code=<id>:` so diagnostic codes (for example `TS2322`) are no longer formatted in a way that can be mistaken for file line numbers.

## [3.8.21] - 2026-04-08

### Changed

- **Session guidance channeling** — session-start guidance is now injected as `system` context instead of synthetic `user` context, reducing acknowledgement-only first replies before task execution.
- **Coverage warning dedupe** — "Pi-lens analysis unavailable" warnings are now shown once per file per session and reset on session baseline reset.

### Fixed

- **Turn-end read-loop pressure** — turn-end findings now suppress duplicate persisted blocker prompts and avoid imperative "read this file" phrasing that could trigger repeated read loops.

## [3.8.20] - 2026-04-08

### Changed

- **Session startup hardening** — background startup tasks now run with session-generation safety guards and startup in-flight tracking, preventing stale task writes across session boundaries.
- **Turn-end overlap guardrails** — turn-end `knip`/`jscpd` checks now skip when the corresponding startup scan is still in-flight.
- **Language-profile centralization** — startup and dispatch now share a centralized project language profile for supported language detection and LSP-capable kind policy.
- **No-config startup defaults** — startup preinstall now applies language defaults (for example JS/TS -> `typescript-language-server`, Python -> `pyright`/`ruff`) while keeping heavy JS/TS scans config-gated.
- **Language setup hints** — `session_start` now emits actionable install hints for detected Go/Rust/Ruby projects when key tools are missing.

### Fixed

- **TODO baseline scan resilience** — unreadable files are now skipped safely instead of crashing TODO scanning in cloud-synced projects.
- **Startup scan gating consistency** — TODO warmup now respects startup warm-cache gating and avoids unnecessary scan work in restricted startup contexts.
- **Path exclusion coverage** — shared exclusion list now includes common agent/tooling directories (`.claude`, `.codex`, `.worktrees`, `.vscode`, and related dirs).
- **Ruff auto-install on Windows** — pip-based installation now supports fallback chains (`pip`, `py -m pip`, `python -m pip`) and process PATH normalization for user-level scripts.
- **Installer race duplication** — concurrent `ensureTool(...)` calls are now deduplicated per tool to avoid duplicate install attempts/noisy logs.
- **Python LSP root fallback** — Python LSP root detection now supports `.git` projects without Python config files.

## [3.8.19] - 2026-04-07

### Fixed

- **Biome autofix gating** — Biome autofix/auto-install now runs only when the project has Biome configuration (`biome.json`/`biome.jsonc`) or `@biomejs/biome` in `devDependencies`, preventing unwanted Biome installs in non-Biome JS/TS projects.

## [3.8.18] - 2026-04-07

### Changed

- **Similarity calibration tightened** — raised semantic similarity threshold to `0.96`, raised minimum transition signal to `40`, and added transition-ratio filtering to reduce boilerplate-wrapper false positives.
- **Dispatch + booboo alignment** — similarity guardrails are now aligned between `/lens-booboo` reporting and the dispatch `similarity` runner.
- **Tree-sitter structural dedupe in booboo** — advanced structural findings now dedupe repeated line-level matches by normalized matched scope so deep nesting/promise chain reports collapse to one representative issue.

### Tests

- Added similarity runner guardrail assertions in `tests/clients/similarity-runner.test.ts`.

## [3.8.17] - 2026-04-07

### Changed

- **Delta-only unused variable blocking** — diagnostics matching unused-value patterns are now promoted to blocking only when they are newly introduced in delta mode.
- **Unused diagnostic heuristics** — improved detection covers TypeScript unused codes/messages and `no-unused*` rule identifiers, while preserving non-blocking behavior for pre-existing baseline debt.

### Tests

- Added dispatch flow coverage for delta-mode unused-value promotion in `tests/clients/dispatch/dispatcher-flow.test.ts`.

## [3.8.16] - 2026-04-07

### Changed

- **Ast-grep fix guidance upgraded** — ast-grep diagnostics now prefer explicit rule-level guidance from YAML (`fix` first, then `note`) before falling back to generic defect-class suggestions.
- **Rule parser metadata support** — YAML rule parsing now supports top-level `note` and `fix` fields (including multiline values) for agent-facing remediation text.

### Tests

- Added parser coverage for `note`/`fix` extraction in `tests/clients/dispatch/runners/yaml-rule-parser.test.ts`.

## [3.8.15] - 2026-04-07

### Added

- **Security rule: no global eval** — added ast-grep rule to block `eval(...)`, `Function(...)`, and string-based `setTimeout`/`setInterval` execution.
- **Security rule: no blank target** — added ast-grep rule to warn on `<a target="_blank">` without `rel=...`.
- **Performance rule: no accumulating spread** — added ast-grep rule to warn on reduce patterns that repeatedly spread accumulators.

## [3.8.14] - 2026-04-07

### Added

- **YAML lint runner** — added `yamllint` dispatch support for `.yaml`/`.yml` files, with LSP prepended when enabled.
- **SQL lint + format support** — added `sqlfluff` dispatch support for `.sql` files and `sqlfluff` formatter integration.
- **SQL file kind support** — introduced `sql` file kind detection and language-id mapping.

### Changed

- **Capability matrix coverage expanded** — YAML and SQL now map to dedicated lint runners in the centralized capability matrix.
- **Lazy auto-install expansion** — added lazy-install support for `yamllint` and `sqlfluff` via installer-managed pip tools.
- **Runner inventory docs updated** — README runner list now includes `yamllint` and `sqlfluff`.

### Tests

- Added YAML/SQL runner parsing/semantics coverage in `tests/clients/dispatch/runners/yaml-sql-runners.test.ts`.
- Updated dispatch plan/integration tests for YAML+SQL capability mapping and group ordering.

## [3.8.13] - 2026-04-07

### Changed

- **Centralized capability matrix** — dispatch planning now derives from `LANGUAGE_CAPABILITY_MATRIX`, which defines per-language capability dimensions and write/full runner groups in one place.
- **Plan generation simplified** — `TOOL_PLANS` (write path) and `FULL_LINT_PLANS` (full scans) are generated from matrix entries instead of duplicated hand-maintained plan objects.

### Tests

- Extended dispatch plan exposure coverage to assert capability dimensions for main languages (`jsts`, `python`, `go`, `rust`, `ruby`) in `tests/clients/dispatch/plan-exposure.test.ts`.

## [3.8.12] - 2026-04-07

### Changed

- **Excluded-dir policy consolidated** — scanners now share `isExcludedDirName(...)` matching logic from `file-utils` instead of ad-hoc `EXCLUDED_DIRS.includes(...)` checks.
- **Pattern-aware exclusions** — exclusion matching now supports case-insensitive exact matches and lightweight glob patterns (for example `*.dSYM`).
- **Cross-scanner consistency** — startup scan, source filter, jscpd precheck, tree-sitter file collection, slop scan, production-readiness scan, and legacy scan-utils path checks now use the same exclusion semantics.

### Tests

- Added exclusion matcher coverage in `tests/clients/file-utils.test.ts`.
- Expanded source-filter coverage for glob exclusions (`*.dSYM`) and case-insensitive directory exclusion in `tests/source-filter.test.ts`.

## [3.8.11] - 2026-04-07

### Added

- **Experimental git guard flag** — added `--lens-guard` to gate commit/push attempts behind a blocker preflight check.
- **Git guard commit preflight** — when enabled, `bash` calls containing `git commit` or `git push` are blocked if unresolved inline blockers or pending turn-end blockers exist.

### Changed

- **Guard status tracking** — runtime now tracks blocker state/summary from post-write pipeline output so commit blocking messages stay concise and actionable.

### Tests

- Added focused coverage for git guard command detection and block/allow behavior in `tests/clients/git-guard.test.ts`.
- Updated runtime tool-result tests for guard status updates in `tests/clients/runtime-tool-result.test.ts`.

## [3.8.10] - 2026-04-07

### Changed

- **LSP default-on** — `--lens-lsp` is now enabled by default to provide unified LSP diagnostics across supported file kinds.
- **Capability-driven LSP dispatch** — dispatch now prepends LSP dynamically by file kind/flag state, while still using runtime `hasLSP(file)` checks for safe activation.
- **Fallback safety switch clarified** — `--no-lsp` is documented and wired as the explicit opt-out path to language-specific fallbacks.

### Fixed

- **`--no-lsp` consistency** — LSP sync/reset/navigation and runner gating now respect `--no-lsp` consistently, so fallback behavior is predictable.
- **LSP/lint overlap noise** — non-blocking lint diagnostics overlapping with LSP on the same file/line are suppressed to keep inline output focused.
- **turn_end actionability** — blocker summaries for jscpd/knip now include direct file hints to reduce path-guessing loops.
- **Architect invalid regex resilience** — malformed `must_not.pattern` expressions in `architect.yaml` are now logged and skipped instead of throwing during checks.
- **Architect runner path/cache stability** — cwd cache keys are now normalized and relative paths use `path.relative(...)`, preventing stale cache misses and Windows path edge cases.
- **`/lens-booboo` target-root consistency** — architectural checks now always reload config for the requested target path so scans don’t drift to a previous working directory.

## [3.8.9] - 2026-04-07

### Changed

- **README restructured** — Expanded the "What It Does" section with write/edit, session_start, and turn_end behavior; added a complete runner list and a dependency table with auto-installed vs manual tools.
- **Test runner strategy improved** — Added hybrid test targeting: rerun known failures first, otherwise run related tests for the edited file.

### Fixed

- **Non-JSON test runner parsing** — Go/Cargo/Dotnet/Gradle/Maven/RSpec/Minitest now use generic parsing instead of returning "Unknown runner".
- **Dispatch delta baseline compatibility** — Baseline lookups now support both normalized absolute and cwd-relative keys to prevent stale/new misclassification in mixed-key scenarios.

## [3.8.8] - 2026-04-07

### Changed

- **README massively simplified** — Reduced the README to core purpose, install/run, key commands, and concise usage notes.
- **Docs trimmed** — Removed deep internal documentation files from `docs/` to keep project docs minimal and focused.
- **Positioning text clarified** — Updated wording to describe pi-lens as real-time inline feedback for AI agents.

## [3.8.7] - 2026-04-06

### Fixed

- **Baseline duplication in dispatch delta mode** — `ctx.baselines.set()` was called with `[...allDiagnostics, ...diagnostics]`, but `allDiagnostics` already contained `diagnostics` from the push below. Baseline inflated by N items per dispatch, causing `filterDelta` to misidentify issues on subsequent writes.
- **No delta on warnings** — `DispatchResult.warnings` was cumulative (total warning count across all runs), so the `N warning(s) -> /lens-booboo` message never decreased even when the agent fixed warnings. Added `baselineWarningCount` to track the baseline separately. Message now shows `3 new (15 total) warning(s)` so the agent sees progress.
- **LSP sync fire-and-forget** — Phase 3 (LSP file sync) was attached via `.then()` without being awaited, so dispatch lint (phase 5) and cascade diagnostics (phase 7) ran against stale LSP state. Now properly `await`ed before subsequent phases.

## [3.8.6] - 2026-04-06

### Changed

- **Remove new-TODO reporting from turn_end** — The agent writes TODOs intentionally;
  reporting them back at turn-end is noise. Removed the diff-against-baseline TODO
  injection from turn-end findings.

## [3.8.5] - 2026-04-06

### Fixed

- **Pyright CLI duplicates LSP under `--lens-lsp`** — The Pyright CLI runner now skips
  itself when `--lens-lsp` is active, mirroring the existing `ts-lsp` behaviour. The
  `lsp` runner (priority 4, Pyright language server) already covers Python type-checking
  in that mode; running the CLI in parallel was redundant.

## [3.8.2] - 2026-04-06

### Fixed

- **npm publish bump** — 3.8.1 was already published with the broken postinstall; 3.8.2 contains the actual fix.

## [3.8.1] - 2026-04-06

### Fixed

- **`console-statement` hijacking `no-console-in-tests`** — The keyword match for
  `console-statement` (`pattern.includes("console")`) was catching `no-console-in-tests`
  because both contain "console". The simpler rule always won, so both fired on every
  console call. Fixed by excluding test-related patterns: `!pattern.includes("test")`.
- **`hardcoded-secrets` malformed tree-sitter query** — Had two top-level S-expression
  patterns instead of a single union pattern `[...]`. Replaced with valid union syntax
  and added `post_filter: check_secret_pattern` so variable names are actually filtered
  against credential patterns. Reduced false positives from 58 → 0 on the codebase.
- **`postinstall` failing on Windows** — `scripts/` was accidentally in `.gitignore` so
  `scripts/download-grammars.ts` was never committed. Added the script, which downloads
  the 10 tree-sitter WASM grammars from unpkg at install time. Also fixed `|| true`
  which is not valid on Windows cmd.exe — replaced with native Node TS execution via
  `node --experimental-strip-types` (Node 22+, no extra deps).

## [3.8.0] - 2026-04-05

### Added — Tree-sitter Expansion

- **Go, Rust, Ruby grammar support** — WASM grammars for 3 new languages downloaded at
  install time via `scripts/download-grammars.ts`. Grammar download script added with
  npm `download-grammars` script and postinstall hook. Tree-sitter structural analysis
  now covers all 7 dispatch languages: TypeScript, TSX, JavaScript, Python, Go, Rust, Ruby.

- **Tree-sitter dispatch for Go/Rust/Ruby** — Dispatch runner `appliesTo` extended;
  extension→language map replaces the brittle `endsWith` chain. Tree-sitter runner
  added to Go, Rust, and Ruby dispatch plans.

- **Incremental parse cache (`TreeCache`)** — AST trees are cached by SHA-256 content
  hash and mtime. Subsequent queries on the same file (same turn) skip re-parsing.
  Cache stores up to 50 files with LRU eviction. `calculateEdit()` + `incrementalUpdate()`
  infrastructure ready for full incremental parsing when old content is tracked.

- **AST navigator (`TreeSitterNavigator`)** — Scope-aware traversal utilities: `findParent()`,
  `isInTryCatch()`, `isInTestBlock()`, `isInLoop()`, `getScopeChain()`, `isShadowed()`,
  `getSiblings()`. Used by post-filters for context-aware rule evaluation.

- **Native predicate support in queries** — Query YAML files now support a `predicates:`
  array field. Rules with inline `#eq?` / `#match?` / `#not-eq?` predicates run filtering
  inside WASM rather than in JavaScript post-filters.

- **Inline fix hints** — Tree-sitter diagnostics now carry `fixable: true` and
  `fixSuggestion: "remove this statement"` when `has_fix: true` in the rule. Displayed
  as `💡 Fix: remove this statement` inline in the diagnostic output. Tree-sitter runner
  is read-only — linters (Biome/Ruff/ESLint) own the autofix phase.

- **New post-filters** — `not_in_try_catch`, `in_try_catch`, `not_in_test_block`,
  `not_in_function`, `check_secret_pattern`, `python_empty_except`, `ruby_empty_rescue`,
  `name_matches_param`.

### Added — New Rules (50+)

**Structural safety (ast-grep, TypeScript + JavaScript):**

- `unchecked-sync-fs` — `fs.statSync/readFileSync/writeFileSync/...` outside try/catch (error)
- `unchecked-throwing-call` — `JSON.parse`, `new URL()`, `execSync` outside try/catch (error)
- `no-nan-comparison` — `x === NaN` always false, use `Number.isNaN()` (error)
- `no-discarded-error` — `new Error()` as standalone statement without throw (error)

**Structural safety (ast-grep, Python):**

- `unchecked-throwing-call-python` — `open()`, `json.loads()`, `os.stat()` etc. outside
  try/except (error)

**Structural safety (ast-grep, Ruby):**

- `unchecked-throwing-call-ruby` — `File.read`, `JSON.parse`, `Integer()` etc. outside
  begin/rescue (error)

**Tree-sitter Python rules (new):**

- `python-mutable-class-attr` — class-level `list`/`dict`/`set` shared across all instances (error)
- `python-debugger` — `breakpoint()`, `pdb.set_trace()` left in code (error)
- `python-print-statement` — `print()` debug output in production code (warning)
- `python-hardcoded-secrets` — hardcoded credential assignments (error)
- `python-empty-except` — except block that only does `pass` (error)
- `python-unsafe-regex` — `re.compile(variable)` ReDoS risk (error)
- `python-raise-string` — `raise "string"` is TypeError in Python 3 (error)

**Tree-sitter Ruby rules (new):**

- `ruby-rescue-exception` — `rescue Exception` catches SystemExit and signals (error)
- `ruby-empty-rescue` — rescue with no body silently swallows errors (error)
- `ruby-debugger` — `binding.pry` / `binding.irb` left in code (error)
- `ruby-puts-statement` — `puts`/`p`/`pp` debug output in production (warning)
- `ruby-hardcoded-secrets` — hardcoded credential assignments (error)
- `ruby-unsafe-regex` — `Regexp.new(variable)` ReDoS risk (error)

**Tree-sitter Go rules (new):**

- `go-hardcoded-secrets` — hardcoded credentials in short/var/const declarations (error)

**JavaScript coverage (38 new rules):**
All runtime-applicable TypeScript ast-grep rules now have JavaScript equivalents:
`strict-equality`, `empty-catch`, `no-throw-string`, `no-cond-assign`,
`no-async-promise-executor`, `toctou`, `no-hardcoded-secrets`, `no-inner-html`,
`no-insecure-randomness`, `no-sql-in-code`, `jwt-no-verify`, `weak-rsa-key`, and 26 more.

### Changed — Severity Upgrades

**17 ast-grep rules upgraded from `warning` to `error`** (will crash / produce wrong output):
`empty-catch`, `array-callback-return`, `getter-return`, `jsx-boolean-short-circuit`,
`no-async-promise-executor`, `no-await-in-promise-all`, `no-bare-except`,
`no-compare-neg-zero`, `no-cond-assign`, `no-constant-condition`,
`no-constructor-return`, `no-insecure-randomness`, `no-prototype-builtins`,
`no-sql-in-code`, `no-throw-string`, `toctou`, `no-comparison-to-none`.

**4 tree-sitter rules upgraded from `warning` to `error`**:
`go-defer-in-loop`, `is-vs-equals`, `rust-unwrap`, `unsafe-regex`.

### Fixed

- **`console-statement` duplicating `no-console-in-tests`** — `console-statement` now
  uses `post_filter: not_in_test_block` so production and test console detection are
  mutually exclusive.

- **`variable-shadowing` never detecting actual shadowing** — Rule now captures both
  `@PARAM` and `@NAME`; `name_matches_param` post-filter only flags when names are
  identical. Previously the rule fired on any variable in a nested function.

- **`isInLoop()` false positives** — `call_expression` removed from loop node type list.
  Previously `isInLoop()` returned `true` inside any function call.

- **`injectPredicates()` inserting at wrong AST position** — Broken predicate injection
  machinery removed. Predicates already work inline in query S-expressions.

- **`sql-injection` rule not matching `db.query()`** — Query now uses union
  `[identifier | member_expression]` to catch both bare `query()` and `db.query()`.

- **`contains_sql_keywords` post-filter inverted logic** — Rule was skipping `sql`
  tagged templates (the primary SQL injection vector). Post-filter removed entirely;
  rule relies on inline `#match?` predicate.

- **`no-discarded-error` ast-grep `not: inside:` not traversing ancestors** — Required
  `stopBy: end` in ast-grep's `inside` predicate to check all ancestors, not just the
  direct parent. Applied to all `not: inside:` rules.

- **Go/Rust/Ruby rules silently skipped** — Runner `appliesTo` was `["jsts", "python"]`
  only. Extended to include `go`, `rust`, `ruby`.

### Fixed (from PR #1 — alexx-ftw)

- **`process.cwd()` wrong for global npm installs** — All asset resolution (WASM grammars,
  tree-sitter query YAMLs, ast-grep rule directories, `default-architect.yaml`) now uses
  `resolvePackagePath(import.meta.url, ...)` which walks up from the module file to the
  package root. Previously, running pi-lens as a globally installed extension would fail
  to find built-in rules and grammars.

- **Session start scanning `$HOME` or generic directories** — `resolveStartupScanContext()`
  gates all heavy startup scans (knip, jscpd, exports index, project index) behind project
  root detection (`.git`, `package.json`, `go.mod`, etc.) and a 2000-source-file budget.
  Pi-lens stays responsive when opened outside a real project.

- **`cachedExports` not cleared on session reset** — Export cache from the previous
  session persisted into new sessions, causing false duplicate-export warnings.

- **`biomeClient.ensureAvailable()` at session start** — Changed to `isAvailable()` so
  session start no longer blocks on a Biome auto-install. Installs happen lazily on
  first file write.

- **Project index not persisted across sessions** — Index now saved to disk after build
  via `saveIndex()`, and `isIndexFresh()` check skips rebuild when the saved index is
  still current.

- **`tree-sitter-query-loader` only loading from `process.cwd()`** — Now loads from
  both the user's project rules directory AND the package's built-in rules, merging
  both sets. Project-specific rules coexist with built-in rules.

---

## [3.7.2] - 2026-04-05

### Added

- **All-clear signal** — When the pipeline runs clean (no blockers, no test failures),
  the agent now receives a confirmation one-liner instead of silence:
  `✓ TypeScript clean · 12/12 tests · 847ms`
  When non-blocking warnings exist: `✓ no blockers · 3 warning(s) -> /lens-booboo · 847ms`
  Agents can now distinguish "checks ran clean" from "checks didn't run".

### Fixed

- **Auto-fix message now names the tool** — `✅ Auto-fixed 3 issue(s) (eslint:2, biome:1)`
  instead of the vague `Auto-fixed 3 issue(s)`. Agents know exactly what was corrected.

### Security

- **Remove `effect` dependency** — Used for 5 trivial `tryPromise` wrappers in one file,
  never consumed via Effect's runtime. Dead dependency removed.
- **`--ignore-scripts` in auto-installer** — `npm install` for auto-installed tools now
  passes `--ignore-scripts` by default. Only packages that legitimately need postinstall
  scripts to download native binaries (`@biomejs/biome`, `@ast-grep/napi`, `esbuild`) are
  allowlisted.
- **`npx -y` replaced with `npx --no`** — LSP server launch via npx no longer silently
  downloads uncached packages. `--no` fails fast if the package isn't cached; the
  interactive-install flow is the correct path for first-time installs.
- **Local-first `sg` (ast-grep) resolution** — All `sg` callers now check
  `node_modules/.bin/sg` → global `sg` → `npx --no sg` (cache-only). No silent
  network downloads of the ast-grep CLI.

---

## [3.7.2] - 2026-04-05 (previous)

### Added

- **ESLint `--fix` in autofix phase** — Projects with an ESLint config now have fixable
  issues auto-corrected (import ordering, jsx style, etc.) before dispatch runs, using
  `--fix-dry-run` to get the accurate fixed count then `--fix` to apply. Availability
  is cached per session. Only fires on JS/TS files with an ESLint config present.

### Fixed

- **Misleading infinite-loop comment in biome/ruff runners** — The comment incorrectly
  stated that writing files from runners would trigger infinite loops (formatters already
  prove this isn't true). Updated to explain the real reason: dispatch runners report
  issues for agent understanding; silently rewriting would leave the agent's context
  window stale.

---

## [3.7.1] - 2026-04-05

### Added

- **ESLint dispatch runner** — Projects with `.eslintrc` / `eslint.config.js` (any variant)
  now run ESLint automatically on every JS/TS file write. Prefers local
  `node_modules/.bin/eslint` over global. Skips silently on projects using Biome/OxLint
  (no ESLint config). ESLint errors (severity 2) are blocking; warnings are non-blocking.

- **golangci-lint dispatch runner** — Go projects with `.golangci.yml` / `.golangci.yaml`
  now run golangci-lint on every `.go` file write (in addition to `go-vet`). Parses JSON
  output. Skips when no config is present (avoids default-rule noise on non-opted-in
  projects). 60s timeout.

- **RuboCop dispatch runner** — Ruby files (`.rb`, `.rake`, `.gemspec`, `.ru`) now run
  RuboCop in lint-only mode on every write. Prefers `bundle exec rubocop` when a Gemfile
  references rubocop. Fatal/error offenses are blocking; convention/refactor are warnings.

- **`ruby` file kind** — `.rb`, `.rake`, `.gemspec`, `.ru` files are now recognised as
  `ruby` kind, enabling file-kind-gated runners and formatter detection.

---

## [3.7.0] - 2026-04-05

### Added

- **Test runner in pipeline** — After every file write/edit, pi-lens now automatically detects and
  runs the corresponding test file (vitest, jest, pytest). Results surface inline so the agent sees
  failures immediately without a separate test step. Supports TypeScript/JS/Python; file-level
  targeted — only the test for the edited file runs, not the full suite.

- **Parallel dispatch groups** — Lint runners now execute in parallel across independent groups
  (e.g. `lsp`, `tree-sitter`, `ast-grep-napi`, `type-safety`, `similarity` all fire at once).
  Typical wall-clock savings: 500–1500ms per file write (`parallelGainMs` logged in latency log).

### Fixed

- **`semantic: "none"` when 0 diagnostics** — LSP, Pyright, and type-safety runners were returning
  `semantic: "warning"` even when `diagnosticCount` was 0 (clean file). Now correctly returns
  `"none"` when no diagnostics are present, `"warning"` when warnings exist, `"blocking"` on errors.

- **`ast_grep_replace` with `apply=true` not writing files** — Replaced tool was silently
  discarding the rewritten content instead of persisting it to disk.

- **Pipeline event loop blocked during test execution** — `spawnSync` in the test runner was
  blocking the Node.js event loop for the duration of the test run. Switched to async spawn.

- **Formatters: venv/vendor/node_modules awareness** — Formatters now skip files inside virtual
  environments, vendor directories, and `node_modules` instead of attempting to format them.
  CSharpier detection also improved.

- **Formatter nearest-wins resolution** — When multiple formatter configs exist at different
  directory levels, the one closest to the edited file is now used (was previously using the
  root-level config regardless of nesting).

- **Prettier auto-install** — Prettier is now auto-installed when detected as the project
  formatter but not present, consistent with the Biome/Ruff auto-install behaviour.

- **6 missing formatters added** — `clang-format` (C/C++/ObjC), `ktlint` (Kotlin), `scalafmt`
  (Scala), `mix format` (Elixir), `dart format` (Dart), `terraform fmt` (HCL) now detected
  and invoked automatically.

- **LSP tier-4 install prompts** — Corrected missing interactive-install prompts for tier-4
  language servers (less common languages). Users now see the install suggestion instead of a
  silent skip.

### Changed

- **`startedAt` added to latency log runner entries** — Every runner entry now records when it
  started, making wall-clock vs. sequential comparisons accurate. `dispatch_complete` also logs
  `parallelGainMs = sumMs - wallClockMs` to quantify parallelism benefit.

- **Dynamic imports removed from hot path** — Dispatch module no longer uses `await import()`
  for runner loading; all imports are static, eliminating ~50ms warm-up latency on first dispatch.

### Tests

- Added formatter venv/vendor resolution and interactive-install coverage
- Added LSP lifecycle test suite with mock LSP server (process spawn, open/change/close, shutdown)

---

## [3.6.7] - 2026-04-04

### Fixed

- **LSP `ERR_STREAM_DESTROYED` crash** — When an LSP process (e.g. rust-analyzer) exits, Node.js emits
  `'error'` events on the destroyed stdio streams. Without listeners these became uncaught exceptions
  that crashed the extension. Added persistent `error` listeners to `stdin`, `stdout`, and `stderr`
  before handing them to `vscode-jsonrpc`, covering the post-`connection.dispose()` window.
  Same guard added to `NativeRustCoreClient` stdin writes.

### Added

- **Rust performance core (`pi-lens-core`)** — Optional Rust binary for CPU-intensive operations.
  All features fall back to TypeScript automatically if the binary is not available (it is **not**
  built automatically on `npm install` — run `npm run rust:build` once if you have Rust installed).
  - **File scanning** — ripgrep’s `ignore` crate for `.gitignore`-aware project scanning
  - **Similarity detection** — parallel 57×72 state-matrix index, persisted to
    `.pi-lens/rust-index.json` between invocations (fixes in-memory cache that reset on every
    process spawn)
  - **Tree-sitter queries** — TypeScript and Rust AST queries via the binary
  - **`NativeRustCoreClient`** — TypeScript wrapper with `isBinaryStale()` freshness detection,
    JSON-IPC over stdin/stdout
  - **Integration tests** — `npm run rust:test:integration` (37 assertions across all commands)

- **Rust similarity fast-path in dispatch runner** — `similarity.ts` now tries the Rust binary
  first (scan → build index → query), falls through to the TypeScript implementation on any
  failure. Feature flag `USE_RUST = true` at top of file.

### Changed

- **Similarity threshold raised from 0.75 → 0.90** — Empirical evaluation showed that below 0.90
  false positives (structurally similar but semantically unrelated functions) outnumber true
  positives with the current 57×72 matrix resolution. Applies to both the dispatch runner and
  `/lens-booboo`.

- **Rust `kind_id` mapping improved** — Replaced `kind % dim` modulo (caused up to 4 unrelated
  node types to share one matrix slot) with even-distribution across named slots plus a dedicated
  last slot for anonymous punctuation tokens. Max named-slot collisions reduced from 4 to 3;
  unnamed tokens no longer pollute named slots.

### Fixed (Rust)

- `tree_sitter_rust::language_rust()` → `language()` (correct API for tree-sitter-rust 0.21)
- `FunctionInfo` missing `#[derive(Clone)]` — caused compile error in `find_similar_to`
- `export function foo()` was missed by the index builder — TypeScript wraps exported functions
  in `export_statement`; replaced flat top-level walk with recursive `collect_functions()`
- `find_similar_to` returned only the first function in a file — changed `find` to `filter`
- `tempfile` moved from `[dependencies]` to `[dev-dependencies]`
- Deleted orphan `test_lsp.rs` (intentional type errors caused rust-analyzer to crash the LSP stream)

### Repository

- Rust source (`rust/src/`, `rust/Cargo.toml`) added to npm `files` whitelist so users can build
  the binary from an npm-installed package
- Removed stale `src/main.rs` rule from root `.gitignore` (no such file at repo root)
- Untracked `docs/plans/2025-04-03-auto-install-logging.md` (committed before `*.md` exclusion rule)

---

## [3.6.3] - 2026-04-03

### Removed (Dead Code Cleanup)

- **Deleted unused interviewer tool** — Browser-based interview with diff confirmation was never used:
  - Removed `clients/interviewer.ts` (290 lines)
  - Removed `clients/interviewer-templates.ts` (240 lines)
  - Removed initialization from `index.ts`
- **Deleted deprecated commands** — All were superseded by `/lens-booboo`:
  - `/lens-booboo-fix` command (fix-from-booboo.ts, 430 lines) — showed warning to use `/lens-booboo`
  - `/lens-fix-simplified` command (fix-simplified.ts, 770 lines) — never registered, unused
  - `/lens-rate` command (rate.ts, 340 lines) — showed warning to use `/lens-booboo`
  - `/lens-booboo-refactor` command (refactor.ts, 207 lines) — depended on removed interviewer tool

- **Deleted duplicate safe-spawn module**:
  - Removed `clients/safe-spawn-async.ts` (220 lines) — 100% duplicate of functions in `safe-spawn.ts`
  - All imports already used `safe-spawn.ts`, making `safe-spawn-async.ts` pure dead code

### Test Suite Overhaul

- **Removed ~85 wasteful/broken test files**:
  - "Is tool available" tests (8 files) — just checked if external CLIs installed
  - Heavy integration tests (2 files) — 5s timeouts, full codebase scans
  - Broken LSP tests (7 files) — import path errors
  - Broken runner tests (7 files) — thin CLI wrappers with wrong imports
  - Trivial utility tests (5 files) — file extension parsing, string sanitization
- **Added meaningful integration tests**:
  - `tests/clients/dispatch/dispatcher-flow.test.ts` — Runner registration, execution, delta mode, conditional runners
  - `tests/extension-hooks.test.ts` — pi API: tool/command/flag registration, event handlers
  - `tests/mocks/runner-factory.ts` — Mock runners for testing without real CLI tools

- **Results:** 22 tests passing in 1.2s (was 104 tests in ~18s with 48 failures)

## [3.6.2] - 2026-04-02

### Added

- **Condensed skill auto-loading** — Injects ~70-token tool selection guidance at session start (vs 1,355 for full skills):
  - Quick reference for when to use lsp_navigation vs ast_grep_search vs grep
  - References full skills for lazy loading (ast-grep, lsp-navigation)
  - Prevents common tool selection errors without loading full skill content

### Changed

- **Streamlined session start injection** — Removed TODO/Knip/jscpd reports from initial context:
  - Scans still run and cache for on-demand access via `/lens-booboo`
  - Reduces session start noise (only active tools list, error reminder, skill guidance remain)
  - Caching preserved for duplicate detection on file writes

## [3.6.1] - 2026-04-02

### Changed

- **Updated package description** — More concise: "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo"

### Repository

- **AGENTS.md is now local-only** — Removed from git repo and added to `.gitignore` so it stays local to each developer's environment
- **Cleaned up debug files** — Removed old test files (`_debug-*.ts`, `_trigger-test.ts`, `_test-*.ts`) from repo

## [3.6.0] - 2026-04-02

### Added

- **LSP Call Hierarchy Support** — Added 3 new operations to `lsp_navigation` tool:
  - `prepareCallHierarchy` — Get callable item at position
  - `incomingCalls` — Find all functions/methods that CALL this function
  - `outgoingCalls` — Find all functions/methods CALLED by this function
  - Use case: "Who calls this function?" and "What does this function depend on?"
- **LSP Navigation Skill** — New built-in skill (`skills/lsp-navigation/SKILL.md`) that guides LLM on when to use LSP for code intelligence vs other tools
- **AST-Grep Skill Improvements** — Enhanced `skills/ast-grep/SKILL.md` with:
  - Testing Tips section (Search → Dry-run → Apply workflow)
  - Metavariable selection guide ($ vs $$$)
  - Specific guidance for "Multiple AST nodes" error
- **Skills Registration** — Extension now registers `skills/` directory via `resources_discover` event, exposing both `ast-grep` and `lsp-navigation` skills to pi
- **Enhanced TDI (Technical Debt Index) with 5-factor formula** — Now captures "worst offender" functions and code unpredictability:
  - **Max Cyclomatic (10%)**: Catches worst function complexity (avg hides bad apples)
  - **Entropy (5%)**: Measures code unpredictability/vocabulary richness in bits
  - Rebalanced weights: MI (45%), Cognitive (30%), Nesting (10%), MaxCyc (10%), Entropy (5%)
  - New thresholds: MaxCyc >10 bad, >30 critical; Entropy >4.0 bits risky, >7.0 critical

### Removed

- **TDR (Technical Debt Ratio)** — Removed orphaned metric tracking system:
  - Deleted `TDREntry`, `TDRCategory` types, `tdrFindings` Map, `updateTDR()` method
  - Removed `convertDiagnosticsToTDREntries()` helper and all `tdrCategory` assignments
  - Deleted TDR test file
  - TDI is sufficient for code health tracking; inline diagnostics provide immediate feedback

### Changed

- **Updated `/lens-tdi` display** — Shows 5 category breakdown with descriptions:

  ```
  Debt breakdown:
    Maintainability: 45% (MI-based)
    Cognitive: 30%
    Nesting: 10%
    Max Cyclomatic: 10% (worst function)
    Entropy: 5% (code unpredictability)
  ```

- **Extended MetricSnapshot** — Added `maxCyclomatic` and `entropy` fields for historical tracking

---

## [3.5.0] - 2026-04-02

### Added

- **Tree-sitter query compilation cache** — 10× performance improvement for structural analysis. Query files (`.yml`) are compiled to binary `.wasm-cache` format once and cached to disk. Subsequent loads use the compiled cache directly, reducing tree-sitter startup from ~50ms to ~5ms per query. Cache uses mtime-based invalidation — automatically recompiles when source `.yml` changes.
- **Rule cache infrastructure** (`clients/cache/`) — New disk-backed cache system with:
  - `RuleCache` class for storing compiled artifacts
  - mtime-based invalidation (auto-refresh when source files change)
  - JSON metadata tracking for cache entries
  - TTL and integrity validation

### Fixed

- **YAML parser colon truncation** — Fixed regex-based parser that incorrectly truncated values containing colons. Changed from `split(':', 2)` to `indexOf(':')` for proper value extraction.
- **Tree-sitter rules directory resolution** — Fixed path resolution to use `ctx.cwd` instead of hardcoded `.pi-lens/rules/` path. Rules now load correctly from the actual project root regardless of where pi is invoked.
- **Tree-sitter post_filter support** — Implemented missing `post_filter` functionality for tree-sitter queries. Rules with post-filters (e.g., semantic validation for `bare-except` vs specific exception handlers) now work correctly instead of being silently skipped.
- **Event handler silent crashes** — Wrapped all event handlers in try/catch to prevent unhandled exceptions from crashing the extension silently. Errors are now logged to stderr instead of terminating the process.
- **Latency logging restored** — Fixed missing latency logging in `tool_result` handler. Runner timing data now correctly flows to `~/.pi-lens/latency.log` again.

### Removed

- **Broken ast-grep rules** — Removed overlapping rules that were causing false positives or conflicts with tree-sitter coverage.

---

## [3.4.0] - 2026-04-02

### Fixed

- **Delta mode was broken** — `dispatchLint()` created a fresh empty baseline store on every call, making delta filtering a complete no-op. Every issue looked "new" every time. Now uses a persistent session-level baseline store. First write captures baseline, subsequent writes only show NEW issues.
- **Duplicate type-checking with `--lens-lsp`** — Both the `lsp` runner (priority 4) and `ts-lsp` runner (priority 5) were calling the same LSP service for TypeScript files. `ts-lsp` now skips when `--lens-lsp` is active.

### Added

- **Inline security rules via ast-grep-napi** — Re-enabled the ast-grep-napi runner for real-time blocking on security violations (`no-eval`, `jwt-no-verify`, `no-hardcoded-secrets`, `weak-rsa-key`, `no-open-redirect`, etc.). Only error-severity rules fire inline; warnings remain in `/lens-booboo`. Skips 5 rules already covered by tree-sitter to avoid duplicates. ~9ms execution time.
- **Pre-write duplicate detection (two layers):**
  - **Exact name match** — Checks exported names in new content against the session’s cached export index. If a function/class/type already exists in another file, blocks the write: `🔴 STOP — function X already exists in utils.ts. Import instead.`
  - **Structural similarity** — Parses new functions, builds AST state matrices, compares against the project index (built at session start). Functions with ≥80% structural similarity trigger a warning with the match location. Non-blocking.
- **Project similarity index at session start** — Builds 57×72 state matrices for all TS functions at session start (cached to `.pi-lens/index.json`). Makes pre-write similarity checks ~50ms instead of seconds.

### Changed

- **Extracted post-write pipeline** — Moved the entire post-write pipeline (secrets, format, autofix, dispatch, tests, cascade diagnostics) from `index.ts` into `clients/pipeline.ts`. `index.ts` reduced from 1764 to 1439 lines.
- **Removed inline complexity warnings** — `⚠️ Complexity increased: +4 cognitive` no longer shown on every write. No agent acts on this mid-task. Complexity data still captured for `/lens-booboo` and `/lens-tdi`.
- **Simplified pre-write handler** — Removed pre-write TypeScript and LSP diagnostics checks (checked old content before write landed — post-write catches everything). Kept only complexity baseline capture and duplicate detection.

---

## [3.3.1] - 2026-04-02

### Fixed

- **LSP spawn `EINVAL` on Windows** — `.cmd` files (e.g. `vscode-json-language-server.cmd`) found via npm global lookup were spawned without `shell: true`, causing `EINVAL` from `CreateProcess`. The `needsShell` recomputation for npm global paths incorrectly treated `.cmd` the same as `.exe`. Fixed in both primary and fallback spawn paths.
- **Unhandled `EINVAL` rejection** — LSP error handlers only caught `ENOENT` (binary not found). `EINVAL` (binary found but can't execute directly) now caught alongside `ENOENT` in both `launchLSP` and `launchViaPackageManager`.

---

## [3.3.0] - 2026-04-02

### Removed

- **`--lens-bus`**: Removed the experimental event bus system (Phase 1). The sequential dispatcher has richer features (delta mode, per-runner latency, baseline tracking) that the bus system never had.
- **`--lens-bus-debug`**: Removed alongside `--lens-bus`.
- **`--lens-effect`**: Removed the Effect-TS concurrent runner execution system (Phase 2). The sequential `dispatchForFile` is the authoritative implementation — it has delta mode, async `when()` handling, and latency tracking that the effect system lacked.

### Changed

- **LSP client**: `waitForDiagnostics` in `clients/lsp/client.ts` now uses a local `EventEmitter` scoped to the client instance instead of the global bus for internal diagnostic signalling.

---

## [3.2.0] - 2026-04-02

### Fixed

- **LSP server initialization errors** — Fixed `workspaceFolders` capability format that caused gopls and rust-analyzer to crash with JSON RPC parse errors. Changed from object `{supported: true, changeNotifications: true}` to simple boolean `true` for broader compatibility.
- **Formatter cwd not passed** — `formatFile` now passes `cwd` to `safeSpawn`, fixing Biome's "nested root configuration" error when formatting files in subdirectories.
- **LSP runner error handling** — Added try-catch around LSP operations to properly detect and report server spawn/connection failures instead of silently returning empty success.

### Changed

- **Go/Rust LSP initialization** — Added server-specific initialization options for better compatibility.

---

## [3.1.3] - 2026-04-02

### Fixed

- **Biome autofix: removed `--unsafe` flag** — `--unsafe` silently deleted unused variables
  and interfaces, removing code the agent was mid-way through writing (e.g. a new interface
  not yet wired up). Only safe fixes (`--write`) are now applied automatically on every write.
  Unsafe fixes require explicit opt-in.
- **Tree-sitter WASM crash on concurrent writes** — The tree-sitter runner was creating a
  `new TreeSitterClient()` on every post-write event. Each construction re-invoked
  `Parser.init()` → `C._ts_init()`, which resets the module-level `TRANSFER_BUFFER` pointer
  used by all active WASM operations. Concurrent writes (fast multi-file edits) raced on
  `_ts_init()` and corrupted shared WASM state → process crash. Fixed with a module-level
  singleton (`getSharedClient()`). Also fixes the secondary bug where each fresh client had
  an empty internal `queryLoader`, making the tree-sitter runner a silent no-op.
- **`blockingOnly` missing in bus/effect dispatchers** — `dispatchLintWithBus` and
  `dispatchLintWithEffect` were not passing `blockingOnly: true` to `createDispatchContext`,
  causing warning-level runners to execute on every write when `--lens-bus` or `--lens-effect`
  was active. Now consistent with the standard `dispatchLint` behaviour.
- **Async `when` condition silently ignored in bus dispatcher** — `dispatchConcurrent` was
  filtering runners with `.filter(r => r.when ? r.when(ctx) : true)`. Since `r.when(ctx)`
  returns `Promise<boolean>`, a truthy promise object was always passing the filter regardless
  of the actual condition. The check is now awaited properly inside `runRunner()`.

### Performance

- **Biome: local binary instead of npx** — `BiomeClient` now resolves
  `node_modules/.bin/biome.cmd` (Windows) or `node_modules/.bin/biome` before falling back
  to `npx @biomejs/biome`. Eliminates ~1 s npx startup overhead per invocation.
  Result: `checkFile` 1029 ms → **176 ms**, `fixFile` 2012 ms → **158 ms**.
- **Biome: eliminated redundant pre-flight `checkFile` in `fixFile`** — `fixFile` was calling
  `checkFile` (a full `biome check --reporter=json`) solely to count fixable issues for
  logging, then running `biome check --write` anyway. The count is now derived from the
  content diff (`changed ? 1 : 0`), saving one full biome invocation per write.
  Combined with the format phase, biome now runs at most **2×** per write (format + fix)
  instead of 3×.
- **TypeScript pre-write check: halved `getSemanticDiagnostics` calls** — `getAllCodeFixes()`
  was calling `getDiagnostics()` internally, but `index.ts` also called `getDiagnostics()`
  immediately before it — running the full TypeScript semantic analysis twice per pre-write
  event (~1.2 s each on a 1700-line file). `getAllCodeFixes` now accepts an optional
  `precomputedDiags` parameter; `index.ts` passes the already-computed result.
  `ts_pre_check` latency: ~2400 ms → **~1200 ms**.

---

## [3.1.1] - 2026-04-01

### Added

- **File-based latency logging** — Performance analysis via `~/.pi-lens/latency.log`
  - New `latency-logger.ts` module for centralized logging
  - Logs every runner's timing (ts-lsp, ast-grep-napi, biome, test-runner, etc.)
  - Logs tool_result overall timing with result status (completed/blocked/no_output)
  - JSON Lines format for easy analysis with `jq`
  - Read with: `cat ~/.pi-lens/latency.log | jq -s '.[] | select(.type=="runner")'`

---

## [3.1.0] - 2026-04-01

### Changed

- **Consolidated ast-grep runners** — Unified CLI and NAPI runners with shared rule set
  - NAPI runner now primary for dispatch (100x faster than CLI spawn)
  - Merged ts-slop-rules (21 files) into ast-grep-rules/slop-patterns.yml (33 patterns)
  - Removed 20 duplicate rule files with conflicting IDs (e.g., `ts-jwt-no-verify` vs `jwt-no-verify`)
  - Total: 104 unified rules (71 security/architecture + 33 slop patterns)
  - CLI ast-grep kept only for `ast_grep_search` / `ast_grep_replace` tools

### Fixed

- **ast-grep-napi stability** — Fixed stack overflow crashes in AST traversal
  - Added `_MAX_AST_DEPTH = 50` depth limit to `findByKind()` and `getAllNodes()`
  - Added `_MAX_RULE_DEPTH = 5` recursion limit for structured rules
  - Added `MAX_MATCHES_PER_RULE = 10` to prevent false positive explosions
  - Added `MAX_TOTAL_DIAGNOSTICS = 50` to prevent output spam
  - NAPI runner now safely handles deeply nested TypeScript files

---

## [3.0.1] - 2026-03-31

### Changed

- **Documentation refresh**: Updated npm and README descriptions for v3.0.0 features
  - New tagline: "pi extension for real-time code quality"
  - Highlights 31 LSP servers, tree-sitter analysis, auto-install capability
  - Clarified blockers vs warnings split (inline vs `/lens-booboo`)

### Fixed

- **Entropy threshold**: Increased from 3.5 → 5.5 bits to reduce false positives
  - Previous threshold was too sensitive for tooling codebases
  - Eliminates ~70-80% of "High entropy" warnings on legitimate complex code

---

## [3.0.0] - 2026-03-31

### Breaking Changes

#### Removed - Deprecated Commands

The following deprecated commands have been removed:

- `/lens-booboo-fix` → Use `/lens-booboo` with autofix capability
- `/lens-booboo-delta` → Delta mode now automatic
- `/lens-booboo-refactor` → Use `/lens-booboo` findings
- `/lens-metrics` → Metrics now in `/lens-booboo` report
- `/lens-rate` → Use `/lens-booboo` quality scoring

#### Changed - Blockers vs Warnings Architecture

- **🔴 Blockers** (type errors, secrets, empty catch blocks) → Appear **inline** and stop the agent
- **🟡 Warnings** (complexity, code smells) → Go to **`/lens-booboo`** only (not inline)
- Tree-sitter rules with `severity: error` now properly block inline
- Dispatcher checks individual diagnostic semantic, not just group default

### Added - Tree-Sitter Runner

New structural analysis runner at priority 14:

- **18 YAML query files** for TypeScript and Python patterns
- TypeScript: empty-catch, eval, debugger, console-statement, hardcoded-secrets, deep-nesting, deep-promise-chain, mixed-async-styles, nested-ternary, long-parameter-list, await-in-loop, dangerously-set-inner-html
- Python: bare-except, eval-exec, wildcard-import, is-vs-equals, mutable-default-arg, unreachable-except
- Blockers appear inline (severity: error), warnings go to `/lens-booboo` (severity: warning)

### Added - Auto-Install for Core Tools

Four tools now auto-install on first use (no manual setup required):

1. **TypeScript Language Server** (`typescript-language-server`) — TS/JS type checking
2. **Pyright** — Python type checking (`pip install pyright`)
3. **Ruff** — Python linting (`pip install ruff`)
4. **Biome** — JS/TS/JSON linting and formatting

Installs to `.pi-lens/tools/` with verification step (`--version` check).

### Added - NAPI Security Rules

Migrated 20 critical security rules to NAPI (fast native execution):

- Rules with `weight >= 4` are **blocking** (stop the agent)
- Includes: no-eval, no-hardcoded-secrets, no-implied-eval, no-inner-html, no-dangerously-set-inner-html, no-debugger, no-javascript-url, no-open-redirect, no-mutable-default, weak-rsa-key, jwt-no-verify, and more
- NAPI runs at priority 15 (after tree-sitter, before slop rules)

### Fixed

- **Tree-sitter query loading**: Added missing `loadQueries()` call before `getAllQueries()`
- **Windows path handling**: Changed from `lastIndexOf("/")` to `path.dirname()` for cross-platform compatibility
- **Dispatcher blocker detection**: Now checks if any individual diagnostic has `semantic === "blocking"`
- **Biome runner npx fallback**: Uses `npx biome` when `biome` not in PATH directly
- **LSP ENOENT crashes**: Added `_attachErrorHandler()` to all 23 manual-install LSP servers
- **LSP initialization timeout**: Increased to 120s (was 45s)
- **ESLint scope reduction**: Removed `.ts/.tsx` from ESLint LSP (now JS/framework files only)
- **Biome/Prettier race**: Biome is now default (priority 10), Prettier is fallback only

### Changed

- **README reorganization**: Removed redundant sections (Architecture, Language Support, Rules, Delta-mode, Slop Detection)
- **Consolidated Additional Safeguards** into Features section with Runners table
- **Updated .gitignore**: Local tracking files stay out of repo
- **Tuned thresholds**: 70-80% false positive reduction in booboo reports

---

## [2.7.0] - 2026-03-31

### Added - New Lint Runners

Three new lint runners with full test coverage:

- **Spellcheck runner** (`clients/dispatch/runners/spellcheck.ts`): Markdown spellchecking
  - Uses `typos-cli` (Rust-based, fast, low false positives)
  - Checks `.md` and `.mdx` files
  - Priority 30, runs after code quality checks
  - Zero-config by default
  - Install: `cargo install typos-cli`

- **Oxlint runner** (`clients/dispatch/runners/oxlint.ts`): Fast JS/TS linting
  - Uses `oxlint` from Oxc project (Rust-based, ~100x faster than ESLint)
  - Zero-config by default
  - JSON output with fix suggestions
  - Priority 12 (between biome=10 and slop=25)
  - Fallback mode after biome
  - Install: `npm install -D oxlint` or `cargo install oxlint`
  - Flag: `--no-oxlint` to disable

- **Shellcheck runner** (`clients/dispatch/runners/shellcheck.ts`): Shell script linting
  - Industry-standard linter for bash/sh/zsh/fish
  - Detects syntax errors, undefined variables, quoting issues
  - Priority 20 (same as type-safety)
  - JSON output parsing
  - Install: `apt install shellcheck`, `brew install shellcheck`, or `cargo install shellcheck`
  - Flag: `--no-shellcheck` to disable

### Changed

- Updated README.md with new runners in dispatcher diagram and available runners table
- Added installation instructions for new tools in Dependent Tools section
- Added new flags to Flag Reference

---

## [2.6.0] - 2026-03-30

### Added - Phase 1: Event Bus Architecture

- **Event Bus System** (`clients/bus/`): Decoupled pub/sub for diagnostic events
  - `bus.ts` — Core publish/subscribe with `once()`, `waitFor()`, middleware support
  - `events.ts` — 12 typed event definitions (DiagnosticFound, RunnerStarted, LspDiagnostic, etc.)
  - `integration.ts` — Integration hooks for pi-lens index.ts with aggregator state
- **Bus-integrated dispatcher** (`clients/dispatch/bus-dispatcher.ts`): Concurrent runner execution with event publishing
- **New flags**: `--lens-bus`, `--lens-bus-debug` for event system control

### Added - Phase 2: Effect-TS Service Layer

- **Effect-TS infrastructure** (`clients/services/`): Composable async operations
  - `runner-service.ts` — Concurrent runner execution with timeout handling
  - `effect-integration.ts` — Bus-integrated Effect dispatch
- **Structured concurrency**: `Effect.all()` with `{ concurrency: "unbounded" }`
- **Graceful error recovery**: Individual runner failures don't stop other runners
- **New flag**: `--lens-effect` for concurrent execution

### Added - Phase 3: Multi-LSP Client (31 Language Servers)

- **LSP Core** (`clients/lsp/`): Full Language Server Protocol support
  - `client.ts` — JSON-RPC client with debounced diagnostics (150ms)
  - `server.ts` — 31 LSP server definitions with root detection
  - `language.ts` — File extension to LSP language ID mappings
  - `launch.ts` — LSP process spawning utilities
  - `index.ts` — Service layer with Effect integration
  - `config.ts` — Custom LSP configuration support (`.pi-lens/lsp.json`)
- **Built-in servers** (31 total):
  - Core: TypeScript, Python, Go, Rust, Ruby, PHP, C#, F#, Java, Kotlin
  - Native: C/C++, Zig, Swift, Dart, Haskell, OCaml, Lua
  - Functional: Elixir, Gleam, Clojure
  - DevOps: Terraform, Nix, Docker, Bash
  - Config: YAML, JSON, Prisma
  - Web (NEW): Vue, Svelte, ESLint, CSS/SCSS/Sass/Less
- **Smart root detection**: `createRootDetector()` walks up tree looking for lockfiles/config
- **Multi-server support**: Multiple LSP servers can handle same file type
- **Debounced diagnostics**: 150ms debounce for cascading diagnostics (syntax → semantic)
- **New flag**: `--lens-lsp` to enable LSP system
- **Deprecated**: Old `ts-lsp` runner falls back to built-in TypeScriptClient when `--lens-lsp` not set

### Added - Phase 4: Auto-Installation System

- **Auto-installer** (`clients/installer/`): Automatic tool installation
  - `index.ts` — Core installation logic for npm/pip packages
  - `isToolInstalled()` — Check global PATH or local `.pi-lens/tools/`
  - `installTool()` — Auto-install via npm or pip
  - `ensureTool()` — Check first, install if missing
- **Auto-installation for**: typescript-language-server, pyright, ruff, biome, ast-grep
- **Local tools directory**: `.pi-lens/tools/node_modules/.bin/`
- **PATH integration**: Local tools automatically added to PATH
- **LSP integration**: TypeScript and Python servers now use `ensureTool()` before spawning

### Changed - Commands

- **Disabled**: `/lens-booboo-fix` — Now shows warning "currently disabled. Use /lens-booboo"
- **Disabled**: `/lens-booboo-delta` — Now shows warning "currently disabled. Use /lens-booboo"
- **Disabled**: `/lens-booboo-refactor` — Now shows warning "currently disabled. Use /lens-booboo"
- **Active**: `/lens-booboo` — Full codebase review (only booboo command now)

### Changed - Architecture

- **Three-phase system**: Bus → Effect → LSP can be enabled independently
- **Dispatcher priority**: `lens-effect` > `lens-bus` > default (sequential)
- **LSP deprecation**: Old built-in TypeScriptClient deprecated, LSP client preferred

### Documentation

- **LSP configuration guide**: `docs/LSP_CONFIG.md` — How to add custom LSP servers
- **README updated**: Added LSP section, three-phase architecture, 31 language matrix
- **CHANGELOG restructured**: Now organized by Phase 1/2/3/4

### Technical Details

- **New dependencies**: `effect` (Phase 2), `vscode-jsonrpc` (Phase 3)
- **Lines added**: ~6,000 across 4 phases
- **Test status**: 617 passing (3 flaky unrelated tests)
- **Backward compatibility**: All new features opt-in via flags

## [2.5.0] - 2026-03-30

### Added

- **Python tree-sitter support**: 6 structural patterns for Python code analysis
  - `bare-except` — Detects `except:` that catches SystemExit/KeyboardInterrupt
  - `mutable-default-arg` — Detects mutable defaults like `def f(x=[])`
  - `wildcard-import` — Detects `from module import *`
  - `eval-exec` — Detects `eval()` and `exec()` security risks
  - `is-vs-equals` — Detects `is "literal"` that should use `==`
  - `unreachable-except` — Detects unreachable exception handlers
- **Multi-language tree-sitter architecture**: Query files in `rules/tree-sitter-queries/{language}/`
  - TypeScript/TSX: 10 patterns
  - Python: 6 patterns
- **Tree-sitter query loader**: YAML-based query definitions with multi-line array support
- **Query file extraction**: Moved TypeScript patterns from embedded code to `rules/tree-sitter-queries/typescript/*.yml`

### Changed

- **README updated**: Added Python patterns to structural analysis section
- **Architect client**: Fixed TypeScript errors (`configPath` property declaration)

### Technical Details

- Downloaded `tree-sitter-python.wasm` (458KB) for Python AST parsing
- Post-filters for semantic validation (e.g., distinguishing bare except from specific handlers)
- ~50ms analysis time per file for Python

## [2.4.0] - 2026-03-30

### Added

- **`safeSpawn` utility**: Cross-platform spawn wrapper that eliminates `DEP0190` deprecation warnings on Windows. Uses command string construction instead of shell+args array.
- **Runner tracking for `/lens-booboo`**: Each runner now reports execution time and findings count. Summary shows `[1/10] runner name...` progress and final table with `| Runner | Status | Findings | Time |`.
- **Shared runner utilities**: Extracted `runner-helpers.ts` with:
  - `createAvailabilityChecker()` - cached tool availability checks
  - `createConfigFinder()` - rule directory resolution
  - `createVenvFinder()` - venv-aware command lookup
  - Shared `isSgAvailable()` for ast-grep
- **Shared diagnostic parsers**: Extracted `diagnostic-parsers.ts` with:
  - `createLineParser()` - factory for line-based tool output
  - `parseRuffOutput`, `parseGoVetOutput`, `createBiomeParser()` - pre-built parsers
  - `createSimpleParser()` - simplified factory for standard formats
- **Architect test coverage**: 5 new tests for the architect runner (config loading, size limits, pattern detection, test file exclusion).
- **Type extraction**: Created `clients/ast-grep-types.ts` to break circular dependencies between `ast-grep-client`, `ast-grep-parser`, and `ast-grep-rule-manager`.

### Changed

- **26 files refactored to use `safeSpawn`**: Eliminated `shell: process.platform === "win32"` deprecation pattern across all clients and runners.
- **Updated runners to use shared utilities**:
  - `ruff.ts`, `pyright.ts` → use `createAvailabilityChecker()`
  - `python-slop.ts`, `ts-slop.ts` → use `createConfigFinder()` and shared `isSgAvailable()`
  - `ruff.ts`, `go-vet.ts`, `biome.ts` → use shared diagnostic parsers
- **Architect runner improvements**:
  - Added `skipTestFiles: true` to reduce noise from test files
  - Updated `default-architect.yaml` with per-file-type limits (500 services, 1000 clients, 5000 tests)
  - Removed `no process.env` rule (too strict for CLI tools)
  - Relaxed `console.log` rule to only apply to `src/` and `lib/` directories
- **Test cleanup safety**: Fixed all test files to use `fs.existsSync()` before `fs.unlinkSync()` to prevent ENOENT errors.

### Fixed

- **Circular dependencies**: Eliminated 2 cycles (`ast-grep-client` ↔ `ast-grep-parser`, `ast-grep-client` ↔ `ast-grep-rule-manager`) by extracting shared types.
- **Test flakiness**: All 70 test files now pass consistently (666 tests total).

### Code Quality

- **Lines saved**: ~350 lines of duplicated code removed across utilities and parsers.
- **Architect violations**: Reduced from 404 to ~50-80 (after test file exclusion + relaxed rules).

## [2.3.0] - 2026-03-30

### Added

- **NAPI-based runner (`ast-grep-napi`)**: 100x faster TypeScript/JavaScript analysis (~9ms vs ~1200ms). Uses `@ast-grep/napi` for native-speed structural pattern matching. Priority 15, applies to TS/JS files only.
- **Python slop detection (`python-slop`)**: New CLI runner with ~40 AI slop patterns from slop-code-bench research. Detects chained comparisons, manual min/max, redundant if/else, list comprehension opportunities, etc.
- **TypeScript slop detection (`ts-slop-rules`)**: ~30 patterns for TS/JS slop detection including `for-index-length`, `empty-array-check`, `redundant-filter-map`, `double-negation`, `unnecessary-array-from`.
- **`fix-simplified.ts` command**: New streamlined `/lens-booboo-fix` implementation with file-level exclusions (test files, excluded dirs) and anti-slop guidance. Uses `pi.sendUserMessage()` for actionable AI prompts.
- **Comprehensive test coverage**: 25+ tests added across all runners (NAPI, Python slop, TS slop, YAML loading).
- **Codebase self-scan**: `scan_codebase.test.ts` for testing the NAPI runner against the pi-lens codebase itself.

### Changed

- **Architecture documentation**: Updated README with complete architecture overview, runner system diagram, and language support matrix.
- **Disabled problematic slop rules**: `ts-for-index-length` and `ts-unnecessary-array-isarray` disabled due to false positives on legitimate index-based operations.
- **Runner registration**: Updated `clients/dispatch/runners/index.ts` with new runner priorities (ts-lsp/pyright at 5, ast-grep-napi at 15, python-slop at 25).
- **TS slop runner disabled**: CLI runner `ts-slop.ts` disabled in favor of NAPI-based detection (faster, same rules).

### Deprecated

- **`/lens-rate` command**: Now shows deprecation warning. Needs re-structuring. Users should use `/lens-booboo` instead.
- **`/lens-metrics` command**: Now shows deprecation warning. Temporarily disabled, will be restructured. Users should use `/lens-booboo` instead.

### Removed

- **Old implementations removed**: 259 lines of deprecated command code removed from `index.ts`.

### Repository Cleanup

- **Local-only files removed from GitHub**: `.pisessionsummaries/` and `refactor.md` removed from repo (still in local `.gitignore`).

## [2.1.1] - 2026-03-29

### Added

- **Content-level secret scanning**: Catches secrets in ANY file type on write/edit (`.env`, `.yaml`, `.json`, not just TypeScript). Blocks before save with patterns for `sk-*`, `ghp_*`, `AKIA*`, private keys, hardcoded passwords.
- **Project rules integration**: Scans for `.claude/rules/`, `.agents/rules/`, `CLAUDE.md`, `AGENTS.md` at session start and surfaces in system prompt.
- **Grep-ability rules**: New ast-grep rules for `no-default-export` and `no-relative-cross-package-import` to improve agent searchability.

### Changed

- **Inline feedback stripped to blocking only**: Warnings no longer shown inline (noise). Only blocking violations and test failures interrupt the agent.
- **booboo-fix output compacted**: Summary in terminal, full plan in `.pi-lens/reports/fix-plan.tsv`.
- **booboo-refactor output compacted**: Top 5 worst offenders in terminal, full ranked list in `.pi-lens/reports/refactor-ranked.tsv`.
- **`ast_grep_search` new params**: Added `selector` (extract specific AST node) and `context` (show surrounding lines).
- **`ast_grep_replace` mode indicator**: Shows `[DRY-RUN]` or `[APPLIED]` prefix.
- **no-hardcoded-secrets**: Fixed to only flag actual hardcoded strings (not `process.env` assignments).
- **no-process-env**: Now only flags secret-related env vars (not PORT, NODE_ENV, etc.).
- **Removed Factory AI article reference** from architect.yaml.

## [2.0.40] - 2026-03-27

### Changed

- **Passive capture on every file edit**: `captureSnapshot()` now called from `tool_call` hook with 5s debounce. Zero latency — reuses complexity metrics already computed for real-time feedback.
- **Skip duplicate snapshots**: Same commit + same MI = no write (reduces noise).

## [2.0.39] - 2026-03-27

### Added

- **Historical metrics tracking**: New `clients/metrics-history.ts` module captures complexity snapshots per commit. Tracks MI, cognitive complexity, and nesting depth across sessions.
- **Trend analysis in `/lens-metrics`**: New "Trend" column shows 📈/📉/➡️ with MI delta. "Trend Summary" section aggregates improving/stable/regressing counts with worst regressions.
- **Passive capture**: Snapshots captured on every file edit (tool_call hook) + `/lens-metrics` run. Max 20 snapshots per file (sliding window).

## [2.0.38] - 2026-03-27

### Changed

- **Refactored 4 client files** via `/lens-booboo-refactor` loop:
  - `biome-client.ts`: Extracted `withValidatedPath()` guard pattern (4 methods consolidated)
  - `complexity-client.ts`: Extracted `analyzeFile()` pipeline into `readAndParse()`, `computeMetrics()`, `aggregateFunctionStats()`
  - `dependency-checker.ts`: Simplified `importsChanged()` — replaced 3 for-loops with `setsEqual()` helper
  - `ast-grep-client.ts`: Simplified `groupSimilarFunctions()` with `filter().map()` pattern + `extractFunctionName()` helper

## [2.0.29] - 2026-03-26

### Added

- **`clients/ts-service.ts`**: Shared TypeScript service that creates one `ts.Program` per session. Both `complexity-client` and `type-safety-client` now share the same program instead of creating a new one per file. Significant performance improvement on large codebases.

### Removed

- **3 redundant ast-grep rules** that overlap with Biome: `no-var`, `prefer-template`, `no-useless-concat`. Biome handles these natively with auto-fix. ast-grep no longer duplicates this coverage.
- **`prefer-const` from RULE_ACTIONS** — no longer needed (Biome handles directly).

### Changed

- **Consolidated rule overlap**: Biome is now the single source of truth for style/format rules. ast-grep focuses on structural patterns Biome doesn't cover (security, design smells, AI slop).

## [2.0.27] - 2026-03-26

### Added

- **`switch-exhaustiveness` check**: New type safety rule detects missing cases in union type switches. Uses TypeScript compiler API for type-aware analysis. Reports as inline blocker: `🔴 STOP — Switch on 'X' is not exhaustive. Missing cases: 'Y'`.
- **`clients/type-safety-client.ts`**: New client for type safety checks. Extensible for future checks (null safety, exhaustive type guards).

### Changed

- **Type safety violations added to inline feedback**: Missing switch cases now block the agent mid-task, same as TypeScript errors.
- **Type safety violations in `/lens-booboo-fix`**: Marked as agent-fixable (add missing case or default clause).

## [2.0.26] - 2026-03-26

### Added

- **5 new ast-grep rules** for AI slop detection:
  - `no-process-env`: Block direct `process.env` access (use DI or config module) — error level
  - `no-param-reassign`: Detect function parameter reassignment — warning level
  - `no-single-char-var`: Flag single-character variable names — info level
  - `switch-without-default`: Ensure switch statements have default case — warning level
  - `no-architecture-violation`: Block cross-layer imports (models/db) — error level

### Changed

- **RULE_ACTIONS updated** for new rules:
  - `agent` type (inline + booboo-fix): `no-param-reassign`, `switch-without-default`, `switch-exhaustiveness`
  - `skip` type (booboo-refactor only): `no-process-env`, `no-single-char-var`, `no-architecture-violation`

## [2.0.24] - 2026-03-26

### Changed

- **Simplified `/lens-booboo-refactor` confirmation flow**: Post-change report instead of pre-change gate. Agent implements first, then shows what was changed (git diff + metrics delta). User reviews and can request refinements via chat. No more temp files or dry-run diffs.
- **Confirmation screen**: "✅ Looks good — move to next offender" / "💬 Request changes" (chat textarea). Diff display is optional.

## [2.0.23] - 2026-03-26

### Changed

- **Extracted interviewer and scan modules from `index.ts`**: `index.ts` reduced by 460 lines.
  - `clients/interviewer.ts` — all browser interview infrastructure (HTML generation, HTTP server, browser launch, option selection, diff confirmation screen)
  - `clients/scan-architectural-debt.ts` — shared scanning utilities (`scanSkipViolations`, `scanComplexityMetrics`, `scoreFiles`, `extractCodeSnippet`)
- **`/lens-booboo-refactor`** now uses imported scan functions instead of duplicated inline code.

## [2.0.22] - 2026-03-26

### Added

- **Impact metrics in interview options**: Each option now supports an `impact` object (`linesReduced`, `miProjection`, `cognitiveProjection`) rendered as colored badges in the browser form. Agent estimates impact when presenting refactoring options.
- **Iterative confirmation loop**: Confirmation screen now includes "🔄 Describe a different approach" option with free-text textarea. Agent regenerates plan+diff based on feedback, re-opens confirmation. Repeat until user confirms or cancels.
- **Auto-close on confirm**: Browser tab closes automatically after user submits.

## [2.0.21] - 2026-03-26

### Added

- **Two-step confirmation for `/lens-booboo-refactor`**: Agent implements changes, then calls `interviewer` with `confirmationMode=true` to show plan (markdown) + unified diff (green/red line coloring) + line counts at the top. User can Confirm, Cancel, or describe a different approach.
- **Plan + diff confirmation screen**: Plan rendered as styled markdown, diff rendered with syntax-colored `+`/`-` lines. Line counts (`+N / −N`) shown in diff header.

## [2.0.20] - 2026-03-26

### Added

- **Impact metrics in interview options**: Structured `impact` field per option with `linesReduced`, `miProjection`, `cognitiveProjection`. Rendered as colored badges (green for lines reduced, blue for metric projections) inside each option card.

## [2.0.19] - 2026-03-26

### Changed

- **`/lens-booboo-fix` jscpd filter**: Only within-file duplicates shown in actionable section. Cross-file duplicates are architectural — shown in skip section only.
- **AI slop filter tightened**: Require 2+ signals per file (was 1+). Single-issue flags on small files are noise — skip them.

## [2.0.18] - 2026-03-26

### Fixed

- **`/lens-booboo-fix` max iterations**: Session file auto-deletes when hitting max iterations. Previously blocked with a manual "delete .pi-lens/fix-session.json" message.

## [2.0.17] - 2026-03-26

### Changed

- **Agent-driven option generation**: `/lens-booboo-refactor` no longer hardcodes refactoring options per violation type. The command scans and presents the problem + code to the agent; the agent analyzes the actual code and generates 3-5 contextual options with rationale and impact estimates. Calls the `interviewer` tool to present them.
- **`interviewer` tool**: Generic, reusable browser-based interview mechanism. Accepts `question`, `options` (with `value`, `label`, `context`, `recommended`, `impact`), and `confirmationMode`. Zero dependencies — Node's built-in `http` module + platform CLI `open`/`start`/`xdg-open`.

## [2.0.16] - 2026-03-26

### Added

- **`/lens-booboo-refactor`**: Interactive architectural refactor session. Scans for worst offender by combined debt score (ast-grep skip violations + complexity metrics). Opens a browser interview with the problem, code context, and AI-generated options. Steers the agent to propose a plan and wait for user confirmation before making changes.

### Changed

- **Inline tool_result suppresses skip-category rules**: `long-method`, `large-class`, `long-parameter-list`, `no-shadow`, `no-as-any`, `no-non-null-assertion`, `no-star-imports` no longer show as hard stops in real-time feedback. They are architectural — handled by `/lens-booboo-refactor` instead.

## [2.0.15] - 2026-03-26

### Removed

- **Complexity metrics from real-time feedback**: MI, cognitive complexity, nesting depth, try/catch counts, and entropy scores removed from tool_result output. These were always noise — the agent never acted on "MI dropped to 5.6" mid-task. Metrics still available via `/lens-metrics` and `/lens-booboo`.
- **Session summary injection**: The `[Session Start]` block (TODOs, dead code, jscpd, type-coverage) is no longer injected into the first tool result. Scans still run for caching purposes (exports, clones, baselines). Data surfaced on-demand via explicit commands.
- **`/lens-todos`**: Removed (covered by `/lens-booboo`).
- **`/lens-dead-code`**: Removed (covered by `/lens-booboo`).
- **`/lens-deps`**: Removed — circular dep scan added to `/lens-booboo` as Part 8.

### Changed

- **Hardened stop signals**: New violations (ast-grep, Biome, jscpd, duplicate exports) now all use `🔴 STOP` framing. The agent is instructed to fix these before continuing.
- **`/lens-booboo` now includes circular dependencies**: Added as Part 8 (after type coverage) using `depChecker.scanProject`.

## [2.0.14] - 2026-03-26

### Fixed

- **`/lens-booboo-fix` excludes `.js` compiled output**: Detects `tsconfig.json` and excludes `*.js` from jscpd, ast-grep, and complexity scans. Prevents double-counting of the same code in `.ts` and `.js` forms.
- **`raw-strings` rule added to skip list**: 230 false positives in CLI/tooling codebases.
- **`typescript-client.ts` duplication**: Extracted `resolvePosition()`, `resolveTree()`, and `toLocations()` helpers, deduplicating 6+ LSP methods.
- **All clients**: `console.log` → `console.error` in verbose loggers (stderr for debug, stdout for data).

## [2.0.13] - 2026-03-26

### Removed

- **`raw-strings` ast-grep rule**: Not an AI-specific pattern. Humans write magic strings too. Biome handles style. Generated 230 false positives on first real run.

## [2.0.12] - 2026-03-26

### Fixed

- **`/lens-booboo-fix` sequential scan order**: Reordered to Biome/Ruff → jscpd (duplicates) → knip (dead code) → ast-grep → AI slop → remaining Biome. Duplicates should be fixed before violations (fixing one fixes both). Dead code should be deleted before fixing violations in it.

### Changed

- **Remaining Biome section rephrased**: "These couldn't be auto-fixed even with `--unsafe` — fix each manually."

## [2.0.11] - 2026-03-26

### Added

- **Circular dependency scan to `/lens-booboo`**: Added as Part 8, using `depChecker.scanProject()` to detect circular chains across the codebase.

### Removed

- **`/lens-todos`**, **`/lens-dead-code`**, **`/lens-deps`**: Removed standalone commands — all covered by `/lens-booboo`.

## [2.0.10] - 2026-03-26

### Changed

- **Session summary injection removed**: The `[Session Start]` block is no longer injected into the first tool result. Scans still run silently for caching (exports for duplicate detection, clones for jscpd, complexity baselines for deltas).

## [2.0.1] - 2026-03-25

### Fixed

- **ast-grep in `/lens-booboo` was silently dropping all results** — newer ast-grep versions exit `0` with `--json` even when issues are found; fixed the exit code check.
- **Renamed "Design Smells" to "ast-grep"** in booboo report — the scan runs all 65 rules (security, correctness, style, design), not just design smells.

### Changed

- **Stronger real-time feedback messages** — all messages now use severity emoji and imperative language:
  - `🔴 Fix N TypeScript error(s) — these must be resolved`
  - `🧹 Remove N unused import(s) — they are dead code`
  - `🔴 You introduced N new structural violation(s) — fix before moving on`
  - `🟠 You introduced N new Biome violation(s) — fix before moving on`
  - `🟡 Complexity issues — refactor when you get a chance`
  - `🟠 This file has N duplicate block(s) — extract to shared utilities`
  - `🔴 Do not redefine — N function(s) already exist elsewhere`
- **Biome fix command is now a real bash command** — `npx @biomejs/biome check --write <file>` instead of `/lens-format` (which is a pi UI command, not runnable from agent tools).
- **Complexity warnings skip test files in real-time** — same exclusion as lens-booboo.

## [2.0.0] - 2026-03-25

### Added

- **`/lens-metrics` command**: Measure complexity metrics for all files. Exports a full `report.md` with A-F grades, summary stats, AI slop aggregate table, and top 10 worst files with actionable warnings.
- **`/lens-booboo` saves full report**: Results saved to `.pi-lens/reviews/booboo-<timestamp>.md` — no truncation, all issues, agent-readable.
- **AI slop indicators**: Four new real-time and report-based detectors:
  - `AI-style comments` — emoji and boilerplate comment phrases
  - `Many try/catch blocks` — lazy error handling pattern
  - `Over-abstraction` — single-use helper functions
  - `Long parameter list` — functions with > 6 params
- **`SubprocessClient` base class**: Shared foundation for CLI tool clients (availability check, logging, command execution).
- **Shared test utilities**: `createTempFile` and `setupTestEnvironment` extracted to `clients/test-utils.ts`, eliminating copy-paste across 13 test files.

### Changed

- **Delta mode for real-time feedback**: ast-grep and Biome now only show *new* violations introduced by the current edit — not all pre-existing ones. Fixed violations shown as `✓ Fixed: rule-name (-N)`. No change = silent.
- **Removed redundant pre-write hints**: ast-grep and Biome pre-write counts removed (delta mode makes them obsolete). TypeScript pre-write warning kept (blocking errors).
- **Test files excluded from AI slop warnings**: MI/complexity thresholds are inherently low in test files — warnings suppressed for `*.test.ts` / `*.spec.ts`.
- **Test files excluded from TODO scanner**: Test fixture annotations (`FIXME`, `BUG`, etc.) no longer appear in TODO reports.
- **ast-grep excludes test files and `.pi-lens/`**: Design smell scan in `/lens-booboo` skips test files (no magic-numbers noise) and internal review reports.
- **jscpd excludes non-code files**: `.md`, `.json`, `.yaml`, `.yml`, `.toml`, `.lock`, and `.pi-lens/` excluded from duplicate detection — no more false positives from report files.
- **Removed unused dependencies**: `vscode-languageserver-protocol` and `vscode-languageserver-types` removed; `@sinclair/typebox` added (was unlisted).

### Fixed

- Removed 3 unconditional `console.log` calls leaking `[scan_exports]` to terminal.
- Duplicate Biome scan in `tool_call` hook eliminated (was scanning twice for pre-write hint + baseline).

## [1.3.14] - 2026-03-25

### Added

- **Actionable feedback messages**: All real-time warnings now include specific guidance on what to do.
- **Code entropy metric**: Shannon entropy in bits (threshold: >3.5 indicates risky AI-induced complexity).
- **Advanced pattern matching**: `/lens-booboo` now finds structurally similar functions (e.g., `formatDate` and `formatTimestamp`).
- **Duplicate export detection**: Warns when redefining a function that already exists in the codebase.
- **Biome formatting noise removed**: Only lint issues shown in real-time; use `/lens-format` for formatting.

## [1.3.10] - 2026-03-25

### Added

- **Actionable complexity warnings**: Real-time feedback when metrics break limits with specific fix guidance.

## [1.3.9] - 2026-03-25

### Fixed

- **Entropy calculation**: Corrected to use bits with 3.5-bit threshold for AI-induced complexity.

## [1.3.8] - 2026-03-25

### Added

- **Code entropy metric**: Shannon entropy to detect repetitive or unpredictable code patterns.

## [1.3.7] - 2026-03-25

### Added

- **Advanced pattern matching in `/lens-booboo`**: Finds structurally similar functions across the codebase.

## [1.3.6] - 2026-03-25

### Added

- **Duplicate export detection on write**: Warns when defining a function that already exists elsewhere.

## [1.3.5] - 2026-03-25

### Changed

- **Consistent command prefix**: All commands now start with `lens-`.
  - `/find-todos` → `/lens-todos`
  - `/dead-code` → `/lens-dead-code`
  - `/check-deps` → `/lens-deps`
  - `/format` → `/lens-format`
  - `/design-review` + `/lens-metrics` → `/lens-booboo`

## [1.5.0] - 2026-03-23

### Added

- **Real-time jscpd duplicate detection**: Code duplication is now detected on every write. Duplicates involving the edited file are shown to the agent in real-time.
- **`/lens-review` command**: Combined code review: design smells + complexity metrics in one command.

### Changed

- **Consistent command prefix**: All commands now start with `lens-`.
  - `/find-todos` → `/lens-todos`
  - `/dead-code` → `/lens-dead-code`
  - `/check-deps` → `/lens-deps`
  - `/format` → `/lens-format`
  - `/design-review` + `/lens-metrics` → `/lens-review`

## [1.4.0] - 2026-03-23

### Added

- **Test runner feedback**: Runs corresponding test file on every write (vitest, jest, pytest). Silent if no test file exists. Disable with `--no-tests`.
- **Complexity metrics**: AST-based analysis: Maintainability Index, Cyclomatic/Cognitive Complexity, Halstead Volume, nesting depth, function length.
- **`/lens-metrics` command**: Full project complexity scan.
- **Design smell rules**: New `long-method`, `long-parameter-list`, and `large-class` rules for structural quality checks.
- **`/design-review` command**: Analyze files for design smells. Usage: `/design-review [path]`
- **Go language support**: New Go client for Go projects.
- **Rust language support**: New Rust client for Rust projects.

### Changed

- **Improved ast-grep tool descriptions**: Better pattern guidance to prevent overly broad searches.

## [2.2.1] - 2026-03-29

### Fixed

- **No auto-install**: Runners (biome, pyright) now use direct CLI commands instead of `npx`. If not installed, gracefully skip instead of attempting to download.

## [2.2.0] - 2026-03-29

### Added

- **`/lens-rate` command**: Visual code quality scoring across 6 dimensions (Type Safety, Complexity, Security, Architecture, Dead Code, Tests). Shows grade A-F and colored progress bars.
- **Pyright runner**: Real Python type-checking via pyright. Catches type errors like `result: str = add(1, 2)` that ruff misses. Runs alongside ruff (pyright for types, ruff for linting).
- **Vitest config**: Increased test timeout to 15s for CLI spawn tests. Fixes flaky test failures when npx downloads packages.

### Fixed

- **Test flakiness**: Availability tests (biome, knip, jscpd) no longer timeout when npx is downloading packages.

## [1.3.0] - 2026-03-23

### Changed

- **Biome auto-fix disabled by default**: Biome still provides linting feedback, but no longer auto-fixes on write. Use `/format` to apply fixes or enable with `--autofix-biome`.

### Added

- **ast-grep search/replace tools**: New `ast_grep_search` and `ast_grep_replace` tools for AST-aware code pattern matching. Supports meta-variables and 24 languages.
- **Rule descriptions in diagnostics**: ast-grep violations now include the rule's message and note, making feedback more actionable for the agent.

### Changed

- **Reduced console noise**: Extension no longer prints to console by default. Enable with `--lens-verbose`.

## [1.2.0] - 2026-03-23

### Added

- GitHub repository link in npm package

## [1.1.2] - Previous

- See git history for earlier releases
