# Changelog

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

## [Unreleased]

### Added

### Changed

### Deprecated

### Removed

### Fixed

### Security

## [4.1.3] - 2026-08-28

### Added

- **Record build identity at session start (refs #1775)** — `sessionstart.log` now logs one bounded line per session start with the serving checkout's commit hash, entry-file mtime, and package version, derived from the running build's own files rather than `process.cwd()`. The dirty flag is deferred (would require a spawn on the session-start hot path); see the issue for the remainder.

- **Rerun externally killed CI Unit tests once (refs #2042)** — a completed CI run now classifies the failed Unit-tests log and reruns only failed jobs when the verdict is `infra-kill`. A per-head-SHA marker and the workflow run-attempt gate prevent rerun loops, while classification errors produce an explicit PR comment.

- **Recover starved CI runs and merge train-approved PRs (closes #2184, closes #2185)** — the merge-train warden classifies every open PR head as runs-concluded-normally, starved-run, absent-run, in-progress, or unknown, re-runs a starved run once, and comments when GitHub drops a dispatch; a new label-gated merge lane lands a `train:approved` PR only when both required checks conclude success on its exact current head.

- **Enable Kotlin structural linting (refs #2198)** — Add eight VTCode-derived Kotlin ast-grep rules (`kotlin-no-lateinit`, `kotlin-no-nullable-boolean`, `kotlin-no-println`, `kotlin-no-unnecessary-let`, `kotlin-no-unsafe-cast`, `kotlin-no-var`, `kotlin-prefer-data-class`, `kotlin-prefer-is-empty`). Kotlin delivers through the ast-grep CLI/LSP lane, because `@ast-grep/napi` 0.45.1 ships no Kotlin grammar.

- **Enable C/C++ memory and I/O idiom rules (refs #2201)** — Add three VTCode-derived rules: `c-no-malloc-free`, `cpp-no-malloc-free`, `cpp-no-printf`. All detection-only, at `severity: warning`, and distinct from CodeRabbit's existing C/C++ security rules.

- **Classify a red Unit tests run as infra-kill, infra-net, or real (refs #2103)** — `scripts/classify-ci-failure.mjs` reads a failed job's log, tells a SIGKILL/exit-137 kill (with or without the `with-memory-watch.mjs` wrapper surviving to report it) and a DNS/network failure apart from a genuine assertion failure, posts one sticky PR comment naming the verdict, and reruns the failed jobs once per SHA when the verdict is infra. Every recognized real-failure shape (a FAIL block, an inline test-failure marker, a file-level collection error, or the overall failed-test tally) wins over any infra signal in the same log, and an unrecognized shape defaults to real rather than infra — the classifier is only as good as the shapes it recognizes, so treat "real" as the safe default, not an absolute guarantee against every possible log shape. The "once per SHA" rerun guard is once per SEQUENTIAL invocation, not across concurrency: concurrent invocations for the same SHA may each attempt a rerun (GitHub answers a duplicate with 403, which the marker records as `failed:403` and a later invocation can retry).

- **Port five codemod gallery candidates (refs #2195)** - add Python `Optional` detection, generator-expression, Ruby symbol-to-proc, Rust character-index, and constrained JSX SVG-attribute rules. Semantic rewrites remain detection-only; guarded Python and Ruby rewrites expose fixes.

- **Enable Java structural linting (refs #2197)** — Add four VTCode-derived Java rules to the ast-grep CLI/LSP lane. The NAPI runner stays limited to its bundled grammars, because `@ast-grep/napi` 0.45.1 ships no Java grammar.

- **Port VTCode's `no-important` CSS rule (closes #2199)** — flags `!important` declarations as a detection-only warning; a safe fix would need to know which selector should win, which the rule cannot infer. CSS and HTML roots are scoped in the napi fallback so language-tagged rules do not scan unrelated parsed roots.

- **Port 5 VTCode Go idiom rules (closes #2200)** — `go-no-fmt-println`, `go-no-panic-in-lib`, `go-no-underscore-func-name`, `go-prefer-errors-is`, and `go-prefer-string-builder` flag `fmt.Println`-family calls, library `panic()`, snake_case function names, direct error comparison, and repeated string concatenation. All five ship detection-only at their triaged severity (warning for `no-fmt-println`/`no-panic-in-lib`, hint for the rest) — each rewrite needs project or call-site context ast-grep cannot verify, so no `fix:` ships. The `+=` arm of `go-prefer-string-builder` is limited to string-literal operands because ast-grep has no type information. `go-no-global-variable` is excluded per the #2195 triage: valid package globals (constants, synchronization primitives) make it too noisy without project-specific policy.

- **Guard a shared checkout against branch switches that destroy another session's work (refs #2007)** — a new opt-in `--lens-checkout-guard` (`guard.sharedCheckout=true`) declines `git checkout`, `switch`, `restore`, `reset --hard`, `stash`, `clean`, `merge`, `rebase`, `pull`, `cherry-pick`, and `revert` when the checkout has uncommitted work and another live pi-lens session is registered on the same root. The refusal names the peer pids and tells you to commit or take a dedicated worktree, rather than moving anyone's work behind their back. Command classification reuses the existing git-guard shell analysis; peer liveness reuses the instance registry.

> Refs #2007

### Changed

- **Split `loop_block` into compute and non-CPU stalls, and name the sweep that caused one (refs #1980, #1723)** — every `loop_block` record now carries `stallClass` (`below-floor`, `cpu-accounted`, `non-cpu-stall`, `system-stall`) and `cpuCoverageRatio`, derived from the `windowCpuMs` that has sat unread beside `durationMs` since #1122. Nine of sixteen genuine 5s+ blocks in a 23h window burned less CPU than the block lasted; those now read as parked, not as work. `suspectSystemStall` is unchanged. The LSP workspace-diagnostics sweep's per-file touch is also bracketed now, so a block inside it names `lsp_workspace_diagnostics_touch` instead of arriving anonymous.

- **Cut the word-index per-edit seam's longest synchronous stretch (refs #2067)** — the cascade and MCP-analyze seams now replace a document through the cooperative primitive, serialized through the index's own operation queue, instead of holding the event loop for the whole replacement. On a 2,829-document, 2.38M-posting corpus the longest synchronous stretch on a large document drops about 3x. Cooperative removal staging now filters each token's postings with the same packed primitive the synchronous path uses, dropping a clock read per posting element. Search results and BM25 scores are unchanged.

- **Bound memory accounting (refs #2132, #2114)** — Add measured byte estimates for review-graph and dispatch-cache residency to `memory_sample`, and keep the word-index persistence hook visible in sampler coverage. #2132 criterion 3 remains undelivered; the `runtime.wordIndex` to `memory_sample` seam can still report `wordIndex:null` and remains a named follow-up.

- **Classify merge-train pagination truncation (closes #2134)** — the warden reports GraphQL pull-request pagination truncation instead of treating a partial population as complete.

- **Gate word-index arena recompaction on a share of the vocabulary (refs #2246)** — the flat 64-store recompaction threshold was crossed by every edit, because one document replacement raises the backing-store estimate by the edited document's distinct-token count. The gate is now 10% of the live vocabulary above a 64-store floor, so fragmentation accumulates proportionally to the index. On a 2,844-document, 2.4M-posting corpus, 300 sequential edits recompact 14 times instead of 299, the per-edit cooperative block drops from mean 32.0 ms to 12.4 ms, and peak resident memory is unchanged (the recompaction share of per-edit CPU falls from 23% to 4%). Small corpora keep the pre-change behaviour: the floor governs any index whose vocabulary is under 640 tokens.

- **Convert the word-index per-edit occupancy guard to a load-invariant work count (refs #2254)** — the per-edit seam's occupancy bound was a wall-clock max-block over a 401-document fixture with `retry: 2`, so it was both flaky and a noisy neighbour in the timing-sensitive test lane. It now asserts the seam's cooperative replacement reads the clock a number of times bounded by the old document's distinct tokens, not by the posting elements it walks, which is deterministic and invariant to runner load. The guard leaves the timing-sensitive lane, and that lane returns to `maxWorkers: 2` now that its one heavy neighbour is gone. The shared `countClockReads` helper moves to `tests/support/perf-harness.ts` so the incremental test and the seam test read one implementation.

- **Weekly stale-issue report now flags missing priority labels (refs #1676)** — the scheduled detector's report on #1323 adds a Priority label coverage section, listing open issues with zero `priority:*` labels and open issues with more than one. Advisory only; the job still never edits an issue.

- **Enforce client dependency boundaries and cycles in CI (refs #1844)** — dependency-cruiser now rejects client import cycles, declared leaf imports, and unapproved additions to the session-start eager graph.

- **Add six test-authoring screens to AGENTS.md (refs #1829)** — Contributor doctrine now names the six ways a green test can assert nothing, each with its screen, a real PR example, and a detection step.

- **Reorganize AGENTS.md for retrieval (refs #1829)** — a task-type index and reading modes up front, the scattered invariant paragraphs pooled into a grouped "Standing invariants" section, placement rules that end top-prepend and tail-append conflicts, the stale version section dissolved (CHANGELOG.md owns versions), and a small HISTORY.md for decision-inert records. Every invariant paragraph preserved verbatim.

- **Contributor and reviewer discipline harvested from six external skill sets plus the 2026-08-26 arc** — AGENTS.md gains the minimalism ladder (climb before writing: exists → reuse → stdlib → dep → one line → minimum), a review materiality bar (no stylistic/hypothetical/line-count findings, nothing a required CI check already fails on, spec-vs-standards axes reported separately, restructure insights route to issues), the deletion test on consolidation verdicts, the exit-137 judgment recipe, and the mechanical-versus-intent rule for maintainer trailing commits; the investigator playbook gains a loop-first diagnosis discipline; the fixer playbook gains commit-before-checkout-proofs and the verbatim-evidence rule; the reviewer playbook gains the quoted-evidence audit and treats fix rounds as fresh PRs (refs #2128).

- **PRs touching tests now carry a Test assessment, and the authoring screens grow to ten** — per touched test file, state what it uniquely pins and what became redundant; removal requires a named surviving test to red on the same mutations. The PR body lint enforces the section (advisory) when the PR touches `tests/`; candidates not deleted in-PR go to the corpus value ledger (#2123). AGENTS.md's test-authoring screens gain four classic vacuity shapes: all-mocks, not-throw-as-sole-assertion, implementation mirror, and snapshot-as-behavior (with the characterization-baseline carve-out).

- **Name and pin the files-touched bus seam (closes #1966)** — Document the two publisher modules, the agent-nudge subscriber, payload and delivery contract, and outcome-level observability; add a source conformance test that fails when the bus surface changes without updating the declaration.

- **Unify the pinned npm version across workflows (#2051)** — ci.yml and release.yml both pin npm 11.18.0 so a single lockfile-writer version governs CI and release installs.

- **Single-flight availability probes (refs #2131)** — concurrent consumers now share one tool/root probe, joined decisions are observable, and budget overruns enter the bounded degradation ledger.

- **Concise zero-read recovery (closes #2335)** — The read guard now delivers its edit-without-read instruction as a single paragraph instead of a three-paragraph block. The 🔄 RETRYABLE marker and the "in this conversation" scope are unchanged.

- **Memoize compiled gitignore globs (closes #1976)** — Reuse compiled ignore patterns for each matcher instance, reducing the measured compile count from 500 to ≤6.

- **Persist word-index changes incrementally off the hot path (refs #2068)** — packed posting lanes now reuse cached wire segments for untouched documents, so a per-edit persist rebuilds only dirty document contributions. Dirty files resolve through one wire-slot map, and each affected token lane flattens once per persist while the existing snapshot worker handles stringify and gzip.

- **Shrink the word index's resident footprint by 4.6x (closes #2069)** — Postings and the forward index now live in packed `Int32Array` lanes over a dense file-id space instead of one boxed `{ file, line }` object per posting. On pi-lens's own 2,682-document tree the index measures 32.7 MB, down from 151.6 MB, and 15.1 bytes per posting entry, down from 70.2. Against the 186.6 MB the issue records before posting interning landed, the reduction is 5.7x. Search results, the persisted snapshot format, and incremental refresh behaviour are unchanged. The `full_rebuild`, `incremental_refresh`, `cold_build`, and `persist_succeeded` records in `word-index.log` gained a `residentBytes` field, and `memory_sample` gained `postingEntries` and `residentBytes`, so a heap census can be reconciled from the logs.

- **Review-graph one-file rebuild (refs #2074)** — the incremental rebuild now reads the graph's own adjacency indexes instead of rescanning every edge. Restoring preserved incoming edges and reading a changed file's import targets both became bucket lookups, and the derived indexes stay live through the update instead of being rebuilt at the end. On a 1,600-file fixture a one-file rebuild drops from 48.0 ms to 33.6 ms median, with edge-metadata comparisons down from 9,600 to 4 and edge visits down from 19,200 to 8. The same fix makes `existedBefore` report the truth, which unblocks reverse-dependency index reuse on every incremental build.

### Fixed

- **Identity-guard seven more in-flight promise caches against ABA eviction (refs #1968)** — `SecurityScanClient.dedupeScan` (gitleaks/trivy/govulncheck), `JscpdClient`, `DependencyChecker`'s per-file and per-project caches, the MCP Stop-hook's `runTurnEndForIpc`, `initLSPConfig`, and `ensureTool`'s per-tool install map all cleared their in-flight entry with a bare delete-by-key, same shape as the dead-code/knip sites #1968 already fixed. A late-settling run could evict a live successor a second writer registered under the same key, causing the next caller to start a duplicate scan/check/install. All seven now delete only when the map still holds their own promise. One sibling, `ast-grep`'s shared availability probe (`runner-helpers.ts`), was reachable TODAY (not latent): a session-boundary reset is itself the second writer, so the bug could fire in production, not only after a future code change.

- **Stop the CI kill record from blaming memory it never ran out of (refs #2042)** — the `[mem-watch]` verdict asserted "the OS reclaimed memory" for every exit-137, including three real kills whose own low-water mark showed 13.0-13.3 GB of 16 GB still available. It now classifies from its numbers, emits a distinct `KILLED WITH HEADROOM` verdict when the box had room, and names the pid it was watching. A new failure-gated CI step captures the kernel's own `dmesg` and `systemd-oomd` records, so the next occurrence names the signal's sender.

- **Re-resolve a runner-detection alias probed before its symlink existed (closes #2077)** — `TestRunnerClient` no longer memoizes a project root whose canonicalization failed, so an alias first probed while it was still missing now converges to the real root's runner verdict on the next probe instead of serving the fallback key's stale "no runner" answer for the client's whole life. Resolved spellings are memoized exactly as before, so the hot path is unchanged.

- **Recompact churned word-index arenas (refs #2117)** — incremental replacements now recompact the posting arena once churn passes 64 backing stores. The per-edit gate is O(1) and the cooperative copy is serialized through the index's async queue and safe against a concurrent edit, so it cannot corrupt postings or stall an edit. Resident-byte estimates include abandoned arena slack, and the bounded refresh record carries before-and-after bytes and store counts.

- **Dedupe review-graph edges after deferred symbol resolution (closes #2127)** — same-batch rebuilds no longer retain duplicate calls or references edges when placeholders converge on real symbols.

- **Report every root a host serves in `pilens_health` (refs #2130)** — the resource footprint projected one scalar `projectRoot` per instance, so a host also serving a subagent's temp worktree read as single-rooted there while `instances.json` listed both. Each `resourceFootprint.perInstance` entry now carries `projectRoots`, resolved through `getInstanceRoots` (the single reader the shared-checkout guard already uses), with the pinned primary still in `projectRoot` for existing consumers.

- **Count every LSP client, and stop leaking a subagent's temp root (refs #2130)** — `memory_sample.subsystems.lsp.clients` now counts clients across every module evaluation, so a host serving a secondary root no longer reports `clients: 1` beside `lspChildCount: 2`. `deregisterInstanceRoot` shares the registry mutation tail, so a short-lived subagent can no longer remove its root before its own queued add lands and leave the temp root in `instances.json` for the rest of the host's life. A host entry synthesized from an `lsp-fallback` guess now yields its primary slot to the session's real root instead of pinning the guess forever.

- **Stamp classifiedBy on ok-path availability decisions (refs #2131)** — seven `logAvailabilityDecision` success-arm calls (biome, dead-code, formatters, jvm-runtime, package-manager, zizmor) omitted `classifiedBy`, so a `cause: "ok"` row could not be attributed to a probe or a caller-asserted repair from the log alone. All seven now stamp `"probe"` for a direct probe success or `"caller"` for an install/join-repaired one, matching their sibling failure arms. A sweep test pins every `cause: "ok"` emit site so the gap cannot recur unnoticed.

- **Normalize review-graph.log's cwd to one form (closes #2141)** — `logReviewGraph` and `logWordIndex` now normalize `cwd` at their single emit seam, so the same project root no longer appears as both `C:\...\pi-free` and `C:/.../pi-free` in the same log.

- **Repair escaped-newline flattened PR bodies in body lint (refs #2145)** — a body can arrive with the literal two-character sequence `\n` (or `\r\n`) standing in for a real line break, the sibling of the space-flattening shape #2149 already repairs. `detectEscapedNewlineBody`/`repairEscapedNewlineBody` restore it losslessly, but refuse outright when the body carries a fenced code block: a flattened fence's own delimiters can no longer be told apart from genuine content, so fence-safe repair stays deferred as recorded on the issue.

- **Stop duplicate merge-train warden actions (closes #2150)** — the warden stops on a non-advancing GraphQL cursor and deduplicates PR records before applying actions.

- **Bound language-server verification output (refs #2169)** — verification retains at most 64 KiB of child output with head-and-tail retention, so a late transport-required line can rescue a valid LSP probe. A truncated verification records one bounded degradation.

- **Match the merge lane to live repository state (refs #2185)** — the lane updates a green branch that is behind instead of attempting a merge master protection would refuse, reads the `(advisory)` name suffix this repository actually uses, resolves duplicate check names to the newest run, requires the `train:approved` label to come from an approver, and deduplicates its merge-failure comment.

- **Stop a routine paging repeat from reddening the merge-train warden (refs #2192)** — the warden's PR reader orders by `UPDATED_AT`, so a PR updated mid-pagination lands on the next page too. That boundary repeat was recorded as a fatal error, once per repeated node, so a fully shifted window could emit up to 200 identical red lines in one 10-minute run. It is now one record per page naming the count, classified by cursor: a repeat with an advancing cursor is benign, a repeat with a stalled cursor stays fatal because it is real truncation. `fetchOpenPullRequests` returns `{ message, benign }` records, so the warden and the merge lane read one classification instead of each deciding for itself.

- **Correct the Java rule set's accumulator and raw-type detection (refs #2197)** — `no-string-concat-in-loop` now requires the accumulator to be a String, so it stops firing on numeric accumulation and starts catching `s += x`. `no-raw-types` skips `instanceof` and `.class`, which have no parameterized form, and reports raw types nested in type arguments.

- **Deflake the output-cap tail-retention test (refs #2197)** — Let the child ignore SIGTERM so the cap's kill cannot settle it before it emits its last line. On POSIX a child's pipe writes are asynchronous, so the cap kill could destroy queued output the assertion needed.

- **Move the dirty-fraction word-index guard off wall clock (refs #2202)** — the incremental word-index occupancy test asserted a same-run timing ratio (`dirty=750 ms < fullMs * 1.5`). Review replicated it 20x under 57-94% CPU load and measured a 0.436x-3.863x spread, wider than the 2x regression the guard exists to catch, and found `retry: 2` only escaped a calibrated 2x-work injection about two-thirds of the time. `serializeWordIndex` now records, per call, how many tokens it re-flattened and whether it took the incremental or full-rebuild path (`getLastWordIndexSerializeWork`, test-only). The guard asserts on that count directly: no timer, no retry, no runner-load dependence, and it fails deterministically on a synthetic work-doubling regression.

- **Fix `playground-verify-rule.mjs` matching the wrong source (closes #2208)** — the harness wrote a caller's `--code` into the upstream ast-grep playground's `query` field, which only feeds its Pattern mode. Config mode (what this harness always uses) matches against `state.source` instead, so the URL hash carried no source and the playground silently fell back to its own hardcoded sample. Every run graded that fixed sample, not the caller's code, so `matches` never reflected the fixture under test. `source` now carries the code; a rule + known-matching snippet reports a real match count instead of a silent 0. Also adds a scrape-side sentinel that catches the same failure mode recurring through upstream schema drift alone (e.g. a future `state.source` rename), and fixes the reported match `lines` clamping to the match count instead of the source's own line count.

- **Run the ast-grep in-process fallback on every JS/TS module extension, and record the languages it cannot serve (closes #2215)** — `.mjs`, `.cjs`, `.mts`, and `.cts` files reached the napi fallback and were dropped without running a single rule, because its extension allowlist was hand-maintained beside three other lists that described the same thing. All four now derive from one language matrix, so the ~470-rule catalog reaches those files. The twelve catalog languages `@ast-grep/napi` bundles no grammar for (python, java, go, ruby, cpp, rust, csharp, c, kotlin, swift, php, scala — 247 of the 470 rules) are recorded as ast-grep LSP/CLI-only rather than silently skipped, the skip telemetry now names each language's delivery route, and a file admitted for a grammar the loaded addon turns out not to have leaves an `ast-grep-napi-language-unavailable` degradation record instead of nothing.

- **Derive the project scan's ast-grep kind per file instead of hard-coding jsts (closes #2217)** — The scanner passed the literal `"jsts"` to `evaluateAstGrepRules` for every napi-evaluated file, including `.css` and `.html`, so `suppressLinterOverlap` (and any future kind-gated ast-grep policy) evaluated non-JS files under a JS/TS linter-overlap decision. It also diverged from the per-edit dispatch path, which derives kind from `detectFileKind` — the same file could see two different kinds depending on which path evaluated it. The scan now calls `detectFileKind` (the shared `KIND_EXTENSIONS` resolver), matching the dispatch path exactly.

- **Normalize the remaining NDJSON logger `filePath` fields (refs #2219, the #2141 class)** — `cascade.log`, `latency.log`, `read-guard.log`, `tree-sitter.log`, and `actionable-warnings.log` now normalize `filePath` once at each logger's single emit seam, so the same file no longer appears in two path forms across a log. Non-path sentinels (`"<quiet-window>"`, `"<tree-sitter>"`, shell commands, coarse labels) mixed into the same field are left untouched instead of being resolved against the process cwd.

- **Remove three real-timer races from tests that only pass under CPU contention (refs #2225, #2235, #2182)** — `safe-spawn.ts`'s cap-then-timeout/cap-then-abort tests raced a real child's stdout against a real 300ms timer (5/8 failures under 8 concurrent runs); they now mock the child so the cap trips before the timeout by construction, in a new `safe-spawn-cap-race.test.ts`. `spawn-timeout-cooldown.test.ts`'s markdownlint-fix guard paired real filesystem I/O with a heavy dynamic import inside vitest's 5000ms default, reproduced as a hard timeout under synthetic load; it now carries a 20s budget. `managed-tool-refresh-strategies.test.ts` had more instances of the dangling-promise-contamination shape #2216 first fixed for one test — a file-wide `vi.setConfig({ testTimeout: 20_000 })` replaces per-test patching. A residual shared-mutable-state race in the same file, independent of any timeout, is left open on #2182.

- **Make a stale compiled `.js` beside a `tests/**/*.ts` file fail loudly (refs #2232)** — `tests/` is excluded from `npm run build`, so any `.js` sibling of a test-support `.ts` file is leftover residue from an earlier, differently configured local compile. Vitest's import specifiers end in `.js`, so that residue silently wins module resolution over the real source, running stale code with no warning. The build-freshness `globalSetup` now also flags this residue and aborts the run naming the file.

- **Make the spawn-timeout cooldown regression test load-invariant (closes #2235)** — load the real pipeline during test-module setup so the 5000ms test budget measures cooldown behavior instead of contention during a heavy dynamic import.

- **Bound the dispatch fact store (refs #2240)** — `FactStore`'s per-file records are now capped at 1024 with LRU eviction, so a several-hundred-file batch no longer retains one entry per distinct path until the heap is exhausted. The files being dispatched are pinned, so a background project walk cannot evict facts a live dispatch is still reading.

- **Harden the dispatch fact-store pin against eviction (refs #2240)** — the debounced ast-grep warning scan is a dispatch entry point, but it never pinned its file or re-derived content, so a project walk in its 2-second window could evict `file.content` and make inline `pi-lens-ignore` suppressions silently stop applying. The scan now pins and re-derives like every other dispatch caller, via a new `beginDispatchFor` that pins WITHOUT clearing the file's already-fresh facts (the earlier clear-and-re-derive cost ~51ms of redundant re-parsing for a file already re-derived by the inline dispatch 2 seconds earlier). The pin is also released at dispatch completion, so the pin set tracks dispatches actually in flight rather than the last 16 files touched. Capacity-eviction telemetry is now labeled per store instead of one shared subject across all six production `FactStore` instances, so a session-start review-graph walk can no longer consume the dispatch store's once-per-session degradation record before a real dispatch runs.

- **Bound retained FactStore content bytes (closes #2247)** — file-fact stores now evict least-recently-used records after retaining 64 MiB of UTF-8 `file.content`, as well as after 1,024 records. In-flight dispatch records remain pinned until settlement. A count-axis and a byte-axis eviction on the same store each get their own degradation record instead of sharing one dedupe key, and a pin whose content alone exceeds the byte budget stops evicting unpinned inserts instead of silently discarding every one of them, recording that state through its own degradation kind.

- **Napi runner css/html admission (#2248)** — Route CSS and HTML files to the napi ast-grep runner when its bundled grammars support them.

- **Anchor `.gitignore`'s scratch `test-*` patterns to the repo root (refs #2250)** — the unanchored `test-*.ts`/`.js`/`.py`/`.md`/`.sh`/`.mjs` rules matched the basename of any tracked file at any depth, silently hiding `clients/test-runner-client.ts` and several `tests/` files from `rg` and other ignore-respecting search tools. `git status` never flagged it because git still tracks the files. The patterns now only match scratch files dropped at the repo root, their original intent.

- **Baseline the git-config contamination guard against pre-suite identity (refs #2251)** — the test-teardown guard no longer fails every local run for a maintainer whose real git identity happens to equal a fixture value (`user.name=t`, `user.email=t@t.local`). It snapshots the config before any test runs and flags only fixture values that appear during the run, so real contamination still fails loudly.

- **Scope the playground source sentinel (refs #2253)** — the verifier now checks the caller's fixture inside the source pane's Monaco editor. A matching rule note in the config pane no longer masks upstream source drift.

- **Bound every in-memory review-graph retention site (refs #2255, #2240)** — the live `ReviewGraph` grew with project size and had no size guard; only the persisted snapshot was capped. On a large repository it was the second unbounded dispatch store behind the multi-gigabyte OOM abort (`FactStore.fileFacts` was the first, bounded in #2243). Two sites retained a graph for the life of the process: the workspace cache and `session.reviewGraph` on a caller's FactStore, two of which are module-scope. Both now go through one memoized retention seam capped by estimated resident bytes (`PI_LENS_GRAPH_MAX_IN_MEMORY_BYTES`, default 512 MiB), evicting with the same centrality-ranked selection the snapshot uses. `memory_sample` counts every retention site instead of the cache alone, deduplicated by object identity, so the sample can no longer read clean while a full graph is resident. A trim never yields an empty graph, and a graph trimmed for size is now distinguished from one whose source walk was truncated: both report as partial, but only the size-trimmed one stays usable as an incremental base, so an over-budget repository is not forced into a full project walk every turn. That marker is process-local and never persisted. Each trim emits one bounded degradation record per workspace.

- **Ast-grep YAML rule caches detect edited and nested files (closes #2262)** — the in-memory rules cache snapshots every YAML file with `mtimeMs` and size, re-checked at most once per 2-second cadence window. Editing an existing rule or adding a nested rule invalidates the cache even when the rule directory mtime stays unchanged; pickup lag is bounded by one cadence window.

- **Topology-derived startup scan and language-profile memos re-arm with the workspace marker index at session start (closes #2263)** — `resetWorkspaceTopology()` walks one registered downstream-cache reset list, clearing `startupScanContextCache`, `languageProfileCache`, and tsconfig-path caches with the source index. Caches are cleared only at session start via the topology-reset registry; mid-session edits are not detected.

- **Move three Java rules off an inert `files:` negation glob (fixes #2280)** — `no-raw-types`, `no-string-concat-in-loop`, and `no-system-out-println` used `files: ["**/*.java", "!**/test/**"]` to carve out test files, but ast-grep 0.45.1's `files:` field has no negation support, so the exclusion silently did nothing. All three now use the real `ignores:` field ast-grep's engine honors natively, restoring the test-file carve-out.

- **Preserve latency logger exports in test mocks (refs #2281)**

- **Detect different-size project-snapshot rewrites inside one mtime bucket (closes #2285)** — The authoritative in-process cache now validates both body mtime and size before serving its snapshot, so a hot project root cannot mask a different-length external write indefinitely. Same-size, same-mtime rewrites remain outside this metadata-only hot-path guarantee.

- **Merge-train duplicate classification (#2289)** — The warden now treats a PR number repeated within one GraphQL page as malformed data and a fatal error. Genuine cross-page window slides remain benign notes.

- **Add a size axis to five mtime-only content memos (closes #2300)** — The MCP warm-build staleness gate, the LSP diagnostic-binding content-hash memo, the workspace-diagnostics per-file freshness cache, the cross-process recent-touches watermark, and the installer's tool-path probe cache now all validate byte size alongside mtime, so an external rewrite landing in the same coarse-granularity mtime bucket with a different length is no longer served as unchanged. Every fix reuses a stat call already being made — no added per-edit I/O.

- **Remove the redundant markdownlint autofix cooldown guard (closes #2301)** — `detectFileChangedAfterCommand` remains the single source of truth for autofix cooldowns, and its direct test owns that contract.

- **ast-grep findings survive a silent auxiliary LSP (closes #2324)** — the napi fallback stays active until the ast-grep LSP completes its first diagnostic publication for the SPECIFIC FILE (not just anywhere on its client), closing the race where a sibling file's publication wrongly satisfied the gate. The fallback and the late-auxiliary delivery lane no longer both fire for the same file: a napi run consumes that file's pending late-auxiliary pair. The remaining narrow case — a server that published once but goes silent on a later touch of the same file — is now a bounded `aux-runner-findings-lost` degradation record instead of a silent drop.

- Auto-repair clearly flattened pull request bodies before advisory lint validation. Repair only splits inline headings; duplicate or otherwise structurally inconsistent template headings refuse repair and preserve the original lint errors.

- **Correct the vulture exit-code comment (closes #1765)** — The comment in `clients/dead-code-client.ts` now states the verified vulture 2.16 exit codes: 0 on a clean run, 3 with findings on stdout when dead code is found, and 1 with an error on stderr for invalid input or parse errors.

- **Read-guard eviction paths now leave a trace (#1918)** — Whole-file eviction (file cap, idle timeout, external-delete cleanup) and the per-file edits-cap trim each emit a bounded, always-on `read-guard.log` record naming the file and which path evicted it, closing the gap #1915 left after fixing the record-cap path.

- **Record formatter cache hits (closes #1940)** — Emit `formatter_selected` with `outcome: "hit"`, `reason: "cache"`, and `cached: true` on formatter detection cache hits, providing hit-rate observability with a single denominator in `latency.log`.

- **Cap dependency-drift blocker re-serves (refs #1950)** — A demoted-but-confirmable inline blocker (`clients/blocker-freshness.ts`) now retires after 3 degraded deliveries with no re-run, instead of re-serving indefinitely. The retirement note says the record can still be confirmed by a fresh dispatch, distinct from #1944's past-EOF retirement, which means the finding is provably unconfirmable.

- **Record message_end cache_usage attribution loss (closes #1956)** — A stale extension ctx now records a bounded `cache-usage-attribution-stale` ledger entry instead of silently writing the `cache_usage` row unattributed; the row still writes so provider token and cost data is never dropped.

- **Gate nested LSP requests (closes #1971)** — Skip `workspace/willRenameFiles`, `workspace/didRenameFiles`, and `codeAction/resolve` unless the server registered them, matching each registration's scheme/glob/file-kind filters at the send boundary while preserving supported edits and unresolved-action fallbacks.

- **Stop re-canonicalizing already-normalized dispatch paths (refs #2016)** — the dispatch context's `filePath`, `cwd`, and `projectRoot` are normalized once at construction, and seven downstream sites no longer pay a redundant `realpathSync` per dispatch. Scanner ids and the relative baseline key move to the cheap syntactic normalizer, which also fixes a key that resolved against the process working directory on Windows and stayed relative on Linux.

- **Budget CI test workers against real memory, and name an OOM kill (refs #2042)** — Fork concurrency and the per-fork heap ceiling now come from one resolver that sizes them against the host's memory instead of two constants tuned on a dev host, and the CI suite runs under a memory watch that reports the low-water mark so exit 137 no longer reads as unattributable infrastructure noise.

- **Retire deleted failed-first test targets (closes #2044)** — Test selection retires only confirmed-missing canonical paths with bounded work, then continues with valid failures or normal discovery.

- **Bound markdownlint verification and preserve typed spawn failures (closes #2045)** — markdownlint-cli2 now verifies through its stdin mode without scanning project files, and verifier logs retain the effective check command and typed timeout or spawn failure kind.

- **Canonicalize test-runner cache roots (refs #2048)** — Test-runner availability and Vitest glob caches now share project identity across path aliases, while positive runner verdicts expire when their supporting config disappears.

- **Honor LSP file-operation filters (closes #2049)** — Send file rename requests and notifications only to servers whose validated scheme, glob, file/folder, and case filters match the renamed resource.

- **Decline LSP roots outside every session project root (refs #2052)** — a file outside every initialized session cwd now receives an explicit outside-project-root skip and one bounded record per foreign root, instead of diagnostics computed under the wrong project context. The full sweep carries the skip into the unconfirmed lane, so a declined file is never reported or cached as clean. A process that initializes several project roots keeps serving all of them.

> Refs #2052

- **Cover the whole failed-Git-integration family.** `git pull`, `git revert`, and `git am` now join merge, rebase, and cherry-pick when opaque recovery drops clean incoming index paths. Truncated `git status` output fails closed, and an undocumented porcelain status pair keeps its path instead of voiding recovery for every other file in the command.

- **Make LSP spawn records count truthfully (closes #2064)** — `lsp_client_selected` reported `cold-spawn` for every caller that merely joined another caller's in-flight spawn, so the one metric that looked like a spawn count over-counted 3.0x in a 21.8 h field window, and one 29.3 s TypeScript spawn read as 39 spawns in 2 ms. The record now names the starter (`cold-spawn`, `spawn-failure`) apart from the joiners (`cold-spawn-joined`, `spawn-failure-joined`), on the same record with the same denominator. A new `lsp_server_spawned` latency record fires once per language-server process start for every server, so `latency.log` finally counts TypeScript spawns, which `lsp_launch_candidate_success` never covered.

- **A crashed LSP client no longer pins its retained text forever (refs #2065)** — the client-count Set backing the incremental-text-retention telemetry only deregistered on a graceful shutdown; a crash (connection error, connection close, or an unexpected process exit) never removed the client, so its retained text stayed counted, and reachable, for the rest of the process lifetime. Deregistration now happens in the one place every death path already converges on, so a crash is cleaned up the same way a graceful shutdown is. The eviction scan that runs on every `didChange` past the retention cap no longer spreads and scans the full per-path map; it tracks text-bearing paths in their own recency-ordered auxiliary set instead, so the scan cost stays proportional to the 128-entry cap rather than to the number of open documents. That auxiliary set is now also cleared on `didClose`, closing a second, smaller instance of the same unbounded-per-path-store class: without it, a closed document that was never evicted by the cap left a stale entry behind on every open/close cycle. `pullGenerations`, a per-path pull-fan-out counter, is deliberately left uncleared on close, for the same reason the codebase already leaves `pullRequestSequences` uncleared: resetting it would let a stale in-flight pull's captured generation match the counter again after a close/reopen and be wrongly accepted.

- **Incremental LSP document text is bounded and released on close (refs #2065)** — full text retained for incremental synchronization is capped at 128 paths and 64 MiB of UTF-16 data, evicts least-recently-sent paths, and is removed with the other per-document state on `didClose`. Periodic `memory_sample` records now expose retained entries and bytes.

- **Per-send LSP document sync makes one JavaScript pass over the previous document instead of two, on `didChange` (refs #2066)** — the newline count behind `lsp_document_send`'s `contentLineCount` and the last-line position behind an Incremental `didChange` range now come from a single scan whose result rides on the existing per-path content-binding entry, so a change no longer splits the previous document into one substring per line to read one of them. Line-count semantics are unchanged: `contentLineCount` still counts `\n` only, while the range still treats `\r\n`, lone `\r`, and `\n` as terminators. The remainder of #2066 is its "one pass total" target: the #1095 sha256 content binding is a second, native full-document pass and still runs on every send.

- **Intern word-index posting files (refs #2067)** — Per-edit document replacement now compares shared file identities instead of re-normalizing every posting element; build/refresh telemetry records posting-entry counts and per-edit replacement cost. This is the declared prerequisite for #2069's posting representation work.

- **Nested `.gitignore` edits now refresh ignore verdicts (closes #2071)** —
  agent writes invalidate every cached project matcher containing the edited
  path, so its per-path memo cannot serve a verdict from an older ignore file.
  Root edits rebuild each affected matcher, while nested edits preserve
  compiled-glob reuse for sibling trees.

- **Give nested ignore rules and path verdicts one freshness clock (closes #2071, closes #1976)** — a nested `.gitignore` edit no longer leaves two paths under the same rule disagreeing, and the ignore matcher stats a nested source once per cadence window instead of once per file. A 2000-file walk drops from 18,064 `statSync` calls (9.03 per file) to 192 (0.10 per file), and from 1,886 ms to 85-92 ms across runs. An externally edited nested ignore file is honored within one cadence window (2 s).

- **Review-graph source-path normalization (refs #2072)** — reuse canonical paths across builds, remove redundant persist coverage normalization, and expose normalization and persist durations in telemetry.

- **Clear outgoing idle-eviction timers on cache replacement (refs #2073)** — Review-graph, reverse-dependency, and authoritative project-snapshot cache replacements now release the prior entry's timer before installing the new entry, preventing one full payload from being retained per rebuild. Regression coverage asserts one live timer after twenty replacements for each cache family.

- **Fail closed on truncated Git file lists.** Truncated `git ls-files` output now reports unavailable tracked and ignored sets instead of presenting partial sets as complete.

- **Opaque-mutation exclusion counter overstated suppression (closes #2081)** — `excludedIncomingCount` incremented for every clean-index-only entry dropped by the failed-integration filter, even when the entry's mtime fell outside the recovery window and it was never going to be dispatched. It now counts an entry only when the mtime-freshness check would otherwise have included it, so the field means what its doc comment claims: dispatches actually prevented. Added a test asserting the `opaque_mutation_status_pair_unknown` latency record's emission and phase identity through the `logLatency` seam, alongside the existing `opaque_mutation_incoming_excluded` coverage — renaming either phase string now reds a test.

- **PR-title checks now validate the live pull-request title (refs #2083)** — rerunning a failed check can recover after the title is fixed without requiring a new pull-request event.

- **PR body lint now rejects unfilled templates and recognizes the fleet's section conventions (refs #1844)** — live body lookup uses the repository API endpoint with a bounded timeout and safe fallback.

- **Read the live PR body in close-keyword syntax checks (refs #2086)** — Reruns now validate the edited PR body instead of replaying a stale event snapshot.

- **Read the live PR body in close-keyword verification (refs #2086)** — a post-merge rerun of `--verify-merged` used to relint the closed-event payload's stale body and, in production, silently kept doing so because the workflow step never carried the `GITHUB_TOKEN` env var the live fetch needs. It now sets that var and fails the check loud on any fetch problem instead of falling back to stale data, so an edit that fixes or introduces a comma-separated close list is reliably seen on rerun.

- **Close two named remainders from the #2088 sweep-floor fix (refs #2088)** — the sweep-floor meta-sweep now recognizes the `expect(x.length).toBe(0)` emptiness spelling (14 test files use it, previously invisible to the census), and `managed-tool-seam-coverage` gained a positive control on its `safeSpawnAsync(` detector so a regex that stops matching fails loud instead of reading as zero violations.

- **Closed the managed-tool seam sweep's vacuous exemption (refs #2088)** — The meta-sweep exempted `managed-tool-seam-coverage.test.ts` with a false reason; the file walks `clients/` from a cwd-relative root and asserted zero violations with no floor, so blinding its walk to an empty array still passed. It now walks from a repo-root-derived path and carries its own scanned-files and detector-signal floors. Reworded two other exemption reasons that wrongly said "not a population sweep" when both carry their own hand-rolled floors. Added the `readdir` named-import spelling to the meta-sweep's enumeration regex, closing an async-walk evasion. Recalibrated the meta-sweep's `minFlagged` to the measured population (55) after the exemption-list growth made the prior figure stale.

- **Governance sweeps now fail on empty populations (refs #2088)** — Added declared scan and finding floors, stale dynamic-runner checks, and a registered meta-sweep for hand-rolled governance scans.

- **Tests that could not run reported as passed (closes #2089)** — Thirteen test bodies bare-returned before their first assertion, so Vitest counted them green while they asserted nothing. Each now skips visibly (`it.skipIf` or `ctx.skip(reason)`), the pnpm symlink case runs unguarded on every platform, and a new sweep over the whole `tests/` tree fails on the shape.

- **Stop metrics-history's git probe from leaking stderr into the TUI (#2095)** — `getCurrentCommit()` now pipes `git rev-parse` stderr instead of inheriting it, so a failing probe reports "unknown" quietly instead of printing a raw `fatal:` line. The same fix applies to the LSP launcher's Windows registry PATH probe, the only other unguarded `execSync`/`execFileSync` call in the runtime.

- Resolve metrics commit attribution against each target file's Git repository. (closes #2099)

- **Truncated tool output is reported as truncated.** Several spawn sites (`git status` opaque recovery, oxlint, `helm lint`, `helm template`, and the shared-checkout working-tree probe) retained unbounded stdout and carried truncation guards that could never fire. Each now caps its output, and every truncation guard in the tree reads `truncatedByOutputCap` before failure or status handling. `safeSpawnAsync` now records `killedForOutputCap`, so callers handle cap termination without guessing from POSIX or Windows exit shapes. A run that hit the cap and then timed out or was interrupted still reports the timeout or abort. Also corrected the trivy scan, `sg` exec/scan, and ast-grep pattern validation. The shared runner ledger now says a tool was stopped at its output cap instead of blaming it for a kill pi-lens sent.

- **Rename the CI classifier's kill label to infra-kill and read kernel evidence (refs #2103)** — every exit-137/SIGKILL kill used to post as `infra-oom` even with memory headroom; the classifier now labels it `infra-kill` and enriches the detail with the kernel kill-evidence step's dmesg/cgroup output already in the log.

- **Fail loudly when the stale open-issues scan is truncated (closes #2104)** — the weekly detector proves exhaustion for the open-issue population, fails loudly when its safety bound is reached, and reports the scanned population. The master commit read remains a deliberately bounded recent window and reports commits beyond its detail cap.

- Serialize overlapping per-path LSP `didChange` sends so Incremental payloads use the latest confirmed document content (closes #2113).

- **Deferred runner results remain visible and actionable (refs #2122)** — Deferred runners now show a pending state at edit time, report failures at turn end, preserve freshness coverage, and bound handoff retention with degradation telemetry.

- **Key session start and the host registry by project root (closes #2129, refs #2130)** — a subagent temp worktree no longer steals the process's primary session. Root identity is now an input to session-start classification, so a start in a different directory takes the reduced path instead of resetting the host's warm LSP fleet and re-running the whole startup battery over unchanged content. Two temp roots in one host previously cost about 50 seconds of opengrep and 53 seconds of word-index rebuild each, and drove host RSS from 290MB to 1.1GB in four minutes. The host registry entry in `instances.json` now holds a set of roots with the primary pinned, instead of one scalar every session start overwrote, so warm attach and the shared-checkout guard can see a peer working under any of its roots. The shared-checkout guard now confirms a peer against every root it serves, so a session working under a secondary root can no longer be missed and have its uncommitted work discarded. `memory_sample` carries the owning root and the distinct-root count of its live LSP clients, which makes a turn index attributable on a multi-root host.

> Refs #2129, #2130

- **Scope availability probe flights per owner (refs #2131, refs #2140)** — Package-manager, dispatch, toolchain, checker, cwd, and security-scan availability owners now hold separate keyed flights, so an owner reset cannot tear down another owner’s probe. Release-managed binaries under `~/.pi-lens/bin` answer before PATH probing, and the resolved path reaches security scans.

- **Resolve LSP-managed release binaries before PATH (refs #2140)** — Every GitHub-release-managed LSP server (clojure-lsp, cue, deno, expert, gleam, marksman, opengrep, rust-analyzer, taplo, terraform-ls, typos-lsp, zizmor, zls) now checks `~/.pi-lens/bin` before walking bare PATH candidates, same as the fast path PR #2148 already gave the CLI-scan side of opengrep/gitleaks/trivy/govulncheck. A managed binary with no PATH entry no longer ENOENTs a launch candidate before the installer step finds the same binary a moment later. For rust-analyzer and deno, this means a pi-lens-managed copy is now preferred over a developer-toolchain-managed one (rustup, `deno upgrade`) when both are present; the managed copy is kept from drifting too far via the existing 7-day managed-tool refresh cadence.

- **Key process-unique state on `globalThis` so it survives a second module evaluation (refs #2146)** — pi evaluates the pi-lens module graph up to nine times per process, so the primary-session registration and the instance-registry mutation tail existed once per evaluation instead of once per process. The concurrent-session guard read an empty registration and ran the full session_start battery per subagent temp root, and concurrent read-modify-write cycles tore `instances.json`. Both now share one versioned process singleton, and `host_boot` records the evaluation ordinal. `session_shutdown` gained the same root discriminator `session_start` has, so a subagent's teardown no longer clears the shared registration and leaves the next subagent to run the full battery.

- **Distinguish clean from absent auxiliary LSP results (closes #2151)** — Turn-end late-auxiliary harvesting now recognizes published clean results and retires stuck coverage pairs with bounded, reconciled telemetry.

- **Adapt the first cold auxiliary wait to observed spawn cost (closes #2152)** — a cold auxiliary touch uses the larger of its declared wait and the server's last successful spawn duration plus a 500ms margin, capped at 8s. Warm touches retain the declared wait capped at 2s, and `PI_LENS_AUX_GRACE_MS` caps the budget (it never raises it).

- **Identity-feeding sorts now use a code-unit comparator, not `localeCompare` (closes #2155, closes #2165)** — the availability-probe cache key in `runner-helpers.ts` sorted env entries with `localeCompare`, a locale-dependent comparator. Two processes (or one process under a changed locale) could order the same env set differently and mint different keys for identical state, causing a silent probe-dedupe miss. Fixed via a new shared `compareOrdinal` helper (`clients/string-utils.ts`), applied to every identity-feeding sort found in the class sweep: dependency-cycle and madge dedupe keys, review-graph signature/hash/cache keys, the Windows spawn cache key, the bounded-hash object-key order, the rule-cache content hash, the workspace-diagnostics scope key, the turn-end-findings signature, and the formatter-config signature. `localeCompare` sorts kept for user-facing display are left untouched.

- **Share one LSP service across module evaluations (refs #2157)** — all evaluations now share one service, its generation handoff, workspace-sweep hold, and classic TypeScript repair guard through versioned process-singleton families. Incompatible live services shut down fast before replacement, and a secondary pipeline crash or a secondary session's idle-reset timer no longer tears down the primary fleet.

- **Consumed `.gitignore` files refresh at matcher lookup (closes #2159)** — pi-lens detects external edits, git restores, and changes under ignored directories with a cadence-bounded freshness probe.

- Use per-file diagnostic publication timestamps to distinguish a fresh clean primary LSP response from an absent response in the tsserver sync racer.

- **Bound the stale-findings re-arm loop and count cap-evicted pairs (closes #2167, closes #2168)** — Late-auxiliary turn-end harvesting now caps a pair that keeps returning stale findings at `MAX_LATE_AUX_REARMS` re-arms, re-arms (instead of dropping) a pair after a transient probe-cache read failure, and counts a pending-coverage cap eviction with a dedicated `capEvicted` counter folded into the turn's reconciliation sum.

- **Bound Svelte and Prisma language-server probes, and Vue's remaining dispatch-side budget (refs #2169, #2176)** — the Svelte and Prisma installer registry entries now carry their own cold-start `--version` verification ceilings (20s and 40s), matching real measured cold runs of 12.4s and up to 27.3s. Separately, Bash, JSON, Prisma, Vue, and Svelte language servers now raise the dispatch lsp-runner's 5-second cold-spawn wait floor to match their installer bounds, so a slow cold spawn cannot still read as unavailable after installer verification would have accepted it.

- PR body repair keeps distinct numbered fix-round headings distinct, so legitimate multi-round bodies can be repaired while duplicate and corrupted headings remain protected.

- **Preserve identity during concurrent instance registration (refs #2173)** — An adjacent O_EXCL lock serializes registry writers, reclaims stale ownership, and preserves session identity during child-first synthesis.

- **Bound the Vue language-server verification probe (refs #2176)** — the managed Vue registry entry now gives its cold-start `--version` probe a 30-second per-tool ceiling, while all other tools retain the 10-second installer default.

- **Migrate remaining Git-using smoke scripts to the scrubbed fixture environment (closes #2177)** — governance now detects fixture identity and path-exemption drift, and the JavaScript helper has direct wrapper coverage.

- **Bound the JSON and Bash language-server verification probes (closes #2194)** — `bash-language-server` and `vscode-json-language-server` measured 9,667ms and 11,047ms cold `--version` starts with closed stdin, close enough to the 10-second installer default that host contention alone could trip a false verification degradation. Both now carry a 20-second `verificationTimeoutMs`, delivered through the managed-local, install, and refresh paths. The refresh delivery for the other five install strategies (pip, gem, github, maven, archive) is now covered by tests too, closing the gap the issue's follow-up comment flagged.

- **Escalate workflow runs stuck queued (closes #2203)** — the merge-train warden classified a run that GitHub queued and never scheduled as `runs-in-progress` on every cycle forever, which read as healthy waiting. A tracked run that has executed zero steps for 60 minutes now classifies `stalled-run`: the warden names it in a PR comment, cancels it on the next cycle, and re-runs it once, bounded by GitHub's own `run_attempt`. A run with executed steps stays in progress however long it takes, and a cancellation the warden did not make is left alone.

- **Stamp classifiedBy on every availability_decision failure arm (refs #2131, #2209)** — 12 call sites across formatters, the JVM runtime probe, zizmor's gh-token latch, PowerShell script analysis, govulncheck, and the shared runner-helpers/ast-grep seams now record whether a probe or the call site itself classified the outcome, closing the failure-arm half of the gap #2205 fixed for successes.

- **Re-check test-runner negative verdicts instead of latching them (refs #2252)** — `TestRunnerClient.detectRunner` and `parseVitestTestGlobs` used to memoize "no runner"/"no config" for the process's whole life once probed. A config file added after the first probe (`vitest.config.ts` appearing, a project scaffolded mid-session) now converges on the same client instance instead of re-serving the earlier miss.

- **Treat a null PR body as empty (closes #2268)** — Let PR-body and close-keyword checks report their actual validation results instead of treating an empty GitHub body as a fetch failure.

- **Make the failed-target cap test load-invariant (closes #2290)** — Seed the real `TestRunnerClient` state with deterministic failed results instead of launching 33 child processes, so the cap assertion is independent of batch contention.

- **Make cross-root eviction coverage load-invariant (closes #2305)** — Seed failed-target eviction state through `TestRunnerClient.recordResult` instead of spawning child processes, while retaining global ordering, root-map reacquisition, and telemetry assertions.

- **Normalize tabs in playground-verify's sentinel comparison, and fail fast on a stable mismatch (closes #2306)** — a first source line with an internal tab (`const\tok\t= arr.indexOf(x) !== -1;`) burned the full 40s poll timeout and reported "likely upstream schema drift": Monaco doesn't render a tab as U+0009, so the raw-tab sentinel never matched, the same failure mode the existing nbsp normalization was added for. `buildScrapeExpr` now collapses every run of tab/nbsp/space to one space on BOTH sides of the comparison instead of hand-listing substitutions. The poll loop also tracks a stable, non-empty rendered-source length across polls and concludes as soon as it repeats unmatched for three consecutive polls, rather than always waiting out the full `--timeout`; the failure message now names both remaining causes (a sentinel-normalization mismatch in this harness, or upstream schema drift leaving the page's default sample rendered) instead of a bare schema-drift verdict.

- **Pin the availability-probe cooldown consult (closes #2309)** — Preserve the transient timeout decision and skip redundant probes while a command is cooling down.

- **Harden the CI classifier's automatic PR comment path (closes #2318, refs #2316)** — escape log-derived HTML comment delimiters, neutralize mentions, and accept only the final anchored classifier marker so failed test output cannot forge rerun suppression or ping users.

- Schedule ast-grep NAPI rules for CSS and HTML edits, restore four sibling structural and IaC runner-kind pairs found by the class sweep, and guard every declared pair against dispatch-plan drift. (#2323; refs #2325)

- **HTML ast-grep catalog coverage (closes #2325)** — Verify that every NAPI-routed language ships enabled rules and that HTML edits report plaintext HTTP links.

- **Avoid unrelated diagnostics after a conflicted Git integration.** Pi Lens now excludes clean incoming index paths from failed Git integration recovery (merge, rebase, cherry-pick, pull, revert, am).

- **Guard package-lock metadata during releases (#2043)** — Detect package identity, dependency, and peer-policy drift at CI, commit, install, and changelog release preflight boundaries.

- **Attribute pi-lens writes and bound review-graph path normalization (refs #2070, closes #2072)** — drift correlation records identify pi-lens writes with bounded normalized paths, while the review-graph scale regression proves warm 8,000-file walks normalize only the changed-file bound.

- **Prove idle-eviction replacements release graph memory (closes #2073)** — Add an enforced forced-GC test for twenty 2,000-node workspace graph replacements. The cache retains under 6 MiB after replacement, proving outgoing eviction timers do not retain prior graphs.

- **Pin diagnostics bus publishers and subscribers (closes #2079)** — Add source-scan conformance coverage for the `pilens:diagnostics` event surface.

- **Test-flakiness and hygiene residuals (refs #2090, closes #2139, refs #2182)** — `project-snapshot.test.ts` now restores its stubbed idle-evict env var so it cannot leak into later tests, and `project-report.test.ts`'s graph-cold assertion checks the cache stayed cold instead of a 22x-loose wall-clock bound. `session-lifecycle.test.ts`'s guard=0 reset test gets a 15s timeout, sized off a measured 5.4-7.2s honest cost under load instead of vitest's tight 5s default. `managed-tool-refresh-strategies.test.ts`'s cross-session re-arm test gets the same budget correction after reproducing its combined-run flake against the degradation-ledger suites.

- **`vi.stubEnv` leak sweep undercount (refs #2090, closes #2223)** — `reverse-deps-cache.test.ts` now unstubs `PI_LENS_REVERSE_DEPS_IDLE_EVICT_MS` in its `afterEach`, the second live instance of the #2090 leak class that the original sweep missed. `check-pr-body.test.ts`'s "renames out of tests/" test unstubbed its GitHub env stubs only after its assertion, so a failing assertion would have left them stubbed for later tests; it now unstubs in `afterEach` like every other describe block in the file.

- **Guard the warmed YAML rules cache with load-invariant work counts (closes #2292)** — assert that repeated cache hits perform no directory, metadata, or content reads inside the freshness cadence, and that the next cadence performs one metadata sweep without reading rule content.

- **Close #2298's rules-cache work-count residuals (refs #2292)** — count the warmed cache's existence and content-open probes, and verify metadata sweeps across nested rule files.

- **Isolate real-Git test fixtures and guard repository config (closes #2163)** — shared fixture environment hygiene, source governance, and a post-suite contamination guard prevent tests from mutating the checkout.

## [4.1.2] - 2026-08-24

### Added

- **Prompt-cache misses now say why, and mixed injections split by source (refs #1071)** — a `cache_usage` record carries `interTurnGapMs` and a `cacheMissCause` verdict of `ttl-expired`, `prefix-broke`, `partial-eviction`, or `unknown`, so the dominant cache cost is readable from `latency.log` instead of reconstructed by hand-joining timestamps. The gap is measured to request time, so a slow response is not mistaken for idle time; `gapBasis` names which endpoint produced the value. A `cache_context` record splits a mixed injection payload by contributing source, with per-source message count, characters, bytes, and an estimated token count. Token figures use a documented four-chars-per-token estimate and are never presented as provider-measured. The TTL threshold defaults to 60s and is overridable via `PI_LENS_PROVIDER_CACHE_TTL_MS`.

- **Quick-mode session_start now logs which steps it skipped (closes #1911)** — quick mode silently skipped slow tool probes, language profiling, preinstall, startup scans, and the error-debt baseline, with no record either way. `session_start` now emits one bounded `session_start_skipped_steps` latency record naming the skipped step set, so a reader can tell "quick mode correctly skipped these" from "the probes silently never ran".

- **Runner parsers are now held to real binary output (refs #1937)** — a parser written from documentation can be wrong in a way no hand-authored test sees, because the test asserts the same imagined shape. Captured real-bytes fixtures now live in `tests/fixtures/runner-output/`, each with a machine-generated provenance header naming the tool, version, and exact argv; a replay suite feeds those bytes through the runner, fails when the parser finds nothing, and fails when the runner's invocation drifts from the argv the fixture recorded. The sweep found four live instances: taplo spawned a `--output=json` flag taplo rejects, stylelint read stdout while stylelint 16+ reports on stderr, phpstan read the error COUNT as if it were the error array and discarded its file-independent findings entirely, and sqlfluff inserted `--dialect` between `--format` and its value so no unconfigured SQL project was ever linted. A scheduled `parser-smoke` lane now runs the tier-1 tools' real binaries over a planted violation, with a pass floor so a run that installed nothing cannot report green.

- **A runner that reads nothing out of a failing tool's output now leaves a record (closes #1948)** — when a dispatch runner's tool exits nonzero, prints output, and the runner's parser extracts zero diagnostics, the degradation ledger gets a bounded `runner-parsed-nothing` row naming the tool, the exit status, the output length, and the first output line. Until now that case was byte-for-byte identical to a clean file in the worklog, which is how five parser bugs (vale, taplo, stylelint, phpstan, sqlfluff) reported clean files for months. A genuinely clean run records nothing, so the ledger does not fill up with one row per save. The gate lives in one shared helper, `parseToolRun`, that fourteen runners now share, and a sweep test keeps a new runner from silently opting out through any of the older spawn-outcome primitives.

- **Mechanical lint batch: actionlint, PR-title lint, markdownlint, OSV scan (refs #1844)** — a new `lint.yml` workflow dogfoods actionlint against every workflow file, validates that every PR title carries a conventional prefix and an issue reference **in the title itself** (`scripts/check-pr-title.mjs` — a reference living only in the PR body no longer counts, since it never reaches the merge-commit subject), and lints Markdown docs with `markdownlint-cli2` under a repo-tuned config. A separate `osv-scan.yml` runs an advisory weekly `osv-scanner` sweep plus a scan on lockfile-touching PRs, writing results to the job summary.

- **Vale prose lint with a vendored Google style base (refs #1844)** — `.vale.ini` at the repo root scopes Vale to `docs/`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `CONTRIBUTING.md`, `.claude/agents/*.md`, and `.changelog/*.md`, using the Google style package vendored under `.vale/styles/Google/` (no `vale sync` at CI time) plus a `pi-lens/` style with three house rules: no `please` in instructions, an em-dash-density check, and a long-sentence check. A new advisory `vale` job in `lint.yml` runs it on every PR. Because the in-product `vale` runner (`clients/dispatch/runners/vale.ts`) activates on `.vale.ini` presence, dogfood sessions now surface Vale findings on doc edits too.

- **Husky-managed local git hooks (closes #1804)** — `npm install` now wires
  a pre-commit hook (changelog fragment validation + `npm run lint`) and a
  pre-push hook (build + a capped, full-path-resolved targeted `vitest`
  selection for changed files, never the full suite; degrades to build-only
  past 25 matched test files or a 2-minute shared test-lock wait). Both are
  skippable with `PI_LENS_SKIP_HOOKS` set to any non-empty value, which
  agents and CI should set; humans leave hooks on. Hook install itself is
  skipped for CI and production/consumer installs, and never fails
  `npm install` on error.

- **latency.log now says whether the LSP pool reused a client or spawned one (closes #1934)** — `lsp_client_selected` fired 5601 times in a 20.8h dogfood window carrying only `{serverId, candidateCount}`, so nothing in the log said whether the pool served a warm client or paid for a language-server spawn. This is the most expensive cache in pi-lens: a miss costs a process. The only estimate was a cross-record inference against `lsp_launch_candidate_success`, which counts something else, so a regression that halved pool reuse was invisible. The record now carries an `outcome` field with one of three values on the same record and the same denominator. `warm-reuse` means the pool served an already-connected client. `cold-spawn` means this selection waited on a spawn, whether it started that spawn or joined one already in flight, so a session-start burst where many files wait on the same spawn reads as several cold spawns and deflates the apparent reuse rate for that window. `spawn-failure` means a spawn ran and failed. Reuse rate is `warm-reuse / (warm-reuse + cold-spawn)`. A spawn failure is deliberately distinct from a clean decline: no root, a breaker already open, host trust refused, or a binary absent while installs are disabled. Declines emit no outcome record and keep their existing ones, so `lsp_client_unavailable` and the `outcome` field can both describe the same event and are counted separately. `getWarmClientForFile`, the warm-only lookup the cascade quiet window and read expansion call per file, previously returned `undefined` and emitted nothing; it now emits a bounded `lsp_warm_client_missing` record naming the candidate server and root, rising-edge per candidate set with the exact count in the degradation ledger.

- **Dogfood sessions on pi-lens's own repo now activate the oxlint, spellcheck, yamllint, and taplo runner lanes (refs #1844)** — four new root config files (`.oxlintrc.json`, `typos.toml`, `.yamllint`, `taplo.toml`) opt pi-lens's own repository into linters it already ships for every other project. `.oxlintrc.json` pins `oxlint` as a new exact-version devDependency (1.79.0, matching the runner's tested fixture), makes oxlint this repo's sole preferred JS/TS lint runner (dropping the `biome-check-json` fallback — that IS the dogfood intent), and excludes the fixture directories that carry no config override of their own; oxlint's nested-config discovery still finds the two fixture directories that deliberately escalate severity for the runner's own smoke tests, so those stay as they are. The oxlint runner also now tells a config-excluded file (`number_of_files: 0`) apart from a genuinely clean one, reporting `skipped` instead of a false `succeeded`. `typos.toml` allowlists evidenced false positives (a routine "mis-" prefix, HashiCorp's brand abbreviation, and similar); its presence enables typos-lsp's blocking opt-in, but findings stay advisory in practice since typos-lsp's own default diagnostic severity is warning. `.yamllint` scopes yamllint to hand-authored YAML (GitHub Actions workflows, top-level config), downgrades line-length to a warning rather than raising its threshold, and defuses the `on:` truthy false positive. `taplo.toml` activates both the TOML linter and the `taplo fmt` autofix formatter, and this PR runs that formatter once so activation doesn't start the lane red. Markdown linting was already live via the pre-existing `.markdownlint-cli2.jsonc` (since #1917) — confirmed with a real `markdownlint-cli2` run, no new config needed.

- **Memory-sample observability upgrades (refs #1999)** — The periodic
  `memory_sample` latency.log line gains three fields. `peakWorkingSetBytes`
  records the OS high-water working set from `process.resourceUsage().maxRSS`,
  so an idle-moment rss sample can be told apart from true growth (on Windows
  libuv backs both counters with one `GetProcessMemoryInfo()` call: rss reads
  current `WorkingSetSize`, maxRSS reads `PeakWorkingSetSize`). A rising-edge
  cadence tightens sampling from every 10 turns to every turn while heapUsed
  grows more than 20% between samples, reverting once growth stabilizes; the
  state resets at each primary session start. Each sample now also carries
  session age, session start time, and turn count, so growth-vs-age curves are
  plottable from logs alone. The `/lens-health` memory line shows peak WS when
  known.

- **Opaque-write recovery for bash commands (refs #2000 phase 2)** — commands the path extractor does not recognize (python/node/perl/PowerShell internal writes) now get a bounded pre/post stat diff of the project source universe. Recovered files are attributed to the read guard as agent-authored and dispatched through the mutation seam, with explicit coverage-unknown telemetry instead of silent gaps.

- **Collect-later delivery for slow auxiliary LSP servers (closes #2001, refs #2002)** —
  When an auxiliary scanner such as opengrep misses its aux-grace window,
  pi-lens now marks the file and server pair in a bounded pending store
  instead of dropping the scanner's eventual findings. The next turn end
  probes the auxiliary's client cache through a read-only seam,
  freshness-gates the result against the mark timestamp, and delivers
  survivors as a `Late auxiliary diagnostics` advisory. A cited file that was
  edited or deleted since the mark drops its findings, and both drop arms are
  counted in the new `late_auxiliary_findings` latency record.

- **Freshness kernel: one comparator, one verdict type for staleness gates (closes #1739)** — `clients/freshness.ts` owns the mtime-vs-reference comparison and shared drift tolerance that six stores had independently reimplemented (three copies carried the identical #1710/#1711 tolerance defect). `freshnessFromMtime` returns an explicit verdict (`fresh` / `stale: modified-after-reference` / `indeterminate: no-mtime-evidence`) so each caller keeps its own no-evidence policy while sharing the comparison. A registered-or-fail sweep fails on any new out-of-kernel mtime-vs-reference comparison in `clients/`.

- **Shared fault-injection test kit (closes #1838)** — `tests/support/fault-injection.ts` promotes the bespoke fault probes reviewers kept rebuilding by hand into four one-call primitives: `spawnWedgedChild` (a real child whose stdin pipe is genuinely full — the #1811 fixture generalized, with its fail-fast-instead-of-hang trap asserted against), `delayInside` (deterministic completion delay inside any mocked async seam), `fireResetAt` (fire a lifecycle hook from inside a seam's implementation at a chosen call — the #1746-R2-F1 shape), and `starveBudget` plus `gatedPromise` (the tiny-budget starvation repro). Every primitive carries its own fidelity test in `fault-injection.test.ts`, so a neutered primitive goes red in CI instead of silently weakening every consumer.

- **Adversarial garbage battery for every CLI lint runner (closes #1839)** — Twenty runners now meet a 12-case battery of hostile outputs (truncated JSON, usage prose on the findings stream, unknown severities, hostile numbers) under format-blind invariants: never crash, never emit malformed diagnostics, never report clean on a nonzero exit carrying bytes. The first pass found 79 violations of that last invariant across 14 runners — all fixed by consolidating their identical tails onto one shared `finishParsedRun` seam. Also fixes htmlhint's `--rules` flag being fed JSON (the tool wants a ruleid list), which left zero rules enabled so every file read clean.

### Changed

- **pi-lens formats its own TypeScript with oxfmt (refs #1844)** — The
  repository now carries an `.oxfmtrc.json` and an `oxfmt` devDependency, and
  an advisory `oxfmt --check` job runs in CI. Because `hasOxfmtConfig` gates
  the oxfmt formatter on exactly that config file, a pi-lens session opened on
  this repository now dispatches oxfmt as the format runner for edited
  TypeScript and JavaScript files.

- **Opaque-write recovery goes git-first (refs #2000)** — inside a git worktree the pre side records only a timestamp and `git status --porcelain` plus an mtime window answers what changed, with no file-universe cap: recovery now works on any repo size, including large monorepos where the stat-walk previously degraded every command to coverage-unknown. The bounded stat-diff path remains for small non-git trees.

- **ONE mutation seam: `RuntimeCoordinator.recordProjectMutation` (refs #2000 phase 1)** — the triplicated bump+change-log pairing (runtime-tool-result, runtime-agent-end, lsp-mutation) is consolidated onto one seam that bumps the seq store, appends a bounded attributed receipt ring (`getMutationsSince`, cap 512 with a surfaced dropped-count), and appends the durable change-log entry. Consumers derive touched-files answers from one store instead of three hand-copied pairings; phase 2's opaque-write recovery feeds the same seam.

### Fixed

- **Search credit ignores pipe truncation (closes #1908)** — `grep -C2 pattern
  file | head -1` no longer credits context lines the pipe cut off. The
  read-guard now detects a truncating pipe tail (`head`, `tail`, `sed q`)
  downstream of a line-numbered grep and falls back to match-line-only
  credit, so a later edit to the uncredited context still requires a read.

- **Cascade tier-3 registry now resets at session_start (closes #1910)** — the outstanding-touch registry and its sweep-scoped expired/evicted counters (`clients/lsp/cascade-tier.ts`) used to survive a session replacement, so a new session inherited the prior session's outstanding touches and a stray eviction or expiry landed on the next session's first reconcile gauge. `handleSessionStart` now clears both, primary-only, same as every other per-session latch in that reset block.

- **Read-guard eviction telemetry is now always on (closes #1913)** — the
  first time a file's read-guard record cap trims in a session, it now emits
  a `read_cap_trimmed` log line (file, evicted count, credit-vs-genuine
  split, raw read count) regardless of `PI_LENS_READ_GUARD_VERBOSE`, so a
  live eviction regression is visible by default instead of only under
  verbose logging. Later trims on the same file keep updating the running
  totals (queryable via `ReadGuard.getTrimStats`, and via the degradation
  ledger's own tally) without flooding the log on every subsequent trim.

- **agent_settled no longer crashes with extension_error when the session is replaced mid-run** —
  stale extension ctx reads are guarded and the ambient abort signal is
  cleared (closes #1924; thanks @Pluto-Yt).

- **A session event on a replaced session no longer reports a pi-lens handler error (closes #1925)** — pi invalidates a captured extension ctx on `newSession`/`fork`/`switchSession`/`reload`, and an event already queued when that happens still reaches pi-lens carrying the dead ctx. `tool_result`, `turn_start`, `agent_end`, and `turn_end` each read a ctx property before any guard, so the SDK's `assertActive()` error was caught by `ExtensionRunner.emit` and reported as an extension error against pi-lens. That report named the wrong cause, said nothing about which handler was affected, and counted nothing. All five session-event registrations, including `agent_settled` (#1924), now go through one wrapper in `clients/session-event-guard.ts`: it probes the ctx once before dispatch, still classifies a stale error that arrives mid-handler, and records every skip in the degradation ledger under `extension-ctx-stale` plus a bounded `session_event_stale_ctx_skip` row in `latency.log`, keyed by event name. A new registered-or-fail sweep reds any future `pi.on` or `pi.on?.` registration that is neither wrapped nor given a stated reason.

- **Git installs start about 750ms faster (refs #1926)** — pi supplies `typebox` and `@earendil-works/pi-tui` from its own runtime, but pi-lens declared both as runtime dependencies. A `git:` install therefore vendored a private second copy of each, and Node evaluated that whole extra module graph every time the extension loaded. Both are now optional peer dependencies, so nothing vendors them. The `PI_TIMING` module import for the dogfood git install drops from 941ms to 147-196ms, and the extensions block drops from 2165ms to 1449ms. One trade-off, stated plainly: because the bare specifiers no longer resolve on disk, pi loads the entry through jiti's transform path instead of a native import, so the FIRST start after an install, an update, or a jiti cache eviction is slower, around 5.6 to 8.2 seconds on the 4MB bundle. Every start after that is the fast one.

- **Updating pi-lens no longer costs a five-second first session (refs #1926)** — pi loads the extension through jiti, which transforms the ~4MB `dist/index.js` bundle and caches the result. Every `git:` install or update produced a new bundle, so the next interactive session paid that transform: 4847ms of `module import`, against a 138ms steady state. The `prepare` chain now runs the transform itself, in `scripts/warm-loader-cache.mjs`, writing the same cache entry pi reads. The first session after an update is warm. The step runs last, is best-effort, and never fails an install; set `PI_LENS_SKIP_WARM_CACHE` to skip it. Each run appends one line to `~/.pi-lens/install.log`.

- **A session swap that starves `session_start` or `context` is now counted, not misreported as a pi-lens crash (closes #1929)** — both handlers survived a dead extension ctx already, but each logged it as `session_start crashed: …` or `context event error: …` and recorded nothing, so a replacement that kept starving them was invisible in aggregate. Both now run through the shared stale-ctx wrapper and leave one bounded `extension-ctx-stale` record keyed to the event name. `context` uses a new value-returning wrapper variant that states its stale-path answer explicitly: `undefined`, pi's "this extension contributed nothing", so the host keeps its own message list rather than receiving a half-built injection. Seven of twelve `pi.on` registrations are now wrapped, up from five.

- **Diagnostics scans stop logging a phantom ast-grep cache-stats record (closes #1935)** — `#1715` already grows the tree-sitter parse cache to span a project scan's file count, so the diagnostics-scan path does not run at the 50-entry interactive default; that part of `#1935` was resolved before this fix landed. What was still broken: every scan logged a second `cache_stats` record under scope `project_diagnostics_ast_grep_scan`, always all-zero, because ast-grep-napi parses through its own native engine and never touches the WASM tree cache that record claimed to measure. That vacuous record is now removed instead of wired to a cache it structurally cannot use; its one real value — ast-grep's own duration and file count, often a scan's most expensive phase — now rides an `astGrep` sub-field on the surviving `project_diagnostics_scan` record instead of disappearing. Added a memory-safety test pinning `treeCacheTotalBytes` at the existing 500-entry scan ceiling (`TREE_CACHE_SCAN_CAPACITY_CEILING`) using realistic, varying per-file sizes: worst case is 6-18MB of resident source bytes at that ceiling, and `TreeCache` enforces no per-entry byte cap of its own, only the entry count. A regression test pins the scanner to never emit the dead ast-grep scope.

- **`cache_context` no longer logs an unusable `unknown` prefix pair on nearly every record (closes #1938)** — `prefixObservation` and `firstMessageChange` were computed from a hash of the pre-injection prefix capped at 64 messages / 2,048 characters. Any transcript past that cap set `prefixHashTruncated`, which forced both fields to `"unknown"`. Every real session outgrows the cap within the first few turns, so the pair reported `unknown` on 97.2% and 7.6% of records respectively — cost without signal. This PR removes the whole family that existed only to support that pair: `prefixObservation`, `prefixObservationUnknown`, `prefixBaseline`, `firstMessageChanged`, `firstMessageChange`, `firstMessageHashTruncated`, `beforeFirstMessageHash`, `afterFirstMessageHash`, `beforePrefixHash`, `afterPrefixHash`, `prefixHashTruncated`, `prefixMessageCountTruncated`, `prefixContentHashTruncated` — 13 fields in all. `cache_prefix_break` already tracks first-message stability with an unbounded hash and never truncates; it remains the source of truth for cache-prefix breaks.

- **Review-graph Tier-3 build never grew the tree cache past the 50-entry default (closes #1941)** —
  `builder.ts`'s full-project rebuild parses every non-jsts file through the
  shared `TreeSitterClient`'s parse-tree cache but never called
  `ensureTreeCacheCapacity`, the #1715 fix already wired into the
  diagnostics scanner. A project with more than 50 non-jsts files evicted
  and re-parsed files past the 50th on every Tier-3 build. The build now
  grows the cache to its actual per-parse working set before extraction
  starts — the full file list on a cold build, or the checkpoint's remaining
  files on a resumed build, since resumed files are reused from the
  checkpoint graph and never re-parsed.

- **A demoted blocker no longer shouts STOP, and it stops re-serving forever (closes #1944)** — the past-EOF gate moved a blocker whose file had shrunk past the cited lines into the advisory channel, but the advisory embedded the blocker body verbatim, so the agent still read "🔴 STOP — 11 issue(s) must be fixed" with line numbers the file no longer had. Nothing retired the record either, so it re-served on every turn end for the rest of the session (measured live at 80+ minutes). A demoted body now drops the STOP banner and the "must be fixed" imperative, renders a dead coordinate as `L<n> (line no longer exists)`, and — when no re-run could ever confirm it — is delivered once and then retired, with the suppression recorded in the degradation ledger under `demoted-finding-retired`. `lens_diagnostics mode=delta` also stops printing 🔴 on a row whose coordinate it just replaced with the stale marker.

- **The oxlint runner now surfaces warning-severity findings instead of discarding them (closes #1947)** — oxlint exits 0 whenever nothing at error severity was found, and warning is oxlint's own default severity. The runner treated any exit 0 as "no findings" and returned early, so a real capture of oxlint on an unused variable — a full JSON report, exit 0 — was thrown away. The runner now parses stdout unconditionally and decides on the parsed diagnostic count instead of the exit code: zero diagnostics is still a clean `succeeded`/`none`, one or more is `failed` with `semantic: "warning"` (or `"blocking"` when a diagnostic is error severity), matching the mapping the runner already used for the nonzero-exit case. A captured real-bytes fixture (`tests/fixtures/runner-output/oxlint/warning-exit-zero.captured.json`, oxlint 1.79.0, exit 0) pins the behavior.

- **Merge-train warden's `update-branch` kick 403s under branch protection (#1959)** — the workflow's job permissions granted `pull-requests: write` but not `contents: write`. The PUT `pulls/N/update-branch` endpoint has GitHub create a merge commit on the PR branch, which needs write access to repository contents, not just to the pull request object; the inline comment attributing it to `pull-requests: write` was wrong. The warden also now tells apart the two reasons update-branch can 403: a fork-owned head PR records a distinct benign outcome (`update-branch-forbidden-fork`, logged, not a run failure), while an own-branch PR still fails the run loudly, since that case is a real permissions bug like this one.

- **The blind review-graph read no longer drops a valid snapshot on a HEAD-only move (closes #1961)** — `getCachedReviewGraph` verified the persisted snapshot's git stamp against the current HEAD, so a plain `git commit` made every reader see "graph: cold" until something rebuilt in-process. Measured stamp lifetime across four workspaces was 737s to 1562s (median), and two of the four were already missing at probe time. The read path now verifies tree IDENTITY, never revision — the same policy the build path's tier-2 load has followed since #300. A snapshot stamped for a different worktree is still dropped, since nothing downstream of this read content-verifies it. A snapshot stamped at a different HEAD is served, and both `project_report`'s trust notes and `module_report`'s warnings say which commit it was built at. That caveat is derived per call from the snapshot's stamped commit and the worktree's HEAD right now, so it never names a commit that has stopped being HEAD and it clears itself when HEAD returns to the stamped one. The mid-build resume checkpoint carried the same defect and got the same fix: it hashes every processed file on resume, so revision equality proved nothing the hashes did not. Both read verdicts now write a bounded `review-graph.log` record (`snapshot_read_dropped`, `snapshot_read_drifted`), rising-edge per workspace off the `review-graph-snapshot-read` ledger kind — the drop used to be silent, which is why it hid for weeks.

- **A settled review-graph build no longer suppresses every later build, and `project_report` only claims a retry when one started (closes #1962)** — `buildOrUpdateGraph`'s dedupe cache deleted its entry on rejection only, so a settled promise for a skipped or completed build answered every later call for the same key for the rest of the process. Only the dispatch pipeline's per-invocation `clearGraphCache()` ever removed it, and the background build `project_report` kicks off never goes through the pipeline: four calls over 37s produced one `build_started` record and three "A retry was started." messages with no retry behind them. The entry now lives only while its build is pending, so genuine concurrency still dedupes and a settled build never latches. `project_report` asks the builder whether a build is already in flight before it claims one started, and reports the current attempt rather than replaying the first one as if it were fresh. A `.pi-lens.json` at the repo root raises `reviewGraph.maxFiles` above the derived 1000-file cap this repo trips, so the tool works on this repo at all.

- **A language server that dies mid-session now records why, and the liveness probe stops provoking the deaths (closes #1969)** — an ast-grep child closed with `code=1` and empty stderr 14 times in one day. The visible fallout was 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps; the cause was recorded nowhere. Two changes. First, an unprompted process `close` now writes an `lsp-server-unexpected-close` degradation-ledger entry naming the server, the exit code, the signal, and whether stderr carried anything. It is written on `close` rather than `exit`, so "stderr was empty" is a fact about the server and not a race with the pipe, and it is gated on `shutdownRequested`, so evictions and ordinary teardown record nothing. Second, the `pingLiveness` round-trip (#1277) no longer hardcodes `workspace/symbol`. It picks a method the server advertised, best first: `workspace/symbol`, then `textDocument/documentSymbol` or `textDocument/hover` on an already-open document, then nothing. ast-grep's tower_lsp backend logged "got a 'workspace/symbol' request, but it is not implemented" on every probe; it advertises `hoverProvider`, so it keeps a real round-trip. When no probe is available the ledger records `lsp-liveness-probe-unsupported`, because liveness derived from process and connection state alone is the weaker verdict and must not pass as the strong one. Also fixed on the same path: `createLSPClient`'s initialize-timeout catch killed the hung child without claiming the kill, so its own teardown reported as a crash. That fabricated the exact `code=1 signal=none stderr=empty` signature this change exists to make trustworthy, and it had been writing a false `lsp_server_unexpected_exit` latency line on every failed handshake since the #615 follow-up.

- **Log writer reopens and retries once on a failed write instead of dropping records silently (refs #1970)** — `ndjson-logger.ts` is the shared write-plumbing behind every NDJSON sink (`latency.log`, `extension.log`, `tree-sitter.log`, `cascade.log`, `word-index.log`, `review-graph.log`, and any other `createNdjsonLogger` caller). This hardens the sink against reachable `appendFile` failures (ENOENT after the parent directory is deleted, EBUSY/EPERM under a syncing OneDrive folder or antivirus lock, EMFILE, ENOSPC) — the specific `ERR_STREAM_DESTROYED` reports that motivated this issue were root-caused to a different process's persistent-stream logger, not this sink, but the reachable-failure classes above land here today with no recovery and no observability. A write that throws now gets one reopen-and-retry (re-verify the parent directory, then retry the write once) before it counts as a loss, matching the `pi-analyze#15` shape. An unrecovered write is counted in a per-sink, in-memory tally (`writeFailures`) rather than thrown or silently discarded, and `degradation-ledger.ts` folds that tally into `getDegradationSummary()` at READ time under a new `log-sink-write-failure` kind — so `pilens_health` names the sink and the dropped-write count. The fold happens by reading ndjson-logger's own tally, never by writing a durable ledger row back through the sink that just failed, so a permanently dead sink cannot recurse into an unbounded chain of self-reporting writes. The tally resets alongside the rest of the ledger at `session_start`.

- **Warmup and workspace-scan walkers gate by file extension before consulting the ignore matcher, cutting a 31.7s stall to sub-second on repos with large ignored file piles (closes #1974, reported by @0xkite)** — `isIgnored` recompiles minimatch patterns per ancestor directory on every call; a runtime-output directory like `wal/` holding tens of thousands of `.log` files, gitignored only by a file-level `*.log` pattern (so the directory itself is never pruned), forced every one of those files through that cost. Six walkers checked `isIgnored` before their own cheap extension gate, paying the expensive check for files the gate would have dropped anyway: `collectSourceFilesForWarmup` (`language-profile.ts`, the reported 31.7s case), `JscpdClient.hasSourceFilesRecursive` (`jscpd-client.ts`), `classifyEntry` (`source-filter.ts`), `makeSourceCountVisitor` (`startup-scan.ts`), `getModuleSourceFiles` (`review-graph/workspace-modules.ts`), and `collectWorkspaceDiagnosticFiles` (`lsp/index.ts`). All six now run their extension (or LSP-server) gate first; output is unchanged, since both gates still have to pass to keep a file — only the order changed. A repo-wide sweep of all 14 files that call `isIgnored` found `tree-sitter-client.ts` and `tools/lsp-diagnostics.ts` already extension-first, and cleared the remaining per-event/no-ext-gate sites by construction; see the PR body for the full per-site table.

- **oxfmt no longer fails on a file its own config ignores (refs #1844)** —
  pi-lens offers oxfmt for every extension oxfmt supports, so in a project
  whose oxfmt config carries `ignorePatterns`, oxfmt was selected for files it
  then refused to touch. It exits 2 with "Expected at least one target file",
  which the strict exit-code posture read as a formatting failure, so every
  edit to an ignored file surfaced an error. pi-lens now passes
  `--no-error-on-unmatched-pattern`, which makes an empty target set a clean
  no-op. Every other nonzero exit, including an unparseable file, still fails.

- **ESLint warnings are no longer discarded (closes #1954)** — The eslint runner treated every exit 0 as "nothing found". ESLint exits 0 whenever no rule reaches error severity, so a run that produced only warning-severity findings was thrown away silently. The runner now parses stdout unconditionally and surfaces the findings; a clean file still reports clean, and exit 2 stays an unavailable skip.

- **Registered tools now always have descriptions in child/subagent sessions (closes #1988)** — the final registration boundary fills missing, empty, and
  whitespace-only descriptions for active, lazy, activation, and wrapped tool
  definitions.

- **mode=full no longer replays stale mid-edit blockers (refs #1993)** — a confirmed, fully-covered LSP sweep is now authoritative for its files: widget-store diagnostics captured from a since-fixed broken intermediate state are retired instead of rendering as current 🔴 blockers beside a clean sweep. Unconfirmed or timed-out sweeps keep the fail-open behavior.

- **Cache-miss attribution stays useful in long sessions (closes #1996)** — classify full model/provider identities and split unexplained or malformed cache evidence by bounded reason, with fail-closed request hashing, activation-owned primary/secondary isolation, sanitized numeric metadata, and a per-session cause summary.

- **Project snapshots no longer rewrite unchanged same-generation bodies (closes #1997)** — Persistence now coalesces concurrent requests and computes semantic identity on its worker before gzip or staging. Same-generation content changes and failed-write repairs still publish.

- **Classify oxlint no-match results as expected skips (closes #1998)**. Oxlint now carries `no-files-matched` through runner telemetry without emitting an extension error or claiming the file is clean. A single fail-closed state machine validates process completion, exit status, stderr, the captured banner, and every JSON summary field's type and range; truncated, malformed, wrong-status, or error-bearing lookalikes retain failure or unconfirmed telemetry.

- **Project-snapshot persistence validates gzip body integrity before skipping (closes #2008)** —
  The skip decision trusted the meta sidecar and a fingerprint match alone, so a torn or
  truncated gzip under an intact meta kept winning unchanged-dedupe and stayed canonical until
  the project sequence advanced. The meta sidecar now records the on-disk gz byte length
  (`gzBytes`) at every successful persist, and the dedupe baselines compare it against a live
  stat: a size mismatch, or a legacy meta without the field, withholds the dedupe fingerprints
  so the pending save republishes and rewrites the body. A skip that would honor evidence
  failing this gate between dispatch and promotion is refused and rewritten synchronously.
  Detections emit one `project_snapshot_body_integrity` latency record plus a bounded
  `snapshot-integrity` degradation-ledger entry per corrupted body path. The baselines read is
  also now a pure read: its in-process seeding write moved to the single dispatch seam that
  owns the persist lifecycle.

- **Installer verify loop broken at the root (closes #2015)** — `verifyToolBinary` now spawns through `safeSpawnAsync` (tree-kill on timeout, typed kill-reason) instead of raw spawn whose SIGTERM orphaned grandchild node processes on Windows `.cmd` shims. A killed/inconclusive prober no longer counts as a verdict: the freshly installed binary is KEPT for cheap re-probe instead of deleted, ending the install/verify/reinstall churn (23 SIGTERMs and 4 cleanups observed in one day).

- **Windows `.cmd`/`.bat` spawns no longer fail when System32 is absent from the child PATH (closes #2023)** — The cmd.exe wrapper prefixed every spawn
  with a bare `chcp 65001 &&`. When the child environment's PATH could not
  resolve `chcp.com`, the lookup failed and `&&` short-circuited, so the whole
  spawn exited 1 with empty output. chcp is now invoked via its pinned
  `%SystemRoot%\System32\chcp.com` absolute path and chained with `&`, so a
  code-page failure can never suppress the real command.

- **Register remaining agent-facing delivery surfaces (#2028)** — The per-edit 🔴 STOP block now drops blockers whose cited file no longer exists, so a deleted file's stale blocker no longer re-blocks the agent with no remediation. All five previously unregistered surfaces (stop blocker, `lsp_diagnostics` output, git-guard verdicts, read-guard preflight errors, thrashing notices) are registered in the finding-delivery gate registry.

- **Wall-clock budget tests get a quiet serial phase (closes #1920)** — Five test files asserting real elapsed-time budgets ran inside the default project's fork storm, measuring scheduler contention instead of code speed (`startup-overhead` measured 659-2321ms against a 500ms budget under load, green solo every time). They now run in a dedicated fully-serialized `wall-clock-budget` Vitest project that phases dead last on a quiet host. A coverage guard keeps the new include list from silently dropping renamed files.

## [4.1.0] - 2026-08-20

### Added

- **CUE code formatting support** — `.cue` files are now formatted with `cue fmt` through the standard formatter pipeline, with smart-default policy (no config required, `cue` binary on PATH).

- **CUE tree-sitter grammar** — `.cue` files now parse under tree-sitter. No publisher ships a CUE wasm, so pi-lens builds one from a pinned upstream commit and commits it to `vendor/grammars/`; `scripts/check-grammar-provenance.mjs` re-hashes it against the pin in `scripts/grammars.lock.json` on every CI run, and the download path refuses a vendored grammar outright instead of retrying a URL that will never exist. This lands the parser only: no CUE symbol or import queries exist yet, so structural symbol search and import extraction still skip `.cue`, and search falls back to the word index. The query rules are tracked in #1522.

- **CUE Language Server support** — `.cue` files now resolve to a dedicated `CueServer` LSP entry launched via `cue lsp serve`, with `cue-lang/cue` registered as a managed GitHub-release tool for auto-install fallback. CUE is a tracked `FileKind` with project/root markers (`cue.mod`), dispatch policy, and an LSP handshake fixture. Coverage is syntax and parse diagnostics only: `cue lsp` leaves conflicting values and failed constraints to `cue vet`.

- **CUE symbol and import extraction (refs #1522, #1519)** — `.cue` files now get tree-sitter symbol (`#Definition`s as types, struct fields as properties, `let` bindings as variables) and import (`import "pkg"`) queries, so structural symbol search and import extraction cover CUE like any other language instead of falling back to the word index. Definitions are told apart from ordinary fields with a `#match?`/`#not-match?` text predicate on the `#`-prefix, since the grammar has no separate definition node kind.

- **`cue vet` auxiliary runner for CUE evaluation errors (refs #1522, #1519)** — `cue lsp` reports load and parse errors as you type but deliberately leaves conflicting values and failed constraints to `cue vet`. A new `cue-vet` dispatch runner covers that gap, running alongside the LSP on every `.cue` edit (the same "lsp covers part, a CLI covers the rest" shape as terraform's `lsp, tflint, trivy-config` group). It vets the whole PACKAGE (the touched file's directory), not the file alone — CUE packages are directory-scoped, so a single-file invocation false-positives on the normative split-schema/values authoring style — then filters the reported errors down to the ones that name the touched file, always surfacing an error it cannot attribute to any file rather than silently dropping it. The touched file's own content is checked for a `package` clause up front: a package-less file goes straight to single-file scope, because a directory holding both a packaged file and a package-less one vets clean at the directory level (the loose file is silently excluded from evaluation, not a failure), so waiting for the directory vet to fail can never catch it. When the directory itself fails outright (two+ differently-packaged files sharing it — also legal CUE), the runner falls back to the touched file alone as well. The package-clause check skips leading blank lines, `//` comments, and file-level `@attribute`s alike (`@extern(embed)` is the documented real case) — treating an attribute-led file as package-less would have false-positived a clean cross-file reference as "reference not found". It runs with `-c=false` so an ordinary schema-only file — no concrete data, a common CUE authoring pattern — doesn't fail vet's default concreteness gate; a real type conflict still reports. Verified against a real `cue v0.17.1` binary end to end, including the tool-smoke fixture's binding diagnostic, a valid multi-file package, a package-less standalone file, a two-package directory, a mixed packaged/package-less directory, and a clean package where a file opens with an attribute before its package clause.

- **Rendered-manifest validation for Helm charts, opt-in (refs #1283)** — `helm lint` checks a chart's source and cannot see what the chart produces, so a chart that lints clean can still fail to install. Setting `{"helm": {"renderValidation": {"enabled": true}}}` in `.pi-lens.json` makes pi-lens render the nearest chart with `helm template` into a scratch directory under the system temp dir and validate the output: a failed render (missing values key, nil pointer in a template expression, a dependency declared in `Chart.yaml` but absent from `charts/`) is reported as a blocking finding on the template that failed, every rendered document must declare `apiVersion` and `kind`, and — when `trivy.enabled` is also set — `trivy config` runs over the rendered manifests, which is the only way Trivy's Kubernetes policy checks can see a chart's real output. Findings map back to the source template through helm's own `# Source:` annotations. It is **off by default** because rendering executes chart-authored template code, and the switch is not sufficient on its own: rendering also requires host project trust, since `.pi-lens.json` is a tracked file that a cloned repository could ship pre-enabled, and the switch is read from the chart's own project root rather than the current directory. Nothing is written to the chart directory, and the scratch directory is removed on every exit path. A missing `helm` reports as runner-unavailable rather than as chart findings, and an IaC pass that could not run says so instead of reading as clean.

- **ast-grep rule: ban bare `object` parameter annotations (Refs #1597)** — Add `no-bare-object-param` to the shipped rule catalog (TypeScript only — JavaScript has no type annotations to match). Fixes the one pre-existing hit, `clients/host-ports.ts:8`'s `HostLogSink` callback type, by narrowing it to `Record<string, unknown>`.

- **Ported anti-slop's AI-authorship rules into the shipped ast-grep catalog (refs #1718)** — a 2026-08-19 maintainer-supervised comparison against dmmulroy/anti-slop's 15 Oxlint rules found six already covered here and adopted the rest that ast-grep's structural matcher can express: `no-unsafe-dictionary-any`/`no-unsafe-dictionary-unknown` (the `Record<K, V>` value-type arms, `error`/`hint`), `no-unknown-parameters`/`no-unknown-returns` (`hint`), `no-known-value-widening` (`hint`, narrowed to the annotated `const x: Record<K, V> = { ... }` sub-case), `require-safety-comment-for-as-unknown-as` (`hint`, scoped to `as unknown as` chains), `no-runtime-typeof` (`hint`, with type-guard/naming/`.d.ts` exemptions), and `no-shape-in-symbol-names` (`hint`). `no-bare-object-param` and `no-chained-type-assertions` were promoted to `error`, and `no-chained-type-assertions` dropped its `as unknown as` exemption for the shipped catalog (users get the stricter upstream form; see the rule's note). `no-widen-then-assert` was not ported — its upstream implementation needs cross-statement variable-resolution data flow ast-grep doesn't have. Two `Record<string, any>` production sites (`clients/cache/rule-cache.ts`, `clients/tree-sitter-query-loader.ts`) were narrowed to `Record<string, unknown>` as part of the adoption. AGENTS.md's AI-authorship-smells section records the confirmed-clean counts and cites the anti-slop source.

- **One primitive for "at most one execution per key" replaces ten hand-rolled copies (closes #1753)** — Ten sites
  wrote the same five lines by hand, and four bugs came out of the copies: a
  leak guard no test exercised (#1690), a late completion that evicted its own
  successor (#1674), a trailing-rerun bit that shipped unpinned (#1687), and a
  latch its own mutation matrix deleted as vacuous (#1722). `singleFlight` now
  owns the share, the clear on both settlements, the successor check, and the
  trailing-rerun coalescing, with a six-mutant matrix proving each guard reds a
  test. Biome and ast-grep availability are converted first; their existing
  tests, including #1690's leak proof, pass unchanged. A ratchet test fails any
  new hand-rolled in-flight state that does not carry a reason.

- **Merge-train warden (refs #1844)** — A scheduled workflow now sweeps every open PR every 10 minutes and mechanizes what a human was doing by hand during the release drive: label and comment once (deduped) when a PR turns merge-conflicted (`mergeStateStatus: DIRTY`), call the native update-branch API when an armed auto-merge PR falls behind, and label and comment once (deduped) when a required check (`Unit tests`, `Lint & type-check`) fails on the current head. It never resolves conflicts, merges, or pushes to a PR branch. A new fast-fail CI job validates `.changelog/` fragments in under a second, ahead of the full Unit-tests lap, so a malformed fragment fails fast instead of after a full suite run.

- **Add autofixes for mechanical ast-grep rules (refs #1850)**

- **Record host-ready delay (refs #1859)** — Add latency telemetry for the monotonic interval from extension load-complete to the first `session_start`, including a host-stall suspicion flag.

- **Three CI smoke lanes for the lifecycle/scope defect classes of the 2026-08 arc (refs [#1605](https://github.com/apmantza/pi-lens/issues/1605))** — a new nightly `.github/workflows/lifecycle-smoke.yml` runs the real production code path (no mocks) against a real spawned process for three shapes that #1531–#1604 shipped and no existing smoke lane could catch: (1) PSScriptAnalyzer real-host `-File` classification under a forced `PSExecutionPolicyPreference=Restricted` on a real Windows runner (`scripts/smoke-psscriptanalyzer-classification.mjs`, both a `pwsh` leg and a Windows PowerShell 5.1 leg), verifying #1540's fix and probing #1604's open pwsh-7-`ConciseView` worry; (2) the availability-lifecycle latch class (#1490/#1494/#1495/#1496/#1537/#1535), driving zizmor's real `gh auth token` probe through a fixture `gh` shim (never the real `gh` CLI) to prove a transient failure records one degradation-ledger entry, then genuinely recovers across a real ~30s cooldown (`scripts/smoke-availability-lifecycle.mjs`); and (3) gitleaks's scratch-tree exclusion scope (#1562/#1574) against a real gitleaks binary and a fixture repo with a fake secret in a tracked file and another under a gitignored secrets-lane scratch dir, asserting exactly the tracked hit surfaces (`scripts/smoke-gitleaks-scratch-exclusion.mjs`). Each script is proven capable of failing: a targeted mutation reinstating the mapped defect turns every lane red before the fix, green after (see the PR body for the red-proof transcripts). Lanes 3/5/6 from the #1605 umbrella are out of scope here and tracked separately.

- **State-machine property tests for the availability latch (refs #1609)** — `tests/clients/dispatch/runners/availability-latch-properties.test.ts` runs a seeded, deterministic PRNG over random sequences of probe outcomes, time advances, session-start re-arms and caller retries against `createAvailabilityLatch`/`classifyProbeFailure`, asserting five invariants after every step: never durably latched on transient-only evidence, re-arm reachable at session_start, cooldown ladder monotone and capped, recovery reachable from every state, and degradation recorded exactly once per episode.

- **Reset-interleaving test explorer (refs #1840)** — `tests/support/reset-explorer.ts` runs an async path under test once per await point it exposes, firing a session-reset hook at that exact point each time, and checks caller-declared invariants after every run. The session-straddling defect shape (a reset landing mid-await, a budget re-arming, a write landing in the wrong session) recurred five times in one review window and every instance was caught only because a reviewer picked one await point by hand; this makes that exhaustive instead of lucky. Adopted on `clients/installer/managed-tool-refresh.ts`'s walk (the #1746 R2-F1 shape) as the reference consumer.

### Changed

- **Anti-slop rules: one promotion to `error`, six deliberate holds ([refs #1727](https://github.com/apmantza/pi-lens/issues/1727), [refs #1777](https://github.com/apmantza/pi-lens/issues/1777))** —
  `require-safety-comment-for-as-unknown-as` moves from `hint` to `error`,
  the tier that blocks a turn. Two changes earn that. The rule now excludes
  test paths structurally, because a four-corpus census (pi-lens, pi core,
  opencode, oh-my-pi) found 79–95% of `x as unknown as T` sites live in test
  doubles; and each of the 16 remaining `clients/` casts was read and given a
  truthful `SAFETY:` comment naming the invariant, with the one unjustified
  cast replaced instead (`clients/runtime-context.ts` no longer casts `null`
  into a cache entry's type). `no-chained-type-assertions` drops its
  `as unknown as` arm, so the two rules now partition the assertion space
  rather than both reporting the same site — every uncommented cast used to
  raise two diagnostics for one defect. Both rules are wired into the CI
  self-scan, which holds pi-lens's own tree at zero.

  The other six #1727 rules — `no-known-value-widening`, `no-runtime-typeof`,
  `no-shape-in-symbol-names`, `no-unknown-parameters`, `no-unknown-returns`,
  `no-unsafe-dictionary-unknown` — stay at `hint`. Structural narrowing was
  attempted on each and none reached a clean census; the residuals run from
  3 to 3,239 legitimate hits per corpus. Every rule's note now carries its
  four-corpus numbers and the reason it stopped, and AGENTS.md's severity
  policy records the promotion procedure those censuses follow.

- **Credit external contributors ahead of v4.1.0** — Add bcachet, EarthChen,
  Don-Yin, ELA718, sujeito-operator, and danblah to the contributors table,
  and restore six contributors (3choBoomer, white-hat, marvtub, mjfaga,
  aeturnal, floatGray) that the table listed but `.all-contributorsrc` had
  dropped.

- **Docs: agent-guide reflects the stale-secrets ACTION NEEDED tier (refs #1522)** — `docs/agent-guide.md`'s honesty-contract table and blockers-vs-advisories section now describe the `🔑 ACTION NEEDED` tier #1627 added: a secrets finding whose cached file changed since the scan demotes to this tier, with its line number withheld, instead of dropping silently or reading as a plain advisory. CUE's LSP/formatter/tree-sitter documentation (`docs/features.md`, `docs/language-coverage.md`) was already brought current by #1520; this fragment covers the sweep verifying those pages and the wider today's-merges gap check, which found the rest of the surfaces (README.md, `docs/servercapabilities.md`'s generation note) already accurate.

- **Codified live-binary discipline in AGENTS.md, per maintainer directive** — new defect shape 16 requires every claim about an external tool's exit codes, output shape, or severity vocabulary to be verified against a real run before it ships in code, comments, tests, or rule notes, and requires tool-output fixtures to be captured from real runs, never hand-written. The rule generalizes a pattern from six PRs caught by review in one window: trivy's `--no-progress` rejection breaking two lanes — one read the usage text as clean, the other failed on every run (#1757/#1781), vulture's real exit code 3 vs. the assumed 1 (#1765), rustc/clippy's six severity levels vs. an assumed two (#1802/#1809), mypy's exit-2 syntax diagnostics (#1822), and biome/pyright field-shape mismatches (#1810/#1809). Also added a one-sentence cross-reference in `skills/pi-lens-write-ast-grep-rule/SKILL.md`'s fixture-validation section.

- **Ast-grep authoring skills now teach the `stopBy` boundary idiom, the four-tier severity policy, and suppression-fixture discipline** (refs #1794).

- **Added the 2026-08-17 through 2026-08-20 recurring defect shapes to the agent catalog** — reconciled the review arc into the existing path-key, process-lifetime, freshness, vacuous-test, and class-sweep guidance, and added eight distinct screens for session latches, cooldown cadence, correlation, provenance, concurrent generations, session-straddling writes, walk cursors, and shared-field writers.

- **docs: language-coverage lists mypy in the Python row** — mypy has been wired in dispatch (config-first, alongside pyright) since the plan.ts integration, but the coverage table never recorded it. The 2026-08-20 tool-gap survey's considered-and-skipped decisions (bandit, checkov, radon) are recorded in the table's notes so they are not re-litigated.

- **AGENTS.md: six new PR-discipline rules from the v4.1.0 review arc** — mutation-proof guards, governance suites after every master merge, composition proven by evidence on overlapping PRs, deferrals re-homed to issues, `file:line` for cited precedents, and bounded observability records that preserve identity; the blast-radius rule now requires measured cost deltas on hot paths.

- **One helper for bounded telemetry, and a sweep that keeps failure paths on it (refs [#1743](https://github.com/apmantza/pi-lens/issues/1743))** — four PRs in two days hand-built the same machinery: a failure path that needs a detailed `latency.log` record, an exact per-identity tally, and a bound so a storming subsystem cannot flood the log. `emitBounded`/`admitBounded` (`clients/bounded-telemetry.ts`) are now that machinery, and they are the intersection of those four, not a new framework. The rising edge is derived from the degradation ledger's own tally, so there is no second latch to re-arm at `session_start`. A per-turn cap carries the caller's turn index in the same object as the limit, so a cap nobody resets is unrepresentable. Every record stamps the discriminating identity into `metadata.identity`, so aggregating by phase still answers which file, method, or record is stuck. All four sites migrate behavior-identical: the orphan-forensics batch (#1705), the pull timeout and late-answer records (#1713), the navRequest rising edge per method and file (#1716), and the loop-block record (#1723). A new sweep, `tests/clients/bounded-telemetry-sweep.test.ts`, walks every `logLatency` call site and requires each failure-shaped phase to be either helper-emitted or listed with the mechanism that already bounds its volume, so a new unbounded failure-path record fails the build instead of shipping. Review round: the two per-file LSP skip records (`lsp_client_skipped_broken`, `lsp_client_skipped_unavailable_command`) also move to the helper — during an outage they fired once per file per touch, so a sweep over a few hundred files wrote a few hundred records describing one fact. They are now rising-edge per (server, file), with the exact totals in the ledger under the new `lsp-client-skipped-broken` and `lsp-client-skipped-unavailable-command` kinds. The sweep's predicate widened to catch them: outcome suffixes now include `_broken`, `_leak`, `_exit`, `_missing`, and `_abandoned`, and a new infix rule catches an outcome named mid-phase with its reason trailing, such as `lsp_client_skipped_unavailable_command`. `tools/` joined the scanned roots. The scanner uses #1755's shared `stripSource`, so a call named only in a comment is not read as a call.

- **One generation guard instead of four hand-rolled ones** — Add
  `clients/generation-guard.ts`: a `GenerationSource` counter owned by the
  seam that resets a store, plus a handle whose `guardedWrite` drops a
  post-await write when the world it captured is gone, and records the drop in
  the degradation ledger under `generation-guard-stale-write`. A keyed
  `GenerationMap` covers stores invalidated per cwd or per request. Dispatch
  availability and the LSP workspace-diagnostics cache now guard through it;
  a test sweep requires every remaining hand-rolled generation compare to
  carry a written reason.

- **Consolidated three duplicated test-fixture families onto shared support helpers (refs #1816)** — a 2026-08-20 survey found the same fixture hand-rolled dozens of times across the suite, each copy free to drift from the others and from the real interface it doubled. `tests/support/fake-child.ts` gives `safe-spawn.ts`'s child-process race tests one faithful `makeFakeChild` (a real `EventEmitter` with real `stdout`/`stderr` streams and a `kill()` that sets `killed = true`, matching Node's real behavior) instead of four separate copies, three of them hand-rolled `{on, emit}` objects that skipped the real `EventEmitter` seam the #1673 fidelity fix exists to exercise; `tests/clients/lsp/launch.test.ts`'s six verbatim `MockChildProcess` classes collapse to one parameterized by pid. Sixteen dispatch-runner test files' hand-rolled `createCtx` builders now delegate to `tests/support/runner-ctx.ts`'s `makeRunnerCtx`, resolving a `getFlag` divergence (`false` vs `undefined` across copies) by confirming — pre- and post-migration counts matched exactly (174 passed, 1 skipped in both) — that the only production read of `pi.getFlag` treats both as equally falsy. `tests/support/with-env.ts` gives `process.env` save/restore a single correct implementation (handles the "wasn't set before" case, unit-tested and mutation-proofed) and replaces four incompatible hand-rolled idioms across the `PI_LENS_DISABLE_TOOL_INSTALL` test files; two of those files' `afterEach` hard-restored the literal `"1"` instead of whatever was actually ambient before the file ran, correct only because `vitest-setup.ts` happens to default to `"1"` — `withEnv` fixes that as a side effect of the consolidation.

- **Workspace-scope LSP queries identify their serving client ([refs #1854](https://github.com/apmantza/pi-lens/issues/1854))** —
  the existing `lsp_navigation_result` latency record now names the LSP server
  that answered each no-path workspace query. Aggregated operation support
  records a bounded per-capability contributor map, so multi-primary routing
  defects are visible without adding another log surface.

- **Turn-end madge circular-dependency check is opt-in, default off (closes [#766](https://github.com/apmantza/pi-lens/issues/766))** — the per-turn-end madge pass only produced debug output while adding tail latency to every turn; it now runs only with `--lens-turn-end-madge` (or `turnEnd.madge.enabled: true`). User-facing madge diagnostics still come from the session-start project scan and `lens_diagnostics`.

- **Narrow `no-non-null-assertion` false positives (refs #1777)** —
  `$M.get($K)!` guarded by `$M.has($K)` in the same function, and
  `$A.pop()!`/`shift()!` inside a `while`/`for`/`if` whose condition checks
  `$A.length`, no longer fire; both exclusions require the same receiver
  and key/array metavariable binding, so a check on a different key, map,
  or array still flags. The rule stays at `warning` — the re-census showed
  the residual is still idiom-heavy rather than dominated by genuine
  cross-boundary risk, so this is a precision fix, not a tier change. See
  the rule's `note:` for the full four-corpus census.

- **A cascade that a slow prelude pushed out of the turn-end window now shortens its neighbour walk to land on time, when shortening it actually helps (refs [#1462](https://github.com/apmantza/pi-lens/issues/1462))** — the flat 40-neighbour cap and the 5000 ms turn-end settle wait were set in two different files with nothing relating them, and dogfood on 2026-08-15 measured what that costs: 50 cascades, median 30 ms, max 3.88 s at 40 neighbours, and a `reverse_deps_cache` refresh ahead of the graph build (2046 ms for `logger.ts`) put both tail runs past the cap. A run that misses the cap is not lost — `settleCascadeRuns` re-parks it, `agent_settled` gives it a second and more generous 15 s drain, and `beginTurn` keeps it pending until it resolves ([#1443](https://github.com/apmantza/pi-lens/issues/1443)) — so the finding arrives a turn late rather than never. That makes the real trade the full neighbour set one turn late against a shorter set on time with the remainder lost for good, and narrowing is only worth paying for when it genuinely converts a late run into an on-time one. The production pipeline now sizes the walk from the live `turn_end` settle clock when available; when that boundary is not observable, it keeps the flat cap, and direct legacy/test callers retain the old fallback. Budget-truncated runs are surfaced as incomplete advisories. In the production path, this gives three zones: the full walk still fits and nothing changes; the walk no longer fits but a shorter one does, which is the rescue band and the only place anything is narrowed; or not even a floor-sized walk fits any more, where the flat cap is kept and the carry-over delivers the whole set late — the cold-session case, since a fresh graph build measures up to ~19 s and a floor-sized stub there would drop 35 neighbours permanently and still miss. A fourth guard keeps `PI_LENS_CASCADE_SETTLE_WAIT_MS` from doubling as a neighbour-count knob: if the configured window could not fit a full walk even at zero prelude there is no late run to rescue, only every run to shrink, so the derivation stands down. At zero pre-turn gap the band is a settle-window elapsed time of 1.0–4.5 s, so the measured median 30 ms cascade is untouched and truncation stays confined to that band. [#1446](https://github.com/apmantza/pi-lens/issues/1446)'s `cascade_result` telemetry carries the evidence: `neighborBudget` is now the budget in force, alongside `neighborBudgetCeiling`, `budgetRemainingMs`, `budgetZone` (which of the four rules decided it) and `budgetDeliveryWindowMs` (the settle wait plus the quiet-window drain, so the whole deadline stack is on the record). Note when reading those rows that `budgetTruncated` inverts under a narrowed budget — it is eligible minus budget, so a smaller budget mechanically raises it; compare truncation within a zone, never across one. The per-neighbour cost the derivation divides by is an estimate (`PI_LENS_CASCADE_NEIGHBOUR_COST_MS`, default 100 ms against a measured 97 ms), and `npm run bench:cascade-budget -- --sweep` reports how the rescue degrades if that estimate is materially low. Maintainer follow-up: the settle clock now measures elapsed time against the coordinator's actual `turn_end` wait, not the compute's own age from the write, which could under-count by the full write-to-turn_end gap and narrow walks that had ample real headroom; raising `PI_LENS_CASCADE_NEIGHBOUR_BUDGET` far enough to silently disarm the rescue band now names itself once per session in the degradation ledger instead of failing silently.

- **Promote laundering, unknown-return, and Reflect rules to errors (refs #1856)** — Clean the remaining named return boundaries and structurally exempt standard Proxy trap forwarding before making these rules blocking.

- **Consolidate the registered-or-fail sweep machinery** — Add
  `tests/support/sweep-kit.ts`, one home for the four pieces every sweep in
  this repo hand-rolled: comment/string stripping with the string policy as a
  caller option, registry semantics (registered-or-fail, exemptions that
  require a reason, stale-entry self-detection), tag and evidence binding
  (one seam per tag, call-shaped needles, nearest-exclusive assignment), and
  the emptiness guard that fails a scan matching nothing. The kit's doc names
  the attack catalogue the finding-delivery gate paid four review rounds to
  learn, and its test suite carries one named fixture per attack. The
  session-state conformance sweep now runs on the kit.

- **Turn-simulation harness and a session-state lifecycle registry (refs #1635)** — two pieces of test infrastructure for the two bug families that dogfooding still catches exclusively. The turn harness (`tests/support/turn-harness.ts`) scripts a sequence of edits, scans and turn ends against a real `handleTurnEnd`, on a logical clock and a temp-dir project, and returns what the agent actually sees at each turn end split into its real tiers — blocker, action-needed, advisory. Four incidents from 2026-08-18 land as its first scenarios. The gitleaks stale-replay scenario runs green and fails against the code from before #1627; the knip stale-cache scenario drives the real `KnipClient` against a real knip and passes now that #1637 has landed; the two whose fixes are not on master are skipped with the blocking issue named rather than rewritten to assert today's behavior. The session-state registry (`tests/support/session-state-registry.ts`) gives every session-scoped latch, hold, memo and dedupe set a declared reset policy, proves five of them re-arm when their reset runs, and checks each one's reset is genuinely reachable from `handleSessionStart` — derived from the source, not from a hand-kept list, because a reset nobody calls is exactly the shape of eight bugs in this arc. A derived sweep flags any `clients/` file holding module-level state paired with a reset seam that is neither registered nor exempted with a reason, and states plainly what its heuristic cannot see.

- **Test fixtures now match the real host's `session_start`/`tool_result` event shape (refs #1681)** — `tests/index-wiring.test.ts` fired `session_start` with `{ sessionId: "wiring-session" }` as the event payload; the real pi host never puts `sessionId` on that event (`packages/coding-agent/src/core/extensions/types.ts:561-568` in the host source — only `type`, `reason`, `previousSessionFile`), and `index.ts`'s own handler already documents this ("the STABLE session id comes from the session manager — the event carries none"). Fixed to `{ reason: "startup" }`, host-faithful, with `sessionId` staying where it belongs: on the mock `ctx` via `makeCtx`. A new scan, `tests/support/host-event-shape-scan.ts` plus its conformance test, pins every `session_start`/`tool_result` fixture repo-wide against `sessionId`/`provider`/`model` ever landing on the event object again — the same drift the umbrella issue's earlier `#1680` fix round hit, now caught mechanically instead of by a human noticing.

- **Pinned trivy's dependency-CVE disposition filter with a regression test (refs #1813)** — `trivyFindingsFiltered` (`clients/runtime-turn.ts:1516-1523`) filters trivy's CVE lane through the same `filterFindingsByDisposition` machinery as the five lanes #1798 already covered, but had no test proving it. The #1798 delta-verify neutered it back to the raw unfiltered cache read and 407 tests across 18 files stayed green. `tests/clients/runtime-turn-secrets-disposition.test.ts` now marks a CRITICAL trivy CVE false-positive through `markDisposition` and proves it stops rendering in the `handleTurnEnd` blocker tier, with the suppression staying visible as a `suppressed by disposition: ... (trivy 1)` trace.

### Fixed

- **Reach never-swept monorepo caches on workspace refresh ([closes #1707](https://github.com/apmantza/pi-lens/issues/1707))** —
  a nested language-server root now clears its enclosing workspace-diagnostics
  cache through the bounded session workspace-root chain.

- **LSP navigation verifies capabilities at the client boundary ([closes #1826](https://github.com/apmantza/pi-lens/issues/1826))** —
  definition, hover, rename, code actions, and the remaining file-scoped
  navigation methods now reject unsupported requests before dispatch. This
  second check preserves the tool-layer gate and distinguishes unsupported
  servers from supporting servers that return no results.

- **Distinguish a slow LSP warm spawn from no clients ([closes #1875](https://github.com/apmantza/pi-lens/issues/1875))** —
  a read warm whose bounded wait expires now reports that its primary server
  is still spawning. A touch reports no clients only when no matching spawn
  remains in flight. The next read reuses the existing single-flight spawn.

- **Avoid doomed waits for historically slow LSP spawns ([refs #1884](https://github.com/apmantza/pi-lens/issues/1884))** —
  a bounded warm touch now returns immediately when its matching server spawn
  is in flight and a prior successful spawn took more than twice the budget.
  Servers without history retain the existing bounded wait.

- **Re-arm direct-LSP and installer availability caches ([closes #1897](https://github.com/apmantza/pi-lens/issues/1897))** —
  the primary `session_start` clears negative direct-LSP cooldowns and
  resolved installer paths, so tools added between sessions are probed again.

- **Bound the cascade tier-3 reconcile backlog and drop the dead neighbour-touch cache ([closes #1899](https://github.com/apmantza/pi-lens/issues/1899))** —
  the outstanding-touch registry now caps its entry count and its entry age
  instead of growing until a quiet window arrives, every sweep writes a backlog
  gauge to `cascade.log`, and an unresolved touch says which of the five causes
  kept it unresolved. The same-write neighbour-touch cache is removed: its read
  gate could only pass inside a single write, so it measured 0 hits across 236
  cold touches.

- **Avoid false read-guard blocks after formatter writes ([closes #1903](https://github.com/apmantza/pi-lens/issues/1903))** —
  Bash-invoked in-place formatters and fixers now refresh explicit file write
  stamps. A uniquely resolved edit `oldText` also overrides coarse FileTime
  staleness, while missing or ambiguous content evidence remains blocked.

- **Credit search hits with the lines the search actually showed ([closes #1904](https://github.com/apmantza/pi-lens/issues/1904))** —
  a bare `grep -n` hit registered as a 5-line read, so the read-guard let edits
  pass against lines the model never saw. A hit now credits its match line, plus
  the context the command printed when it carried `-A`, `-B`, or `-C`. Each
  record states the margin it credited and why. The range-snapshot ledger also
  reports the caller's outcome (enforced or bypassed by content match) instead
  of only its intent, and the per-file read store is bounded at 128 records.

- **A size-capped write no longer says both "attached" and "too large to attach" ([closes #1590](https://github.com/apmantza/pi-lens/issues/1590))** —
  two layers used to phrase the post-autofix instruction. The pipeline claimed
  the attached content was authoritative whenever autofix changed the target
  file, and the tool-result layer, the only one that sees the 2 MiB attachment
  cap and the per-command aggregate budget, appended "you must re-read, the
  content is too large to attach" for that same file. A write whose post-fix
  content exceeded the cap carried both sentences, and a multi-file bash write
  past the shared budget did the same for the degraded path.

  The pipeline now returns the changed-file data instead of a verdict, and
  `handleToolResult` renders the one sentence from the one decision it owns.
  The shared budget is threaded into each synthetic per-file call, so a bash
  write decides each attachment once rather than attaching and then overruling
  itself. The telemetry follows: `authoritative_content_attachment_decision`
  logs one row per path, carrying `attached`, `size-capped`, or
  `aggregate-budget-degraded`, where a degraded path used to log two rows and
  rely on the later one winning.

- **Preserve biome's info/hint severity in dispatch** — `biome-check`
  collapsed every non-error biome diagnostic to `warning`, even though
  biome's own JSON declares `information` and `hint` tiers. Biome findings
  now carry the same four-tier `Diagnostic.severity` ast-grep-napi adopted
  in #1787: `information` maps to `info`, `hint` stays `hint`. Only `error`
  still blocks. The on-demand ast-grep CLI path's `formatDiagnostics`
  summary also gained an `info(s)` bucket — an info finding was previously
  counted in the total but named in no tier line.

- **cuelsp's silent clean-open no longer reads as inconclusive (refs #1522, #1519)** — Measured directly against the real `cue lsp serve` v0.17.1 binary: a cold `didOpen` on an already-clean `.cue` file publishes nothing at all inside the wait budget, so a touch used to time out and report `inconclusive`. `SERVER_DIAGNOSTIC_STRATEGIES.cue` now marks it `silentOnClean`, so the shared push-only clean-confirm gate reads "no publish, notify succeeded" as confirmed clean — matching the behavior already shipped for typescript and marksman. An edited document still publishes normally (both the error and the empty array that clears it), so this only changes the cold-open case.

- **Preserve ast-grep rule severity tiers through dispatch** — The dispatch
  runner collapsed every `warning`, `hint`, and `info` rule severity to
  `warning`, so the quiet tier that 43 shipped rules declare did not exist
  downstream. All four tiers now reach `Diagnostic.severity`. The turn-end
  advisories say how many findings are hint or info, the code-quality report
  spends its cap on warnings before hints, and hint-tier rules that carry a fix
  still route to actionable warnings. The `lens_diagnostics` and TUI-footer
  tallies count hint and info alongside warning, so a hint-only file is never
  reported as clean. Only `error` blocks, exactly as before.

- **Persist degradation-ledger decisions in `latency.log`** — every accepted
  once-record and every tally increment now writes a bounded
  `degradation_ledger` row with its kind, subject, and current count. Scanner
  coverage gaps and stalled LSP notify barriers now enter the ledger too, so a
  session remains auditable even when no degradation summary reaches the
  transcript.

- **A write that already handed back its post-fix content no longer also tells you to re-read it (closes [#1464](https://github.com/apmantza/pi-lens/issues/1464))** — since #1414 a `write` whose immediate autofix changed the file gets the full authoritative content attached to its own tool result, but the next turn still opened with "N file(s) were autofixed after your last turn: a.ts — re-read before editing". The nudge's relevance filter admits any path with read or edit history, and a file the agent just wrote is in edit history by definition, so the one case where the agent demonstrably holds the current bytes was the case that nudged hardest. The write path now tells the nudge accumulator what it delivered (`noteAuthoritativeContentAttachment`), passing the same boolean that drove the attachment rather than re-deriving the size cap or the per-command budget in the nudge layer. Suppression is exactly as narrow as the delivery: a size-capped attachment, a bash multi-file write whose aggregate budget degraded a per-file "attached" back to a re-read warning, a side-effect file the autofix changed but did not attach, and deferred `edit` autofix at `agent_end` all keep nudging. A later touch of the same path re-arms it too — the default deferred format at `agent_end` makes those attached bytes stale, and stale bytes are worth a re-read. The `agent_nudge` phase gains `filesContentDelivered` so over-suppression is auditable instead of only showing up as silence.

- **Knip's per-turn delta no longer reads a shifted finding as newly unused ([#1483](https://github.com/apmantza/pi-lens/issues/1483))** — the finding key included the line number, and the delta is filtered to exactly the files the edit touched, so lines shifted by that same edit landed right where the filter looks. A finding that only moved down now keys the same as before and drops out of the delta; a genuinely new finding still appears. The key now shares `stableFindingKey` with the dead-code delta ([#1477](https://github.com/apmantza/pi-lens/issues/1477)), which had the identical bug and the identical fix — one rule, one place, for both scanners.

- **A stalled PowerShell check no longer disables PSScriptAnalyzer for the rest of the session (closes #1490)** — The runner ran its own child processes to find a PowerShell interpreter and check for the PSScriptAnalyzer module, and every failure looked the same to it: a timeout, a Windows spawn error and a genuinely missing binary all reported nothing at all. Any of them was remembered as "PowerShell analysis is not available" until pi-lens restarted. Both probes now go through the shared spawn layer, so a timeout is distinguishable from an absence, and the verdict is owned by the same availability policy as knip, madge, Go and Rust: a stall is retried after a short wait, and only a genuine absence sticks. Each decision lands in `latency.log` as an `availability_decision` entry with its cause and timing.

- **A silent auxiliary no longer reads as confirmed clean (closes [#1493](https://github.com/apmantza/pi-lens/issues/1493))** — An auxiliary scanner that published nothing inside its own budget now narrows the touch's verdict to `confirmation: "partial"` and is named in `unconfirmedServerIds`, the same treatment #1470 gave a scanner cut off by the aux grace timer. Before this, silence only stayed honest when the scanner was the sole auxiliary and burned the whole touch deadline; with a second auxiliary answering fast, the wait settled early and the silence went unrecorded, so a touch claimed a clean bill of health for a server that said nothing. Both no-answer shapes now run through one policy, `auxiliaryCoverageGap` in `clients/lsp/diagnostic-binding.ts`. Genuinely clean files are unaffected: a scanner that ran to budget and published an empty set advances `diagnosticsVersion`, so it reads as `answered` and its touch stays `confirmed`. So does an unreporting scanner — silent or cut off — whose stored publication is already bound to the touch's exact content. Where the verdict does narrow, `pi-lens lsp-diagnostics` adds an "Auxiliary coverage INCOMPLETE" line naming the scanner, and the dispatch LSP runner reports an empty result as `skipped` rather than clean. `lsp_touch_file` carries the new `auxUnconfirmedServerIds` field, which joins to the `silent` rows in `lsp_aux_wait_outcome`.

- **A stalled version check no longer disables eslint, credo or clippy for the rest of the session (closes #1494)** — The per-directory probe these three runners share cached its verdict forever, so a single `eslint --version` that timed out on the first JS/TS save silently dropped eslint for that project until pi-lens restarted. Credo's 10-second `mix credo --version` on a cold BEAM and clippy's 8-second `cargo clippy --version` had the same failure mode, and clippy went further by treating the timeout as grounds for an install attempt. The verdict now goes through the shared availability policy from #1467/#1476: only a genuine absence sticks, while a timeout, abort or host stall is retried after a short cooldown. ESLint autofix in the post-write pipeline carried its own copy of the latch and is fixed the same way. Every decision is recorded in `latency.log` as an `availability_decision` entry with its cause, timing and retry window.

- **A stalled PATH lookup no longer disables a formatter until you edit a config file (closes #1495)** — Detecting rustfmt, shfmt, zig, dart, nixfmt, gofmt and about a dozen other formatters runs `which`/`where` on a 5-second budget. A single timeout dropped the formatter and then wrote that empty result into the detection cache, which is only invalidated when a formatter config file changes size or timestamp — so formatting stayed silently off for the rest of the session, looking to you like pi-lens deciding the file needed no formatting. PATH lookups now go through the shared availability policy: a timeout is retried after a short wait, only a genuine absence sticks, and an empty detection caused by a stalled probe is no longer cached at all. Editing a config file still re-checks PATH, so installing a formatter mid-session works as before. Each lookup is recorded in `latency.log` as an `availability_decision` entry with its cause.

- **A stalled package-manager check no longer downgrades pi-lens's own managed installs to npm (closes #1496)** — Checking which package manager is installed spawned a `where`/`which` probe on a 5-second budget, and a timeout was remembered as "not installed" for the rest of the session. The blast radius was internal: the resolver only serves installs into pi-lens's managed tools directory and `pilens_rebuild` on a pi-lens source checkout, never a user project's lockfile. A timed-out check is now retried after a short wait instead of latching, matching the #1467/#1476 policy already used for knip, madge, govulncheck, vulture, biome, ast-grep, Go, and Rust. Each decision is recorded in `latency.log` as an `availability_decision` entry with its cause and timing.

- **A repeatedly timing-out `go install` no longer re-compiles govulncheck every few minutes forever (closes #1497)** — #1489 rightly made a timed-out install transient, but it inherited the probe-class retry schedule (30 s doubling, capped at 5 min), calibrated for a 1.5–5 s version probe. On a host where the 60 s `go install` reliably exceeds its budget, steady state was a 60 s compile every 5 minutes with no terminal state. Install-class transient failures now escalate on their own two-rung ladder (5 min, then 10 min) and give up on the third attempt, spacing at most three ≤60 s compiles across ~15 minutes. Install-class failures also hold their own cooldown slot, so a cheap version probe failing in the meantime can no longer pull the next compile forward. The ceiling is terminal for the SESSION, not the process: it re-arms at the next `session_start` (or on a successful run), so a genuinely transient network failure still recovers without a host restart. When the ceiling is reached, the verdict says so — the degradation ledger records `install-retry-exhausted` (visible via `pilens_health`), the `availability_decision` row is marked latched with the install facts behind it, and the user-facing message reports a retry ceiling that clears next session instead of promising a retry that will not come.

- **Tool-availability records now show what actually failed, and a failed auto-install is no longer a silent verdict (closes #1500)** — When pi-lens decides a tool is unavailable it writes one `availability_decision` line to `latency.log`. Those lines reported the verdict but not the evidence, so "the tool is missing" looked identical whether a probe returned "not found" or an auto-install had just failed for a reason that would clear on a retry. Each line now carries the raw spawn facts (exit status, failure kind, errno, whether an install was attempted) and says whether the classification was derived from a probe or asserted by the caller. Security-scanner clients — gitleaks, trivy, opengrep, govulncheck — used to latch "not installed" after a failed install without recording anything at all; that write is now recorded like any other decision.

- **Transient security-scanner probe records now carry their retry schedule (closes #1501)** — When a `gitleaks`/`trivy`/`opengrep`/`govulncheck` version probe timed out, the `availability_decision` record in `latency.log` said the tool was off but not when it would be retried, because the record was emitted before the caller decided the cooldown. The shared seam now owns the transient cooldown and logs `retryAfterMs` alongside the existing outcome, cause, and timing fields, matching the shape the install and scan paths already emitted. Success and durable-absence records are unchanged.

- **Venv-resolved tool paths are no longer wrapped in literal quotes (closes #1508)** — When a linter such as `ruff` resolved through a project virtualenv (`.venv/bin/ruff`), the availability seam returned the path wrapped in double-quote characters — a leftover from the `shell: true` spawn era. Every spawn now runs with `shell: false` (#817), so the quotes became part of the filename, the probe failed with ENOENT, and the runner silently reported no diagnostics. The path is now returned verbatim on every platform, and the resolved command is pinned quote-free by regression tests. Reported by @phionax.

- **Auxiliary diagnostic waits and their outcome rows are now decided per file (closes [#1531](https://github.com/apmantza/pi-lens/issues/1531))** — `diagnosticsVersion` is a per-client counter that any file's publication advances, so on a client shared by two files in flight an unrelated publication both ended this file's wait early and read as evidence that the scanner had answered for it. Every store of fresh diagnostics now also stamps the path it was stored for (`diagnosticsVersionsByPath`, written by the new `bumpDiagnosticsVersion` seam and cleared with the rest of a path's state in `clearDiagnosticsForPath`), and both the `minVersion` freshness gate and the auxiliary evidence check read that per-path stamp. Direction was under-detection — a false `answered`, never a false finding — but it also skewed `lsp_aux_wait_outcome`, the rows used to reason about auxiliary health. Interleavings that used to record a false `answered` now record `silent` and demote the touch's confirmation to `partial`, so sweeps that touch several files against one auxiliary report a higher (and honest) partial rate.

- **A silent auxiliary no longer reads as confirmed clean on batch scans either (closes [#1533](https://github.com/apmantza/pi-lens/issues/1533))** — #1493 narrowed the verdict for an unreporting scanner on the per-file lane; `clientScope: "all"` — the batch and directory scan surface — kept aggregating the same silence as `confirmation: "confirmed"`. Auxiliaries are spawned on that scope but it never enters the auxiliary grace wait, so no coverage evidence was ever derived and neither no-answer shape could be computed. A `"all"`-scope touch now derives the same evidence from post-wait state: every spawned auxiliary is recorded as `answered`, `silent`, or `deferred` through the one `auxiliaryCoverageGap` policy, so a scanner that said nothing about these bytes narrows the touch to `"partial"` and is named in `unconfirmedServerIds`. The evidence is read from a wait that already happened rather than from a second one, so the fan-out latency #1459's resync gate recovered stays recovered. `lsp_aux_wait_outcome` now covers this lane too, tagged `waitShape: "aggregate"` (versus `"aux_grace"` for the per-file producer) — `cut_off` cannot arise there because no ceiling is armed. A scanner the caller excluded by design (the workspace sweep's own opengrep exclusion) is never spawned and so is never reported as a gap. Where this changes a verdict: an auxiliary whose budget is the largest among the servers waited on (opengrep on every per-edit path) already left its touch unconfirmed by lapsing the aggregate deadline, so those results are unchanged; a faster scanner beside a slower primary — typos or ast-grep next to rust-analyzer — settles inside that deadline and now narrows a result that used to read confirmed, which costs a skipped cache seed for that file and buys an honest verdict. One further limit stated plainly: `diagnosticsVersion` is a per-client counter, so two concurrent touches sharing one auxiliary client can still cross-satisfy each other's evidence — the verdict then misses a silence rather than inventing one, and [#1544](https://github.com/apmantza/pi-lens/issues/1544)'s per-path counter closes it.

- **A failed grammar download no longer disables a language for the rest of the session (closes [#1536](https://github.com/apmantza/pi-lens/issues/1536))** — `TreeSitterClient.ensureGrammar` deduped concurrent grammar fetches by keeping the SETTLED promise, so one offline moment (a DNS hiccup, a CDN blip) memoized `false` for the life of the process; symbol search, module reports, and structural rules for that language stayed degraded even after the network came back. The in-flight entry is now evicted on settle, and a failed download gets a bounded exponential cooldown (30s, 60s, 120s, capped at 5 min, the same `transientRetryDelayMs` shape `availability-policy.ts` already uses) instead of a permanent latch. A durable CDN verdict (404/410 — the wasm genuinely does not exist) still latches, since a retry cannot change that answer; every other failure (network error, timeout, 5xx) retries. The degradation message now says "will retry" instead of "reinstall" for a retryable cause, and the user notification fires once per session per distinct retry delay, so a continuing failure streak that survives a session boundary is announced again instead of going silent.

- **A lazy install that dies on a network blip is retried instead of skipped for the session (closes #1537)** — the "we already tried this" key went in BEFORE the install ran and never came out, so a `gem install rubocop` or `rustup component add rustfmt` that failed on a transient error was never attempted again, and the tool stayed silently skipped. The storm guard stays — these spawns are up to 3 minutes and both entry points are reached per save — but it now keys off the attempt's OUTCOME: a transient failure is retried on #1497's install-class ladder (5 min, then 10 min, then the verdict is terminal for the session), while a durable one holds immediately. A missing package MANAGER is the durable case: no cooldown conjures a `gem`. The hold is released only at `session_start`, beside the other per-session availability resets.
  - The two copies of that guard became one. `formatters.ts` carried its own `_lazyInstallAttempts` Set duplicating `lazy-installer.ts`'s, and a fix in one was not a fix. State, classification, retry ladder and spawn options now live in `lazy-installer.ts`; `formatters.ts` keeps only the part that is formatter business (a fresh binary on PATH invalidates every "not found" verdict). Because these installs are machine-global, the two seams share one hold per tool: a runner and a formatter asking for the same `gem install rubocop` are asking for the same one thing. Callers that arrive while an install is still running now join it instead of starting a second one.
  - "We tried and the network failed" is now tellable from "this tool cannot be installed here". The lazy installers recorded nothing at all. Each attempt now produces an `InstallAttemptFact` in #1534's vocabulary; the rust-clippy runner reads it through `describeInstallAttempt` and puts it in the `availability_decision` record beside the verdict it produced, so a failed `rustup component add` reaches `latency.log` instead of looking like a silent skip. Each suppression also logs one `install`-subsystem line with the cause, the attempt count and the remaining cooldown.

- **A stalled PATH probe no longer hands your project to its second-choice formatter for the session (closes #1539)** — `detect()` returned a plain boolean, so "rubocop is not installed" and "the probe for rubocop timed out" arrived at the selection pass as the same answer. The runner-up won, the result was non-empty so #1495/#1532's poison guard never fired, and `detectionCache` is invalidated only by a config file's mtime or size: standardrb owned the repo until you restarted or edited a config. Each candidate now carries a verdict read from the PATH latch — `available`, `missing`, or `unreachable` — so a selection whose preferred candidate was never asked is provisional: it is not cached, it re-detects once the cooldown expires, and `formatter_selected` records `reason: "preferred-unreachable"` with the candidates it could not reach instead of the config-shaped `"explicit-config"`. A genuine absence still caches exactly as before, because that is a real fact about the project.
  - The poison guard now sees every binary a detection probed, not just the formatter's own. It matched stalled probes against `command[0]`, and the extras were assumed harmless because "they run only after the primary answered". Not so: `rustfmt` genuinely absent plus a stalled `which rustup` skips the `rustup component add rustfmt` that would have fixed it, and nothing named `rustfmt` is transient, so the empty result cached for the session. The guard reads the real probe record now, which also covers the co-equal-alternative case (`pwsh` ?? `powershell`).
  - A formatter held off by a probe cooldown is visible in `latency.log` while it is off. The latch's memo branch returned before `logAvailabilityDecision`, so one record covered arbitrarily many decisions and anyone counting `availability_decision` rows undercounted the outage. Cache-served verdicts now emit one row per cooldown window, marked `servedFromCooldown: true` so they stay separable from real probes.

- **An execution-policy-blocked PSScriptAnalyzer run no longer reports the file as clean (closes #1540)** — Both availability probes call PowerShell with `-Command`, which the execution policy does not gate, but the real analysis runs with `-File`, which it does. Under `Restricted`/`AllSigned` — the default on many corporate Windows hosts — the analysis exits nonzero with a `SecurityError` on stderr and nothing on stdout, and the runner read that empty stdout as zero diagnostics: a blocked analyzer reported as a clean file. A `-File` run is now verified directly: a nonzero exit or a crashed/signal-killed process (`status === null`) records a legible `availability_decision` (policy and interpreter included) and a `grammar-blocked` degradation-ledger entry instead of a silent pass or a silent skip, and only an actual exit-0 run is read as evidence about the file's diagnostics. Review round: a `status === null` spawn-UNKNOWN no longer latches `-File` off for the session (mirrors the nonzero-exit branch's own transient downgrade); both degradation-ledger calls use `incrementDegradationCount` so a retried block doesn't flood the ring buffer; the policy-block regex now requires the `SecurityError` + script-load pairing instead of a bare `unauthorizedaccess` match. A separate exit-0-but-empty-or-malformed-stdout gap is tracked as #1598.

- **Fish had no LSP runner in the registry, and 21 extensions had no language id, because two seams were hand-copies of the language registry (closes [#1545](https://github.com/apmantza/pi-lens/issues/1545))** — `LANGUAGE_POLICY` decides which kinds are `lspCapable`, but the lsp runner's `appliesTo` and the LSP `LANGUAGE_EXTENSIONS` table each kept their own transcription of it, so a language could be registered and still miss either one silently. Both had drifted. `appliesTo` omitted `fish`, so `RunnerRegistry.getForKind("fish", …)` returned no LSP runner and `getAvailableRunners("/p/a.fish")` led with `fish-indent`. Routing was unaffected (main dispatch resolves group runner ids directly, and `PRIMARY_DISPATCH_GROUPS.fish` names `lsp`), and `getForKind`'s only callers today are `getAvailableRunners`, which nothing in production calls, and the `@internal` `dispatchLint`, which has no production caller either — so this was a latent wrong answer in the registry rather than lost coverage, and it would have become real the moment any surface started asking the registry what runs on a file. `LANGUAGE_EXTENSIONS` was the bigger gap: powershell had no entry at all (`.ps1`, `.psm1`, `.psd1`), cxx covered seven of its twenty-four registered extensions (`.c++`, `.cp`, `.hxx`, `.inl`, `.ipp`, `.tpp`, `.txx`, `.c++m`, `.cppm`, `.cxxm`, `.ixx`, `.cu`, `.hip`, `.m`, `.mm`, `.cl`, `.clcpp` all missing), and `.json5` was absent, so `didOpen` announced each of those as `plaintext`. How much that costs depends on the server, and the honest answer is "less than it looks for most of this list": driving real servers over raw LSP, vscode-json-language-server and yaml-language-server return identical diagnostics under `plaintext` (7/7 and 3/3), and clangd derives the language from the path, so the cxx entries are very likely no-ops too. A `DocumentSelector`-based server such as PowerShell Editor Services is the case where the announced id decides whether the document is handled at all. The table is now correct either way, and correct-by-construction rather than by transcription. Both seams derive from `getLspCapableKinds()`: `appliesTo` is the policy list itself, and `LANGUAGE_EXTENSIONS` is the curated per-extension table (which stays, because one kind can span several ids, `.ts` vs `.tsx` and `.c` vs `.cpp`, and because servers want `shellscript` rather than the registry's `shell`) filled in from `KIND_EXTENSIONS` for anything the curated table does not already name. Registering a language as `lspCapable` is now the only step either seam needs. Six structural guards plus two behavioral pins in `tests/clients/lsp-capable-seam-coverage.test.ts` hold that in place: `appliesTo` non-empty (`getForKind` reads an empty one as "every kind"), every `lspCapable` kind present in it, no non-capable kind claimed there, every `lspCapable` extension present in `LANGUAGE_EXTENSIONS`, every one of them resolving to something other than `plaintext`, and the set of reachable language ids pinned by hand so a kind whose registry label no server answers to needs a deliberate review decision rather than passing silently. Each guard is proven red under the mutation it exists to catch. Found during the adversarial review of [#1520](https://github.com/apmantza/pi-lens/issues/1520), which hit both seams at once; refs [#1519](https://github.com/apmantza/pi-lens/issues/1519).

- **`biome-client-dedupe` test now isolates the in-flight probe dedupe from package-manager spawn count (refs [#1546](https://github.com/apmantza/pi-lens/issues/1546))** — `dedupes concurrent first-time callers to a single probe` armed `safeSpawnAsync` as a one-shot mock, assuming `probeBiome` calls it exactly once. Since #375, `getBiomeBinary` falls through to `findGlobalBinary` when no local or pi-lens-managed biome binary exists, and that helper legitimately probes every installed package manager's global bin dir (`where npm`, `npm config get prefix`, and so on) through the same `safeSpawnAsync` seam. On a machine without a local/managed biome binary — reproduced by moving both aside — those extra calls silently consumed the one-shot mock and `probeBiome` dereferenced the resulting `undefined`. The test now mocks `package-manager.js`'s `findGlobalBinary` to resolve `undefined` directly, the same isolation `biome-install-evidence.test.ts` already uses, so it stays scoped to `ensureAvailable`'s own in-flight dedupe (#120) instead of incidentally depending on how many package managers happen to be findable on the host.

- **A captive portal can no longer poison a grammar file permanently (closes [#1548](https://github.com/apmantza/pi-lens/issues/1548))** — the runtime grammar download trusted `res.ok` alone, so an airport-wifi or corporate-proxy login page returned as 200 OK was written to disk as `tree-sitter-<lang>.wasm`. That counted as a successful download, so no cooldown was armed and nothing was recorded, while every later resolve found the garbage file and reported the language as available — `Language.load` then failed on every parse, with no path back to a re-download until someone deleted the file by hand. The downloaded bytes are now checked against the WebAssembly preamble (`\0asm`) BEFORE anything is written; a non-wasm body is a retryable failure, so it flows through the same cooldown, notification, and degradation-ledger path a network failure does, and the record names the real shape ("returned an HTML page — a captive portal or proxy intercepted the request") instead of blaming the package manager. Grammar resolution also rejects a non-wasm file that is already on disk, so a file poisoned before this fix shipped is ignored and re-fetched instead of degrading the language forever. An ignored file is recorded once per session rather than once per process, so a session that starts with the poisoned file still on disk still reports why the language is degraded. Separately, an exception thrown anywhere in the grammar-fetch task now arms the same cooldown rather than escaping with no retry state.

- **A workspace sweep no longer speaks for a scanner that missed its deadline (refs [#1549](https://github.com/apmantza/pi-lens/issues/1549))** —
  when an auxiliary scanner misses the sweep deadline, the result now names the
  uncovered lanes instead of reporting the file as fully answered. A partially
  covered snapshot is never cached and never reconciled into the widget, so a
  later read re-asks the scanner that stayed silent.

- **One slow scanner no longer discards every good answer in a cascade sweep (refs [#1549](https://github.com/apmantza/pi-lens/issues/1549))** — a neighbour touch attaches about five servers, and its verdict was `inconclusive = notifyWriteTimedOut || diagnosticsTimedOut` with both flags TOUCH-WIDE. The diagnostics deadline is the maximum over the servers waited on, so opengrep's 3500 ms budget set the deadline for the whole touch: a clean TypeScript answer that landed in 100 ms reported as "nothing is known about this file". Measured over 6,079 sweeps in eight days, 97.6% of neighbour touches read inconclusive, against 15% for ordinary edit-time touches in the same window; a dogfood session after the [#1528](https://github.com/apmantza/pi-lens/issues/1528) notify-write flood fix still showed 37 of 37 inconclusive, which confirmed the merge rule rather than queueing as the driver. The verdict is now decided from the PRIMARY population alone, through one pure policy (`resolveTouchVerdict`): a touch whose primary answered reports its findings as usable and names the auxiliary it does not speak for, using the existing `confirmation: "partial"` plus `unconfirmedServerIds` machinery from [#1470](https://github.com/apmantza/pi-lens/issues/1470)/[#1493](https://github.com/apmantza/pi-lens/issues/1493)/[#1533](https://github.com/apmantza/pi-lens/issues/1533). Every consumer that treats confirmation as proof of coverage therefore still fails closed — no cache seeding, no widget reconcile, no clean bill of health — while the findings reach the agent instead of being thrown away. An auxiliary can no longer make a touch inconclusive whichever deadline it missed; only a primary can, and the answer is decided from EVIDENCE (a per-path publication stamp advancing, or a fresh per-file cache entry), never from how a promise settled, so a client that exposes neither accessor keeps the pre-fix verdict. The capability-aware silent-clean confirm is primary-scoped for the same reason: a clean markdown file whose marksman silence IS the answer no longer stays inconclusive because an opengrep scan beside it had not finished. Two safety consequences ride along: an auxiliary whose notify write never landed has its stale findings dropped from the merged result (they describe the previous revision, and before this they were unreachable behind the blanket verdict), and the diagnostics-timeout degradation ledger now records only the servers that actually went unheard rather than every spawned server. That drop is judged on a merge-time content-binding read unioned with [#1493](https://github.com/apmantza/pi-lens/issues/1493)'s pre-notify snapshot, because the snapshot is captured before the write and cannot see the write that lands late and then publishes for these exact bytes — [#1459](https://github.com/apmantza/pi-lens/issues/1459)'s own documented signature. One predicate decides both the drop and the coverage naming, so a scanner is never named uncovered while its findings ride along in the result. Attribution is on the record: `inconclusiveServerIds` and `inconclusiveReason` (`notify-write`, `diagnostics-wait`, or `mixed`) on the touch result, in `lsp_touch_file`, and in the cascade's own `neighbor_touch` row; `lsp_diagnostics_timeout` gains `unansweredServerIds` plus `attributedToPrimary`; and `lsp_scanner_coverage_gap` gains `auxNoAnswerServerIds` so the record that proves a scanner blackout survives the verdict change. The issue's `latency.log` retention item (one rotation sibling keeps about two days) is deferred and re-homed on the issue, and the sub-30% rate gate is measured on the next dogfood.

- **Cascade "could not compute impact" records now name the file that actually caused it (closes #1550)** — When a cascade run cannot enumerate a file's dependents, pi-lens writes one `cascade_indeterminate` line to `cascade.log`. That line labelled itself with the turn's first edited file and listed its reasons with no file attached — but the runs it summarises carry their own paths, and a run can be carried over from an earlier turn or belong to a file the turn never edited. So the log blamed the wrong file: a Markdown edit was credited with a missing graph node it could never have produced, and a plain source file was credited with the test-file-exclusion reason that only applies to tests. Each reason is now attributed to the file that produced it. A missing graph node also records what was looked up and what the graph held nearest to it — file-node count, surviving symbol nodes, known siblings in the same directory — so a cold graph, an unadmitted file, and a genuine graph inconsistency read differently in the log. Agent-facing advisories were already correct and are unchanged.

- **The availability-policy coverage gate no longer waves through a verdict that dodges the word "boolean" (refs #1552)** — Routing inheritance decided whether a unit that delegates its spawn to a routed helper may borrow that helper's coverage. It worked as a blacklist: inherit unless the unit's own memo "looks boolean". A `Map<string, "yes" | "no">` string-union verdict spells neither "boolean" nor a policy-factory name and slipped through, and a genuine boolean reached through a type alias slipped through the same way, since the word only ever appears in the alias's own declaration. The gate now works as a whitelist: a unit inherits routing only when its own memo is traceably a policy factory's handle, directly or through one hop of a module-local wrapper. An unrecognised shape defaults to unrouted instead of routed.

- **A resolved blocker no longer re-appears as "Unresolved from this turn" after pi-lens has confirmed the file clean (refs #1561)** — When a blocker on file A is caused by something in file B, fixing B re-analyzes B, not A. A's stale verdict was invalidated only by a later dispatch of A itself or by A being deleted, so it kept being injected at every turn end for the rest of the session. In one dogfood session it was re-served six times, and the agent burned three turns proving it wrong. Worse, three of those re-serves happened after the agent ran `lsp_diagnostics` on the file and pi-lens answered "confirmed clean": that confirmed result corrected the widget footer and nothing else. It now also retires the file's stale blocker, and clears the `--lens-guard` commit gate that the blocker was holding — both the in-memory latch and the persisted record, so a retired finding stops blocking the commit instead of being quoted back as the reason. Retiring is deliberately narrow. It requires a fresh, content-bound result with nothing at the blocking tier; an unconfirmed answer, a silent server, a partial answer missing an auxiliary scanner, a cache replay, or a reconcile that threw all count as unknown, never as clean. It is ordered against the dispatch that recorded the blocker, so a slow old clean cannot erase a newer finding. And it must cover the tools that actually raised the blocker: inline blockers come from every runner, not just the language server, so an eslint, actionlint, biome-check or ast-grep security finding is never retired by a language-server check that did not consult those tools, and a record whose provenance is unknown is never retired at all. Each retire is logged, so the eviction is confirmable from the runtime log. Blockers that go stale before any fresh verdict exists need a dependency-aware staleness key and remain open in #1561.

- **A truncated grammar download no longer poisons the file permanently ([#1564](https://github.com/apmantza/pi-lens/issues/1564))** — the #1548/#1560 wasm-magic check catches a captive portal's HTML, but a connection dropped mid-transfer still starts with a genuine `\0asm` preamble, so it passed. The runtime download path now verifies the full body against the pinned sha256 in `scripts/grammars.lock.json` (the same manifest the postinstall path already trusts), falling back to a Content-Length compare when no pinned hash is available. A `Language.load` failure on a file resolution just vouched for now also records a degradation naming the grammar and the loader's error, and invalidates the resolve memo so the next demand re-fetches instead of reusing the same broken file forever.

- **Tests no longer reset a duplicate copy of the module state they are guarding (closes #1565)** — Vitest resolves an import specifier literally and this repo's runtime is the compiled output, so `x.ts` and `x.js` are two module instances. A test that imported a module's reset or inspection API through `.ts` held a private copy of that module's mutable state: the `beforeEach` reset cleared the copy, the code under test kept reading the compiled original, and the assertion passed without ever exercising the behaviour it claimed to guard. Seventeen test files were reaching the same module both ways, most of them around the availability latches. Every test now imports the compiled `.js` the runtime imports, a new structural guard (`tests/config/module-instance-coverage.test.ts`) fails CI on any new `.ts` specifier for a build-compiled module, and a live regression test proves the session availability reset actually clears the compiled latch. No shipped behaviour changes; the guarded behaviour is now genuinely guarded.

- **The availability-policy coverage gate no longer inherits routing from a memo that merely mentions a policy handle's name (refs #1566)** — The #1552 whitelist checked whether a policy handle's name appeared anywhere in the memo's declared type or value. A memo typed `Awaited<ReturnType<ReturnType<typeof makeToolProbe>>>` unwraps a routed wrapper's return type down to a plain boolean, and a memo built by `emptyCache<boolean>(makeToolProbe)` merely hands the wrapper to an unrelated helper as an argument — both spell the handle's name without holding the handle, and both inherited routing they never earned. The gate now requires the handle's name to be the memo's own un-nested `ReturnType<typeof name>` or its own direct `= name(...)` call, so a name present anywhere else in the text no longer counts.

- **The ast-grep native-addon load no longer starves a concurrent caller or latches across sessions (refs [#1567](https://github.com/apmantza/pi-lens/issues/1567))** — `loadSg()` guarded the `@ast-grep/napi` load behind a plain "attempted" flag, set before the load began. The per-edit fallback runner and the session-start project scanner both call it and could race: a caller arriving while a load was already in flight read the flag as "already tried" and got back `undefined` for a load that was still pending and about to succeed — a false-negative starvation, not a duplicate load attempt. Any load failure then latched for the rest of the process, with no re-arm at session start, so a repaired install stayed invisible until the host restarted. The fix shares one in-flight promise across every caller, evicted on settle so a rejected load is never replayed (the `#1536` pattern); every failure now holds until `resetAstGrepNapiLoadState()` re-arms it at the next `session_start`. There is deliberately no in-process cooldown-then-retry for a transient failure: `@ast-grep/napi` loads via a dynamically imported `file://` URL, and Node's ESM loader permanently caches a module record that threw during evaluation, so a same-process retry would only replay the cached rejection. A classifier still distinguishes a narrow, positively-identified transient errno family from a genuine failure (an unrecognized error, including any real native-binding incompatibility, is genuine by default) so the degradation ledger — a held load now records an `ast-grep-napi-unavailable` entry, distinguishing a degraded scan from a clean one with nothing to report — says something honest about what happened.

- **A stalled ast-grep binary no longer pins the whole session to `npx` (closes #1568)** — Both ast-grep availability sweeps walk an ordered candidate list — local `node_modules/.bin`, then PATH, then a package manager's global bin, then `npx --no -- ast-grep` — and stop at the first candidate that answers. A candidate that timed out was recorded, but the record was read only when *every* candidate had failed. So one busy second at warm-up, with a healthy ast-grep on PATH, ended with `npx` selected and cached as a durable fact: a Node process started for every ast-grep invocation for the rest of the session. A win reached while a preferred candidate was merely unreachable is now provisional — it is used immediately, and re-checked once that candidate's cooldown expires, so a recovered binary wins its place back without a restart. A genuine absence still caches, unchanged. And if the re-check itself times out on a host that is still busy, the command already proved working is kept rather than discarded — a timeout says nothing about a tool that ran 30 seconds ago. The `availability_decision` entry in `latency.log` says which case it was, carrying `provisional: true`, `unreachablePreferred`, `retained`, and `retryAfterMs`.

- **A tool tier degraded by a transient stall no longer survives 24h across process restarts ([#1569](https://github.com/apmantza/pi-lens/issues/1569))** — `getToolPath`'s persistent probe cache (`~/.pi-lens/probe-cache.json`) recorded only the winning path, with no memory of whether a preferred candidate along the way had merely stalled rather than proven broken. A `--version` probe that timed out, was killed, or hit an unspawnable-process glitch (Windows `spawn UNKNOWN`, EAGAIN/EBUSY) fell through to a lower-priority tier exactly like a genuine absence, and that degraded selection was then trusted for the full 24h TTL — with no session reset touching it, since the cache outlives the process. The persisted entry now carries whether any candidate was transient at selection time, and a tainted entry ages out after the shared transient cooldown (5 min) instead of the full TTL, so a process restarting minutes later re-probes instead of inheriting the stall.

- **A failed lazy-module load or classic-tsserver repair no longer stays broken for the rest of the process (closes [#1570](https://github.com/apmantza/pi-lens/issues/1570))** — `clients/lsp-lazy.ts`, `clients/formatters-lazy.ts`, `clients/dispatch/lazy.ts`, and `clients/mcp/session.ts` each memoized a lazy `import()`/session-context promise with `x ??= ...`, which keeps a REJECTED promise cached for the process lifetime: one transient module-load failure (EMFILE, a momentary fs error) permanently poisoned that seam for every later caller. A new shared `createLazyImport` helper (`clients/lazy-import.ts`) evicts the memo on rejection so the next demand retries; no cooldown, since module loads are cheap. `clients/bootstrap.ts`'s per-client-fail-soft bootstrap promise got the same defensive eviction, though it is not expected to reject in normal operation. Separately, the classic-tsserver repair guard (`clients/lsp/server.ts`) was a one-shot per-process flag with no re-arm — a repair that failed once (an offline install, a registry hiccup) stayed unrepairable for every later session in a long-lived extension-host process. `resetLSPService({reason: "session_start"})` now re-arms it on every session start.

- **Formatter config-file cache now sees files created mid-session, for several formatters that previously missed it (refs #1572)** — the per-cwd formatter cache invalidates when a tracked config file's mtime/size changes, but `psscriptanalyzer-format`'s settings file, `google-java-format`'s marker, `cljfmt`'s bare `.cljfmt`, several `cmake-format` variants, sqlfluff's `setup.cfg`, oxfmt's `vite-plus.json`/extra `vite.config` extensions, and the Kotlin/Spotless gradle files were never in the tracked list — so adding one of them to a project after pi-lens had already looked at that directory kept returning the stale, pre-opt-in answer for the rest of the session. All are now tracked.

- **`psscriptanalyzer-format` can now be selected for `.ps1`/`.psm1`/`.psd1` files, and honors its settings file (closes #1572)** — `.ps1`'s formatter policy required explicit project config before offering the formatter, but the formatter had no config check at all: no configuration of a PowerShell project could ever select it. A project with a `PSScriptAnalyzerSettings.psd1` (or `ScriptAnalyzerSettings.psd1`) now selects `psscriptanalyzer-format` the same way a `.clang-format` or `stylua.toml` selects its formatter — and the resolved settings file is now passed to `Invoke-Formatter -Settings`, so an opted-in project's declared rules apply instead of the stock ruleset. A new coverage guard asserts every registered formatter is selectable under some configuration, so this shape can't recur silently. `tryLazyInstall`'s unreachable `golangci-lint` arm — golangci-lint already auto-installs through the shared tool-installer seam — was removed rather than wired to a second, redundant install path.

- **npm-global tool discovery now flags a stalled package-manager probe as transient (refs #1585)** — `isAvailable`'s bare boolean return could not tell a genuine "pnpm not installed" from a `where`/`which pnpm` probe that stalled, so `allAvailableGlobalBinDirs` silently dropped pnpm's global bin dir with no way to warn its caller. `findNpmGlobalToolPath`'s existing `onTransient` callback (from #1569) now fires for this case too, so `getToolPath` no longer caches a degraded npm-global selection untainted for the full 24h TTL.

- **A scanner that answered while its resync sat queued is no longer reported as a blackout (refs [#1586](https://github.com/apmantza/pi-lens/issues/1586))** — the fan-out gate allows one outstanding `didOpen` resync per auxiliary, and a touch that queues behind another is DEFERRED. Whether a deferred scanner nonetheless covers the file is a content-hash question, and the deferred door asked it of the PRE-NOTIFY snapshot: a set captured before the write went out, which by construction cannot see the publication that arrives after it. So when the write the gate deferred behind landed and the scanner published for exactly the bytes this touch carries, the touch still dropped those findings from `.diags` and still named the scanner in `unconfirmedServerIds` — a scanner that had demonstrably reported reading as one that had gone silent, which is [#1549](https://github.com/apmantza/pi-lens/issues/1549)'s underclaim one door over. Coverage is now ONE rule (the pre-notify snapshot unioned with a live content-binding read, both going through a single content-match comparator so a door cannot acquire a rule of its own by writing the comparison inline) evaluated ONCE, as the last statement before the merge. Every door that shares the merge's consequences reads that frozen answer: the merge drop, the deferred door, the merged content binding — where a dropped contributor now loses its findings and its fingerprint together, rather than the fingerprint being filtered on the raw deferral set — and the result's own `unconfirmedServerIds`, which reconciles the auxiliary wait-outcome verdict (decided earlier, when the wait ended) against the merge before anything is claimed. Freezing it is what makes the fix safe in both directions: `touchFile` awaits after the merge, and asking the live rule again when the coverage gap is named let a publication landing in that window un-name a scanner whose findings the merge had already dropped — `confirmed` over a `.diags` missing the scanner's answer, which is the overclaim direction and the one that unblocks the last-known-diagnostics cache prime and the warm mark that the coverage gap exists to hold shut. The honest case is unchanged and still fails closed: a deferred scanner with no binding for these bytes keeps its stale findings dropped and stays named, and a scanner whose breaker was open never attached at all, so there is nothing to read for it and it stays named too. `lsp_scanner_coverage_gap` and `lsp_touch_file` report the deferrals the touch is actually uncovered for rather than every deferral; the gate's own action keeps its unconditional record in `lsp_notify_resync_deferred`.

- **`TreeSitterClient.init()` no longer replays a dead `import()` on every file parse (refs [#1592](https://github.com/apmantza/pi-lens/issues/1592))** — `init()` called `loadWebTreeSitter()`, a dynamic `import()` of a fixed resolved `file://` URL, and only latched permanently on errors matching an Emscripten `Aborted`/`abort()` signature. Any other rejection — for example the module throwing during evaluation — left `initPromise` cleared, so the next `init()` call (one per file parse, via `withTreeSitterRoot()`) re-imported the same URL and replayed Node's cached rejection instead of re-attempting the load: a retry that looked like a retry and structurally could not be one. `init()` now classifies the rejection (`classifyWebTreeSitterLoadFailure`, mirroring `classifyAstGrepLoadFailure`): a RESOLUTION-shaped failure (`ERR_MODULE_NOT_FOUND`, a transient fs errno) still retries on the next call, because it can plausibly clear on its own; an EVALUATION-shaped failure latches for the SESSION and records a `web-tree-sitter-load-failed` degradation once. The latch is session-scoped, not process-lifetime — `resetLoadStateForSession()` on `TreeSitterClient`, exposed as `resetTreeSitterClientLoadState()`, is wired into `resetDispatchBaselines()` beside `resetAstGrepNapiLoadState()`, matching the `#1567`/`#1575` precedent — and stays distinct from the existing `wasmAborted` latch, which covers a corrupted heap from a runtime that DID load and deliberately stays process-lifetime. `clients/lazy-import.ts`'s docstring now states the underlying ESM-cache limit explicitly: eviction-and-retry recovers a resolution failure but replays the cached rejection for an evaluation failure, so callers must not read "retries" as "can recover from a broken compiled module mid-process." A class sweep of every dynamic-import retry/cooldown site in `clients/deps/*.ts` and its callers found no other dead-retry sites: the `@ast-grep/napi` loader already latches this way (`#1575`), and every other lazy `import()` in the codebase is either a one-shot local-module load (no retry ceremony to be dishonest about) or a network/filesystem-backed retry (grammar downloads, `Language.load` on WASM bytes) that is genuinely retryable and outside the ESM-cache class. The new session-scoped latch is registered in the `#1635` session-state registry (`tests/support/session-state-registry.ts`), replacing that file's prior blanket exemption for `tree-sitter-shared.ts`.

- **The retained-winner arm no longer re-serves an ast-grep command this same sweep just proved gone** — both the shared `isSgAvailableAsync` sweep and `SgRunner.ensureAvailable()` retain a provisional winner across a stalled sibling tier, but neither checked whether the memoized winner itself was among the candidates that ENOENTed in that same pass. A previous provisional winner (say `npx`) that goes durably missing while an unrelated tier merely stalls used to still answer available with the dead command; the sweep now tracks which candidates it proved durably absent and skips the retained arm when the memoized command is one of them, falling through to the genuine-absence path instead (#1593).

- **Basename-classified files (`Makefile`, `Dockerfile.<suffix>`) reached `didOpen` as plaintext, invisible to the #1545 extension-seam guards (refs #1594)** — `clients/file-kinds.ts`'s `SPECIAL_FILENAMES` classifies `Makefile`, `Dockerfile(.<suffix>)?`, and `CMakeLists.txt` by basename regex into `lspCapable` kinds, but `clients/lsp/language.ts`'s `getLanguageId` only resolved language ids through `LANGUAGE_EXTENSIONS`, a flat extension/literal-basename map. `Dockerfile` and `CMakeLists.txt` happened to work because two hand-kept literal keys covered their exact spelling; `Makefile` had no entry at all, and `Dockerfile.dev`/`Dockerfile.prod`-style suffixes never matched the literal `"Dockerfile"` key. `getLanguageId` now falls back to a `BASENAME_LANGUAGE_PATTERNS` list derived from the now-exported `SPECIAL_FILENAMES`, filtered to `lspCapable` kinds — the same single source of truth `detectFileKind` already uses, not a second hand-kept literal list. The two now-redundant literal keys (`"CMakeLists.txt"`, `"Dockerfile"`) were removed from `CURATED_LANGUAGE_EXTENSIONS` since the derivation covers them. `terragrunt.hcl`/`root.hcl` stay unresolved by design: `terragrunt` is `lspCapable: false`.
  - `clients/file-kinds.ts` — `SPECIAL_FILENAMES` exported.
  - `clients/lsp/language.ts` — `getLanguageId`, `BASENAME_LANGUAGE_PATTERNS`.
  - `tests/clients/lsp-capable-seam-coverage.test.ts` — basename-classifier coverage guards mirroring the existing extension-classifier guards 4-6.

- **Seven more config-gated formatters were unselectable under any project configuration (refs [#1595](https://github.com/apmantza/pi-lens/issues/1595))** — same shape as [#1572](https://github.com/apmantza/pi-lens/issues/1572): commit `038cd1df` set `defaultWhenUnconfigured: false` for `csharpier`, `ormolu`, `taplo`, `terraform`, `swiftformat`, `fantomas`, and `mix` without adding a matching `hasExplicitFormatterConfig` check, so neither selection branch could ever pick them. Each now wires into `EXPLICIT_FORMATTER_CONFIG_CHECKS` against its real config-file convention — `.csharpierrc`, `.ormolu`, `taplo.toml`/`.taplo.toml`, `.swiftformat`, `.fantomasignore`/`.editorconfig`, `.formatter.exs` — or, for `terraform fmt` (which has no config file at all), the `.terraform.lock.hcl` manifest `terraform init` writes as an explicit per-project opt-in marker. `nixfmt` stays unreachable: it has no config surface and no manifest-marker equivalent, so a documented exception replaces the fix.

- **An exit-0 PSScriptAnalyzer run with empty or malformed stdout no longer reports the file as clean (refs #1598, refs #1540)** — `PS_SCRIPT` always writes the literal `[]` marker or a diagnostics array before exiting 0, but `parsePSAnalyzerOutput` treated any unparseable stdout the same as that marker, so a lost or truncated write (a host quirk, not a real clean file) reported `succeeded` with zero diagnostics identically to a genuine clean run. The parser now returns `null` for empty or malformed stdout, distinct from a real `[]`; the runner reads `null` as an unreadable run, logs an `availability_decision` (tool `psscriptanalyzer-stdout`) and a `grammar-blocked` degradation-ledger entry, and skips without latching `-File` off — one bad read is not durable evidence the interpreter is broken, mirroring the nonzero-exit branch's own guard. #1540's fix covered the `-File` nonzero-exit and `status === null` paths in the same function; this covers the exit-0-but-unreadable-stdout gap its review flagged as out of scope.

- **`BiomeClient`'s in-flight leak guard could not fail (refs #1601)** — the regression test for "the `ensureInFlight` slot doesn't leak after a failed probe" used a probe failure that latches durably, so the second `ensureAvailable()` call short-circuited on the availability memo and never reached the `ensureInFlight` check the test claimed to cover; removing the `finally`-clear it names left the test green. The suite now probes through the one path that actually reaches that check — a transient (timeout) verdict whose cooldown has expired — and is proven red against the described mutation. A second test covers the settled-slot-through-cooldown window directly: two callers racing in as the cooldown opens must share one fresh probe rather than one of them resolving off a leaked settled promise. A sweep of every other `ensureInFlight`-based client (`sg-runner.ts`, `dead-code-client.ts`, `knip-client.ts`, `dependency-checker.ts`, `security-scan-client.ts`, and the shared `toolchain-availability.ts` factory ruff and friends route through) found the same `finally`-clear on all of them; no production code changed.

- **PSScriptAnalyzer's execution-policy regex tightening now has regression tests (refs #1604)** — the #1556 review round tightened `policyDenied`'s detection from a bare `securityerror|execution polic(y|ies)|running scripts is disabled|unauthorizedaccess` alternation to requiring `securityerror` paired with one of the other three, closing a false positive where a bare `UnauthorizedAccessException` (a file-permission error, unrelated to the execution policy) or a stray mention of "execution policy" in unrelated stderr latched `-File` off as a durable policy block. That tightening shipped with no test proving the false positive it closed. Two regression tests now pin it: reverting the regex to the pre-#1556 alternation turns both red, confirming the current AND-conjunction is load-bearing, not vacuous.

- **Security scanners log a compensating `available` row when auto-install recovers a failed probe (refs #1606)** — gitleaks, trivy, and opengrep probe on PATH first and fall back to the pi-lens installer; govulncheck falls back to `go install`. When the probe failed but the install then resolved the binary, the probe's `unavailable` record stood alone in `latency.log`: nothing said the tool came back. An auditor reading the log concluded the lane was off when it was on. A second `availability_decision` row now fires on every recovery path, with `verdict: "available"`, `cause: "ok"`, `classifiedBy: "caller"`, and the binary's basename plus its install source in `evidence`.

- **agent_end no longer misreads a disabled feature as a missing cache (closes [#1607](https://github.com/apmantza/pi-lens/issues/1607))** — The `actionable_warnings_autofix` reader at `agent_end` read the actionable-warnings cache on every call, but the cache is only ever written when the `lens-actionable-warnings` flag is on. In production that flag is off, so the read always missed and logged "cache missing or expired, skipping fixes" — 428/428 times in the retained log, per a maintainer-supervised forensics pass, always blaming a stale cache instead of the real cause: the writer is disabled. The reader now checks the writer's own flag before attempting a read, so a disabled writer produces no read attempt and no misleading log line. When the flag is on and a read does miss, `CacheManager.inspectCache` (already used by `git-guard.ts` for the same purpose) distinguishes "cache absent" from "cache expired," instead of collapsing both into one ambiguous message.

- **`language: TypeScript` ast-grep rules now run on `.tsx` files** — the napi runner compared a rule's declared language against `.tsx`'s exact grammar name (`"tsx"`), so every `TypeScript`-tagged rule's language guard skipped it on React/TSX sources, leaving 120 of 263 shipped rules dark there. `.tsx`'s grammar is empirically a syntactic superset of `.ts`'s for every construct the shipped catalog's rules target, verified per rule (not assumed) by asserting each `TypeScript` rule's existing fixture-test `invalid:` snippet still matches parsed as tsx — so a `TypeScript`-tagged rule now also runs against a `.tsx` file's parsed root. A rule skipped purely for a language/file mismatch is now recorded in the same aggregated `astgrep_napi_unsupported_rules_skipped` latency telemetry the runner already emits for wholly-unsupported languages, so an empty result on a `.tsx` scan is distinguishable from "rules never ran" (#1608).

- **Several installer writes and two persisted-state read paths did not survive a hard kill cleanly (refs [#1609](https://github.com/apmantza/pi-lens/issues/1609))** — a derived sweep test (`tests/clients/atomic-write-sweep.test.ts`, `tests/support/atomic-write-scan.ts`) that enumerates every raw filesystem-write call under `clients/` at test-run time (not a hand-maintained list) found `clients/installer/index.ts` writing several downloaded tool artifacts (bare-gzip binaries, bare binaries, Maven jars and launchers, archive-install shims, the npm bootstrap `package.json`, and a GitHub "extra asset" sibling file) straight to their final path instead of through the shared atomic tmp+rename seam (`clients/atomic-write.ts`, #762) — a process killed mid-install could leave a corrupt binary or launcher behind. All nine sites now go through `writeFileAtomicAsync`, which gained an optional `mode` option so the executable bit still travels with the staged file across the rename. The sweep itself judges each write call by its own TARGET argument and by each file's own `node:fs`/`node:fs/promises` import bindings — not by a whole matched line or a whole-file "imports the seam somewhere" check — so a scratch-named DATA argument or a differently-named `fs` import can't hide an unrelated violation. The `session_start` orphan-staging sweep (`clients/runtime-session.ts`) now also covers the installer's `bin/` and `tools/` directories, which it omitted entirely; `sweepOwnStagingFiles` doesn't recurse, so an orphaned staging file left there by a kill mid-install was never reaped and repeated kills would have accumulated full-size orphan binaries. Fault injection also found two read-side gaps: `clients/instance-registry.ts` degraded a corrupt/torn `instances.json` to empty exactly like a genuinely missing file, with no way to tell a real torn-write regression from a normal clean start — a non-`ENOENT` read failure (or a valid-but-wrong-shape file, which a torn tail can produce) now records one `instance-registry-corrupt` degradation via `recordDegradationOnce` (deduped per session, since the read path is called far more often than just at session start), while a missing file stays silent. And `deserializeProbeCache`'s locked write-side merge threw on a torn `probe-cache.json` instead of degrading like its sibling deserializers, so a torn cache file could never be repaired by a later successful flush — it now degrades to `{}` and logs the recovery, matching `diagnostic-dispositions.ts` and `actionable-warnings.ts`.

- **~16 CLI runners now log recovery when auto-install fixes a failed tool probe (closes #1612)** — `resolveAvailableOrInstallUnshared`, the shared seam behind golangci-lint, ruff, shellcheck, pyright, knip, jscpd, and about a dozen other runners, wrote a latched `unavailable` `availability_decision` row when the PATH probe missed, then fell through to the pi-lens installer. If the installer resolved the binary, nothing recorded that recovery — the durable log kept saying the tool was off after it came back on. This is the same defect shape #1606/PR #1610 fixed in `security-scan-client.ts`'s `ensureViaInstaller`, on a seam with a much larger blast radius. A second row now fires on that recovery path, with `verdict: "available"` and an `evidence.source` tag DERIVED from the tool registry's own install strategy (`managed-dir`, `pip-user`, `github-release`, `archive-dist`, or `maven-jar` — never hand-mapped). The row fires once per correction, and its `install`/`resolved` evidence is read from the installer's own attempt record rather than assumed from a truthy result, so a cache-resolved recovery reads `install: "not-attempted", resolved: "cache"` instead of dishonestly claiming a fresh install on every dispatch.

- **Marking a secrets/security finding false-positive or won't-fix now sticks, on every reporting lane (refs [#1617](https://github.com/apmantza/pi-lens/issues/1617))** — `applyDispositions` (the dispatch pipeline's agent/user mark filter) was only ever wired into the per-edit dispatch path; gitleaks, trivy, and govulncheck reported straight from their own scan caches with zero disposition wiring, so an agent-marked false-positive re-reported on every turn_end gate check and every `lens_diagnostics mode=full` scan. A new `applyDispositionsMultiFile` (`diagnostic-dispositions.ts`) extends the SAME anchor derivation dispatch already uses to lanes whose findings span many files in one report, grouping by each finding's own file and reading its current content once; a file that can't be read fails OPEN (still reported) for a false-positive's content-hashed anchor, while a weak-anchored suppress mark — which never looks at content — still applies. Wired into `lens_diagnostics mode=full`'s single `record()` choke point (covers knip, jscpd, madge, gitleaks, govulncheck, opengrep, trivy, dead-code in one place) and into turn_end's gitleaks secrets gate plus its govulncheck/trivy advisory sections, using each lane's existing `ProjectDiagnostic` adapter so a mark made against what `lens_diagnostics` showed the agent is honored everywhere. A disposition-dropped finding now surfaces a "suppressed by disposition: N" trace in both surfaces instead of silently vanishing (the #1616 suppressed-bucket rule). Trivy's *secret* findings (as opposed to its CVE findings, which are covered) have no existing `lens_diagnostics`-surfaced identity to anchor a mark against and are tracked as a follow-up rather than shipped with an unreviewed anchor shape.

- **Idle-reset timer no longer destroys the LSP service mid-sweep (closes [#1618](https://github.com/apmantza/pi-lens/issues/1618))** — `handleTurnEnd` arms a detached idle-reset timer on a file-less turn, but `lens_diagnostics mode=full` grants its own workspace sweep a 300s wall-clock ceiling; a maintainer-supervised forensics pass found the timer firing mid-sweep and mislabeling ~81 service-destroyed files as budget exhaustion. `clients/lsp/workspace-sweep-hold.ts` adds a token-based hold that `runWorkspaceDiagnostics` acquires for its whole call (try/finally, so an overlapping sweep or a throw still releases correctly); the idle-reset timer now defers instead of firing while any sweep holds it, and re-arms a fresh delay once the last sweep releases. The delay on EVERY idle-reset path (base, subagent, and cross-process-budget-shortened) is now derived from the sweep's own wall-clock ceiling plus a safety margin, so the constants can't drift back into this relationship — an accepted, deliberate cost of a longer default idle residency (up from 240s to ~360s) for the derivation's coherence. A hold also clears at `session_start` and force-releases past its own max age (with a distinct log record), so a leaked hold can't permanently disable idle reset. `LSPWorkspaceDiagnosticResult` gains a discriminated `unconfirmedReason` (`budget` / `inconclusive` / `coverage_gap` / `service_destroyed` / `error` / `binding_mismatch`) so a destroyed service, a stale content binding, a real timeout, and a thrown error each render distinctly instead of collapsing into "within budget"; `touchFile`'s destroyed early-return now emits a cheap, local `lsp_touch_file` record instead of leaving zero trace; and `scripts/analyze-pi-lens-logs.mjs` (the forensics tool that found #1618) now reads the per-reason tally instead of blaming budget exhaustion for every unconfirmed file.

- **LSP teardown no longer hangs on a wedged server, leaking the process (refs #1620, #1459)** — `clientShutdown` bounded the `shutdown` request but awaited the `exit` notification with no timeout. A notification write on a pipe that is not draining neither resolves nor rejects, so the await never returned. Everything after it was skipped: the connection was never disposed, no `lsp_client_shutdown` record was written, the child was never deregistered from the instance registry, and the process was never killed. The wedged-write breaker calls this teardown precisely when a server's stdin has already been proven wedged, so every demoted server leaked — three surviving `ast-grep` processes on one host, plus older orphans, with coverage degrading as the leak compounded. The `exit` notification now has its own ceiling (`PI_LENS_LSP_EXIT_NOTIFY_TIMEOUT_MS`, 1s, matching the request budget, so a healthy server still exits gracefully), and disposal, the record, the deregistration, and the process-tree kill moved into a `finally` block so they run no matter how either write behaves. The `lsp_client_shutdown` record gains `exitNotifyTimedOut` and a rolled-up `shutdownOutcome` of `graceful`, `forced`, or `fast`, so a forced teardown is countable from the log instead of showing up as a missing record.

- **LSP teardown: live-wedged proof, precise failure attribution, and safe idempotent shutdown (refs #1620)** — closes out the residuals from #1624's bounded `clientShutdown` fix. Adds a real-child-process fixture (`FAKE_LSP_WEDGE_STDIN_AFTER_INIT`) proving the bound holds against a genuinely wedged OS pipe, not just a mocked connection — the prior unit-level test alone did not exercise real pipe backpressure. `lsp_client_shutdown` now distinguishes a real timeout, a genuine rejection, and a SWALLOWED stream error (`safeSendRequest`/`safeSendNotification` resolve instead of rejecting on EPIPE/disposed-connection, which previously reported a nothing-was-delivered teardown as `shutdownOutcome: "graceful"`) via `shutdownRequestUndelivered`/`exitNotifyUndelivered`, alongside the existing `*TimedOut` pair. `clientShutdown` is idempotent — two callers racing to shut down the same client (8 call sites can race the same state) share one teardown instead of each running the RPC handshake and inflating any `shutdownOutcome: "forced"` count — but ONLY when the in-flight teardown's options are at least as aggressive as the new call's; a `fast`+`processExiting` session-exit call no longer inherits a weaker in-flight graceful teardown's wait (or its `taskkill` spawn, which `processExiting` exists specifically to forbid while the event loop is closing). A rejected teardown also no longer latches a permanently-dead promise — it clears so the next call actually retries instead of silently giving up on the leak.

- **Rename propagation no longer hangs on a wedged LSP server (refs #1621, #1620)** — `LSPService.renameFile` awaited `client.closeDocument` and `client.didRenameFiles` with no timeout. A notification write on a pipe that is not draining neither resolves nor rejects, so one wedged server stalled its `Promise.all` and blocked the rename for every healthy client alongside it. Both notifies now carry their own budget (`PI_LENS_LSP_RENAME_NOTIFY_TIMEOUT_MS`, 1500ms). Rename propagation is best-effort advice to servers, not a correctness gate, so a timed-out `didRenameFiles` notify is recorded in the existing failure ledger and the rename still completes; a timed-out `didClose` still aborts and resynchronizes the rename, matching the existing failure path. Each ledger entry now carries a `disposition` of `timedOut` or `rejected`, so an empty failure list still means clean and a stall is never indistinguishable from a genuine rejection.

- **Cached secret findings no longer replay pre-edit line numbers (refs #1622)** — the turn-end secrets blocker cited a gitleaks line for the full 30-minute cache TTL even after the agent edited that file, because the delivery gate probed only whether the cited path still existed. Existence is not authority: the TTL says the cache is young and the stat says the file is there, and neither says the line number is still true. The gate now returns a three-way freshness verdict per cited path, from one memoized `statSync` per delivery. A deleted path still drops. A path whose mtime is newer than the store's `scannedAt` is **demoted, never dropped** — the finding is still surfaced, out of the blocker tier and without the line number the edit invalidated, because dropping it would let any edit, malicious or accidental, mute a real credential. An unmodified path delivers unchanged at full severity. Trivy's secrets lane gets the identical gate, and govulncheck drops the cached call-site line for an edited file while keeping the CVE. An unparseable or absent `scannedAt` disables the staleness arm rather than demoting a whole store on a clock anomaly. Each demotion writes one bounded `finding_stale_line_demote` record per store per delivery, mirroring `finding_dead_path_drop`, so the replay window is readable from `latency.log` instead of only inferable. What a deleted file means is now per-store: for secrets it drops, because a credential in a deleted file cannot be rotated; for govulncheck it demotes, because the CVE is pinned by `go.mod` and one deleted call site does not un-pin it. Demoted secrets keep their rule id and source (`src/config.ts — generic-api-key [gitleaks]`) so the agent can still triage them, and they render in their own tier with an imperative preamble rather than under the "no action required this turn" advisory label. A turn whose only finding is a demoted secret reports as `stale_secrets_pending` rather than `clean`.
  - `clients/advisory-provenance.ts` — three-way freshness verdict, per-delivery stat memo, the `gateFindingsByPathFreshness` delivery seam, and the per-store `onMissing` policy.
  - `clients/runtime-turn.ts` — gitleaks, trivy secrets, and govulncheck read through the gate; demoted secrets get their own tier, preamble, rule/source detail, and turn-result telemetry; the govulncheck header renders off the post-gate list so it can never print with zero rows.
  - `clients/latency-logger.ts` — `finding_stale_line_demote` excluded from `lastPhase` attribution, like its drop-record sibling.

- **Fix "age unknown old" grammar in lens_diagnostics cache-age notes (refs #1623)** — the render call sites that report a lane served from cache in `lens_diagnostics mode=full` (test-runner findings, the cheap project scan under `refreshRunners=cached`) appended the literal word "old" after `formatCacheAge` unconditionally. When the underlying cache metadata had a missing or corrupt timestamp, `formatCacheAge` already degraded to "age unknown" instead of fabricating a NaN age (#1623 fix-round F4) — but the caller still glued "old" onto it, rendering the ungrammatical "test-runner (age unknown old)". A new `formatCacheAgeOld` helper (`clients/project-diagnostics/extractors.ts`) is the single place that decides whether "old" belongs on the string; both render sites now go through it instead of hand-rolling the suffix.

- **lens_diagnostics now names every lane that did not run, instead of rendering silence as clean (refs [#1623](https://github.com/apmantza/pi-lens/issues/1623))** — `mode=full` without `refreshRunners` never ran the heavyweight analyzers (gitleaks, knip, trivy, govulncheck, opengrep, jscpd, madge, dead-code) or the cheap in-process project scan (tree-sitter/fact-rules/ast-grep), but said nothing about it — a result with no gitleaks section read exactly like "gitleaks ran and found nothing," per a maintainer-supervised dogfood forensics pass. Each lane now renders a "not run (\<reason\>)" marker. Each gate captures its own specific reason at the exact point it decides, reusing the SAME availability taxonomy (`AvailabilityOutcome`/`AvailabilityCause`) dispatch-side messages already use — so a durable absence ("gitleaks binary unavailable"), a transient probe under its own retry cooldown ("retry cooldown (15s), not a missing install"), and a project-trust install denial for govulncheck are three different, honest sentences instead of one confident "unavailable" guess. That taxonomy is a single source of truth (`fetchFreshProjectDiagnostics`'s `coldReasons`, `clients/project-diagnostics/fresh-fetch.ts`) the render layer reads rather than re-deriving. A second dogfood pass reported the mirror problem for cache-served lanes (test-runner's cache-only findings, and the cheap project scan under `refreshRunners=cached`, including when nothing has been cached yet) being misread as a fresh run — all now render their status explicitly ("cached, 18m old — not re-run", or "not run (no cached scan; refresh to populate)"), guarding a corrupt/missing cache timestamp against rendering a fabricated "NaNh old".

- **A blocking secrets finding can no longer be silenced by a loosely-matched mark (refs [#1617](https://github.com/apmantza/pi-lens/issues/1617))** — the disposition filter added for the secrets/security lanes let a WEAK-anchored suppress or defer (matched on file/tool/rule/message, no line content) drop a `"blocking"`-tier finding. Two distinct secrets sharing one gitleaks rule — two different AWS keys, say — collapsed onto the same weak anchor, so marking one false-positive silenced both. A blocking finding now drops only via the STRICT, content-bound false-positive anchor; a weak suppress or defer never touches it, in the dispatch filter, the cache-only instant filter, and the session-wide defer set alike. The suppressed-count trace (`lens_diagnostics mode=full` and turn_end) now also breaks its total down per analyzer ("gitleaks 2, knip 1"), and a per-project defer can no longer bleed into an unrelated project sharing the same relative file path.

- **Trivy secret findings now honor dispositions (closes #1628)** — Trivy's own secret scan (`TrivyResult.secrets`) had no `ProjectDiagnostic` identity for an agent to anchor a `lens_diagnostic_mark` call against, so a false-positive mark could never suppress it — the same bug #1617/#1625 fixed for gitleaks, govulncheck, and Trivy's CVE lane, left open for Trivy's secret lane. A new `trivySecretFindingToProjectDiagnostic` adapter (rule id `trivy-secret:<ruleId>`, namespaced apart from the CVE lane's `trivy:<vulnerabilityId>`) now surfaces these findings in `lens_diagnostics mode=full` and routes them through `applyDispositionsMultiFile`/`turn_end`'s disposition filter, matching the existing gitleaks/govulncheck/Trivy-CVE wiring.

- **`instance-reaper-prune-concurrency` no longer times out flakily under CI contention (closes #1629)** — root-caused, not just re-budgeted. Solo, the 40-iteration `#1217` concurrency test measured 1.7-3.3s against vitest's 5000ms default, but 8 runs alongside 5 contended sibling suites blew that budget 3/8 times (5.1-6.4s) — genuine test-budget starvation, not a `pruneDeadInstances` regression (the write path is unchanged fs I/O; #1738's registry-write serialization did not touch it). The describe block now declares an explicit 30s timeout (matching this repo's existing `HEAVY_IO_TIMEOUT_MS` convention), with the measurement in a comment. A second, independent defect surfaced under the old 5s budget: a timed-out test's `for` loop keeps running in the background (vitest's timeout races a timer against the test promise; it does not cancel in-flight work), and its next `pruneDeadInstances` call re-reads the now-repointed `PI_LENS_HOME` and writes its zombie staging file into the NEXT test's temp directory — failing that test's "leaves no staging files behind" assertion with the PRIOR test's litter. `afterEach` now drains the last-known in-flight prune promise before the next test's `beforeEach` repoints the env var, closing that window. Both the timeout comment and the drain are backed by controlled before/after measurement (N=8 contended local runs pre-fix: 3 failures; N=4 contended runs post-fix: 0 failures) and a mutation probe (forcing a real timeout at a 700ms budget: with the drain, only the timed-out test fails, cleanly; with the drain removed, the sibling test also fails on leftover `.tmp-` litter, 3/4 runs).

- **knip no longer reports an export as unused after a new test imports it (refs #1630)** — pi-lens runs knip with its own `--cache-location`, and knip's glob cache only revalidates the directories that contributed a matched file. A directory that matched nothing when the cache was written is never recorded, so a consumer added to it stays invisible to every later cached run: knip keeps reporting the export as unused, and pi-lens renders that stale verdict as fresh. This is what made a dogfood session see an "unused export" warning that a direct `knip` run in the same project did not reproduce. pi-lens now deletes knip's glob cache before each run, which forces a fresh file walk while the module and plugin caches — which carry nearly all of the speed — survive. Measured on this repo with knip 6.4.1: uncached 3.3s, fully cached 1.3s, glob cache dropped 1.4s. Upstream knip fixed the glob cache in 6.28.0; the prune is unconditional because the managed install floats and older versions are still common.

- **Cached cross-file blockers no longer replay for the whole session after the cause is fixed in a dependency (closes [#1631](https://github.com/apmantza/pi-lens/issues/1631))** — a blocker recorded on a file F is a verdict about F *and everything F imports*, but every existing invalidation path keyed on F alone. When the cause was fixed in a dependency G — especially out-of-band, where G is never dispatched — no event fired and F's stale verdict re-served at every turn end and on every `lens_diagnostics mode=all` for the rest of the session (the #1561 dependency-axis remainder, observed live four times). A turn-boundary freshness sweep now runs before a cached blocking finding is re-served: it stats F and its forward imports (resolved through the parse layer — no reverse-dependency index, so the tests-free-index blocker that holds #1561's full invalidation does not apply) against the verdict's timestamp. On drift the finding is DEMOTED, not dropped (#1419): the turn-end re-serve moves it out of the authoritative blocker channel into a `[stale — re-run to confirm]` advisory, and the widget store that feeds `mode=all` marks it stale so it renders as a non-blocking `[stale]` entry instead of a 🔴. The gate lives at the cache layer on both stores — `RuntimeCoordinator` inline blockers and `widget-state` — and never re-pulls LSP verdicts on its own, avoiding the document-resync hazard of re-querying an LSP whose in-memory dependency document is itself stale. Each turn logs a bounded `blocker_freshness_sweep` phase (and `mode=all` a `blocker_freshness_widget_gate` phase) naming the revalidated/kept/truncatedImports counts so a future replay is reconstructible from `latency.log` alone. The full dependency-axis invalidation (actively re-verifying consumers when a dependency is dispatched clean) remains deferred to #1561.

- **Dependency-drift blocker freshness gate: review-round hardening (refs #1631)** — the read-time freshness gate from PR #1633 landed with issues found across two review rounds. The `+1ms` drift tolerance (borrowed from same-process single-writer reconciles) false-demoted a blocker recorded immediately after its own file write on Windows, where mtime measurably leads `Date.now()` by up to ~11.4ms; the tolerance is now `+50ms` at every comparison site, including a third site (`reconcileStaleWidgetFiles`'s own-file gate) found while red-proving a flaky-looking "control" test — the same host-clock skew, not cross-test state. Demotion is now narrowed to language-server-sourced findings (`sources`/`tool === "lsp"`): an ast-grep secret or a CVE finding doesn't stop being true because a file it imports changed, so it stays fully blocking through a dependency edit. A dependency-drift demotion no longer survives `exportWidgetState`/`importWidgetState` — the same #1348 shape as a failed-formatter entry, one field over — so a real blocking finding can't resume as permanently non-blocking after a session restore with the dependency pinned back into the past (`WIDGET_STATE_VERSION` bumped to 3). Round 2 found the fix itself restored `diagnosticCounts` verbatim from the snapshot instead of recomputing it from the migrated (stale-stripped) entries — a resumed record could report `isBlocking() === true` on an entry while its own `diagnosticCounts.blocking` still said 0, inverting the one predicate every consumer trusts; counts are now derived the same way every other write site derives them. Forward-import resolution is now memoized per turn (keyed on path+mtime, reset on `RuntimeCoordinator.turnIndex` advancing, not process-lifetime), covering both the turn-end sweep and the `mode=all` gate that were independently re-parsing the same files. Each `blocker_freshness_sweep`/`blocker_freshness_widget_gate` latency record now names how many entries had their import list truncated at the drift-check cap. The footer/red-dot render no longer silently drops a demoted finding — it now renders with a dimmed marker and the stale-marker text instead of vanishing from the list `isBlocking` was also gating the render loop against. `STALE_LINE_MARKER` moved out of the turn orchestrator into its own leaf module (`clients/stale-marker.ts`) after round 2 found the low-level widget store importing it from `runtime-turn.ts` introduced a new module cycle. The `retired` count in `BlockerFreshnessCounts`, always zero because the gate is architecturally demote-not-drop, is removed rather than kept as dead API surface (and the two remaining doc sites that still promised it are corrected); the module docs name the dynamic `import()`/`require()` resolution boundary as an honest gap.
  - `clients/blocker-freshness.ts` — `MTIME_DRIFT_TOLERANCE_MS = 50` (exported), `sources`-based LSP-only demotion gate, per-turn import-resolution memo, `ForwardImportMtimes.truncated`, `BlockerFreshnessCounts.truncatedImports`, `retired` removed.
  - `clients/widget-state.ts` — `reconcileStaleWidgetDependencyBlockers` narrowed to `tool === "lsp"` and returns `{ demoted, truncatedImports }`; `reconcileStaleWidgetFiles`'s own two tolerance checks widened to the shared constant; `migrateEntryStamps` strips `stale` on import; `importWidgetState` recomputes `diagnosticCounts` from the migrated entries instead of restoring the snapshot's counts verbatim; `WIDGET_STATE_VERSION` bumped to 3; `renderWidget`'s blocker-detail loop renders demoted entries instead of dropping them; `STALE_LINE_MARKER` now imported from `clients/stale-marker.ts`.
  - `clients/stale-marker.ts` (new) — `STALE_LINE_MARKER`, moved out of `runtime-turn.ts` to break a widget-state → runtime-turn module cycle (`madge --circular clients/widget-state.ts`: 45 → 44 cycles, the one involving `runtime-turn.ts` gone).
  - `clients/runtime-turn.ts` — imports `STALE_LINE_MARKER` from the new leaf module instead of defining it (resolving the merge conflict with master's #1627); `retired` dropped from, `truncatedImports` added to, the turn-end latency record.
  - `tools/lens-diagnostics.ts` — imports `STALE_LINE_MARKER` from the leaf module instead of a hardcoded literal; passes the runtime's `turnIndex` into the `mode=all` gate and logs `truncatedImports`.
  - `AGENTS.md`, `.changelog/fix-1631-blocker-freshness-gate.md` — `retired` replaced with `truncatedImports` in both doc sites that still promised the removed count.

- **Every agent-facing finding surface now either routes through the shared freshness/disposition gate stack or explicitly states its cache age — no more silent third state (refs [#1634](https://github.com/apmantza/pi-lens/issues/1634))** — #1461/#1561/#1622/#1631 each converged the SAME freshness fix onto one store at a time (secrets, govulncheck, cached cross-file blockers), but nothing enumerated every surface that renders findings to the agent, so a new store could still bypass both the gate and any honesty about staleness. The trivy CRITICAL-dependency-CVE blocker, non-critical CVE advisory, and license-risk advisory in `clients/runtime-turn.ts` were exactly that gap: package-pinned findings with no cited file:line to freshness-gate, reading a session_start cache that can be arbitrarily old, rendered with zero age signal — a 🔴 STOP blocker from hours ago presented as current. `clients/finding-delivery-gate.ts` is the new single documentation home: a `DELIVERY_SURFACES` registry enumerates every render seam (turn-end blocker/advisory tiers, `lens_diagnostics` mode=full/all, the widget footer, agent nudges, the persisted project-diagnostics snapshot) as either `gated` (names the real gate call) or `labeled` (names a reason and an age source), and `assertNoDeliveryBypass()` rejects any third shape. The three trivy reports now carry a `formatCacheAgeLabel()` suffix (`"trivy, scanned 45m ago"`, degrading to `"scan age unknown"` on a missing/unparseable timestamp rather than fabricating a number) instead of no signal at all. `tests/clients/finding-delivery-gate.test.ts` ground-truths every registered surface's declared gate/label call against its actual source file, so a gate call removed from source without updating the registry — or a labeled surface silently losing its age string — fails the suite.

- **markdownlint and 14 other CLI runners now log recovery when the install-fallback helpers fix a failed probe (closes #1636)** — `resolveToolCommandWithInstallFallback` and `resolveCommandArgsWithInstallFallback`, the seam behind actionlint, biome-check, hadolint, htmlhint, ktlint, markdownlint, mypy, oxlint, rubocop, sqlfluff, stylelint, swiftlint, taplo, vale, and yamllint, recovered a tool through the pi-lens installer after a checker had already latched it `unavailable`, but wrote no compensating row. The durable log kept saying markdownlint's lane was off while it ran and succeeded (live evidence from a dogfood session). This is the third site of the #1606 defect shape: #1610 fixed `ensureViaInstaller`, #1612/#1615 fixed `resolveAvailableOrInstall`. This fix reuses the exact `emitCompensatingAvailableRow` constructor #1615 landed rather than forking a third copy, so both seams share the once-per-correction memo and the same evidence derivation. Carried over from #1615's review: `resolved` now distinguishes `"cache"` (a session/probe cache hit), `"path"` (a plain PATH or managed-dir discovery, no install involved), and `"declined"` (policy refused the install and handed back whatever discovery found anyway) — collapsing all three into `"cache"` misread a project-trust decline as a resolved-and-trusted answer. `getInstallAttempt` is now a hard import (a removed export throws instead of silently reading as "not attempted").
  - `clients/dispatch/runners/utils/runner-helpers.ts` — `emitCompensatingAvailableRow` (shared row constructor), `resolvedTagForAttempt`, `verifyOrInstallCommand` wiring.
  - `clients/dispatch/runners/utils/availability-policy.ts` — `ProbeEvidence.resolved` widened to `"cache" | "path" | "declined"`.
  - `clients/installer/index.ts` — `getLastEnsureResolutionSource`, the new signal that lets the compensating row tell a plain PATH/managed-dir discovery apart from a cache hit.

- **`createVenvFinder` now checks the managed tools dir before falling back to PATH (closes #1638)** — the resolver behind `createAvailabilityChecker` (golangci-lint, ruff, shellcheck, pyright, shfmt, spotbugs, terragrunt, tflint, trivy, helm, jscpd, knip, ktfmt, madge, and others) checked venv paths, then fell straight to a bare-name PATH probe. A tool installed only into `~/.pi-lens/tools/node_modules/.bin/<tool>` (where `ensureTool` puts npm-strategy installs) never resolved there, so every dispatch re-probed PATH, missed, spawned a doomed `--version` process, and only then fell through to the installer's own cache a few lines later — one wasted spawn per dispatch, for the life of the session, on top of the availability noise that made #1615's compensating row re-fire on every call. `createVenvFinder` now checks `findManagedNodeToolBinary` — the same managed-dir lookup already used elsewhere in this file — between the venv paths and the bare PATH fallback.
  - `clients/dispatch/runners/utils/runner-helpers.ts` — `createVenvFinder`.

- **TypeScript diagnostic-sequence records now report a real settle duration, a real version, and a distinct source for every settle shape (closes #1639)** — `lsp_typescript_diagnostic_sequence` had the same three bugs on BOTH its producers. The push-path producer, `logSequence` (the `textDocument/publishDiagnostics` handler), is the one that actually generated the issue's cited evidence: it put time-since-`didOpen` in the top-level `durationMs` field on every settled record instead of the settle operation's own time (147/239 records showed >60s "durations" for settles that took milliseconds), hardcoded `version: docVersion ?? null`, and logged its raw per-publication receipt with no `settleSource` at all — the exact "unsettled-then-settled" pair (~61ms apart, near-identical document age) the issue described as a double-emit is this function's own designed shape (a receipt record immediately followed by its eventual settle record), not the pull path. The pull-path producer, `logTypeScriptPullSettle`, had the identical durationMs/version bugs and a real (if rarer) double-settle: `ensureWarmForSweep`'s warm-up-only readiness probe and a sweep's real content-collecting touch both run a genuine pull round trip for the same file — both fixed here too. `durationMs` now measures the settle itself (the debounce wait for a `quiet-window` settle, 0 for an immediate `first-push` settle or a `pull`/`pull-warmup` request's own round trip); document age keeps its own honest name, `metadata.elapsedSinceDidOpenMs`. `version` never reports `null`: push settles report the real server-reported version or `"push-unversioned"`; pull settles report the real tracked value from `diagnosticsVersionsByPath` or `"pull-unversioned"`. Every settle shape now carries an explicit `settleSource` — `"first-push"`, `"quiet-window"`, `"publication"` (the raw per-publication receipt, not itself a settle), `"pull"`, or `"pull-warmup"` — so no two legitimate observations read as indistinguishable duplicates.
  - `clients/lsp/client.ts` — `logSequence` (push path) and `logTypeScriptPullSettle` (pull path) both take the settle's own `durationMs` and an explicit `settleSource`; the push handler's per-publication receipt is now tagged `"publication"` instead of logging with no source at all; both fall back to an honest `-unversioned` marker instead of `null`.
  - `clients/lsp/index.ts` — `touchFile`'s per-server wait tags `pullSettleSource: "pull-warmup"` when the touch's `source` is `"lsp_sweep_warmup"`.
  - Checked `scripts/analyze-pi-lens-logs.mjs` for consumers of the old `durationMs`/`version: null` semantics on this phase: none exist today, so nothing else needed updating.

- **TypeScript diagnostics on files outside every tsconfig no longer block** — a file that matches no project's `include` is still checked by tsserver, but in a synthetic *inferred* project with default compiler options the project's own `tsc --noEmit` never uses. Those diagnostics now render as warnings labelled `not in any tsconfig project — checked with inferred settings; add <dir>/** to a tsconfig for authoritative checking` instead of unlabelled 🔴 blockers. They are demoted, never suppressed: the live report that prompted this found the batch was a mix of phantom errors and genuine ones the project's own gate cannot see. Detection asks tsserver directly (`projectInfo` → `configFileName`); when the probe cannot answer, nothing is demoted.

- **Diagnostics no longer cite lines past the file's current EOF (refs #1641)** — a live session got errors at lines 407-410 of a file that was 402 lines on disk and unmodified: the LSP's in-memory document had drifted longer than disk with no write ever happening, so the mtime-based freshness gates (#1622/#1627) had nothing to catch. The fix is a structural, orthogonal check: a diagnostic whose cited line exceeds the file's CURRENT on-disk line count cannot be describing current content, regardless of any timestamp. The gate demotes the entry (`stale: true`), never drops it — the underlying issue may still be real, just at a coordinate this gate can no longer vouch for — and writes one bounded `diagnostic_past_eof` record naming the file, cited line, actual line count, and serving surface. `lens_diagnostics` (mode=all and mode=full, via `formatAllMode` — the single point both converge through) triggers a best-effort document resync on a demotion, so the desync heals instead of re-serving the same stale verdict on the next call; the TUI widget's higher-frequency render path demotes without resyncing, to avoid storming the LSP with didOpen calls from a loop that fires far more often than an agent's edit cadence. A new `lsp_document_send` record logs each didOpen/didChange's document version and content length at SEND time, so a later past-EOF citation can be paired with the send that produced it and the divergence moment reconstructed. The line-count check counts newlines in bounded chunks (never a full-file read) and memoizes the result in a shared, mtime-invalidated, size-bounded cache — see the review-round fragment below for the full cost story.
  - `clients/diagnostic-line-freshness.ts` — new: `demotePastEofDiagnostics` (the gate), `getCachedLineCount`/`createLineCountCache` (mtime-invalidated line count), `resyncDocumentOnPastEof` (best-effort didOpen resync via `LSPService.openFile`).
  - `clients/widget-state.ts` — `WidgetDiagnostic.stale`; `isBlocking`/`countDiagnostics` exclude demoted entries from the blocking/error/warning tallies; the gate runs in `getFileDiagnosticSummaries`, `getFileDiagnostics`, and the TUI's blocking-diagnostics render loop.
  - `tools/lens-diagnostics.ts` — `formatAllMode` gates the final merged summary set (covering both the cached mode=all path and mode=full's freshly-merged LSP results, since a "fresh" sweep's server can still be citing its own stale in-memory document); demoted lines render `PAST_EOF_STALE_MARKER` instead of a line number, and a file whose only findings are demoted stays visible with a "stale — re-run to confirm" note rather than reporting clean.
  - `clients/lsp/client.ts` — `recordSentContent` now logs `lsp_document_send` (version + content length) on every didOpen/didChange.

  Turn-end's "unresolved from this turn" inline blockers are NOT covered — that store only carries a pre-formatted summary string (`{ filePath, summary }`, `runtime-coordinator.ts`), with no structured line field for this gate to check, and #1633's dependency-freshness gate is already the active work on that exact structure. Composing a line-count check into it belongs with that PR, not duplicated here.

- **Past-EOF diagnostic gate review round (refs #1641)** — six findings from adversarial review. The line-count convention was `wc -l` style, but `WidgetDiagnostic.line` is LSP `range.start.line + 1` addressing: a document with N newlines has N+1 addressable lines (the position after a trailing `\n`, or the single line of an empty document, is real, and servers anchor EOF diagnostics there — e.g. TS1005 `'}' expected`). A 2-real-line file with a trailing newline was demoting its OWN valid EOF error at line 3. Fixed the count to `newlineCount + 1` uniformly. The line-count cache was scoped to one render pass and re-created every call, which deduped nothing (each file appears once per pass) — every serving call paid a full synchronous file read, measured at 35.68ms/call across 40 files and 30.78ms per TUI frame on a 6MB file. Replaced the full read with a bounded chunked byte scan (raw file descriptor, no UTF-8 decode, no whole-file string) behind a 4MB size gate (matching `captureReadContentBinding`'s precedent), and made the cache shared and FIFO-bounded across calls instead of single-use — it still cannot go stale, because every read re-stats the file first and only trusts the memo when the mtime still matches. The store also used to latch: once a diagnostic was marked `stale`, the gate never re-checked it, so a transient shrink (a formatter pass, a checkout, a partial write) that later restored stayed permanently demoted. The gate now re-derives the verdict from current inputs on every call — the rising edge logs and resyncs, the healing direction is silent, and a restored line un-demotes on its own next read. The `lsp_document_send` telemetry record's line count used `content.split("\n")`, which both disagreed by one with the gate's pre-fix convention and allocated one substring per line (13.3ms/200k allocations on a 7MB send); it now counts newlines directly, matching the gate exactly. The gate now runs AFTER dispositions and rule policy in `lens_diagnostics`, so a policy-dropped or marked-suppressed finding never triggers a resync or a telemetry record for something that isn't actually being served. `PAST_EOF_STALE_MARKER` now composes with the shared `STALE_LINE_MARKER` base (extracted out of `runtime-turn.ts` into `clients/stale-marker.ts`) instead of duplicating the string.
  - `clients/diagnostic-line-freshness.ts` — `newlineCount + 1` line convention; chunked byte-level newline scan (`countNewlinesChunked`) replacing the full-file read; 4MB size gate; shared, FIFO-bounded, mtime-invalidated cache (`sharedLineCountCache`) as the default, with `createLineCountCache()` still available for test isolation; `demotePastEofDiagnostics` re-derives `stale` on every call instead of short-circuiting on an already-demoted entry, logging/resyncing only on the rising edge.
  - `clients/lsp/client.ts` — `recordSentContent`'s `contentLineCount` counts newlines directly instead of `content.split("\n")`.
  - `clients/widget-state.ts` — `applyPastEofGate` always persists the freshly re-derived array back into the record (not only when something newly demoted), so a healed entry's `stale: false` actually reaches the store.
  - `tools/lens-diagnostics.ts` — the past-EOF gate moved to run after `applyWeakDispositions`/`applyRulePolicy`.
  - `clients/stale-marker.ts` — new: `STALE_LINE_MARKER`, extracted from `runtime-turn.ts`.

- **Verify round: line-count memo keyed on size too (refs #1641)** — moving the line-count cache to module scope (the review round's F2 fix) introduced a HIGH finding: the memo was keyed on `mtimeMs` alone, and this host's mtime resolution (~1ms) is coarse enough that two writes in the same tick collide — truncate-then-write, a formatter write-back, a checkout, or pi-lens's own auto-format immediately followed by the agent's write. Measured live at 207/300 shrink/restore cycles serving the wrong line count, including a first-read-of-cycle returning a stale count for an 11-line file — a false-demotion source in the same class as the review round's own F1 line-convention bug. `LineCountCacheEntry` now also stores `size`, and a cache hit requires both `mtimeMs` and `size` to match; a shrink/restore cycle always changes size, so this closes the collision with no added I/O (size is already on the same `fs.Stats` the mtime came from).
  - `clients/diagnostic-line-freshness.ts` — `LineCountCacheEntry.size`; `getCachedLineCount` compares both fields for a hit.

- **Turn-end inline blockers now gate on past-EOF lines too (closes #1641)** — the "Unresolved from this turn" blocker re-served at `turn_end` (`RuntimeCoordinator`'s `_pendingInlineBlockers`) could still cite a line beyond the file's current on-disk line count, re-asserted at full blocking authority. #1664 wired the past-EOF gate into every `WidgetDiagnostic` surface (widget-state, `lens_diagnostics`, the TUI render loop) but deferred this one store to #1633, which merged without it. The record now carries the cited lines structurally (captured at write time from the same diagnostics the summary text was rendered from, not re-parsed from that prose later), and a new turn-end sweep (`clients/blocker-past-eof.ts`) demotes a record citing an out-of-bounds line to the `[stale — re-run to confirm]` advisory instead of the authoritative blocker channel — the same demote-not-drop precedent, re-derived fresh every turn end so a transient shrink-then-restore un-demotes on its own. It composes with #1631's dependency-drift gate on the same store via a shared `staleReason` field: each gate only heals demotions it made itself.

- **A gitignored worktree edit could dirty the parent checkout (refs #1642)** — `tool_result` resolved a relative path against the project root instead of the cwd/worktree the call actually ran under, so a worktree edit could collapse onto a same-relative-path file in the parent checkout, which the deferred-format staleness fallback then formatted. `tool_call` now records the resolution basis (its own cwd) by tool-call identity (correlated via `toolCallId`/`callId`/`requestId`/`id`, whichever the host populates); `tool_result` resolves its own authoritative post-execution path against that basis instead of guessing. A relative path with no recorded basis fails closed (logged as `path_attribution_missing`) rather than falling back to a guess; a genuine call-time/execution-time divergence is logged (`path_attribution_refused`) as a diagnostic without gating the outcome. A blocked call's recorded basis is cleared immediately so it never leaks into the correlation cache. The deferred-format staleness fallback now also requires the queued record's true origin cwd (distinct from the workspace-root `turnStateCwd` used for bookkeeping) to match the claiming session's before treating an aged-out record as a recoverable orphan; a mismatched-origin record is left queued and logged, never formatted or deleted.

- **The #1605 lifecycle-smoke lane's phase 5 now genuinely exercises an absent `gh`, and `safeSpawnAsync` decides a never-started spawn from the `close` event's own shape (refs #1651)** — phase 5 pinned outcome `"missing"` for a genuinely-absent `gh` binary, and passed on Windows but got `"non-installable"` on Linux CI. A debug pass added a raw `spawnSync("gh", ["auth", "token"])` probe right where the lane's own removal happens, and it answered `status: 1, stderr: "no oauth token found for github.com"` — a genuine, correctly-completed `gh` run. GitHub-hosted `ubuntu-latest` runners carry a *second* `gh` on PATH that `which gh` doesn't report first; the lane's PATH exclusion only stripped the one `which` did report, leaving the other one reachable the moment the fixture was deleted. Phase 5 was never testing a missing binary on Linux at all — it was asking the classifier to explain a real rejected-auth answer while asserting the missing-tool verdict. `classifyGhTokenFailure` was correct both times; the harness's setup was not. `findAllOnPath` (`which -a` on POSIX; Windows' `where` already lists every match) now finds every `gh` on PATH, and `pathWithGhMasked` swaps in a shadow directory — every OTHER file in that directory linked, `gh`/`gh.exe`/`gh.cmd` omitted — instead of excluding the whole directory: an earlier version of this fix excluded the whole directory and broke phases 1/2/4 instead, because GitHub-hosted `ubuntu-latest` keeps `getconf` in the same `/usr/bin` the second `gh` lives in, and pi-lens's own resource sampler shells out to it. Separately, this investigation also surfaced (and fixed) a genuine, narrower `safeSpawnAsync` hazard: Node's docs leave the `error`/`close` event order unspecified for a child that never launched, and the real shape for that case is a negative code (`-2` for ENOENT on Linux, `-4058` on Windows), with `error` sometimes landing a full tick later via `setImmediate`/`process.nextTick`. The `close` handler now decides straight from `code === null || code < 0` — a shape no completed process ever produces — independent of whether or when `error` fires, reusing `error`'s Error when it's already available and synthesizing an ENOENT-shaped one otherwise, with a single `resolved` flag so whichever handler decides first wins outright (including the inverse: a healthy `close(0, null)` is never downgraded by a late, unrelated `error`, e.g. a post-exit `kill()` failing with `EPERM`). `classifyGhTokenFailure` also gets a belt for the same reason: it no longer trusts a bare `!res.error` as proof `gh` ran and answered — a `null`/negative `status` alone (OS-independent, read off Node's own report) now routes through the missing/transient handling regardless of whether `error` happened to be attached. That spawn-race fix did not turn out to be what made phase 5 fail (the harness bug did), but it closes a real correctness gap this investigation surfaced.
  - `scripts/smoke-availability-lifecycle.mjs` — `findAllOnPath`, `pathWithGhMasked`, `maskedGhDir`, `shadowFile` (replace the single-match `findOnPath`/`pathWithoutDirOf`).
  - `clients/safe-spawn.ts` — the `close`/`error` handlers inside `safeSpawnAsync`.
  - `clients/zizmor-config.ts` — `classifyGhTokenFailure`'s `neverAnswered` guard.
  - `tests/clients/safe-spawn-close-before-error-race.test.ts` — regression tests for the `close(-2, null)`/`close(null, null)` shapes (with and without a delayed or absent `error`) and the healthy-run non-inversion case.

- **Package-manager availability latches re-arm at `session_start` (closes #1653)** — `resolveNodePackageManager` keeps one `AvailabilityLatch` per pnpm/yarn/bun/npm in a module-local map, so a genuine "missing" verdict from one session stayed latched into the next: install pnpm mid-day, start a fresh session, pi-lens still reported it missing until a process restart. Same module-local shape as psscriptanalyzer's latches (#1490) and zizmor's `gh auth token` cache (#1535) — `resetDispatchAvailabilityState`'s generation counter never reached it because nothing called the module's own reset hook. `handleSessionStart` now calls `_resetPackageManagerCache()` in its per-session reset block, beside `resetZizmorTokenAvailability()` and `resetPsScriptAnalyzerAvailability()`.
  - `clients/runtime-session.ts` — `handleSessionStart`'s per-session reset block.
  - `clients/package-manager.ts` — `_resetPackageManagerCache`'s doc comment now records it as production wiring, not just a test hook.
  - Review round: making the reset a real production path exposed a latent race in `isAvailable`'s in-flight probe map — its `.finally` deleted the map entry by key rather than by identity, so a pre-reset probe that settled after a new session's own probe for the same manager was already in flight could evict that newer entry and cause a duplicate spawn. Fixed with the same identity guard `resolveMadge` in `dependency-checker.ts` already uses for the equivalent race.

- **Deferred format/autofix drain no longer runs mid-run on auto-retry or overflow-compaction (refs #1654)** — pi's `agent_end` fires on every completion of `_runAgentPrompt`, including a run about to auto-retry or resume after overflow-compaction; pi computes `willRetry` only after emitting `agent_end` and never exposes it to extensions. Running the #1387 deferred-format/autofix drain there could format files the agent was still actively working on between retries, shifting lines under queued work and staling in-flight content bindings (the #1642 harm family). The drain now runs at `agent_settled` — pi's documented once-per-run signal ("no automatic retry, compaction, or queued continuation will run") — with a best-effort, time-boxed safety net at `session_shutdown` so a run that ends without ever settling does not strand queued work forever.

- **Hardened pi-lens against five pi host-contract facts it mishandled (refs #1655)** — pi's `emitToolCall` is the one extension emit path with no per-handler `try`/`catch`, and its caller rethrows as "Extension failed, blocking execution", so any unguarded throw inside pi-lens's `tool_call` handler blocked the user's tool call outright; the handler now absorbs every throw, records one `tool-call-handler-throw` degradation per tool name, and lets the tool proceed. Telemetry identity no longer comes from `tool_result`/`session_start` event fields that pi never sets — it is read from `ctx.model`, so the reported model and provider stop being `"unknown"` for the whole session, and the seven vacuous fields plus the unreachable branch they fed are gone. A tool_call's edit batch width is snapshotted at handler entry instead of re-read from the live, mutable `input` object hundreds of lines later. pi-lens now resolves a tool path the way pi does, in both of pi's stages: the base resolution (unicode-space folding, `@` mention-prefix stripping, `~` expansion, `file://` conversion, Git-Bash drive paths) and then the fallback ladder (narrow no-break space before AM/PM, NFD, U+2019, NFD plus curly quote). A path pi opens — a macOS screenshot name, an `@`-prefixed mention, a `file://` URL, a non-breaking space — is no longer invisible to the read guard, the LSP warm, and the dispatch, and a path that still does not resolve records a `path-variant-unresolved` degradation instead of returning in silence. A blocked tool call also stays blocked when its own bookkeeping fails: the read guard's refusal is no longer discarded by a throw in the telemetry that follows it. The invariants behind the tool cwd basis and bash-side path attribution are pinned by tests against pi's own build.

- **`safeSpawnAsync`'s post-exit pipe-idle wait no longer pays out its full 100ms grace window on every call (refs #1656)** — the wait now finishes as soon as Node's `close` event fires (the stronger, already-available signal that stdio is fully released), instead of always idling out the timer. A normal spawn, where nothing holds its pipes open, now settles in a few ms again; a daemonized descendant that never emits `close` is unaffected and still bounded by the existing grace/cap timers.

- **`safeSpawnAsync`'s post-exit pipe-idle wait no longer lets a late, unrelated error steal an already-decided healthy verdict (refs #1656)** — the wait added to fix the daemonized-descendant hang left the outcome undecided across the whole (bounded, up to 2s) idle-grace window, widening a race a prior fix had closed down to a single microtask: a post-exit `kill()` failing with EPERM 10ms after a clean `close(0, null)` could flip a successful run to a spawn failure. The verdict is now latched immediately once the exit/close event decides it, before the idle wait runs, so a late `error` can never downgrade it.

- **`safeSpawnAsync` no longer hangs forever on a daemonized descendant that inherits its stdout/stderr pipe (refs #1656)** — the async spawn path waited for Node's `close` event, which only fires once every file descriptor referencing the child's stdio has been released. A Windows-orphaned grandchild (no job object) can hold that inherited pipe open indefinitely, so `close` never fires even though the process we spawned is long dead — the caller hangs, and a hung prober silently loses its result rather than failing loudly. Adopting pi's `waitForChildProcess` construction: finalize off `exit` instead, then wait for stdout/stderr to fall idle (no data for 100ms, re-armed on every chunk, capped at 2s so a pathological never-quiet descendant can't extend the caller's overall budget). A quiet inherited handle now releases after one grace window; an actively-streaming child is still captured in full.

- **A compensating `available` row that corrected nothing no longer silences the real one** — the once-per-correction memo now burns only when a latched `unavailable` row actually stood before it. Runners that reach the install seam with no probe of their own (biome-check, oxlint) emitted a row that pre-empted the next genuine latch-then-recover for the same tool and directory, leaving the durable log saying the tool was off while it ran. Managed-tool resolution also runs the installer's own `verifyToolBinary` check instead of a bare `existsSync`, so a broken shim no longer shadows a working PATH binary; a settled verdict is memoized per shim per session, a verification that never got to run keeps the optimistic answer under a bounded cooldown, and concurrent first touches share one probe (#1657).

- **Pull diagnostics no longer miss whole categories on multi-source servers** (#1667) — a server that registers `textDocument/diagnostic` once per diagnostic source (Roslyn: syntax, semantic, analyzers; vtsls) had every `registerOptions.identifier` discarded, so pi-lens issued one bare pull and never asked for the other sources. The client now pulls every registered source in parallel plus the bare request, answers on the first source with findings for the file, and merges slower sources into the cache in the background. Result-id inheritance (`kind: "unchanged"`) is tracked per source, a source retired by `client/unregisterCapability` no longer leaves its findings in the cache, and workspace-pull support is read from the registration's `workspaceDiagnostics` flag instead of guessed from the method name.

- **LSP servers now learn about files a bash command deleted, even though pi-lens never sends `workspace/didChangeWatchedFiles` type 3 anywhere (refs #1668)** — the only enqueue site for that notification (`handleNotifyOpen`, via the #271 `WatchedFilesQueue`) fires on a document's first open and can only emit type 1 (Created) or 2 (Changed). A turn that ran `rm`/`git rm`/`mv` on a file with no notification at all left gopls' package cache, rust-analyzer's vfs, and clangd's index stale, with diagnostics for the deleted module persisting forever. `bash-file-access.ts` gained `extractDeletedPathsFromCommand`, which proposes the explicit targets of `rm`, `git rm`, and the source side of `mv` — never a directory sweep or a `git clean`, since those don't name a file to confirm. `ReadGuard` gained `hasKnownPath`/`forgetPath` so the check only fires for a path pi-lens already has a read or write record for (never an unrelated `rm`), and only after confirming the path is actually gone from disk. A confirmed delete now reaches every already-active LSP client for that file's servers via `LSPService.notifyExternalFileChange`, routed through the same #271 debounced queue a burst of deletes still coalesces into one notification per server — this never spawns a server just to tell it about a delete. Bash-created/modified files that were never opened already got their type-1/2 notification through the existing first-open queue path once the dispatch pipeline's LSP runner touches them; that leg needed no new code, only regression coverage. A git-status delta at turn boundaries (to catch changes outside pi-lens' own write/edit/bash surface — external editors, other processes) was judged and scoped out of this PR as a separate, costlier mechanism. Re-opening an already-open document on a bash-authored change (type 2 on reopen) stays out of scope per the issue, pending a measured cost on gopls/rust-analyzer.
  - `clients/bash-file-access.ts` — `extractDeletedPathsFromCommand`.
  - `clients/read-guard.ts` — `ReadGuard.hasKnownPath`, `ReadGuard.forgetPath`.
  - `clients/lsp/client.ts` — `handleNotifyExternalChange`, `notify.watchedFileChange`.
  - `clients/lsp/index.ts` — `LSPService.notifyExternalFileChange`, `notifyExternalFileChange` cross-layer seam.
  - `clients/runtime-tool-result.ts` — bash delete detection wired into the tool_result handler.

- **LSP client answers `workspace/diagnostic/refresh` and honors Incremental text sync (refs #1669)** — a server telling us its diagnostics are stale (`workspace/diagnostic/refresh`) previously got `MethodNotFound`; the client now replies `null`, drops every on-disk workspace-diagnostics sweep cache the process has swept under (not just the wrong per-server root marker), clears the same per-document pull state a normal resync already clears for every open document, and proactively re-pulls each open document under pull mode. A `workspace/diagnostic` sweep's `unchanged` report against a resultId basis the client no longer holds now falls back to a real per-file pull instead of silently reporting the file clean. Separately, the client always sent whole-document `didChange` events regardless of the server's negotiated `textDocumentSync.change` kind; an Incremental-only server now receives a single ranged edit spanning its entire previous document (counting CRLF/lone-CR line endings correctly), sent only once the notification is confirmed to have actually reached the transport. `Full`/`None` servers are unaffected.

- **Monorepo Cargo and Maven projects no longer spawn one heavy LSP server per crate or module (refs [#1671](https://github.com/apmantza/pi-lens/issues/1671))** — rust-analyzer's existing workspace hoist walked up to an ancestor `Cargo.toml`'s `[workspace]` table without ever checking the session ceiling, so a hit above the session cwd could pick a root outside the project boundary; the walk is now bounded the same way `enforceLspRootCeiling` already bounds crate roots. jdtls had no hoisting at all: every Maven module spawned its own server even inside a declared parent build. It now walks up through a chain of `pom.xml` files, hoisting to a parent only when that parent's `<modules>` actually declares the child as a member — an undeclared sibling directory stays independently rooted, and the walk still respects the session ceiling. `NearestRoot`'s doc comment now says plainly that `excludePatterns` skip a directory and keep walking, rather than aborting resolution.

- **Path-attribution follow-ups: bounded orphan degradation, documented id-fallback assumption, pinned reclaim test (refs #1678, #1642, #1648)** — A deferred-format record abandoned by its origin worktree used to log a full raw event on every subsequent `agent_end` forever. The raw, detailed event (file identity, origin, age) now fires only on the RISING edge — the first time a given record is observed as an orphan — and every later repeat of the same record is counted only by `incrementDegradationCount`, so the ledger holds one bounded entry with a running count instead of an ever-growing stream of log lines. `resolveToolCallCorrelationId`'s widest fallback (`event.id`) now documents the assumption it makes — a host that reuses one id per message, not per call, would cross two parallel tool calls in a turn — and notes that real pi's own tool events carry no top-level `id` field at all, making this rung dead code against today's host. Added regression tests pinning the rising-edge gating (exactly one raw event across 3 agent_ends on the same orphan) and the already-correct "mismatch-flush leaves queued, later match-flush reclaims and formats" reclaim path.

- **Bound the playground rule verifier's child-process wait (closes #1679)** — `scripts/playground-verify-rule.mjs` spawned its CDP/Chrome helper scripts and waited on the child's `close` event with no timeout. A wedged or daemonized child hung the script forever. It now routes through the shared `safeSpawnAsync` (bounded timeout, tree-kill), matching the rest of the codebase's spawn discipline.

- **The secrets and govulncheck gates' disposition filters are now proven, not just present (closes #1694)** — `filterFindingsByDisposition` was applied to both the live and stale arms of the gitleaks/trivy-secrets freshness gate since #1625/#1628, and to govulncheck's combined live+stale array, but none of those three had a regression test proving it. Mutating `trivySecretsStaleFiltered.kept`/`gitleaksStaleFiltered.kept` back to the raw pre-filter `.stale` array, or `govFiltered.kept` back to the raw `[...govGate.live, ...govGate.stale]` array, left the whole suite green. New tests in `tests/clients/runtime-turn-secrets-disposition.test.ts` mark a finding false-positive in each of the three lanes and prove it stops reappearing (the gitleaks/trivy-secrets cases use a stale finding — a scan whose cited file was edited afterward, routed into the 🔑 ACTION NEEDED tier; the govulncheck case mirrors the review round's own mutant), each going red against its own lane's mutant independently. `docs/dispositions.md` now documents the dual-scanner double-mark: gitleaks and trivy anchor the same credential under different `tool`/`rule` identities, so clearing a corroborated finding takes two `lens_diagnostic_mark` calls — expected defense-in-depth, previously undocumented. `trivySecretsToProjectDiagnostics` (no consumer outside its own file) is no longer exported.

- **Give `findingPathFreshness` the same mtime-skew tolerance its sibling freshness gates carry (closes #1708)** — `findingPathFreshness` (`clients/advisory-provenance.ts`) compared a finding's cited-file mtime to the scan timestamp with zero tolerance. On Windows, a file's mtime can lead the immediately following `Date.now()` read by up to ~11.4ms, the same host skew `blocker-freshness.ts`'s `MTIME_DRIFT_TOLERANCE_MS` already covers. Without it, a file written and scanned within that window demoted a real secrets STOP blocker to an ACTION NEEDED tier, flaking `runtime-turn-secrets-disposition.test.ts`. `findingPathFreshness` now reuses the same `MTIME_DRIFT_TOLERANCE_MS` constant instead of a second hand-tuned number.

- **Give `reconcileProjectDiagnosticsSnapshot` the shared mtime-skew tolerance, not a bare +1ms (closes #1711)** — `reconcileProjectDiagnosticsSnapshot` (`clients/project-diagnostics/cache.ts`) compared a diagnostic's file mtime to the scan timestamp with only a +1ms slack. PR #1710 measured the real write-then-scan skew at up to ~11.4ms on Windows and gave `findingPathFreshness` and `isEntryFresh` the shared `MTIME_DRIFT_TOLERANCE_MS` (50ms) for it, but deferred this consumer. It is the worse sibling: its stale arm DROPS diagnostics outright rather than demoting them, so a same-tick write silently lost a finding from the persisted cache. It now reuses `MTIME_DRIFT_TOLERANCE_MS` from `blocker-freshness.ts`, and the stale-boundary doc comment at `clients/advisory-provenance.ts:264` (and two other spots that had drifted the same way) now cite the tolerance instead of a bare `mtime > scannedAt`.

- **Pull-diagnostic timeouts are observable again (refs [#1713](https://github.com/apmantza/pi-lens/issues/1713))** — the per-request `withTimeout` around `textDocument/diagnostic` and `workspace/diagnostic` pulls rejected on timeout, and the rejection skipped every settle emit, so a timed-out pull left no trace beyond a 10-entry `pullFailureHistory` ring buffer. `pullDiagnosticSource` and `clientRequestWorkspaceDiagnostics` now write an `lsp_pull_diagnostic_timeout` latency.log record on every timeout (file, identifier, effective budget, whether a `previousResultId` was echoed, elapsed), and attach a telemetry-only continuation to the abandoned request promise. If the server answers anyway after the caller gave up, that continuation records `lsp_pull_late_answer_discarded` and increments the `lsp-pull-late-answer` degradation-ledger kind (subject = the same file+identifier key the pull path already uses), so a chatty late-answering server's discards are counted, not spammed, and the identity survives aggregation. A late REJECTION is not an "answer" and is not recorded. No behavior change: the timeout path still abandons at budget exactly as before; the instrument only observes. Per-pull overhead is one extra `Date.now()` call (~60ns) on the success path — everything else runs only inside the timeout branch.

- **Full-scan sweeps no longer out-run an auxiliary scanner (refs #1714)** — A
  `lens_diagnostics mode=full` sweep used to hand a single-threaded scanner more
  documents than it could read, which stalled ast-grep and forced a kill. Each
  auxiliary now holds a bounded number of unacknowledged `didOpen` notifies; past
  that, the next notify waits for a request round-trip proving the server drained
  its input. A file the throttle holds back is reported as uncovered rather than
  dropped, so it stays in the sweep's coverage gap. Tunable per server class via
  `LSPServerInfo.notifyInflightLimit` (default 8, ast-grep 4) and globally via
  `PI_LENS_LSP_AUX_NOTIFY_INFLIGHT`.

- **`navRequest` timeouts are observable again (refs [#1716](https://github.com/apmantza/pi-lens/issues/1716))** — the per-request `withTimeout` around every hover/definition/references/signatureHelp/documentSymbol/workspace-symbol/call-hierarchy request rejected on timeout, and the rejection skipped every settle emit, so a timed-out nav request left no trace at all. `navRequest` is the highest-volume LSP call site (one call per turn, often several), so unlike the `lsp_pull_diagnostic_timeout` fix ([#1713](https://github.com/apmantza/pi-lens/issues/1713)), which logs every timeout, this fix bounds the detailed record to the rising edge — the first timeout, and separately the first late answer, per (method, file) each session — while the degradation-ledger counters (`lsp-nav-request-timeout`, `lsp-nav-late-answer`) still count every occurrence exactly, so a storming server floods neither latency.log nor memory. No behavior change: the timeout path still resolves `undefined` at budget exactly as before; the instrument only observes. Per-request overhead is one extra `Date.now()` call (~125ns) on the success path.

- **The ast-grep self-scan now actually runs (closes [#1718](https://github.com/apmantza/pi-lens/issues/1718))** — `scripts/run-astgrep-pi-lens.mjs` hardcoded a single author's now-nonexistent machine paths (`C:/Users/R3LiC/Desktop/pi-lens[-rules2]`) as both the scan target and the rules source, so the dogfooded #1158 defect-shape rules (`no-raw-json-store-write`, `no-win32-isabsolute-for-qualification`, `no-bare-host-path-in-win32-branch`, now tagged `metadata.category: pi-lens-self-scan` in their YAML) never scanned pi-lens's own tree — not in CI, not on any contributor's machine. It printed an error internally but exited 0, so the failure was invisible. The script now derives its repo root from its own file location and reads the rule set from the shipped `rules/ast-grep-rules/.sgconfig.yml`, filtered to the tagged subset (scripts/lib/astgrep-self-scan.mjs). It runs in the `test` CI job (`npm run astgrep:self-scan`, ~3.5s over 1074 files in `clients/` + `tests/` — cheap enough for every PR) and fails on any finding outside the committed `rules/ast-grep-rules/self-scan-baseline.json` baseline, regenerable via `npm run astgrep:self-scan:update-baseline` after triage. Triage on this pass: production code (`clients/`) is clean; `no-raw-json-store-write` fired 200× in `tests/` on throwaway test-fixture JSON writes — not durable production state, so `tests/**` joins the rule's existing `ignores:` (build scripts, the atomic-write seam itself) rather than being silently baselined. The baseline ships empty. `tests/scripts/astgrep-self-scan.test.ts` is the registered-or-fail guard: it asserts a nonzero scanned-file count (an empty result must read as errored, not clean — AGENTS.md defect-shape 10) and runs the real scan against a synthetic violation outside pi-lens's own tree, so a future regression that silently kills the scan again fails there instead of shipping unnoticed a second time.

- A cascade's neighbor re-check no longer notifies auxiliary scanners
  (ast-grep, opengrep, typos) for every changed neighbor. A neighbor's
  content did not change — only its import target did — so an auxiliary
  scanner's file-local verdict for it cannot have changed, and the cascade's
  own merge already discarded any aux re-derivation by construction. The
  touch now uses `clientScope: "primary"` (language server only), matching
  the sibling tier-aware touch. Measured on a representative slow-aux
  fixture: 0 aux notifies per neighbor (was 1 per configured auxiliary) and
  a ~850ms per-neighbor conclusive-latency drop (900ms to 50ms) when an aux
  scanner is slow to answer. (#1720)

- **Knip now runs the project's own knip (refs #1721)** — When a project
  installs knip, the dead-code scan spawns that copy instead of pi-lens's
  managed one. A pinned-stale managed knip previously reported unused exports
  the project's own `npx knip` did not, and acting on those flags deletes live
  code. A project that installs knip also no longer needs pi-lens's managed
  copy at all. Each scan records the binary, its version, and the config it ran
  with in `~/.pi-lens/sessionstart.log`.

- **`loop_block` now names a synchronous block still in progress** — every
  dispatch runner brackets its run with a start/finish marker
  (`clients/latency-logger.ts`), and an event-loop block attributes to
  whichever bracket — still running, or recently closed — overlaps the
  block's own time window the most, instead of only the previous, unrelated
  phase that had already finished (#1723).

- **`loop_block` now records every block over the floor, not only the session's biggest (refs [#1723](https://github.com/apmantza/pi-lens/issues/1723))** — `turn_end` gated the `latency.log` write itself on `shouldLogWorstBlock` (a new-session-maximum test), so any block that did not beat the running high-water was silently dropped. In a session where an early large block set the bar, every later block — including the exact ones that could starve an LSP pull (#1549/#1713) — went unrecorded, making the loop-block-vs-pull-timeout correlation undecidable. `turn_end` now gates on a new floor-only `shouldLogLoopBlock` (still ≥60ms) and keeps `shouldLogWorstBlock` only to compute the `worstSoFar` metadata flag and to decide whether to advance the session high-water. Volume stays bounded by call cadence, not by the gate: the check runs at most once per turn (the histogram window resets every turn), so the cap is one `loop_block` record per turn. Attribution also widens from a single `lastPhase` pointer to a bounded `recentPhases` ring (last 3 of a 5-entry cap) via `getRecentLoggedPhases`, because the phase actually causing a synchronous block is often still in flight — and so unlogged — when the block fires, leaving the single last-*completed*-phase pointer aimed at unrelated prior work. No change to block *detection* (`isSuspendSuspectedBlock`, the histogram, or the per-turn window) — only to what gets persisted.

- **A forced LSP shutdown could leave the dead pid registered in `instances.json` for the rest of the session (refs #1724)** — `recordLspChild` and `removeLspChild` each did their own unserialized read-modify-write of the registry file. A client-ceiling eviction's forced shutdown (`removeLspChild`) landing at roughly the same moment as the replacement client's spawn (`recordLspChild`) let the later write revert the earlier one: the replacement's write was built from a read taken before the removal landed, so it silently resurrected the just-deregistered pid alongside the new one. Dogfooding caught a 40-minute phantom this way — a force-shut `ast-grep` process stayed listed as alive across several heartbeats until session-end teardown finally cleared the whole entry. `recordLspChild` and `removeLspChild` now share one mutation queue, so a same-process record and remove can never interleave their read-modify-write against each other. `removeLspChild` also takes an optional spawn marker and refuses to drop a pid whose recorded marker no longer matches — defense against the pid being recycled onto an unrelated child in the (now much narrower) window before the deregistering write lands.

- **Sweep the remaining author-machine paths baked into scripts/ and package.json (closes [#1728](https://github.com/apmantza/pi-lens/issues/1728))** — the class sweep from #1718 found three more hardcoded `C:/Users/R3LiC/...` literals. `scripts/run-ts-rules-pi-lens.mjs` now derives its scan root from its own file location (`import.meta.url`) instead of a dead absolute path, so it actually scans the checkout it runs from. `scripts/run-all-ts-rules-posthog.mjs` scans an EXTERNAL checkout rather than pi-lens's own tree, so there is no repo-relative default to derive; it now takes the target via `--posthog-dir`/`POSTHOG_DIR` and exits with a usage message instead of silently no-oping when unset. `package.json`'s `harness:*-poc` scripts dropped their hardcoded `--pi-bin "C:\Users\R3LiC\..."` flag entirely — `scripts/run-harness.mjs`'s own `$APPDATA`-derived PATH resolution already finds `pi.cmd`/`pi` for any contributor, so the literal was both wrong for everyone else and redundant. `tests/scripts/no-hardcoded-machine-paths.test.ts` is the registered-or-fail guard: it greps every script under `scripts/` plus `package.json` for a `C:\Users\<name>` / `C:/Users/<name>` literal, with a reviewed allowlist (currently one entry, tracking `scripts/run-astgrep-pi-lens.mjs` until #1718's own fix in PR #1729 lands) that must itself still contain the literal it excuses or the test fails.

- **Managed tools now refresh instead of drifting stale (refs #1730)** —
  pi-lens installed each managed npm tool once and never re-resolved it, so the
  copy it ran could be many minor versions behind what its own recorded range
  permits. A managed knip 28 minors behind reported 62 unused exports on a tree
  the project's own knip reported clean, and acting on flags like those deletes
  live code. pi-lens now re-resolves one managed tool per session, at most once
  a week per tool, on a background timer that never blocks startup. Every
  refresh records the version it moved from and to in
  `~/.pi-lens/sessionstart.log`, and verifies the updated binary still runs
  before trusting it. A refresh that fails records one degradation and clears
  the cached tool path so the next probe re-checks the tree, rather than
  blocking the tool. Set `PI_LENS_DISABLE_TOOL_REFRESH=1` to turn it off.

- **taplo, biome, stylua, and vulture now prefer the project's own binary and config over pi-lens's managed copy (refs #1731)** — The #1721 sweep found
  the same defect shape in six more runners. taplo's project-local check was
  unreachable whenever pi-lens's managed shim answered first, and even its
  fallback missed an npm-installed taplo on Windows (wrong extension). biome's
  client path returned the first session's auto-installed binary for every
  later project, no matter what that project shipped. stylua had no
  project-local check at all — PATH only. vulture never looked at the
  project's own `.venv`. jscpd and vulture also always passed
  `--min-lines`/`--min-tokens`/`--exclude` (jscpd) and
  `--min-confidence`/`--exclude` (vulture), silently overriding a project's
  own `.jscpd.json`/`[tool.vulture]` thresholds. biome's `--config-path` did
  the same to a project's own `biome.json`, blocking its nested-config
  resolution in monorepos. sqlfluff now spawns with the file's own `cwd`
  instead of the extension host's, so it resolves `.sqlfluff` against the
  right project.

- **Runner exit codes now gate clean vs. errored (refs #1736)** — Knip, jscpd,
  and vulture no longer read an empty result on a nonzero exit as "clean."
  A broken shim, a crash, or a config-load error now reports as errored and
  records a bounded degradation entry naming the runner and its exit status,
  instead of silently showing zero issues.

- **Managed tools from GitHub releases, pip, gem, archives and Maven now refresh too (refs #1747)** —
  #1730 unfroze the npm-installed tools. The other 39 managed tools stayed on
  whatever version pi-lens resolved the day it first installed them, because
  the installer only runs when a tool is absent. A stale ast-grep, ruff,
  gitleaks or terraform-ls produces exactly the wrong verdicts #1730
  documented for knip. All five remaining strategies now share the same
  weekly per-tool cadence, the same one-refresh-per-session budget and the
  same degradation record as the npm path. GitHub tools re-resolve
  `releases/latest` with a stored `ETag`, so an unchanged release downloads
  nothing; archive and Maven tools compare the version pinned in pi-lens's
  own registry and touch the network only when it moved; pip and gem tools
  re-run their install command in upgrade form. A refreshed binary that
  cannot report a version is treated as a failed refresh, not a success. The
  refresh honors the same install kill-switch, project-trust gate and install
  lock as a normal install. On an EXISTING machine, GitHub tools have no
  recorded release tag yet, so each one's first refresh downloads its current
  release once before the ETag comparison starts paying off — roughly 27
  sessions of one-time downloads at the one-per-session budget, spread over
  weeks, not a burst. Set `PI_LENS_DISABLE_TOOL_REFRESH=1` to turn the whole
  mechanism off.

- **ruff `S` security rules now run by default; the IaC misconfig lane now actually scans (refs #1757)** —
  the bundled `config/ruff/core.toml` enables `S` (Bandit-equivalent), with
  the sub-rules that over-fired on real code (`S101`, `S311`, `S603`,
  `S607`) excluded and the exclusion reasons recorded in the config; a
  project-local ruff config still overrides the bundled one unchanged.
  Separately, two `trivy config` call sites — the per-edit IaC lane
  (`clients/dispatch/runners/trivy-config.ts`) and the Helm
  rendered-manifest pass (`clients/dispatch/runners/helm-render.ts`) — were
  passing `--no-progress`, a flag that subcommand rejects; every real
  invocation exited 1 with trivy's usage text on stdout instead of scanning
  anything. `trivy-config.ts`'s empty-output-only guard missed this
  entirely and reported a clean scan; `helm-render.ts` already checked exit
  status unconditionally, so it failed loud instead, but still never
  scanned a single rendered manifest. Both call sites are fixed, the
  trivy-config runner now treats any nonzero exit as an error regardless of
  stdout content (with a bounded degradation-ledger record so this can't go
  unnoticed again), and the lane now also covers CloudFormation templates
  (yaml and json).

- **Tree-sitter grammar wasms now refresh when the pinned version moves (refs #1760)**
  — a grammar wasm downloaded once was never replaced when this repo
  bumped the version it pins, because the cached file's name carries no
  version. A future fix to a broken grammar build (the class #255 and #427
  already hit) would never reach a machine that had already downloaded the
  broken one. pi-lens now compares each cached grammar's sha256 against the
  currently pinned manifest (`scripts/grammars.lock.json`) before trusting it,
  memoized per file so steady state costs no extra hashing and never touches
  the network. A mismatch is treated as a missing file: the existing lazy
  fetch re-downloads it, verifying and atomically swapping in the new build,
  so a failed re-download never destroys the working cached copy. This also
  catches on-disk corruption, which nothing detected before.

- **LSP resync timeout no longer blames a server still spawning (closes #1766)** — When the pre-dispatch resync's wait budget expired while a language server's first spawn was still in progress, the log wrongly read "server slow/wedged" — a verdict about a running server, not a cold start. The record now emits `reason: "spawn-in-flight"` and says the server is still cold-spawning when that is the case, and keeps the "slow/wedged" wording only when no spawn is in flight.

- **`runtime-session.test.ts` no longer flakes under CI contention (closes #1767)** — root-caused, not just re-budgeted. Every test in this file drives `handleSessionStart`, which does real synchronous fs work plus fire-and-forget background scans (`scheduleStartupScans`) that reach their mocks only after a real `setImmediate` tick and real per-file fs reads. Solo runs measured 8-13s for all 14 tests (well inside `vi.waitFor`'s 1000ms default and vitest's 5000ms `testTimeout` default), but 24 contended runs (this file run 6x in parallel) failed 8 times (33%) — some on individual `vi.waitFor` calls timing out, others on the whole test blowing the 5000ms default while `handleSessionStart` itself ran long. Confirmed deterministically too: `--testTimeout=300` failed 12 of 12 pre-fix runs, 0 of 14 post-fix. This confirms the reporter's own A/B finding that `clients/runtime-session.ts` is not the cause. Every `vi.waitFor` call in the file now uses an explicit 30s budget (`HEAVY_IO_TIMEOUT_MS`, matching `tests/clients/ast-grep-rule-precedence-followups.test.ts`'s existing convention), and the enclosing `describe`/`it` blocks use a 45s budget (`TEST_BUDGET_MS`) — kept deliberately larger so a genuine failure still surfaces `vi.waitFor`'s specific message instead of racing it against the outer test-timeout's generic one. Post-fix, 18 of 19 completed contended runs passed with zero genuine test failures (the 19th run hit an unrelated `npx` infrastructure error, not a test assertion). Test-only change; `clients/runtime-session.ts` is untouched.

- **Genuine pull timeouts now count in the degradation ledger (#1771)** — `lsp_pull_diagnostic_timeout` previously wrote a detailed latency.log record but tallied nothing, so a storming server was invisible in aggregate. It now increments `lsp-pull-diagnostic-timeout` with a subject that preserves server and file identity.

- **`index-loop-block-wiring` case (a) no longer times out flakily under load (closes #1772)** — pre-existing test-budget starvation, not a regression. Case (a) does a cold `await import("../index.js")` after `vi.resetModules()`; cases (b) and (c) also reset modules but reuse the already-warm transform cache from case (a)'s run, so only case (a) pays the full cost. That cost measured up to 9.2s solo on a busy box, already over vitest's 5000ms default (timeout, not an assertion failure). Two independent controlled A/B comparisons (#1743 builder, #1761 reviewer) showed `origin/master` fails at the same or higher rate as feature branches, and CI stays green on both heads, confirming this is environmental, not a code defect. The describe block now declares an explicit 30s timeout, matching the `HEAVY_IO_TIMEOUT_MS` convention (`tests/clients/ast-grep-rule-precedence-followups.test.ts`) and #1764's `instance-reaper-prune-concurrency` fix, instead of hoisting the import: the per-test isolation actually comes from the `beforeEach` mock clears, not the re-import, but hoisting would still order-couple case (c)'s `worstSoFar` assertions to whatever high-water state case (b) leaves behind, so the import stays per-test. A repo-wide sweep for the same shape (cold `import("../index.js")` + `resetModules`, no timeout budget) found four untouched siblings, one reproduced red in the same session — filed as #1778.

- **Diagnostic pulls no longer dispatch once the caller's budget is exhausted (#1773)** — `textDocument/diagnostic` and `workspace/diagnostic` pulls used to send an unwinnable 1ms request when the remaining budget was 0 or negative, then log it as a genuine timeout. Below a 5ms usable floor the pull is skipped outright and recorded as `lsp_pull_skipped_budget_exhausted`, so `lsp_pull_diagnostic_timeout` only ever means a pull was really attempted.

- **A pull timeout's abandoned request now traces server rejections, not just late answers (#1774)** — when an abandoned pull rejects (for example a permanent server error after the caller gave up) instead of answering or staying silent, it now emits a bounded `lsp_pull_late_rejection` record with the error code and elapsed time. The rejection is still swallowed; only the observability changes.

- **Two more `index-*-wiring` test files budget their cold import (closes #1778)** — `tests/index-memory-sample-wiring.test.ts` and `tests/index-smells-rollup-wiring.test.ts` share #1772's flake shape: a cold `await import("../index.js")` after `vi.resetModules()` per test, racing vitest's 5000ms default `testTimeout`. Both describe blocks now declare an explicit 30s timeout, matching #1779's `index-loop-block-wiring` fix and this repo's `HEAVY_IO_TIMEOUT_MS` convention, instead of hoisting the import — hoisting would defeat the per-test module-state isolation these wiring guards check. `tests/index-integration.test.ts` and `tests/index-lsp-idle-reset.test.ts` (the other two files in #1778's sweep) already carry an equivalent per-test 45s timeout and needed no change.

- **`session_start` self-heals cachedExports after a starved sequence read (closes #1785)** — under heavy load, `handleSessionStart`'s bounded change-log sequence read (#1162's 250ms budget) can time out even when the underlying log is empty, purely from CPU scheduling starvation. The freshness gate correctly refuses to trust a snapshot it can't validate yet, so `cachedExports`/`projectRulesScan` stay empty — but the deferred read that continues in the background only reseeded `runtime.projectSeq`, never re-checked snapshot freshness, so a project snapshot that really was current stayed unhydrated for the rest of the session even after the real answer arrived. Quick mode (the interactive hot path) now retroactively hydrates from the snapshot once the deferred read confirms it was fresh — a deterministic completion signal, not a sleep. Full mode is deliberately left alone: its cold path actively rescans instead of accepting an empty state, so retroactive hydration there risks overwriting fresher, actively-computed data. A `snapshot-sequence-read-timeout` degradation-ledger record now carries the project root and the on-disk snapshot's age/size whenever the budget is blown, so a real recurrence is diagnosable from the ledger alone. Characterization also corrects the issue's original hypothesis: the suspected `saveProjectSnapshot` worker-persist race does not reproduce — the in-process authoritative-snapshot cache (`clients/project-snapshot.ts`) already guarantees read-your-own-write consistency within a process.

  Review round found the first version of this fix could itself be defeated by quick mode's own cold-start warmup (2s later by default): the warmup saves a snapshot built from the still-cold live runtime, and a re-load at deferred-hydration time could read that emptied snapshot instead of the good one. The retroactive-hydration closure now captures the on-disk snapshot BEFORE the warmup timer is even armed and hydrates only from that captured value, immune to anything the warmup writes afterward. The hydration itself is now additive per field (`hydrateRuntimeFromProjectSnapshotIfIdle`): it never nulls a live `wordIndex` from a snapshot missing one, and never clears a live `cachedExports`/`projectRulesScan` — each field only hydrates from the snapshot while its own runtime copy is still idle.

  A second review round found that capture itself was too expensive to run unconditionally on the interactive hot path: it called the full `loadProjectSnapshot` (gunzip + parse the entire body, dominated by `wordIndex`'s postings graph) on EVERY quick-mode session start, bypassing the #947 skip-stale meta gate and briefly holding a second copy of the postings graph in memory (#1370). A new narrow loader, `loadProjectSnapshotExportsAndRules`, excises the heavy fields (`wordIndex`, `files`, `symbols`, `reverseDeps`) from the raw JSON text before `JSON.parse` ever sees them, instead of parsing everything and discarding the unwanted parts afterward.

  A third review round replaced even that narrow capture: it was still 210-289ms of synchronous read+gunzip+scan on every quick-mode session start, to guard a timeout that rarely fires. Quick mode's own cold-start warmup already loads the on-disk snapshot for its own purposes (reusing a cached `startupScan` verdict), strictly before any save the warmup itself might make — so the fix now has the warmup publish that already-loaded read (narrowed to exports+rules) for the retroactive-hydration path to reuse, at zero marginal cost, instead of paying for a second, separate read. This also closed a leftover retention bug the narrow loader's own test hook had reintroduced: an earlier observability hook held the full narrowed body text at module scope, unconditionally — the exact retention class the narrow loader exists to avoid. It's now a bounded `{length, containsHeavyKey}` digest instead.

- **`workspace/symbol` no longer goes to LSP servers that never advertised it (refs #1789)** — without a `filePath`, `LSPService.workspaceSymbol` picked "the first active client" from the full spawned-client map, auxiliary scanners (ast-grep, opengrep, zizmor, ...) included, with no capability check of its own; a workspace whose only spawned client was an auxiliary sent every workspace-wide symbol query to it and ate a wasted round trip each time (2026-08-20 plegma dogfood: ast-grep). The gate now reads `client.getOperationSupport().workspaceSymbol` — the same source `lsp-document-symbols.ts`'s documentSymbol gate already reads, populated from the initialize result's `capabilities` — right at the point that resolves and calls the target client, for both the `filePath` and no-`filePath` branches. Caller-visible shape is unchanged: an unsupported server returned `[]` before (from a thrown/absorbed error) and returns `[]` now (from the short-circuit). A repo-wide sweep of every `navRequest`/`safeSendRequest` call site in `clients/lsp/client.ts` found one sibling with the same "chokepoint, not caller, decides the target and never checks" shape — `LSPService.incomingCalls`/`outgoingCalls` resolve their target server from the call-hierarchy item's own file, which can cross servers in a polyglot workspace — left open as a follow-up rather than folded into this contained fix.

- **`blocker_freshness_sweep`'s population excludes cache-served diagnostic rows (closes [#1790](https://github.com/apmantza/pi-lens/issues/1790))** — the turn-end freshness gate (`clients/blocker-freshness.ts`, `sweepInlineBlockerFreshness`) built its population solely from `RuntimeCoordinator`'s inline-blocker map, a live-dispatch-only store. A workspace-diagnostics cache hit never touches that map — it writes straight into `widget-state.ts`'s `files` store (`reconcileScanDiagnostics`, called from the cache-serve branch of `tools/lsp-diagnostics.ts`). During the 2026-08-20 dogfood `blocker_freshness_sweep` logged `total:1 kept:1` while five stale cache-served blocking rows still rendered in the widget — the sweep's population never saw them, so its drift check never ran over them either. `sweepInlineBlockerFreshness` now accepts `options.additionalEntries`: widget-store rows fed in by `runtime-turn.ts` from the new `widget-state.ts` export `getWidgetBlockingFilesForSweep` (one entry per file with a currently-blocking, LSP-sourced diagnostic, baselined on the earliest `observedAt`), deduped by file path against the inline-blocker map so a file present in both stores is drift-checked once, not twice. Review round 1 found the dedup DROPPING the widget row on a duplicate path instead of also demoting it: a live dispatch writes both stores for the same verdict (`runtime-tool-result.ts`'s inline write, `pipeline.ts`'s widget write), and `markInlineBlockerStale` only ever touches `RuntimeCoordinator`'s map — so the widget's own `isBlocking` for the file stayed true even after the inline entry demoted, the exact ghost this issue exists to kill, just moved one store over. A duplicated path now CHAINS the widget demote onto the inline entry's demote instead: one drift check, both stores written. Review round 2 found the chain itself still ghost-prone: it fired even when the inline entry it chained onto was INELIGIBLE for the drift check (already latched `stale` from a prior turn's one-way demotion, an unstamped legacy record, or a non-LSP/mixed-sources entry like an unrelated ast-grep finding on the same file) — the sweep's own gate short-circuits before ever calling `demote()` on those, so the chained widget demote silently never fired, either as a permanent forever-ghost (the stale latch never re-arms) or reappearing the instant an unrelated inline blocker happened to share the file. A duplicate now only chains when the inline entry is itself eligible (`isEligibleForDriftCheck`, the same three gates factored out so this decision can't drift from the real ones); an ineligible duplicate gives the widget row its own separate population entry instead, so its own eligibility and its own baseline decide its own fate. The dedup key is `normalizeEphemeralMapKey` (cheap, no syscalls), not `normalizeMapKey` (measured ~313µs per deleted path via its `realpathSync`/walk-up fallback — a real cost on a turn-end hot path, and the wrong key besides: the widget store this population is deduped against keys on `normalizeEphemeralMapKey` itself). A widget-origin row that drifts is demoted through the new `markWidgetFileBlockersStale` — the same store and the same demote-not-drop write `reconcileStaleWidgetDependencyBlockers` already performs, just triggered by the sweep's own drift check instead of an explicit `lens_diagnostics mode=all` call; a comment at that write site pins the single-file-baseline assumption it currently relies on (safe today because every LSP-diagnostics writer replaces a file's errors wholesale with one `observedAt` per write) so a future mixed-stamp merge writer can't make it over-demote silently. The source defect — the cache serving genuinely stale rows in the first place — was fixed separately in [#1786](https://github.com/apmantza/pi-lens/pull/1786) (cache expiry + clean-answer eviction, closes [#1782](https://github.com/apmantza/pi-lens/issues/1782)); this is the defense-in-depth remainder, so a future cache-path defect cannot render invisible to the sweep again. Bounded cost: one extra pass over `widget-state.ts`'s per-turn file count (already bounded by `MAX_INACTIVE_FILE_RECORDS`), filtered to files with a live blocking diagnostic; the dedup key itself is now a pure string operation with no stat/realpath cost, and the per-file drift check is still bounded by the existing `MAX_DRIFT_CHECK_IMPORTS` cap and per-turn import-resolution memo.

- **Clean workspace-diagnostics cache entries no longer replay across a dependency edit on a cold session** — A clean entry recorded while a
  reverse-dependency index was available now refuses to serve on a later
  session that has none, instead of falling back to mtime-only checking. The
  cache used to trust any clean entry's own mtime alone whenever this
  session's dependency graph wasn't built yet, so a dependency change (or a
  config change that flipped a file from clean to failing) went unnoticed for
  as long as the file's own bytes stayed untouched. Refused entries count
  toward the existing `lsp_workspace_diagnostics_cache_expiry` record in
  `latency.log` (`depIndexColdRefusals`). An entry recorded on an equally cold
  session keeps today's mtime-only behavior — it never claimed more than that
  check can verify.

- **`lens_diagnostics` no longer double-counts error findings (closes #1799)** — The compact
  header and the mode=all/full summary printed the same error-severity findings
  twice, once as "blocking" and again as "errors", making three real problems
  read as six. `semantic === "blocking"` and `severity === "error"` always
  describe the same set of findings, so both surfaces now report blocking and
  warnings only. Also removed the always-zero `byTier.error` field from
  `clients/actionable-warnings.ts`, since error-severity diagnostics never
  reach the actionable-warnings path.

- **`callHierarchy/incomingCalls` and `outgoingCalls` no longer go to servers that never advertised `callHierarchyProvider` (closes #1803)** — `LSPService.incomingCalls`/`outgoingCalls` resolved their target server from the call-hierarchy item's own file and called straight through, with no check of `getOperationSupport().callHierarchy`. Both now read `client.getOperationSupport().callHierarchy` — the same source `detectOperationSupport` populates from the initialize result's `callHierarchyProvider` (clients/lsp/client.ts:5253) — right before dispatch, mirroring the #1789 gate on `workspaceSymbol`. Because `lsp_navigation`'s shared capability pre-check only runs for path-based operations (`needsFilePath`, tools/lsp-navigation.ts:1099-1104 excludes call-hierarchy traversal), the service-only fix would have silently turned a live, user-visible unsupported-server error into a false "no callers found" clean empty — the review round caught this and added a matching pre-check in the tool layer's `incomingCalls`/`outgoingCalls` cases, so an unsupported server now reports the same discriminated `emptyReason: "unsupported"` shape every other capability-gated operation already uses, while a supporting server with zero callers still reports a clean empty. A pattern sweep of every `getClientForFile`-resolved navigation method in `clients/lsp/index.ts` found eleven more siblings with the same service-layer gap (`definition`, `typeDefinition`, `declaration`, `references`, `hover`, `signatureHelp`, `documentSymbol`, `codeAction`, `rename`, `implementation`, `prepareCallHierarchy`), all already gated at the `lsp_navigation` tool layer — filed as #1826 as defense-in-depth, not a live bug.

- **Biome findings can now reach actionable warnings** — biome's real
  `lint --reporter=json` output (probed live against the shipped 2.5.x
  binary) carries no `tags` field and never did, so the old
  `d.tags?.includes("fixable")` check always missed and every biome
  diagnostic's `fixable`/`autoFixAvailable`/`fixKind` were permanently
  false. The parser now resolves each rule's real fix tier via
  `biome explain <rule>` (the one place biome states fixability as
  structured text, sourced from the running binary's own rule registry),
  cached for the process lifetime since fix tier is a property of the rule,
  not the occurrence. Also aligned the diagnostic location field to biome's
  real `location.path` (the parser and its fixtures assumed
  `location.source`, a field that never shipped) (#1810).

- **`workspaceSymbol`'s no-`filePath` branch now picks a client that actually supports it (refs #1812)** — without a `filePath`, `LSPService.workspaceSymbol` stopped at `state.clients`' first entry by insertion order, even after the #1789 capability gate. In a polyglot workspace where an auxiliary scanner (ast-grep, opengrep, zizmor, ...) spawned before a supporting primary server, every workspace-wide symbol query silently returned `[]` with zero requests, and the supporting primary never got a look-in. A new `selectWorkspaceScopeClient` helper scans the spawned-client map for the first LIVE client matching a predicate, preferring a primary (non-`"auxiliary"`) server over an auxiliary one — mirroring the `role !== "auxiliary"` preference `getClientForFile` already applies for `filePath`-scoped requests, and the `isAlive()` filter `getCapabilitySnapshots`'s own no-`filePath` branch already applies. Only when NO live spawned client supports the query does it fall back to `[]`. Swept and fixed the same "clients[0] by insertion order" shape at the other four no-`filePath` chokepoints in `clients/lsp/index.ts` — `getAdvertisedCommands`, `executeCommand`, `getOperationSupport`, and `getWorkspaceDiagnosticsSupport` — onto the same helper; `getCapabilitySnapshots`'s no-`filePath` branch was already correct (it iterates every client, not just the first). A follow-up review round found the multi-primary case where `getOperationSupport`'s own no-`filePath` probe (no predicate) can disagree with `workspaceSymbol`'s predicate-driven selection — filed as #1846, not fixed here.

- **A warm session's dependency index no longer vacuously clears files it never scanned** — `isEntryFresh`'s per-file dependency check used to treat
  "this file is absent from the reverse-deps index" the same as "the index
  confirmed this file has zero imports": both returned an empty import list,
  so the freshness loop iterated zero times and reported clean. A session
  with a real index covering the rest of the project could still fail-open,
  with no signal at all, for the one file outside that index's own coverage.
  `getImports` now returns `undefined` for an uncovered file instead of
  silently coercing it to `[]`, and a clean entry stamped with dependency
  knowledge is refused and evicted for that file the same way #1793 already
  refuses one on a session with no index at all. Refusals from both cases
  share the existing `depIndexColdRefusals` counter in `latency.log`. The
  reverse-deps index itself carried the same masking one layer down —
  `clients/reverse-deps.ts` used to stamp an empty import list for a file it
  never scanned (known only as another file's import target, or recorded in
  the snapshot with no imports field at all); it now leaves that file's
  entry absent, matching the distinction `getImports` relies on.

- **Actionable-warning and code-quality-warning ids now canonicalize the file path like disposition anchors already did** —
  `createActionableWarningId` and `createCodeQualityWarningId` hashed a RAW
  `relativeFile`, so a drive-letter-case or slash-variant file path produced
  a different id than `diagnostic-dispositions.ts` would compute for the
  same file (the #533 orphaned-record class, swept from 1 of 3 copies to
  all 3). Both now share `clients/finding-identity.ts`'s canonicalizing
  `relativeFile` and 12-char hash length; a warning already suppressed in
  `actionable-warning-state.json` under the pre-fix, 10-char id is still
  honored and migrated forward onto the new id (#1816).

- **Session-state conformance coverage catches a new latch landing inside an already-registered file (refs [#1817](https://github.com/apmantza/pi-lens/issues/1817))** — `tests/clients/session-state-conformance.test.ts`'s coverage sweep audited FILES: once a module was registered or exempted, any NEW module-level `Map`/`Set`/`PathKeyedMap` added inside it was invisible, because the sweep never re-checked what was already inside a file it had already cleared. `#1801`'s review round 1 found this the hard way — `staleGrammarVersionAt`, a process-lifetime latch on the `TreeSitterClient` singleton, sat unregistered and unreset for a whole review round while the sweep stayed 55/55 green. `tests/support/sweep-kit.ts` gains `auditSymbolCounts()`, built on the existing `auditRegistry()` machinery: each flagged file's LIVE detected-symbol count is folded into its registry id (`file@N`), so a file whose count changes presents an id the pin does not name and reds as an ordinary unaccounted item — no parallel registry, no new exemption semantics. `tests/support/session-state-registry.ts` pins the current count for all 72 files the sweep flags today (`SESSION_STATE_SYMBOL_COUNTS`), and the conformance suite diffs it against a live scan on every run. Proven red-first by seeding an uncleared module-level `Map` into the already-registered `tree-sitter-shared.ts` (the `#1801` F1 shape exactly): the new pin test failed with `tree-sitter-shared.ts@1` unaccounted; reverting the seed restored green. Chose the cheaper of the issue's two options (a pinned per-file count) over full symbol-to-reset attribution — the registry's `state` field is free text that does not reliably map onto the scanner's detected identifier names across its ~30 entries, so attributing every symbol to a reset or a reason would be a real migration, not a rider on this fix.

- **ast-grep-napi LSP supersede gate B tests now pin the gate, not the fallback (closes #1830)** — the two "skips ..." tests under `LSP supersede gate (#239 Phase 2)` mocked `@ast-grep/napi` with a bare `parse: vi.fn()`, so a neutered gate B still returned `"skipped"` by falling through to the load/parse skip path. `expect(result.status).toBe("skipped")` couldn't tell the two skip reasons apart, so the tests passed whether gate B fired or not. A `mockWorkingSgLoad()` helper now makes the NAPI fallback path succeed, so only gate B can still produce `"skipped"`. Neutering the gate's `.includes("ast-grep")` check now turns both tests red with `"succeeded"`, confirming the assertion is load-bearing.

- **`require-safety-comment-for-as-unknown-as`'s `SAFETY:` valve no longer exempts every later cast in the same block (closes #1834)** — the backward comment scan now stops at the first non-comment sibling instead of walking unbounded to the top of the block.

- **Workspace-wide capability checks now describe every live client, not just the first (closes #1846)** — `LSPService.getOperationSupport()` called without a `filePath` answers for the workspace, but it returned a single client's capabilities: whichever `selectWorkspaceScopeClient()` picked with no predicate. In a multi-primary workspace — a `json` server spawned before `typescript`, for example — the first client's missing `workspaceSymbolProvider` reported the operation unsupported even though the later client advertised it. `tools/lsp-navigation.ts`'s workspaceSymbol gate read that snapshot and threw `__UNSUPPORTED__`, so `LSPService.workspaceSymbol` — which #1812 taught to find the supporting client — was never reached, and the probe disagreed with the call it was gating. Each capability now resolves through `selectWorkspaceScopeClient` with a per-capability predicate, so the snapshot is ORed across exactly the clients that routing would consider. Aggregation inherits that helper's `isAlive()` filter rather than re-deriving one: a dead client cannot contribute a capability nobody can answer. The `filePath`-scoped branch is unchanged, a single-client workspace gets the same snapshot as before, and no active client still returns `null`.

- **`require-safety-comment-for-as-unknown-as`'s `SAFETY:` valve now accepts a comment above an exported, class-field, or enum-member cast (closes #1847)** — the rule's `inside:` kind list was missing `export_statement`, `public_field_definition`, and `enum_assignment`, so a documented `export const`, class-field, or enum-member cast had no way to satisfy the ERROR-tier blocking rule.

- **Resource sampling distinguishes failed process-table queries from empty samples (closes #1863)** — failed or timed-out Windows and POSIX resource queries now remain unknown, with bounded query-specific degradation telemetry, instead of being treated as clean zero-sample results.

- **Ignore zero-byte freshness reads (refs #1865)** — Keep live diagnostics visible during truncate-then-write windows and record the observed file size for past-EOF demotions.

- **Reuse knip results for unchanged project content** — Turn-end and startup
  scans now reuse the last successful knip result when the runtime project
  sequence has not changed. Telemetry distinguishes cached results from
  executed scans, and a new session re-arms execution.

- **Identify TypeScript diagnostic sequence records** — Every
  `lsp_typescript_diagnostic_sequence` row now names its server and whether it
  records a publication or a settled answer, instead of leaving the minimum
  outcome identity implicit.

- **Accept SAFETY comments on object-literal members (refs #1870)** — `require-safety-comment-for-as-unknown-as` recognizes the TypeScript `pair` node while keeping unrelated sibling comments scoped to their own member.

- **javac fallback skips classpath-less compiles inside Maven/Gradle projects (closes #1877)** — when the Java LSP runner misses its wait budget, dispatch falls back to the `javac` runner, which compiles the single file with no `-classpath`/`-sourcepath`. On build-tool projects every non-JDK import becomes a blocking `package does not exist` false positive that gates agent edits and stays cached in session diagnostics after jdtls recovers. The runner now walks up from the edited file with the shared `hasJavaBuildDescriptor` seam (the same gate SpotBugs uses) and returns `skipped` when a `pom.xml`, `build.gradle(.kts)`, or `settings.gradle(.kts)` is present; the dispatcher's coverage notice reports the LSP gap honestly. Standalone `.java` files with no descriptor keep the javac fallback.

- **Tally verified relative-path attribution guesses instead of logging one record per read (refs [#1884](https://github.com/apmantza/pi-lens/issues/1884))** — existing workspace-root guesses now increment one session counter and emit one `path_attribution_verified_rollup` latency row at shutdown; missing, ambiguous, or non-existent guesses retain the full `path_attribution_missing` record with its raw and guessed paths.

- **Keep context-free compiler findings non-blocking ([closes #1885](https://github.com/apmantza/pi-lens/issues/1885))** —
  standalone javac, C/C++ syntax checks, Zig single-file builds, and direct
  elixirc runs still report useful compiler errors, but no longer stop an edit
  when missing project classpaths, build flags, modules, or dependencies could
  have caused the finding. A recovered Java LSP clean result also replaces any
  javac findings already held in session diagnostics.

- **Unverified path guesses remain visible ([refs #1886](https://github.com/apmantza/pi-lens/issues/1886))** —
  a same-named workspace file no longer verifies a tool result when the
  execution target and cwd are unavailable for comparison. The full
  attribution record is retained, lifecycle rollups are tested through
  `session_start` and primary or secondary `session_shutdown`, and the
  session tally remains explicitly best-effort because a crash loses its
  memory-only count.

- **The widget count now includes project self-scan findings** —
  `lens_diagnostics mode=full` projects its correlated cross-lane result into
  widget state, so `ast-grep-napi` findings remain counted when the ast-grep LSP
  lane is broken or unconfirmed (#1888).

- **Formatter PATH availability re-arms at session start (closes [#1895](https://github.com/apmantza/pi-lens/issues/1895))** —
  formatter `which` latches now clear with the primary session reset, so a
  formatter binary installed or removed between sessions is detected on the
  next session instead of keeping the previous session's verdict.

- **Coverage-marker dedupe key sorts with an explicit code-unit comparator.** SonarCloud S2871 flagged the bare `.sort()` in the silent-scanner set; the key now sorts locale-independently so dedupe identity cannot vary by environment.

- **Stale workspace-diagnostics cache entries now expire** — A cached entry that
  asserts findings and predates the current session start, or that has aged past
  a four-hour in-session ceiling, is dropped at serve time and the file goes back
  through a fresh check. A cache hit used to skip that check, so an entry no
  newer pull re-answered replayed its diagnostics forever, across sessions,
  rendered as current blocking errors. Clean entries keep serving: their only
  claim is "nothing changed", which the existing mtime, dependency, and
  content-hash gates check. Each sweep that expires entries writes one
  `lsp_workspace_diagnostics_cache_expiry` record to `latency.log` with the
  count and the oldest age.

- **A clean re-answer now clears a cached diagnostics entry** — A
  `workspace/diagnostic` pull returns a project-wide report, but pi-lens read
  only the part covering the files it had just asked about. An explicit
  zero-diagnostic answer for a file served from cache was discarded, so a server
  that re-checked a file and found it clean could not dislodge its stale
  blockers by any means available to a user. Those answers now flow through the
  sweep's ordinary result list, which overwrites the cache entry and clears the
  widget rows.

- **Surface silent LSP scanners to the agent (refs #1867)** — Per-edit
  diagnostics now render a compact coverage marker when one or more auxiliary
  scanners did not answer. An empty partial result is labeled incomplete instead
  of looking like a clean pass, and repeated notices retain the existing
  per-file session dedupe.

- **Heal the LSP view after an untracked disk edit** — An edit made outside
  the tracked write/edit path, such as a bash-tool bulk edit, sent no
  `didChange`, so the language server kept publishing pre-edit diagnostics
  indefinitely. pi-lens now records what content actually landed on each
  server and, on a bounded cadence, compares disk size and mtime against that
  record. A document whose bytes really changed is re-pushed within 10 seconds
  of the next LSP activity, to every server holding it — the primary language
  server and the auxiliary scanners alike. The sweep stats at most 64 documents
  per pass
  (1.7ms measured), reads only the ones whose stat diverged, and issues at
  most 4 resyncs per pass so a bulk edit heals in paced rounds.

- **`no-non-null-assertion` narrows two more idiom shapes** — a `!` guarded
  by an identical truthy ternary condition, or re-asserted on a
  `.filter()`-checked property immediately chained into `.map()`, no longer
  flags. Both exclusions are scoped to the nearest enclosing ternary or
  arrow function and require the same metavariable binding, so a
  differently-bound guard still flags. Pi-lens's own residual drops from 36
  to 27; the rule stays at `warning` pending the tier decision tracked in
  #1818.

- **`no-unknown-returns` drops its callback/type-alias arm** — a
  `function_type` (`(x: T) => R`) is a type-position annotation, and a
  callback contract that returns `unknown` is legitimate, unlike a real
  function whose value-position return skipped parsing at a boundary.
  Dropping that arm takes pi-lens's own `clients/` + `index.ts` from 21 hits
  to 9 (#1824).

- **Orphan backstop: off the session-start critical path, with verified kills ([closes #1857](https://github.com/apmantza/pi-lens/issues/1857))** —
  The registry-independent orphan sweep scanned the OS process table inline at
  session start. One dogfood session measured that scan at 9344ms, overlapping
  and starving language-profile warmup. The sweep now runs on an unref'd timer
  30 seconds after session start, under a machine-wide 30-minute cooldown, with
  a 5-second hard timeout on the enumeration child. Session start pays nothing.

  Three correctness defects went with it. Kill outcomes were counted as
  successes: `killPidTree` returned no signal, so a permanently unkillable
  process reported as reaped and paid the full sweep again every session. Kills
  are now verified by a post-kill liveness poll, and a survivor is recorded by
  identity (`<binary>#<pid>`) through the degradation ledger. The sweep's only
  record used to be a `{scanned, killed}` count on the reaped path, so "ran and
  found nothing", "never ran", and "threw" were the same absence; every sweep
  now logs one of `clean`, `reaped`, `error`, `cooldown`, or `disabled`. And a
  name-matching process spawned seconds ago but not yet registered was
  kill-eligible the moment its launcher shim exited — a window measured at
  890ms in the same session — so a process must now be at least 60 seconds old
  before the backstop will touch it.

  The registry-driven reaper carried the same attempt-counted-as-kill defect
  and gets the same verified accounting.

  Review round: the sweep runs under the repo's cross-process directory lock,
  so two sessions starting together cannot both pay for it. A candidate spared
  by the spawn grace arms exactly one follow-up sweep, instead of waiting for
  a next session that a long-lived host may never start. The scan timeout
  terminates the scanner child through the same tree-kill-and-verify path an
  orphan gets, rather than firing one unverified signal and walking away. And
  a sweep whose kills all failed reports `unverified`, not `reaped` — the same
  accounting defect this work exists to fix, reproduced one level up at the
  outcome surface.

- **Preserve pyright's information severity and fix its diagnostic positions in dispatch (closes #1802)** — `pyright` collapsed every `"information"`-tier diagnostic to `warning`, even though pyright's own `--outputjson` output declares `error`/`warning`/`information`. Pyright findings now carry the same four-tier `Diagnostic.severity` biome-check adopted in #1791: `information` maps to `info`. Only `error` still blocks. The parser also read a top-level `start` field that real pyright output never sets — every diagnostic landed at line 0/column 0 — and now reads the documented `range.start.line`/`character` (zero-based, converted to pi-lens's one-based `line`/`column`). `rust-clippy`'s mapping got its own fix: rustc/clippy's `compiler-message.level` serializes six values, not two, and a top-level `"note"`/`"help"` message can carry a real primary span. `note` now maps to `hint`, `help` to `info`, and an internal-compiler-error (`"error: internal compiler error"`) is classified as blocking instead of silently falling through to `warning`.

- **Eight ast-grep rules stop flagging correct code** — the 2026-08-20 severity audit found
  broken escape valves, patterns that crossed function boundaries, and
  content-shape gaps that fired on parameterized queries and static markup.
  A follow-up adversarial review then caught three of the fixes
  over-correcting (an escape valve exempting more than the false-positive
  case, or a narrowed match losing a detection class); those are fixed too.
  Per rule: `no-dupe-class-members`'s static-modifier check missed `static
  async` methods (false positive) and, in the first fix, over-exempted
  static members entirely, losing genuine `static foo(){} static foo(){}`
  duplicates (false negative) — both fixed, staticness is now compared
  BETWEEN the pair, not blanket-exempted. `no-return-value-in-generator`
  conflated yield/return across nested functions. `no-compile-call` now
  suppresses literal and same-scope `ast.parse`-derived sources, scoped to
  the nearest enclosing function (a first pass climbed past nested
  functions) and with the single-argument call form restored.
  `no-server-bind-wildcard`'s first fix anchored to `.run`/`.listen`/
  `.serve` call targets, which missed `uvicorn.Config(...)`, bare
  `run(host=...)`, and other server-startup shapes — reverted to the
  original broad match with a narrow exclusion for the actual false
  positive (a pydantic `.model_construct()`/`.construct()` call).
  `no-sql-in-code`/`-js` now require string concatenation or template
  substitution, mirroring the already-correct Python sibling, and exclude
  literal-plus-literal concatenation (compile-time-constant text, nothing
  to inject); the residual corpus hits match the declared
  concatenation/substitution shape, though a few interpolate module-level
  constants rather than user input. `no-inner-html`/`-js` now exempt
  literal/empty-template RHS. `redundant-unsafe-function` now exempts a
  preceding `/// # Safety` doc comment, bounded to the comment block
  immediately above the function (an unbounded first pass let one
  `# Safety` comment anywhere earlier in the file exempt every later
  `unsafe fn`). `no-await-expression-member`/`-js` no longer recommend a
  rewrite that reads a property off the unresolved promise (prose only,
  no rule change; kept rather than retired — the corrected hint is not a
  no-op, though retirement stays available as a separate call).
  Refs #1806.

- **Seven linters no longer report a clean file after a failed run** —
  `markdownlint`, `mypy`, `sqlfluff`, `stylelint`, `swiftlint`, `vale`, and
  `yamllint` read their tool's exit status zero times, so a rejected flag, an
  unreadable config, a crash, or a `SIGKILL` parsed to zero diagnostics and was
  reported as a clean file. All seven now route the spawn through a shared
  `classifyRunOutcome` primitive, return `skipped`, and record one bounded
  `runner-empty-result` row naming the tool, its exit status, and the signal.
  A shared `formatToolFailure` gives every runner one wording and one
  truncation, and `SpawnResult` now carries the signal as a field instead of
  burying it in an error message. Two bugs the same survey found are fixed
  alongside: the degradation ledger appended its repeat count before a second
  truncation at the same bound, so a 200-character reason lost the count; and
  the quarantine file lock required a finite `createdAt` before it would test
  PID liveness, so an owner record without one was never reclaimed and every
  caller burned its full wait.

- **Project snapshot misses now name the rejecting gate (refs #1858)** — session-start diagnostics distinguish an absent body, stale meta gate, invalid body, and sequence-stale snapshot instead of reporting every null load as `missing`.

- **Tree-sitter cache now spans a full-project scan** — the parse-tree cache
  grows to fit a scan's file count (capped at 500 entries, or
  `PI_LENS_TREE_SITTER_CACHE_SCAN_CAP`) instead of staying fixed at 50, so a
  repeat `lens_diagnostics mode=full` scan reuses trees from the first pass
  instead of re-parsing every file (#1715).

- **Two error-tier ast-grep rules now run in the CI self-scan** —
  `no-unsafe-dictionary-any` and `no-bare-object-param` shipped at `error`
  without being wired into the `pi-lens-self-scan` category, so pi-lens's
  own tree was never actually audited by them. Both confirm clean (0
  findings) on the full tree (#1825).

### Security

- **A cascade sweep no longer blacks out the security scanner, and a blackout no longer reads as clean (closes [#1459](https://github.com/apmantza/pi-lens/issues/1459))** — A `clientScope: "all"` sweep used to fan a full-text `didOpen` resync at opengrep for every neighbour at once. Its stdin stopped draining, three per-server notify-write deadlines expired, and the #743 breaker opened for 15 s; every touch inside that window skipped the scanner and still resolved `confirmation: "confirmed"`. An auxiliary now gets at most one outstanding resync, as a bounded QUEUE rather than a drop: a healthy scanner accepts each write in milliseconds, so every neighbour of a sweep is still scanned, and only a scanner that cannot accept a write inside the budget makes a waiter give up and report itself uncovered. The slot is claimed synchronously, so waiters cannot all wake and write at once, and the queue wait is bounded by the caller's own budget rather than a flat write budget. A write that lands late but inside the wedge window retracts the timeout it was charged for; a write nothing accepts for the whole wedge window is demoted by its own timer, without waiting for a later touch to notice. A deferred auxiliary records a distinct `deferred` outcome in `lsp_aux_wait_outcome` rather than occupying the `silent` row, which stays reserved for a scanner that had the content and published nothing (#1493). #1493's content-hash exemption outranks a deferral: a scanner whose stored publication is bound to exactly these bytes stays covered, and its findings still reach the result. A deferred scanner is neither waited on nor read from, so it cannot burn its budget on a version that can never advance or contribute the previous content's findings. A scanner that never attached (breaker open) or never received the content (deferred resync) now lands in `unconfirmedServerIds`, narrows the touch to `"partial"`, and reaches the agent through `CascadeNeighborResult.unconfirmedServerIds` and an explicit "scanners did not cover" advisory line — so no consumer, and no agent, can read the gap as a clean bill of health. New `latency.log` records: `lsp_scanner_coverage_gap`, `lsp_notify_resync_deferred`, `lsp_notify_write_late_landed`.

- **A stalled `gh auth token` probe no longer disables zizmor's online audits for the session (closes #1535)** — A timed-out or unspawnable `gh auth token` lookup used to be memoized as "no token" with no TTL, silently turning off zizmor's GitHub-aware audits (`known-vulnerable-actions`, `unpinned-uses`, `impostor-commit`) for the rest of the session while the scan kept reporting success. The lookup now routes through the shared availability policy: only a genuine answer (`gh` ran and returned an exit code — including a clean run with no token — or is proven absent) is cached — a timeout, host stall, or unspawnable probe expires on a bounded cooldown and is retried, with every offline spawn (including one served from a still-cooling cache) recorded as a degradation instead of silent. The verdict also resets at the start of every session, so running `gh auth login` and starting fresh is picked up immediately, not just after a cooldown. Caveat: this only decides the token a *new* zizmor process launches with — an already-running (warm) zizmor keeps the token it started with until it next respawns, so recovery from a transient failure takes effect on the next spawn, not immediately.

- **Directory-scanning security lanes no longer scan pi-ecosystem scratch trees, and gitleaks recognizes placeholder secrets (closes #1562)** — `gitleaks`, `trivy fs`, and `opengrep` each hand their target directory to an external binary that does its own tree walk, bypassing pi-lens's own scratch/cache-tree exclusion (the same `.pi/`, `.claude/`, `node_modules/`, … list `knip`/`jscpd` already honor). In practice this meant a gitignored web-research cache (`.pi/greedysearch-sources/`) could serve a cached doc example's `YOUR_API_KEY` placeholder to the agent as a leaked secret — a false positive that cost a full turn to debunk. gitleaks excludes a NARROW pi-ecosystem/agent/cache-only tier (a review-round probe caught the first cut over-excluding real leak surfaces like `dist/`, `vendor/`, `.vscode/`, `build/` — those stay fully scanned), via a generated gitleaks config plus a TS-side backstop that demotes (never silently drops) a scratch-tree hit to an info-level, non-blocking finding with a `tracked`/`ignored`/`scratch`/`untracked` observability field. trivy/opengrep exclude the broader existing walker-parity list via their own native mechanism (`--skip-dirs`, `--exclude`). gitleaks also gets a placeholder-secret allowlist (`YOUR_API_KEY`, `<your-key>`, `xxx`, …) and its generated exclusion regexes match both `/` and `\` path separators. An untracked `.env` with a real-shaped secret is still reported — the fix does not become a blanket "respect `.gitignore`". `dead-code-client.ts`'s `VULTURE_EXCLUDES` list, found independently drifted from the shared exclusion list during this sweep, is now derived from it too.

## [4.0.1] - 2026-08-16

### Added

- **Live native-TS7 diagnostic fixture (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — an opt-in (`PI_LENS_INTEGRATION=1`) integration suite launches the real `tsc --lsp` server against a Vitest-typed fixture, asserting no `Mock`/`mockResolvedValueOnce` false positives after settling while an intentional type-error control still surfaces, and recording the publication/settle sequence.

- **Cascade injection, test-targeting, and cache-effectiveness telemetry (Closes #1446)** — `cascade_injected` (what text and how many neighbors/diagnostics reached `blockerParts` — i.e. were queued for the turn-end advisory, not confirmation of agent delivery, which can still be suppressed later; answering item 1) and `cascade_test_targets` (which tests were suggested for cascade neighbors, including the zero-suggestion case, answering item 2) are new `cascade.log` records. `cascade_result` now also carries `cacheHits`/`recentlyCleanHits`/`coldTouches`/`deferredTouches` (item 5's cache-effectiveness measurement, unblocked now that #1444 has landed) and `neighborBudget`/`budgetTruncated` (item 4's telemetry half). Item 3 (`lsp_diagnostics_timeout` server attribution) was already delivered by #1457. Item 4's budget-vs-settle-time sizing change is re-homed as #1462.

- **Four ast-grep rules for the type-laundering smell family (Closes #1447)** — Add `no-unknown-laundering`, `no-conditional-empty-object-spread` (+ `-js`), `no-reflect-apply` / `no-reflect-get` (+ `-js`), and `no-chained-type-assertions` to the shipped rule catalog, alongside the existing `no-as-any` and `no-any-type`.

- **Attribute worklog and disposition entries to the model/provider that produced them (closes #1448)** — `WorklogEntry` and `DispositionLogEntry` gain optional `model`/`provider` fields, populated at append time from the runtime's telemetry identity (`RuntimeCoordinator.telemetryModelId`/`telemetryProviderId`) and blank when the runtime doesn't know its identity. Provider is the host's explicit value when reported, else a conservative parse of the model id (`clients/model-provider.ts`: a single `/` or `:` separator, or a small known-prefix table; blank on ambiguity — never guessed). `npm run logs:smells` now prints a per-model rollup (rule × model counts, auto-fixed vs. agent-required rates) from `worklog.jsonl`. Old worklog/disposition-log entries stay valid; readers treat both fields as optional.

- **Prefix filters for word-index queries: lang, file, ext (Closes #1450)** — `symbol_search`/`pilens_symbol_search` queries can now mix plain terms with composable `lang:`, `file:`, and `ext:` prefix filters, plus `-` negation, e.g. `lang:jsts file:clients/ -file:test rank`. A hand-rolled tokenizer (`parseWordIndexQuery` in `clients/word-index.ts`) splits filters from terms; `lang:` resolves through `KIND_EXTENSIONS` (`clients/file-kinds.ts`), the single source of truth, so there is no second, hand-maintained language list. Filters apply as a pre-ranking predicate, composing with the existing `paths`/`lang` structured options, before BM25/priors/centrality scoring runs. An unrecognized prefix or `lang:` kind throws a typed `WordIndexQueryError` naming the supported list instead of silently degrading to a literal search term.

- **Live classic-TypeScript repair fixture (refs [#1436](https://github.com/apmantza/pi-lens/issues/1436))** — an opt-in (`PI_LENS_INTEGRATION=1`) integration suite stages a real managed-tools tree in the reported broken shape (TypeScript 7 with no `lib/tsserver.js`, cached by a prior probe) inside an isolated `PI_LENS_HOME`, then drives the production spawn through the real installer: it asserts the version-gated repair fires exactly once, the tree self-heals to the pinned classic compiler, and the classic launch serves diagnostics again. A negative control proves a disallowed-install spawn stays discovery-only and mutates nothing.

### Changed

- **docs: document the per-edit autofix routing (refs #1414)** — `docs/agent-guide.md` and `docs/features.md`/`docs/usage.md` now describe the write-immediate vs. edit-deferred autofix split, the authoritative post-fix content attached to `write` tool results (with its size cap and shared multi-file bash budget), and the coalesced `{autofix, format}` deferred-mutation queue that drains at `agent_end`.

- **Skill docs realigned with shipped behavior and split for token economy (closes #1424, refs #1423)** — `pi-lens-write-ast-grep-rule`'s stale `-js` twins gotcha now reflects the #657 NAPI runner grammar dedup (`ruleLanguageForFile` in `ast-grep-napi.ts`) instead of advising a fix that already shipped; its NAPI-runner-specifics and hard-to-express-pattern gotchas moved verbatim into a new `reference.md` behind a pointer, keeping `SKILL.md` to the minimal template, language list, rule-condition syntax, and the load-bearing `stopBy` warning. `pi-lens-ast-grep`'s Structural-intent parameters table now documents `hasKind`/`hasDescendantKind` per-tool (`ast_grep_search`'s `hasKind` is immediate-child only, with a separate recursive `hasDescendantKind`; `ast_grep_replace` has only the immediate-child `hasKind`) with a cross-tool divergence warning, and its Agent task recipes table is trimmed to entries not already covered by the Patterns table. A new `tests/skills/skill-doc-drift.test.ts` pins param names against real tool schemas, path references against the filesystem and `package.json`'s published files, both rule-writing skills' language lists against their `rule-schema.json` enums, and `<!-- verified: ... -->` claim-comment format.

- **Dead code reports the per-turn delta, not the whole project ([#1477](https://github.com/apmantza/pi-lens/issues/1477), refs [#127](https://github.com/apmantza/pi-lens/issues/127))** — turn_end used to re-inject the entire vulture cache every turn. A fixed list of pre-existing findings burned context, and the one actionable signal — a symbol the current edit just orphaned — never stood out. Dead code now follows knip's contract: a new `owns()` seam re-scans only when the turn touched a file of that language, and the result is diffed against the previous scan and filtered to the edited files. Only that delta is injected. It also feeds `projectDiagnosticsDelta`, and the block moved ahead of the delta-report write, so a dead-code-only turn persists a report `lens_diagnostics` can read. A timed-out or killed scan backs the runner off, and a failed scan never overwrites a good cached one. The project-wide list stays available on demand through `lens_diagnostics`. vulture also gets `--ignore-decorators`, so framework-invoked symbols such as fixtures and route handlers stop appearing as permanent noise.

- **The auto-format nudge names the action, not the process** — a cross-process batch used to read "by another pi-lens instance (e.g. a subagent's)", which sent agents investigating what other instance was running. It now reads "by an automatic run outside your turn". Which process formatted the file is an implementation detail the reading agent cannot act on; the origin split stays exact in the telemetry.

- **Word-index resume stats run through a bounded parallel walk (refs [#1409](https://github.com/apmantza/pi-lens/issues/1409))** — session-start incremental refresh now stats source files with a bounded indexed cursor pool (8 workers over libuv's 4-slot threadpool) instead of serial synchronous calls, while publishing metadata in original walk order so churn classification and rebuild preflight remain deterministic. Per-file stat failures retain the previous absent-file semantics, supersession stops new claims and settles in-flight work, and phase telemetry separates snapshot load, deserialize, source walk, stat walk, refresh reads, and the synchronous snapshot-save span.

### Fixed

- **Timed-out pull diagnostics stop aging the LSP server (refs [#1889](https://github.com/apmantza/pi-lens/issues/1889))** — Document and workspace diagnostic pulls now send `$/cancelRequest` when their local deadline expires. Repeated native-TS7 touches no longer leave abandoned requests running and building a server-side backlog; an aging regression test holds outstanding pull work to one across repeated timeouts.

- **TypeScript LSP roots follow governing configs (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — TypeScript and JavaScript files now prefer their nearest `tsconfig.json` or `jsconfig.json` (filtered by extension family — a `.ts` file skips a jsconfig-only directory) while preserving package-boundary client isolation, and classic servers emit bounded, read-only project-association telemetry after the first open. Classic-server tool discovery (`typescript-language-server`, `tsserver.js`) now walks up from a nested config root instead of only checking the root itself. Known accepted tradeoff: honoring nested config roots enlarges the population of roots subject to #1373's pre-existing open-order sensitivity.

- **Post-fix decisions are observable (Closes #1432)** — Add bounded decision telemetry for advisory provenance, TypeScript project identity, deferred mutations, authoritative-content attachment, bus context resolution, and time-scoped smell rollups.

- **Native TS7 diagnostic waits stabilize versionless publication bursts (refs [#1412](https://github.com/apmantza/pi-lens/issues/1412))** — TypeScript wait strategy resolution now follows the launched server variant: classic typescript-language-server retains authoritative first-push seeding, while native `tsc --lsp --stdio` debounces provisional pushes until a quiet window or advertised pull provides the settled result. Bounded latency telemetry records publication shape and settle source without diagnostic text.

- **Stale advisories are historical, not current blockers (refs #1413)** — Turn-end and async test caches record immutable capture provenance and SHA-256-confirm every affected file at delivery. Changed, legacy, malformed, unreadable, truncated, and superseded findings remain non-blocking historical context; deleted per-file findings disappear, while unchanged findings retain live blocker framing across turn and project-sequence drift. Monotonic async-test generations prevent older batches from overwriting newer results, and MCP and in-process delivery share one classification without changing acknowledgement, one-shot consumption, or commit-gate state.

- **Per-edit autofix no longer mutates files mid-turn (closes #1414)** — `edit` queues pipeline autofix for the owning `agent_end`, where autofix runs before the stable formatting pass; `write` keeps immediate autofix and returns authoritative post-fix file content, while write-then-edit paths stay demoted for the rest of the turn. Deferred mutation records coalesce both phases, preserve session ownership, deduplicate project-wide Rust/Dart fixers, and merge independently requeued phases.

- **Bus publishers keep activation context ownership (closes #1415)** — Event producers pair each live emitter with its OWN activation's context (no fallback to a process-global "latest ctx", which could belong to an unrelated sibling activation) and guard lens events through the shared stale-session seam with the same occurrence-scoped failure gating every other producer uses. Lens emit failures now count toward the `bus-stale` degradation smell like every other producer's — intentional: a probe-undefined or resolve/emit race on the lens path is a genuine failure that should be visible, not silently excluded.

- **`ast_grep_replace` now matches `ast_grep_search`'s `hasKind`/`hasDescendantKind` surface (closes #1423)** — `ast_grep_replace`'s `hasKind` param description wrongly said "contain a descendant"; it actually restricts to an **immediate child** (ast-grep default `stopBy: neighbor`), same as `ast_grep_search`. The description now matches the real behavior. `ast_grep_replace` also gained the `hasDescendantKind` param `ast_grep_search` already had — the explicit recursive form (`stopBy: end`) for when the target kind is nested below an immediate child; it's mutually exclusive with `hasKind`, surfaced as a clear synthesis error from both tools. `skills/pi-lens-ast-grep/SKILL.md`'s structural-intent table drops the now-obsolete "replace has no `hasDescendantKind`" divergence warning and documents both params as available on both tools.

- **pi's own CLI output is no longer swallowed by the console guard (Closes #1434)** — The guard used to replace every `console.*` method globally and permanently once the extension loaded. pi's one-shot commands print through `console.log`, so `pi list` exited 0 with no output in any project whose directory loaded the extension first. The guard is now a dispatcher: it routes a write to the extension log only while pi-lens owns execution, and passes every other write to the original console method. Windows open around module evaluation, the extension activation, and every callback registered through the host API's `on` and `register*` members. `AsyncLocalStorage` carries the window across `await` boundaries. Terminal safety is unchanged for pi-lens code paths. One cost note for Node 22: an active `AsyncLocalStorage` adds per-`await` overhead process-wide on that runtime. `PI_LENS_CONSOLE_GUARD=0` disables the guard and avoids that cost entirely.

- **Managed TypeScript self-heals the classic LSP fallback (closes [#1436](https://github.com/apmantza/pi-lens/issues/1436))** — Pi Lens now pins its managed classic compiler to TypeScript 5.9 and repairs existing managed TypeScript 7 trees when `tsserver.js` is absent, while leaving project-local TypeScript 7 on the native `tsc --lsp --stdio` path.

- **Deliver late cascade results instead of dropping them (closes #1443)** — A cascade whose compute missed the turn-end settle cap, or whose neighbor diagnostics landed in the quiet window after the turn ended, was carried over and then discarded unread: the turn-end filter rejected every run stamped with an earlier turn, so the carry-over path was dead code (the two measured cases were the highest fan-out cascades of the day). Late runs now merge into the following turn's output, a run superseded by a newer write is dropped with a logged record instead of silently, and the carry is bounded to one turn.

- **Settle native TS7 cascade checks (closes #1444)** — Cascade neighbor checks on native TS7 no longer burn the in-lane wait budget on a publication that only arrives later; the result is collected in the quiet window instead, and a late clean result now also clears the neighbor's stale footer errors. On the classic (full-wait) lane, a neighbor whose diagnostics wait lapsed used to produce no output at all — which read as "clean" — and now renders an explicit inconclusive note; unconfirmed checks are still never cached as clean. `cascade_result` records how many neighbors were deferred, so a fully-deferred cascade is distinguishable from a clean leaf.

- **Cascade no longer blames the review graph for test-file edits (closes #1445)** — A `missing_node` cascade result had two causes that read the same but meant opposite things: a real gap in the graph, and a test file excluded from the graph by design (#260). Test-file edits now get a distinct `excluded_by_role` reason that stays in telemetry but never reaches the agent as a "review graph was unavailable" advisory. A genuinely missing source file still reports `missing_node` and still triggers the advisory.

- **Test-runner results report a real duration and a real skip count (closes #1452)** — Every vitest and jest run logged `(0ms)`: `parseJsonTestOutput` hardcoded `duration: 0` and read `numSkippedTests`, a field neither reporter emits, so a file with skipped tests also always reported 0 skipped. Duration now comes from the per-suite `startTime`/`endTime` the reporters do emit (wall-clock span across suites, falling back to summed per-assertion durations), and skips come from `numPendingTests` + `numTodoTests`. PHPUnit's own `Time:` summary line — both the `00:00.123` clock form and the legacy `1.23 seconds` / `123 ms` forms — is parsed instead of discarded. An unparseable summary reports no duration at all rather than a wrong figure (see #1479 — this sentence read "still reports 0" until that landed, and both entries are still unreleased, so one release's notes would otherwise have contradicted the other).

- **Keep the tool list stable across forks and reloads (closes #1453)** — When a session is forked, reloaded, or resumed, pi tells the model about every pi-lens tool again. Pi-lens now puts the list straight back to what it was, including the situational tools the model had activated, so the tool list the model sees does not change and your cached prompt still applies. Use `--no-lazy-tools` (or `tools.lazy: false`) to keep every tool active from the start instead.

- **Auxiliary LSP scanners reach per-edit diagnostics (closes #1458)** — Auxiliary waits now use each server's declared budget within a 2-second post-primary ceiling, in both the touch push wait and the `getDiagnostics` aggregation lane. Late findings carry into the next unchanged-content read only when their SHA-256 content binding matches. Each touch logs a per-scanner wait outcome (answered, silent, or cut off — decided from evidence of an actual publication, not just promise settlement) with elapsed time and effective budget. Independently diagnosed by @snowyukitty, who reported the same root cause and both affected call sites in #1471.

- **Secret blockers no longer name deleted files (refs #1461)** — Scanner result caches were valid on wall-clock age alone, so a gitleaks finding stayed a 🔴 STOP blocker for the rest of its 30-minute window even after the file it named was deleted. The advisory provenance guard did not catch it: it validates the files the agent edited, not the paths inside the findings. Provenance now also validates finding-cited paths, and gitleaks drops findings whose path is gone rather than demoting them — a deleted file offers no remediation. Findings on files that still exist are delivered unchanged; a finding with no path, an unreadable path, or a path past the stat budget is still delivered. Each drop writes one bounded `finding_dead_path_drop` record to `latency.log` naming the store, the count, and a sample of paths.

- **A slow tool check no longer disables an installed tool for the whole session (closes #1467)** — When the check for knip, madge, govulncheck, or vulture timed out, pi-lens remembered the tool as unavailable until you restarted, and told you to install a tool that was already installed. Knip produced no findings for weeks because of this. A timed-out check is now retried after a short wait, the message says the check timed out instead of naming the wrong cause, a failed run keeps the last good cached findings instead of overwriting them, and each decision is recorded in `latency.log` as an `availability_decision` entry with its cause and timing.

- **A cut-off auxiliary no longer reads as confirmed clean (closes #1470)** — When
  the aux grace timer cut off a scanner such as opengrep, the touch still reported
  an unqualified confirmation, so a hung security scanner produced a result that
  read as a clean bill of health. The confirmation is now narrowed rather than
  discarded: it names the servers it does not speak for, so `lsp_diagnostics` says
  which coverage is missing, the cascade no longer wipes a live finding on that
  evidence, the workspace sweep stops caching a partially covered result, and the
  per-edit lane stops reporting an empty result as checked — on both the
  incumbent-touch route and the warm-attach route a per-edit check takes in a
  live session. A primary that
  answered stays trustworthy — its findings still reach you. A related gap stays
  open: a scanner that answers within its budget but publishes nothing still reads
  as clean (#1493).

- **A slow first probe no longer disables an installed tool (closes #1476)** —
  biome, ast-grep, Go, cargo and govulncheck now tell a probe timeout apart from
  a missing install, the way knip and madge already did. Before this, one stalled
  second at session warm-up latched "tool is not installed" for the life of the
  process, and biome and ast-grep also paid for an auto-install nobody needed. A
  transient verdict now expires and the tool comes back without a restart, its
  cooldown escalates on a host that stays sick, and every verdict is recorded in
  `latency.log` as `availability_decision` with its cause, timing and retry
  schedule. A structural coverage test parses `clients/` and fails when a new
  version probe parks its verdict outside the shared policy; the hand-rolled
  latches that predate it are listed in the test as named, shrink-only gaps
  rather than being quietly excluded.

- **The turn-end log no longer prints `(0ms)` for a run it never timed (closes #1479)** — `(0ms)` was the string for a genuinely sub-millisecond run *and* for one nobody measured: a JSON payload with no suite timestamps, a pytest or ExUnit or PHPUnit summary the parser could not read, a runner error, or nothing run at all. A reader could not tell "measured 0" from "unmeasured", which is the confusion #1452 was reported for in the first place. `TestResult.duration` is now absent when the run was not measured — a present `0` means a real reading, because pytest does print `in 0.00s` — and the turn-end line renders `(unmeasured)` for the absent case. The agent-facing `formatResult` string is unchanged.

- **Cargo, dotnet, maven, rspec and minitest runs report a real duration (closes [#1480](https://github.com/apmantza/pi-lens/issues/1480))** — `parseGenericRunnerOutput` read an elapsed time only for go, so the seven runner families behind it reported a hardcoded `0`. Each duration is now parsed from the summary block the runner already prints, and each pattern is applied only to the runner it belongs to, so no runner can be scored by another's output — gradle used to satisfy go's probe and report the whole-build wall clock as test time. Gradle stays unmeasured: its console summary carries no test elapsed time. Multi-module maven runs are scored by the sum of their surefire aggregates rather than by a single module, a runner that exits non-zero can no longer be reported as a pass, and the pytest and ExUnit summaries are rounded to whole milliseconds instead of logging `2009.9999999999998ms`. The "was this measured at all" rule now lives in one module (`clients/run-duration.ts`) that both the turn-end log and the agent-facing summary read, rather than being re-derived at each site.

## [4.0.0] - 2026-08-14

### Added

- **Bounded LSP pull-failure telemetry and lifecycle test seams (refs [#1292](https://github.com/apmantza/pi-lens/issues/1292), items 1 and 3)** — advertised diagnostic-pull failures retain the latest ten operational failures and surface through LSP status, while unsupported `-32601` responses remain unreported; focused coverage exercises acquisition races, descendant cleanup, shutdown protocol races, and honest push fallback. Strategy-gated `didSave` (item 2) remains out of scope.

- **Occupancy and CPU-budget tests stay in the timing-sensitive Vitest project (refs [#1230](https://github.com/apmantza/pi-lens/issues/1230), [#1081](https://github.com/apmantza/pi-lens/issues/1081))** — a meta-test derives the expected membership of `timingSensitiveInclude` from the tests themselves (importing `measureMaxSyncBlockMs` from the perf harness, or calling `process.cpuUsage`) and fails if such a test lands outside the project, so the quiet phased measurement window can't silently drift. It reads the real config object rather than its source text (a commented-out entry is a removal, not a match), and rejects include entries whose file no longer exists so a rename can't leave a dead glob behind. The six sampler tests that had already drifted out are phased back in.

- **Claude Code `Stop` hook drives the per-turn pass ([#538](https://github.com/apmantza/pi-lens/issues/538))**. `pi-lens-analyze` gains a warm-server-only turn-end mode (`--turn-end`, or a `Stop` payload on stdin) that runs incremental knip/madge, dependency checks, tests, and actionable-warning aggregation without an explicit `pilens_turn_end` call. PostToolUse records edited files in workspace turn-state and clears inherited pi session stamps before Stop consumes it. The tagged request shares the one-shot workspace IPC endpoint and build-staleness gate with warm analysis. A server-side FIFO orders every analyze and turn-end request, so a timed-out hook cannot let Stop overtake analysis still running in the server or race another turn-end. Missing or incompatible warm servers produce one reason-specific line on stdout and stderr and exit 0 (see the turn-end residuals entry below). `SubagentStop` stays excluded because its edits already feed the shared worklist.
- **Generic read-recording bridge for co-process extensions ([#1265](https://github.com/apmantza/pi-lens/issues/1265))** — `clients/read-bridge.ts` mounts a `ReadBridge` object at `globalThis[Symbol.for("pi-lens:read-bridge")]` after the runtime initialises. Any co-process Pi extension whose custom tool performs file reads outside pi-lens's normal tracking can call `bridge?.recordRead({ filePath, requestedOffset, requestedLimit })` so that a subsequent `edit` on the same file is not blocked by the read-before-edit guard. The bridge is an advisory, trust-based protocol — it stamps the timestamp itself (`Date.now()`) matching the internal read path, and validates that `filePath` is a non-empty string and offsets/limits are finite positive integers. The bridge respects `no-read-guard`, gitignored paths, and external/vendor paths (consistent with the bash read-guard gate). Registration is a factory-scoped singleton (`_readBridgeRegistered` guard, same pattern as `_turnSummaryEmitRegistered`); `_readBridgeGetFlag` is refreshed on every factory activation so flag changes take effect immediately. Absent bridge is safe — producers guard with `bridge?.recordRead()`.

- **tflint respects the project's linter policy (refs #1117)** — every other
	dispatch runner gates on `getLinterPolicyForCwd` before spawning; tflint was
	the one that never consulted it, so a project that elected a different
	terraform linter got tflint's findings on top of its own. It now skips when
	policy does not prefer it, matching golangci-lint and terragrunt. A project
	`.tflint.hcl` is also an explicit opt-in, so `hasTflintConfig` promotes the
	`.tf`/`.tfvars` policy from `smart-default` to `config-first` the same way
	`.golangci.yml` does for Go. tflint ships built-in rules and still runs
	unconfigured, so the no-config default is unchanged. `hasTflintConfig` takes
	the edited file's directory rather than the project cwd, so it agrees with
	what the runner hands tflint: keyed off cwd it would miss every `.tflint.hcl`
	living in a terraform subdirectory, which is the common monorepo layout.
- **tflint honors a repo-root `.tflint.hcl` (refs #1117)** — tflint resolves
	`.tflint.hcl` from its own working directory and never walks parents (its only
	fallback is `~/.tflint.hcl`), and the runner spawns it from the edited file's
	directory. A config at the repo root therefore governed nothing beneath the
	root: every module under `modules/` or `envs/` was linted with tflint's
	built-in defaults, silently. The runner now walks up for the nearest
	`.tflint.hcl` (via `findNearestDirWithAnyBasename`, so the same home-guarded,
	depth-capped, mtime-invalidated walk every other marker lookup uses) and
	passes it as `--config`. Skipped when `TFLINT_CONFIG_FILE` is set, since
	`--config` outranks the env var in tflint's own precedence and would override
	a deliberate choice. A `.tflint.hcl` that declares an uninitialized plugin
	makes tflint exit non-zero with empty stdout, which the guard fix above turns
	into a SKIPPED rather than a false clean.
- **Terragrunt linting and formatting** — `terragrunt.hcl` and `root.hcl` now
	get a dedicated `terragrunt` file kind (filename-detected only; a plain
	`.hcl` file stays unmapped). New `terragrunt` dispatch runner wraps
	`terragrunt hcl validate --json`, and a `terragrunt-hcl` formatter wraps
	`terragrunt hcl fmt`. Terragrunt has no LSP, so it's runner/formatter only.
	The `terragrunt` binary installs the same way as tflint (GitHub release,
	bare per-platform binaries). Also added `.tf` (Terraform) to the
	`trivy-config` runner's `appliesTo`, gated behind the existing
	`trivy.enabled` opt-in — terragrunt itself stays excluded from trivy, since
	trivy evaluates the Terraform language, not terragrunt config. The two
	entrypoint filenames live in one exported `TERRAGRUNT_FILENAMES` constant
	(`clients/file-kinds.ts`, same single-source-of-truth pattern as the .NET
	root markers), consumed by kind detection, both tool policies, the formatter,
	and the project/root markers; `tests/clients/terragrunt-filenames.test.ts`
	derives its cases from that list, so a call site that hand-copies it and
	misses a name fails CI. Diagnostic ids carry line, column, and a normalized
	message slug (the actionlint shape), because `hcl validate` reports no rule
	code: a line-only id collapses two findings at one position in the
	dispatcher's dedupe (keyed `filePath:line:column:defectClass:rule||id`) and
	hides a changed finding from delta mode, which keys on `id` alone.
	Diagnostics are attributed by resolving `range.filename` against the unit
	directory and comparing through `pathsEqual`, not by basename: a unit that
	pulls in its parent via `find_in_parent_folders()` gets diagnostics from a
	parent `terragrunt.hcl` whose basename matches the edited file's but whose
	line numbers belong to another file.
- **Smells self-surfacing (refs #1123 item 3)** — `scripts/analyze-pi-lens-logs.mjs`
	(`npm run logs:smells`) already catalogues a wide set of operational smells,
	but it's MANUAL: the #1123 investigation found 20 stale-ctx `emit_failed`
	rows and 37 opengrep respawns sitting unread in logs for days until an audit
	went looking. `clients/smells-rollup.ts` adds a small ALWAYS-ON rollup
	covering the two smells the issue named, without touching every producer of
	the underlying logs and without re-scanning the size-rotated (up to ~10MB,
	`clients/log-cleanup.ts`) `~/.pi-lens/*.log` files on the `session_start`
	hot path. **Design + cost bound:** a BOUNDED TAIL READ — at most 64KB from
	the end of `bus-events.log` and `latency.log` each (~128KB total I/O per
	check, enforced by construction via a single sized `fs.readSync` at a
	computed offset in `tailReadText`, never a full-file scan) — counts
	stale-ctx `emit_failed` rows (`outcome === "emit_failed"` with an `error`
	containing the SDK's `"stale after session replacement"` fragment,
	`session-lifecycle.ts`'s documented benign-but-worth-watching class) and
	opengrep respawns (`phase === "lsp_server_respawn"` with
	`metadata.serverId === "opengrep"`, `clients/lsp/index.ts`'s existing,
	unmodified respawn log point). Because both source logs are append-only
	NDJSON, the tail is always the most recently written activity, so the same
	bounded read serves as both the cross-session glance and a live/this-session
	proxy — no separate write-time counters were added at the producer call
	sites (kept the change to one new module + three call sites). Both counts
	are gated by trivial threshold constants (`SMELLS_THRESHOLDS`, currently 5
	each) so a single stray event never surfaces. Three surfaces: one
	`session_start` line (only emitted when a threshold trips, via
	`runtime-session.ts`'s `emitSmellsSessionStartLine`), an always-on compact
	`/lens-health` line (current counts regardless of threshold, matching
	#1123 item 2's `formatMemoryHealthLine` style), and a `turn_end` note —
	re-checked every 20 turns (`shouldCheckSmellsThisTurn`), `ctx.ui.notify`d
	at most ONCE per smell per session (`checkSmellsAndNoteOnce`'s gate,
	re-armed by `resetSmellsSessionState()` at the next `session_start`).

	New tests: `tests/clients/smells-rollup.test.ts` (tail-scan cost bound
	proven by construction — a file far larger than the byte budget only ever
	yields tail content; threshold gating for both the session_start line and
	the always-on health line; the once-per-session notify gate) and
	`tests/index-smells-rollup-wiring.test.ts` (turn_end wiring: nothing before
	the check interval, one notify at the trip turn, no repeat notify on the
	next check turn for an already-notified smell) — fail-then-pass verified
	against a deliberately broken threshold gate.

- **Oxfmt formatting support for Svelte (refs #1134)** — `.svelte` is now a
	recognized oxfmt extension, gated by a stricter conditional than oxfmt's
	other extensions. Empirically verified against the real `oxfmt` npm
	package (0.62.0, scratch fixture outside vitest, per this repo's
	verify-the-CLI-contract-empirically rule): oxfmt requires BOTH the
	`svelte` package installed AND the config's `svelte: true` flag enabled —
	either alone exits non-zero ("excluded by ignore rules" or "Cannot find
	module 'svelte/compiler'"), only both together format the file. The new
	`hasOxfmtSvelteConfig` (`clients/tool-policy.ts`) encodes this, consulted
	only for `.svelte` in `formatters.ts`'s `hasExplicitFormatterConfig` — the
	other oxfmt extensions are unaffected. **Class fix**:
	`oxfmtFormatter.extensions` (`clients/formatters.ts`) and
	`OXFMT_SUPPORTED_EXTENSIONS` (`clients/tool-policy.ts`) were two
	hand-maintained parallel lists (the #883 single-source-of-truth class);
	`OXFMT_SUPPORTED_EXTENSIONS` is now exported as the sole source of truth
	and `oxfmtFormatter.extensions` derives from it directly, plus a drift-guard
	test asserting they stay equal. `docs/language-coverage.md`'s Svelte row now
	reports the formatter and its gating condition instead of "—". The real
	format-smoke fixture (`tests/fixtures/format-smoke/`) was NOT extended: no
	fixture in that harness has a dependency-install step (oxfmt itself is only
	found via a global `which` lookup, not `ensureTool`), so a svelte fixture
	would either always skip (oxfmt unavailable) or, worse, hard-fail with a
	"Cannot find module 'svelte/compiler'" error if oxfmt happened to resolve
	without svelte actually installed — a strictly worse risk than omitting it.
- **Instance health + memory-attribution observability (refs #1123 item 2)** — the #1126 sizing study diagnosed a 1.37 GB pi-lens instance from code + serialized artifacts alone, because nothing recorded a per-subsystem trajectory over time (the same detection-without-attribution gap loop_block had before #1122/#1125). Three pieces:
	1. **Vanished-instance markers.** `deregisterInstance()` (`clients/instance-registry.ts`) synchronously removes a process's own `instances.json` entry on a clean `session_shutdown`, so an entry whose owning pid is confirmed dead is, by construction, proof that process never reached that shutdown path — no new "clean shutdown" flag is needed, the existing `heartbeatAt`/`rssBytes` fields already ARE the "lastSeen"/"rss" pair this needs (`clients/vanished-instance-marker.ts`). `session_start` now reads the registry and logs one `sessionstart.log` line per such entry — `previous instance pid X last seen <ts> (RSS <y>MB) exited without shutdown` — BEFORE `sweepOrphans()` (`clients/instance-reaper.ts`) prunes those same dead-pid entries; the read is sequenced ahead of the sweep (via `.finally()`) rather than let both fire-and-forget calls race, or the vanished set would already be empty by the time the marker ran.
	2. **Periodic memory-attribution sample (`clients/memory-sampler.ts`).** Every 10 turns, one `memory_sample` `latency.log` phase line: `process.memoryUsage()` (rss/heapUsed/heapTotal/external/arrayBuffers) plus O(1)/O(bounded-cache-size) per-subsystem counters — review-graph resident workspace-cache entry count + summed node/edge counts (`getReviewGraphWorkspaceCacheSnapshot`, `clients/review-graph/builder.ts`), word-index doc/posting/forward-entry counts (off `runtime.wordIndex`), loaded tree-sitter grammar/parser/query-cache counts + tree-cache size/bytes (`TreeSitterClient.getRuntimeStats`, `clients/tree-sitter-client.ts`), and the dispatch cascade's turn-bounded cache sizes (`getDispatchCascadeCacheStats`, `clients/dispatch/integration.ts`). Every field is a `Map`/array `.size`/`.length` read or a `process.memoryUsage()` call — nothing iterates a large structure's contents, nothing snapshots the heap. **Documented gap vs the #1126 spec:** the WASM linear-memory byte length (`Module.wasmMemory.buffer.byteLength`) is NOT included — inspecting the installed web-tree-sitter 0.25.10 package confirmed that value lives in a private closure (`bindings.ts`'s `Module` singleton) with no public export, and reaching it would require either internal reflection (brittle across versions/bundling) or overriding Emscripten's `wasmMemory` init option with a hand-built `WebAssembly.Memory` (risks a memory-import mismatch breaking ALL structural analysis, for an observability-only feature) — `process.memoryUsage().arrayBuffers` is used as the process-wide proxy instead (WASM linear memory backs an ArrayBuffer, so it's already included there).
	3. **`/lens-health` memory block**, reusing the same sample: RSS/heap/external plus the tree-sitter cache byte total and review-graph node/edge counts, following #1125's compact single-line health-line style. `instances.json`'s `rssBytes`/`heartbeatAt` refresh cadence needed no new wiring — `runtime-turn.ts`'s existing per-turn `updateHeartbeat()` call (#449 slice 1) already refreshes both every turn, which is what makes marker (1)'s RSS meaningful.

	New tests: `tests/clients/vanished-instance-marker.test.ts` and `tests/index-vanished-instance-wiring.test.ts` (fail-then-pass: a dead-pid entry logs the marker and is still pruned afterward; a live-pid or already-clean entry logs nothing), `tests/clients/memory-sampler.test.ts` and `tests/index-memory-sample-wiring.test.ts` (cadence assertions: nothing before turn 10, exactly one sample at turn 10 and turn 20, never in between), and an `/lens-health` line assertion in `tests/index-wiring.test.ts`.

- **Handle-origin tracer debug flag (refs #1123 item 4)** — institutionalizes the `async_hooks` handle-origin tracer the #1097 print-mode-hang investigation hand-rolled to find a leaked, ref'd `setTimeout` (root-caused/fixed in #1110); the #1097 reporter explicitly asked for a built-in active-resource dump. New `clients/debug-handles.ts`: `PI_LENS_DEBUG_HANDLES=1`, read ONCE at extension load, gates everything — unset (the default), every export is a no-op past that one boolean check (no writer constructed, no `async_hooks` hook installed, zero cost on the hot lifecycle paths that call it). Set at startup, `dumpActiveHandles(label)` writes one ndjson line to `~/.pi-lens/debug-handles.log` (standard `createNdjsonLogger` family — size-bounded, log-cleanup registered) with `process.getActiveResourcesInfo()` counts by resource type, plus per-type creation-site stack attribution from a bounded `async_hooks` tracker (capped at `TRACKER_MAX_ENTRIES`, never unbounded growth even in a timer-storm session) that is itself only installed when the flag was already on before the module first loads (real per-resource-creation overhead, strictly opt-in). **Eviction is protect-the-earliest, not drop-oldest**: a #1097-style leak is typically among the earliest handles created in a session, so a naive drop-oldest policy would let a later burst evict exactly that evidence — the first `TRACKER_PROTECTED_COUNT` insertion-order entries are pinned, eviction targets the oldest entry outside that zone, and a running `evictedCount` is always present on the dump entry once the tracker is installed (even at `0`) so an attribution gap from a burst past the cap is explicit rather than silent. Wired into `index.ts` at two points: `agent_settled` (after the quiet-window work is scheduled — the #1097-class leak is only visible once whatever settle itself queued is in flight) and `session_shutdown` (after teardown — whatever survives IS the leak). New tests: `tests/clients/debug-handles.test.ts` (flag unset → no writes/no tracker; flag set → dump emitted with type counts + creation-site attribution; a 600-entry burst against the 500 cap proves the first `TRACKER_PROTECTED_COUNT` and the most-recent entries both survive with `evictedCount` correctly reported; a real `async_hooks.createHook`-driven test creates and clears a genuine `setTimeout` and asserts it appears in, then is pruned from, a real dump) and two fail-then-pass wiring cases in `tests/index-integration.test.ts` asserting the dump fires strictly after LSP teardown / after quiet-window scheduling at the real `index.ts` seams.

- **Source-walk generated-artifact escape hatch (closes #1107, phase 2 of 2)** — three pieces, building on phase 1's
	counters (#1111):
	1. **Directory-level skip counting.** `shouldRecurseIntoDir`'s
		`isGeneratedArtifactDirectoryName` branch (`clients/source-walker.ts`)
		pruned whole directories (`generated/`, `codegen/`, `__generated__/`, …)
		with zero counting — an entire directory of real files reported zero.
		`SourceCollectionResult` gains `generatedDirSkips`, threaded through the
		same `SourceWalkSkipCounters` seam and the `source_walk_skip_summary`
		log line: one count per PRUNED DIRECTORY, never per file inside it
		(enumerating the contents would defeat the pruning). The
		`SourceCollectionResult` docblock's former "KNOWN GAP" note is now the
		real field doc.
	2. **Tool-facing surfacing.** `ProjectDiagnosticsSnapshot` gains
		`generatedFileSkips` (the raw skip total, all evidence tiers),
		`generatedNameOnlySkips` (the narrower at-risk subset — see below), and
		`generatedDirSkips` (only present, like `scanTruncated`, on a walk that
		actually ran); `lens-engine.ts`'s new `generatedSkipNotice` renders a
		one-line notice mirroring `scanTruncationNotice`'s (#784) style, wired
		into both `pilens_project_scan` (`mcp/server.ts`) and `lens_diagnostics`
		(`tools/lens-diagnostics.ts`). **Review fix (P1, empirically proven):**
		the notice originally keyed off the raw `generatedFileSkips` total,
		which includes STRONG evidence (lockfiles, declaration files,
		minified/bundle/chunk output — expected on almost every real repo) —
		a repo with just `index.ts` + an ambient `.d.ts` + `package-lock.json`
		showed "2 file(s) excluded" on every single scan, forever. It now keys
		off `generatedNameOnlySkips`: the narrower bucket of WEAK name matches
		trusted with NO corroborating evidence check at all (only reachable
		when a caller opts out of the header probe; the default project-walk
		path always enables it, so this is rare-to-zero in practice — meaning
		the notice is meaningful again when it does fire) plus `generatedDirSkips`
		(directory pruning has no escape hatch, so it stays a genuine
		unverified-content signal). Both `ProjectDiagnosticsScanOptions` and
		both tool surfaces also gain `includeGenerated` (default `false`) so
		the notice's opt-out advice is now actually actionable, not just prose.
		`module_report` has no walk-backed section of its own to thread this
		through (it reads the cached review graph, built asynchronously
		elsewhere) — surfaced in the project-diagnostics path only.
	3. **Content-probe escape hatch (the actual behavior change).** A file
		matching a generated-artifact NAME pattern but with no other evidence is
		now KEPT instead of silently dropped. `generated-artifacts.ts` splits
		its path check into STRONG evidence (a generated directory segment, a
		lockfile, minified/bundle/chunk output — always conclusive, never
		rescued) and WEAK evidence (the remaining filename-regex patterns, e.g.
		`gen.ts`, `foo_generated.go`); `classifyGeneratedOrArtifactDetailed`
		requires a WEAK match to be corroborated by a generated-code header in
		the first 4 KB (or the sibling-source probe already checked upstream by
		every walk-driven caller) before treating it as an artifact —
		cheapest-first. With NEITHER piece of evidence, the file is KEPT and
		counted under the new `generatedNameOverrides` counter (also in the
		rollup log line), so the heuristic's rescues stay observable.
		**Review fix (P2, maintainer decision):** minified/bundle/chunk output
		(`bar.min.js`, `vendor.bundle.js`, `runtime.chunk.js`) is STRONG tier,
		not WEAK — the escape hatch's evidence checks are structurally dead for
		it (minifiers strip banners, so the header leg never confirms; the
		sibling probe looks for `app.min.ts`, never `app.js` at a different
		stem, so that leg is unreachable too), which would have made it a
		PERMANENT override rather than an occasional rescue. `app.min.js` next
		to `app.js` is skipped unconditionally, like a lockfile.
		Shipped **ungated**: the existing `LENS_FLAGS`/`.pi-lens.json` toggle
		registry is sized for whole-subsystem behavior (lsp/tests/delta/…), not
		a narrow heuristic refinement, and the new `includeGenerated` scan
		option (item 2) already gives callers a full, actionable opt-out.
		Documented tradeoff: a false-KEEP (one extra file an agent must judge)
		is preferred over the prior silent false-DROP (a real file invisibly
		never analyzed) — this changes walk output for review-graph/word-index/
		project-diagnostics/call-graph: a repo with a real `gen.ts` gains
		coverage (intended), and a repo with a headerless generated file with
		no source twin also gains coverage (accepted per the issue). Invariant
		preserved for lockfiles, declaration files, minified/bundle/chunk
		output, and anything the ignore-matcher/extension filter already
		excluded — none of those are ever rescued.

- **Machine-wide test-suite lock (closes #1101)** — `npm test` /
	`npm run test:unit` / `npm run test:integration` now route through the new
	`scripts/with-test-lock.mjs` wrapper, which acquires a single machine-wide
	`~/.pi-lens/test-suite.lock` before running vitest and releases it after.
	Concurrent full-suite runs (several agents on parallel worktrees, plus an
	interactive run) previously each spawned a fork pool sized for a dedicated
	machine and fought over CPU/RAM, producing vitest worker-crash cascades and
	timing-budget flakes that looked like real bugs but weren't; they now
	serialize (queue) instead. A waiting run prints a heartbeat line at least
	every 15s so it never looks hung, and — mirroring the installer's
	`.install.lock` — a lock is stale only after its recorded PID is confirmed
	dead, never on a timer alone. `PI_LENS_TEST_NO_LOCK=1` opts out (CI sets it:
	runners are isolated, nothing to serialize against). Lock core lives in
	`scripts/lib/suite-lock.mjs`, unit-tested for acquire/release, dead-PID
	takeover, and real contention (two concurrent acquisitions serialize).
	AGENTS.md documents the lock plus the companion layer-1 policy for agents
	running tests concurrently.
- **Source-walk skip observability (refs #1107, phase 1 of 2)** — the source
	walk silently dropped real files whose NAMES match generated-artifact
	heuristics (a real `src/gen.ts` was invisible to
	`collectSourceFilesWithBudgetAsync`, with nothing anywhere saying so), an
	invisible coverage hole for review-graph/word-index/project-diagnostics/
	call-graph. This phase adds observability only — it does NOT change which
	files get skipped (a content-probe escape hatch for name-only matches is
	phase 2, tracked on #1107). `SourceCollectionResult` gains two additive
	optional counters, `generatedOrArtifactSkips` (files dropped by the
	generated/artifact NAME heuristic, `isGeneratedOrArtifact`) and
	`buildArtifactSkips` (files dropped by the sibling-source artifact probe,
	`isBuildArtifact`) — separate from extension-filter and ignore-matcher
	skips, which are policy/config driven rather than name/content heuristics.
	Both `collectSourceFilesWithBudget` and `collectSourceFilesWithBudgetAsync`
	(and therefore every consumer that funnels through them — the review-graph
	builder, project-diagnostics scanner, word index, etc.) populate the
	counters and log a one-line rollup through the existing `logLatency`
	channel (`phase: "source_walk_skip_summary"`) whenever either is nonzero; a
	clean walk with no name/artifact-probe skips emits no new log line.
	`tests/clients/graph-cache.test.ts`'s two remaining `gen.ts` fixtures
	(unrelated to the drift-path test, which already used `alpha.ts`) were
	unknowingly exercising empty-walk graph builds — renamed to
	`buildstamp.ts`/`identitycheck.ts` since their assertions only cover
	buildGeneration/cache-mode plumbing, never node/symbol content, so the
	rename is behavior-preserving.

- **LSP diagnostics content binding (refs #1095, first PR)** — diagnostics are now
	bound to the document content they were computed against, so a consumer can ask
	"were these diagnostics computed against what's on disk now?" instead of inferring
	staleness purely from mtime/TTL proxies. When a `publishDiagnostics` notification
	echoes a document `version`, the owning client binds the stored diagnostics to a
	content fingerprint captured at didOpen/didChange SEND time (never a disk read on
	the notification path). `getAllDiagnostics`/`touchFile` results and the
	workspace-diagnostics cache lookup expose a `binding`
	`{version?, contentHash?, boundToCurrentDisk: boolean | "unknown"}`, with
	`boundToCurrentDisk` verified lazily against disk (memoized per file+mtime, reading
	with the identical raw-UTF-8 transform the payload was built with, so CRLF/BOM files
	round-trip correctly). Across multiple contributing clients the merged binding is
	`false` if ANY contributor mismatches disk, `"unknown"` if all are unknown, else
	`true`. `tools/lsp-diagnostics.ts` adopts it: a result whose binding demonstrably
	mismatches disk (`boundToCurrentDisk === false`) is demoted to inconclusive — even a
	non-empty result no longer re-cements the footer with a stale view (the #1092
	re-cementing path). Servers that never report a version yield binding `"unknown"` and
	behave exactly as before (zero regression). Binding state (`bound`/`mismatch`/
	`unknown`) is logged in the `lsp_touch_file` latency metadata so an unbinding server
	is diagnosable. Non-goals (stated for follow-ups): cascade adoption in
	`clients/dispatch/integration.ts` (#1094 in flight), pull-diagnostics resultIds,
	widget per-entry timestamps (#1093), aux retag (#1094).
- **Cascade adopts LSP diagnostics content binding (refs #1095, second PR)** — the
	cross-file cascade (`clients/dispatch/integration.ts`) now consults the `binding`
	from the first PR instead of trusting a snapshot purely on TTL. A passive neighbor
	snapshot whose diagnostics `boundToCurrentDisk === false` (the server's view diverged
	from disk — e.g. the PRE-fix content) is no longer reconciled into the footer widget;
	it falls through to an active touch on the existing cold-snapshot budget. This kills
	the window where the first cascade after a fix-edit replayed a neighbor's stale
	pre-fix snapshot. `"unknown"` bindings (version-less servers) keep EXACTLY the prior
	TTL-only behavior; `true` reconciles (TTL stays the outer bound). On the active-touch
	path, a bound-false result is treated like `inconclusive` — composed with it into one
	`isConfirmedTouch()` predicate so a future flag can't be missed at just one gate — so
	it neither reconciles nor seeds the recently-clean neighbor cache. `observedAt` stays
	the snapshot's publish time (`entry.ts`) for passive reconciles. Binding-rejected
	snapshots/touches are logged through the cascade channel (`bindingState`). The lazy
	binding getter is re-read from a fresh `getAllDiagnostics()` every cascade run (the
	Map is never retained across turns), with a caveat comment guarding future retention.
- **Project-level rule policy via `.pi-lens.json` `rules.<id>.disable` / `rules.<id>.select`** — a project's own config can now narrow what diagnostics actually surface.
	Filtering is output-only, so the baseline, widget state, and dedup cache stay
	authoritative and a policy edit never corrupts or resets delta tracking.
	Matching is project-wide: the `<id>` key is a grouping label, not a filter
	scope. Every `disable` list across every key unions into one drop list, and
	every `select` list unions into one allowlist, so
	`"security": { "disable": ["no-eval"] }` works without `security` being a real
	rule id. Disable wins over select project-wide, even across different keys.
	A non-empty `select` anywhere silences every rule not listed, project-wide, so
	it is a big hammer (documented as such). Findings that carry no rule id at all
	are exempt from select, since `Diagnostic.id` is a dedup key rather than a rule
	id and measuring it against an allowlist would have silently dropped blocking
	findings like eslint parse errors. A user lists `no-eval` once and the filter
	covers `no-eval`, `ast-grep:no-eval`, and `no-eval-js`, reusing the rule-id
	normalization that `inline-suppressions.ts` and `tools/lens-diagnostics.ts`'s
	dedup already applied, now consolidated in
	`clients/dispatch/rule-id-normalize.ts` so the three surfaces cannot drift.
	That normalization conflates a rule whose real name ends in `-js` with its stem
	(`prefer-js` with `prefer`), which is now pinned by tests and called out in the
	docs. The policy applies on the per-edit dispatch path
	(`clients/dispatch/dispatcher.ts`, resolved from the project root so a
	package-local `.pi-lens.json` in a monorepo cannot shadow the root's policy,
	matching the root `lens_diagnostics` already used) and across the
	`lens_diagnostics` tool's `mode=delta`, `mode=all`, and `mode=full` paths. Each
	loads the policy map once per call, filters after inline suppression and
	disposition, and re-summarizes so the blocking/error/warning counts reflect the
	drop, including delta mode's carried-over tally and mode=full's structured
	`details` counts. Silencing a rule is not the same as fixing it: the stored
	delta baseline is deliberately unfiltered, so dispatch compares against a
	policy-filtered view of it. Otherwise every disabled finding would sit in the
	"fixed" set on every single dispatch and inflate the agent's resolved tally
	(`trackAgentFixed`) for as long as the rule stayed off. In the project-config
	loader (`clients/project-lens-config.ts`) the lists are tolerantly parsed: a
	non-array, an empty array, or an all-whitespace array is logged once and
	dropped, matching the existing threshold-validation warn-once contract.
	Threshold-only entries are unaffected, and policy-only entries (no `threshold`)
	coexist cleanly. A `rules.<key>` whose value is not an object, or whose keys are
	all unrecognized, now warns instead of failing silent. That covers the two
	shapes a user is most likely to reach for first, both of which parse as valid
	JSON and do nothing: the lists written directly under `rules`, and `only`
	instead of `select`. New `tests/clients/dispatch/rule-policy.test.ts` (32 tests:
	matcher, normalization, project-wide grouping, disable-wins-select, the `-js`
	conflation, the rule-less select exemption, and the hot-path fast return),
	`tests/clients/dispatch/rule-policy-dispatcher.test.ts` (13 tests covering
	dispatch integration including monorepo root-versus-nested config resolution,
	baseline integrity, and the resolved-tally guard),
	`tests/clients/dispatch/rule-id-normalize.test.ts` (7 tests), and
	`tests/tools/lens-diagnostics-rule-policy.test.ts` (14 tests covering the three
	mode paths, `select` on the cache-only paths, the delta carried-over tally, the
	project-diagnostics delta report, and mode=full's structured details).
	Docs: `docs/settings.md` and `docs/globalconfig.md` gained the project-wide
	schema, precedence, the select warning, and the `-js` caveat. #533 hygiene is
	preserved, so typo and unknown-key warnings still surface and foreign LSP
	namespaces stay silent.

- **Document the standing maintenance-routines menu (invoke-on-request)** — catalog named maintenance sweeps and their safety rails in `AGENTS.md`.

- **Ast-grep dogfood rules for raw JSON store writes and win32 path qualification (refs [#1158](https://github.com/apmantza/pi-lens/issues/1158))** — flag direct JSON store writes and `win32.isAbsolute` qualification calls, with the atomic-write and `isFullyQualified` seams documented as the sanctioned alternatives.

- **Helm chart linting (refs #1283, slice A)** — YAML and `.tpl` edits within a chart now run one bounded, canonical-root-deduplicated `helm lint`; warnings remain advisory and chart/template errors block. Rendered-manifest validation remains deferred to slice B.

- **Session degradation telemetry in health and performance reports (refs #1292 items 4–5)** — Trust refusals, output-mode suppressions, TypeScript idle evictions, grammar blocks, untrusted formatter skips, and actionable spawn-failure buckets now remain visible as bounded per-session telemetry; LSP pull failures also appear beneath their server in `pilens_health`.

- **Remaining silent degradation gates now surface in `pilens_health` (refs #1292)** — LSP breaker trips and diagnostic-timeout tallies, formatter failures, and tree-sitter WASM aborts are recorded with bounded per-session dedupe.

- **Deterministic LSP acquisition-race coverage (refs #1292)** — concurrent initialization and an aborted waiter now assert single-client ownership, zero leaked leases, and teardown reaping through the shared interleaving kit.

- **Weekly stale open-issue detector (closes #1323)** — the scheduled GitHub workflow reports open issues referenced by closing-shaped `master` commits or issue-named regression tests for human triage; it never closes issues automatically.

- **Opt-in compact one-line tool result rendering (closes #1327)** — pi-lens tools can now render as a single theme-aware line (`<status glyph> <tool name> — <summary>`) instead of two rows, via `--lens-compact-tool-line` / `ui.compactToolLine=true` in `~/.pi-lens/config.json`. Default off — behavior is byte-identical when disabled. Expand-to-view-full-output is unchanged.

- **HostPorts capability boundary (refs #1358, S2)** -- a typed interface over every host capability the engine consumes (notify, trust, mode, log, emit, status, spawn policy, render, session, workspace, flags, tools), with headless-parity defaults and the existing getter seams as thin adapters; no behavior change, direct-ctx migration deferred to S4.
- **Global LSP configuration fallback (refs [#870](https://github.com/apmantza/pi-lens/issues/870))** — load shared LSP settings from `~/.pi-lens/lsp.json`, with project configuration taking precedence.
- **Per-rule configuration controls (closes [#444](https://github.com/apmantza/pi-lens/issues/444))** — enable or disable individual rules through `.pi-lens.json`.
- **Edit-batch read-guard observability (refs [#1053](https://github.com/apmantza/pi-lens/issues/1053))** — expose batch-level read-guard activity for clearer diagnostics.
- **Review-graph call evidence (closes [#1070](https://github.com/apmantza/pi-lens/issues/1070))** — include call/reference evidence in project-intelligence reports and codebase models.
### Changed

- **Safe dependency refresh for v4** — updated the pi host SDK and TUI to `0.84.2`, ast-grep NAPI to `0.45.1`, and Biome to `2.5.8`; refreshed the transitive dependency graph to remove the audited high-severity `nanoid` finding. Major or otherwise non-essential updates remain deferred for follow-up validation.

- **Installer probe-cache persistence now uses the shared durable-store protocol (closes #1212)** — awaited commits use the shared quarantine-recovering bounded PID lock, authoritative in-lock reread/delta merge, throwing atomic publication, and in-lock mirror refresh while retaining deferred/failed retry outcomes; TTL, existence, and mtime validation remain read-side policy. Turn-state folding remains a future decision, not part of this change.

- **Shared durable-store and atomic-stage protocols (refs #1212, closes #1209)** — diagnostic dispositions and actionable-warning suppression now use one locked in-lock-reread/merge/throwing-atomic-write commit seam, while one staging namespace module owns `.tmp-<pid>-<threadId>-<seq>` minting, classification, and bounded own-stage sweeping.

- **Standalone managed-tool clients share one typed availability/install seam (closes [#1290](https://github.com/apmantza/pi-lens/issues/1290), refs [#1214](https://github.com/apmantza/pi-lens/issues/1214))** — madge, Knip, Biome, and ast-grep now join Ruff/jscpd behind runner helpers. Single-command clients use the cached typed checker; ordered-candidate clients retain their local/global/npx/platform search through a typed custom-probe wrapper. The seam also owns managed child environments and read-only discovery, while a source coverage test rejects direct `ensureTool()` calls or bare managed-tool spawns outside sanctioned wrappers. Raw ENOENT consumers in `lsp/launch.ts` remain for #1214's safe-spawn-wide taxonomy.

- **Cooperative word-index startup work (closes #1224, closes #1225, closes #1226, refs #1215)** — centralizes monotonic event-loop work budgets, time-budgets shared source walking and word-index reads, yields within high-document-frequency posting filters while publishing each replacement atomically, and reuses the bounded refresh preflight file/stat set for full rebuilds instead of walking and stating it twice.

- Ruff and jscpd availability now use the shared dispatch checker, including typed missing-command installation, in-flight dedupe, install-failure suppression, and session reset (refs [#1290](https://github.com/apmantza/pi-lens/issues/1290), phase 1; not closing the issue). The seam adds configurable probe timeout and a managed-command fast path to preserve jscpd's 1500ms PATH probe and managed-tools lookup.

- **Review-graph persistence lifecycle telemetry (refs #1048)** — `review-graph.log` now correlates graph/build and persistence generations, sequence hints, graph timestamps/counts, exact total/persisted node/edge/file coverage, source-walk truncation, coalescing and supersession, worker outcomes, and fallback status with bounded metadata, while preventing stale-generation fallback writes; source contents and paths are never added to the new metadata.

- **Prompt-cache context attribution (refs #1018)** — add bounded `cache_context` latency records that correlate context injection sources, placement, message counts, sizes, and privacy-preserving hashes with provider `cache_usage` by session/turn, while keeping provider cache misses and local prefix changes distinct.
- **Friendlier AST search (refs #125)** — correct agent-facing examples, add exact grammar-kind and explicit recursive descendant searches, bound explicit path lists, keep pagination output aligned with `maxMatches`, and document lazy-tool activation timing.

- Document red-first regression-test and worktree-hygiene conventions

- **Adopt commit-message and doc/prose (Google + STE) writing conventions**

- **Refresh README/docs for v4 and update contributors** — reconciled the
  README feature list and `docs/` reference against current code: documented
  the four memory idle-eviction env vars (`PI_LENS_TS_IDLE_EVICT_MS`,
  `PI_LENS_WORD_INDEX_IDLE_EVICT_MS`, `PI_LENS_PROJECT_SNAPSHOT_IDLE_EVICT_MS`,
  `PI_LENS_REVIEW_GRAPH_IDLE_EVICT_MS`), the session degradation ledger
  surfaced through `pilens_health`/`/lens-health`, deferred-format bounded
  concurrency, and the `--lens-guard` commit/push blocker; linked
  `docs/mcp.md` from the README and added an MCP-server bullet. Added six
  external contributors (Nathan Cooke, Eli Stark, Marvin Aziz, Mark Faga,
  aeturnal, floatGray) to the README contributors table, sourced from
  `git shortlog` and merged-PR authorship.

- **Dogfood the shape-2 path-semantics rule (refs #1158)** — Flag host-default path operations inside branches committed to Windows path semantics.

- **Cooperative-budget acceptance coverage hardened (closes #1215)** — the shared time-budget helper's occupancy and abort-latency guarantees are now locked by non-vacuous regression tests at 800-item scale (assertions within a small multiple of the budget); no runtime behavior change.

- **Per-entry changelog files end merge-train conflicts (closes #1321)** — User-facing changes can now land as independent per-entry files and roll up in the version-bump PR before normal CI and tag-time verification.

- **TypeScript language services release idle program memory (refs #1332)** — After five inactive minutes (configurable with `PI_LENS_TS_IDLE_EVICT_MS`), TypeScript LSP clients shut down and rebuild transparently on the next request instead of retaining fully hydrated programs indefinitely.

- **Terminal behavior now follows the pi host's run mode (refs #1334)** — pi-lens reads `ctx.mode` (`"tui" | "rpc" | "json" | "print"`) instead of assuming it owns an interactive terminal. The diagnostics widget mounts only in `tui`, and proactive `ui.notify` chatter is logged rather than rendered in the one-shot `print`/`json` modes, so piped and machine-readable runs stay clean. `/lens-widget-toggle` now says which mode is blocking it instead of blaming the pi version. Hosts that expose no `mode` field behave exactly as before.

- **Enforce the host-SDK type-only import rule that keeps clean installs working (refs #1334)** — `@earendil-works/pi-coding-agent` is an optional peer that pi omits at install time, so a value import of it breaks pi-lens at user sites. That rule was prose only; a regression test now scans every shipped source file for static, dynamic and `require` value imports and fails the build on one. The edit-tool result payload also now uses the host's own `EditToolDetails` type instead of a locally re-declared shape.

- **A formatter that exits nonzero is now reported as a failure (closes #1337)** — formatter exit-code strictness was opt-in, so any formatter that had not opted in turned every nonzero exit — rejected flags, a crashed binary, an unparseable file — into `{ success: true, changed: false }`, indistinguishable from "already formatted". That default is what let `ruff format` silently no-op for a full release cycle (#1336). The seam is now strict by default, with an audited opt-out (`lenientExitCode`, carrying its evidence) for the four lint-autofix formatters whose exit status reports remaining offenses *after* a successful rewrite: `rubocop -a`, `standardrb --fix`, `ktlint -F`, and `sqlfluff fix`. **Behavior change:** the other 29 formatters now surface an error where they previously reported a silent no-op — most visibly, saving a file with a syntax error reports the formatter's parse error instead of quietly doing nothing. Three supporting fixes make that surfacing trustworthy: Biome now passes `--no-errors-on-unmatched` (it exits 1 on paths its own `biome.json` ignores, which would otherwise report a failure on every edit under an ignored directory); the reported message skips decorative banners and falls back to stdout, so tools that report there — Biome, ktlint, `mix format` — no longer surface a bare exit code; and the PowerShell formatter now runs with `$ErrorActionPreference = 'Stop'` plus a null guard, so a failing `Invoke-Formatter` reports the failure instead of exiting 0 and truncating the file it was asked to format.

- **Re-home probe-cache ageing and cooperative yields (closes #1354)** — authoritative probe-cache commits now prune expired entries while preserving durable-store quarantine recovery; the remaining corpus-scaled scan yields use the cooperative time budget.

- **Warm dispatch, LSP async consumers, and formatter catalog lazily at session start (refs #1394 — Phase 2).**

### Removed

- **Dead-code cleanup: 5 orphaned production modules + the `server-strategies.ts` deprecated shim (refs #1090)** — `clients/project-metadata.ts` (orphaned since the `/lens-booboo` removal, #397/#396), `clients/lsp/interactive-install.ts` + its test (superseded by `resolveAndLaunch`, #374/#375), `clients/ast-grep-parser.ts` (superseded by the napi runner, #197), `clients/types.ts` (initial-commit LSP types, superseded by `LSPDiagnostic` in `clients/lsp/client.ts`), and `clients/lsp/lsp-index.ts` (stillborn barrel, zero references ever) all had zero remaining importers, re-verified at current master (including dynamic `await import()` patterns). `clients/lsp/server-strategies.ts` (a `@deprecated` re-export shim over `wait-policy/strategies.ts`) is also deleted after repointing its last two consumers — `scripts/probe-clean-signal.mjs` and `tests/clients/lsp/server-strategies.test.ts` — directly at `clients/lsp/wait-policy/strategies.ts`, plus ~25 stale doc/comment references across `CONTRIBUTING.md`, `docs/lsp-capability-matrix.md`, and the LSP/probe source. Non-goals: `clients/module-report-lsp.ts` and `clients/source-groups.ts` (parked pending #236 / roadmap decisions) and the un-export candidates identified in #1090 remain untouched.

### Fixed

- **Git guard closes shell-expansion and command-wrapper bypasses (refs #1084, PR #1395)** — command-position normalization now handles all supported IFS and positional-parameter forms, and guarded verbs nested in known or unknown command-string launchers fail closed while literal text remains non-blocking.

- **LSP roots and per-session clients are conservatively bounded (closes #1325, refs #1126, refs #1129)** — manifest-bearing fixture conventions (`tests/fixtures`, `__fixtures__`, and `testdata`), gitignored directories, and atomic-write staging namespaces no longer become standalone LSP roots; their files attach to the nearest eligible ancestor project. Client reuse remains keyed by server and normalized resolved root, while a configurable `PI_LENS_LSP_CLIENT_CEILING` (24 by default) evicts the least-recently-used idle client through graceful shutdown before spawning another and never evicts a client with an active LSP request.

- **Fully qualified Windows paths use one shared classifier (closes [#1213](https://github.com/apmantza/pi-lens/issues/1213))** — drive-relative (`C:foo`) and rooted-relative (`\\foo`) paths are no longer mistaken for self-contained absolute paths; UNC and drive-absolute paths remain fully qualified across command validation, installer caching, and managed-tool retention.

- **Kotlin formatter selection follows Spotless configuration deterministically (closes #1306)** — a Spotless `kotlin { ktlint() }` block pins ktlint and `kotlin { ktfmt() }` pins ktfmt, so both selection branches cannot claim the same Kotlin file and unconfigured projects retain the style-preserving no-format policy.

- **Managed npm clients see and execute off-PATH installs (closes [#1289](https://github.com/apmantza/pi-lens/issues/1289))** — madge and Knip availability probes receive the managed environment, Knip threads the same environment into analysis, Biome checks the managed absolute candidate before its npx fallback, and every install result is retained as the command used by the client.

- **Turn-end madge telemetry stays compact and its batch metadata is runtime-tested (closes [#1250](https://github.com/apmantza/pi-lens/issues/1250), [#1251](https://github.com/apmantza/pi-lens/issues/1251))** — aggregate batch counters remain exact, while per-target timing breadcrumbs are retained only for spawns taking at least 100 ms and capped at 12 slow targets, preserving the smells-rollup lookback; the real turn-end path now verifies madge batch execution and metadata propagation.

- **Rule-policy normalization and source-filter matrices no longer drift (closes #1087)** — P3.2 derives CodeRabbit's language suffixes from the vendored rules tree so disable/select and inline suppression cover every shipped language; P3.3 aligns source-twin precedence ordering with sibling resolution and adds a behavioral agreement guard while preserving the broad `.jsx` build-artifact fallback.

- **Smart-default formatters preserve existing indentation when a repository has no style configuration (closes [#1144](https://github.com/apmantza/pi-lens/issues/1144))** — Biome and shfmt now infer and pin a file's indentation for unconfigured formatting, skipping when their style cannot be inferred; Prettier and Ruff receive the same fallback indentation flags. Explicit formatter configuration and `.editorconfig` remain authoritative.

- **Bus emitters no longer retain a stale session context after replacement (closes #1128)** — all pi-lens event publishers resolve the live `pi.events` emitter at delivery time through a shared getter seam; deferred lens events also resolve inside their `setImmediate` callback. The sweep found no other outliving session-context emitter closures: the quiet-window summary already uses its current-context holder and its stale-session guard; LSP repaint callbacks capture UI primitives intentionally rather than `ctx`; remaining lifecycle callbacks use the event-time context or runtime generation gates.

- **Durable disposition marks no longer lose sibling-process updates (closes [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — the repo-wide shared-store audit classifies every durable writer and records accepted best-effort caches explicitly. The sole unguarded behavior-gating read/modify/write store, `diagnostic-dispositions.json`, now takes a bounded pid-owned cross-process lock, re-reads under that lock, and merges only its anchor delta, so an older writer cannot promote a stale whole-file snapshot over a concurrent mark.

- **Behavior-gating durable stores no longer lose sibling-process updates (closes [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — the repo-wide shared-store audit classifies every durable writer and records accepted-loss telemetry explicitly. Diagnostic dispositions and actionable-warning suppression state now share a bounded pid-owned cross-process lock, re-read under that lock, and merge only the caller's delta before atomic replacement. Disposition telemetry fires only after persistence succeeds, and turn-state clear/cycle/deferred-format paths now enforce the same live-owner boundary as edit registration.

- **Tool discovery caches now recover from session changes and deleted binaries (closes [#1203](https://github.com/apmantza/pi-lens/issues/1203))** — cwd-scoped runner probes and the shared ast-grep memo follow the existing dispatch session generation, while installer in-memory positives revalidate absolute paths and evict the matching persisted entry before re-probing when the binary disappears.

- **Managed ruff, jscpd, and madge installs are now actually used after auto-install (refs [#1289](https://github.com/apmantza/pi-lens/issues/1289))** — retain the absolute path returned by `ensureTool` so off-PATH managed shims are spawned directly instead of falling back to the bare command name.

- **The shared async walk engine no longer reads directories synchronously (refs [#1137](https://github.com/apmantza/pi-lens/issues/1137), [#1170](https://github.com/apmantza/pi-lens/issues/1170))** — `walkTreeStackAsync` (`clients/source-walker.ts`) already `setImmediate`-yielded every N entries, but every per-directory read still went through `readDirEntriesSafe` → `fs.readdirSync`. Chunked yielding only covers the CPU axis: on a cloud/network-backed tree (OneDrive) one stalled directory read held the Node event loop — and pi's TUI — for the entire stall, no matter how often the walk yielded around it. This is the exact shape #1170 fixed in `pipeline.ts`'s autofix snapshot walk while explicitly deferring the shared engine; fixing it here means every async walker (`collectSourceFilesAsync`, `countSourceFilesWithinLimitAsync`, and so the session-start source scans and word-index refresh) inherits the fix at once. The traversal itself is untouched: the one shared generator now yields a directory-read *request* and each driver satisfies it — `readdirSync` for the sync driver, `fs.promises.readdir` for the async one — so the sync/async pair still cannot drift, and visit order, `maxFiles` cap behaviour and results are identical. `tools/lsp-diagnostics.ts`'s directory-mode `collectFiles` walk is converted the same way (it was fully synchronous with no yielding at all, bounded only by files *kept*, and ran once per language in the `LANG_EXTENSIONS` loop); it keeps its immediate-descent recursion rather than adopting the stack-based shared driver, because the `maxFiles` cap makes traversal order observable. A `measureMaxSyncBlockMs` fail-then-pass occupancy screen injects the same per-directory stall into both APIs and pins that the async walk no longer holds the loop (verified to fail against pre-fix code: a 420ms block against a 30ms budget).

- **Direct-write cache stores now publish JSON atomically (refs [#1239](https://github.com/apmantza/pi-lens/issues/1239) [#1205](https://github.com/apmantza/pi-lens/issues/1205))** — turn state, rule, diagnostics, call-graph, codebase-model, actionable-warning, and metrics-history stores stage JSON through the shared tmp-plus-rename writer, so a process killed during a write leaves the previous complete cache value rather than a truncated file.

- **Dispatch's install-failure suppression never cleared, and a completed install could keep serving a pre-install madge resolution (closes #1266, refs #1276)** — `handleSessionStart`'s install-attempt suppression window (`installFailureReasons`, #1222/#1223) had no reset hook, so a tool that legitimately failed once stayed suppressed for the rest of the process even across sessions; it is now reset at session start for both sequential and MCP session starts (the existing concurrent-session guard already prevents a subagent session start from over-resetting a live parent). Separately, `DependencyChecker`'s madge managed-path memo (#1276) is keyed only by `projectRoot` but reads PATH/discovery/managed-install state that a completed install changes, and `finishInstallAttempt` dropped it via a fire-and-forget dynamic import — a caller that started the next madge resolution the instant `installTool`/`ensureTool` resolved could still observe the stale pre-install memo, and a rejected import became a silent unhandled rejection with no invalidation at all. The reset is now `await`ed before `installTool`/`ensureTool` return, wrapped in try/catch that logs (rather than swallows) an import failure, so a resolution issued right after a successful install deterministically sees the reset memo. The checker's own instance registry (needed so the reset reaches every live checker, not just whichever one ran the install) now holds instances via `WeakRef` instead of a strong `Set`, so short-lived checkers (tests, reinit) can be collected instead of retained for the process's lifetime; and the bare-command spawnability revalidation `resolvedCommandIsStale` added under #1276 is now cached per checker (invalidated by that same reset) instead of re-running a synchronous PATH scan on every cached resolution.

- **Orphan staging-file reaping no longer deletes legitimately named files (refs [#1267](https://github.com/apmantza/pi-lens/issues/1267), [#1260](https://github.com/apmantza/pi-lens/issues/1260))** — the session-start atomic-stage sweep now requires the full writer-emitted `.tmp-<pid>-<threadId>-<seq>` shape with strict, unpadded decimal components, so names such as `backup.tmp-000123-02-03` are left alone while genuine current-shape dead-pid orphans are still reaped.

- **`no-bare-except` and `duplicate-function-arg` grammar coverage, including a binding-aware fix for the broadened traversal (closes [#1269](https://github.com/apmantza/pi-lens/issues/1269), closes [#1270](https://github.com/apmantza/pi-lens/issues/1270))** — `no-bare-except`'s `not: any: [...]` exception-spec enumeration (identifier/attribute/tuple/parenthesized/as_pattern/subscript) was a denylist that any new node shape could slip past; it now matches the grammar's bare `except:` syntax directly (`pattern: "except:"`), so a future exception-spec kind cannot become a false negative by omission. `duplicate-function-arg`'s query previously bound `(required_parameter (identifier) @NAME)` with no depth restriction, so a rest parameter (`...a`) and other non-`required_parameter` shapes went unmatched; the query and its `same_param_name` post-filter's `bindingNames` helper were broadened to walk the full parameter pattern (covering rest params, arrow functions, and destructured/renamed collisions). That broadening over-corrected: `bindingNames` treated every descendant `identifier` as a bound name, so a reference inside a destructuring **default** (`{a = b}`, `[a = b]`, `{x: a = b}`, `{a = (() => b)}` — the `b` is a default-value expression, not a binding) or inside a **computed property key** (`{[key]: a}` — `key` is a reference) was wrongly counted, producing false-positive duplicate reports against an unrelated same-named parameter elsewhere in the list. `bindingNames` is now binding-aware: it descends into an `assignment_pattern`/`object_assignment_pattern`'s `left` field only (never `right`, the default expression) and skips `computed_property_name` entirely, while still walking nested destructuring binding positions so `{a}`/`[a]`/renamed-binding collisions keep flagging. (`{a = a}, a` legitimately still flags — the left side of `{a = a}` binds `a`, colliding with the second parameter's own `a`; Node itself rejects that snippet with "Duplicate parameter name not allowed in this context".) Both false-positive fixtures were confirmed to reproduce against the pre-fix traversal before the change and pass after.

- **Stop-hook turn-end residuals: stdin hang, invisible skips, unguarded client cwd, delivery-loss on the ack error path, and transcript rendering (refs [#1271](https://github.com/apmantza/pi-lens/issues/1271), [#1272](https://github.com/apmantza/pi-lens/issues/1272), [#1273](https://github.com/apmantza/pi-lens/issues/1273), [#1274](https://github.com/apmantza/pi-lens/issues/1274), [#1275](https://github.com/apmantza/pi-lens/issues/1275))** — five defects found in post-merge review of #1211, all on the turn-end path.
  - `pi-lens-analyze`'s `main()` awaited the stdin read BEFORE examining `--file`/`--turn-end`, with no timeout and only an `isTTY` guard, so any spawner that left a piped stdin open hung the bin forever — a regression on the pre-existing `--file` path, and as a Stop hook a full 60 s Claude Code timeout every single turn. argv now wins: stdin is read only when neither flag answers the question, and the read is raced against a 2 s timer so a stuck pipe degrades to "no payload" instead of hanging. Every existing test called `child.stdin.end()`, which is exactly why the suite could not catch this; the two new guards deliberately do not.
  - Every turn-end skip wrote one line to **stderr** and exited 0, and Claude Code does not surface stderr from a hook that exits 0 — so a permanently dead integration was byte-for-byte indistinguishable from a clean turn, indefinitely. The skip now also goes to stdout (the hook's only transcript-visible channel, one line), the message names the actual wire reason instead of always claiming "no warm pi-lens MCP server" (absent server, slow pass, schema skew and stale build have different remedies), and the hook records `{ran, skipped, lastSkipReason, lastRunAt}` per workspace so `pilens_health` reports it — the #544 precedent applied to the hook process, which is separate and short-lived and therefore left no other trace. That per-workspace status file is a shared-tmpdir read-modify-write two concurrent Stop hooks for the same workspace can race; the write now routes through `writeFileAtomic` (`clients/atomic-write.ts`) so a race can no longer torn-read the file (parse failure resetting both counters) or hit a Windows sharing violation — the read-modify-write itself can still drop one increment under a true race, which is accepted for this bounded best-effort telemetry.
  - The warm turn-end IPC route trusted the client-supplied `cwd`, called `ensureReady` on it and ran **that directory's configured test runner** there. Turn-end and its ack now reject any cwd that is not the server's own `DEFAULT_CWD` or inside it, and log the mismatch; every legitimate client already derived the socket path from that same cwd, so the guard costs nothing legitimate. A subdirectory of the workspace stays accepted. The guard's first cut wrongly rejected the exact workspace root on win32 whenever the cwd it was handed differed from `DEFAULT_CWD` only in case (drive letter or a path segment): `path.relative` is case-insensitive there and returns `""` for that pair, but the guard's `rel !== ""` term read that as "outside the workspace" — a real gap, since Claude Code's Stop-hook cwd can legitimately differ in case from how the server derived its own `DEFAULT_CWD` (`ipcPathForCwd`/`workspaceHash` in `clients/mcp/ipc.ts` already `.toLowerCase()` for exactly this reason), silently no-opping the feature at the root on every turn. Fixed by dropping the redundant `target === DEFAULT_CWD` fast path and the `rel !== ""` term together — `rel === ""` only ever means "this is the workspace root" and must be accepted, not rejected.
  - `acknowledgeTurnEnd` deleted the pending delivery BEFORE calling `commit()`, so a throwing consume destroyed the only handle to the findings — the exact loss the two-phase protocol exists to prevent, reintroduced on its error path. It now commits first and drops the capability only on success. `pendingTurnEndDeliveries` also had no expiry and no size cap (every timed-out client and every distinct raw-cwd alias left an entry for the life of the process); it now expires at 10 min and caps at 8, both dropping entries **without** committing so the findings re-arm for a later Stop rather than vanishing. The route no longer starts a heavy pass for a client whose socket is already destroyed, and byte-identical in-flight Stop requests (no files, no sessionId) coalesce onto the one pass instead of a third being rejected outright by the single-waiter queue.
  - Transcript rendering: the 2000-character cap sliced UTF-16 code units and split surrogate pairs, putting U+FFFD where an emoji marker belonged; the cut now backs off a high surrogate. The `tests` section is the raw vitest failure dump, so ANSI escape sequences and C0 control characters (newline/tab excepted) are stripped before capping, which also removes the half-sliced-escape leak. The injection-framing strip was `startsWith`-only and now removes every occurrence, so a token carried into the middle of a joined section cannot survive.

- **Marksman silent-clean confirmation now requires a liveness check, so a wedged server is no longer rendered clean (refs [#1277](https://github.com/apmantza/pi-lens/issues/1277))** — #1253 converted a diagnostics timeout on a `tier3-silent` server (e.g. Marksman) into a confirmed-clean verdict from the static capability classification alone. A server that accepted the notify write and then became unresponsive was therefore reported clean rather than inconclusive — the unresponsive-server-as-clean silent-failure shape. The gate now additionally requires `clientPingLiveness`, an active `workspace/symbol` round-trip bounded at 300ms and independent of the notify-write: a wedged server never answers, the timer wins, the ping returns false, and the result stays inconclusive. A genuinely silent-clean server (alive, tier3-silent, no diagnostics) still confirms clean, and the enumerable `confirmation` wrapper contract is unchanged. Guarded by a notify-accepted-then-unresponsive test confirmed to fail against the pre-fix body.

- **Stop-hook turn-end findings are no longer lost when the client deadline wins (refs [#1218](https://github.com/apmantza/pi-lens/issues/1218))** — the warm Stop route now separates execution from durable delivery. It returns a delivery capability over one one-shot connection, and consumes the turn-end/test finding bridges only after a receipt acknowledgement on a separate one-shot connection. A timeout, late reply, or connection close leaves findings available for a later authorized Stop; repeated acknowledgements are idempotent. Turn-state writers now carry explicit pi/MCP ownership, so `sessionId:null` cannot erase a shared worklist or let a live foreign session consume it. Spawn tests use isolated temp workspaces/data/home roots and refuse the real workspace socket.

- **Markdown `lsp_diagnostics` now preserves Marksman's authoritative silent-clean confirmation (closes [#1253](https://github.com/apmantza/pi-lens/issues/1253))** — `LSPService.touchFile` already treated a successful Marksman notify followed by its expected no-publish clean behavior as confirmed, but the tool boundary retained only the empty diagnostics array and `inconclusive` flag. The tool then reclassified the same empty tier-3 result, attempted the TypeScript-only fallback path, and rendered clean Markdown as inconclusive. `TouchFileResult` now carries explicit confirmation metadata for completed collections, and every local diagnostics request routes through `touchFile` when available — including default all-scope batches with no explicit wait. Primary-scope and non-TypeScript all-scope consumers accept that confirmation; all-scope TypeScript still runs its synchronous tsserver fallback before any clean verdict, so unpublished TypeScript findings remain visible. Absent metadata, failed writes, timeouts, binding mismatches, and ambiguous capability state remain unconfirmed; confirmed clean results remain cacheable and widget-reconcilable, and real findings are unchanged. Two carriage holes that only became reachable once the confirmation was surfaced to the tool are closed with it. (1) `touchFile` recorded its recent-touches debounce entry even when a server's notify write TIMED OUT or rejected, so the next touch inside the 1500ms window skipped the notify entirely and reported `notifyWriteTimedOut: false` — the exact precondition both silent-clean gates (#799 single-server, #814 aggregate) read as "the server saw this content". A `silentOnClean` server that never received the file was therefore confirmed clean one debounce window later; in a batch this fired routinely, because `ensureWarmForSweep`'s warm-up touch is the first push and the per-file touch is the second. The debounce is now keyed **per server** (`file:clientScope:serverId`) and each entry is recorded at the moment that server's own write lands, so a failed write re-pushes instead of laundering into a confirmation. Per-server rather than the file-level "record only when every spawned server's write landed" this fix first shipped as: `recentTouches` carried no server component, so withholding the entry withheld it from every co-touched server, and one wedged auxiliary disabled the debounce for its HEALTHY siblings — re-pushing them on the next touch and clearing diagnostics they had already computed, which is the same per-file collapse #743 removed from the notify-write deadline (and which its own in-file doctrine comment forbids). Keyed per server both hold at once: the stalled server has no entry and is re-pushed, while every sibling whose write landed keeps its own debounce and its original timestamp, so the window still expires naturally instead of being extended by reuse. The version-baseline diagnostics wait is likewise per-server now, so a server that WAS pushed still gets its `minVersion` wait when a sibling was debounced away. Guarded by a two-server test (one stalled, one healthy, same content twice) confirmed to fail against the pre-fix body — the healthy server's `notify.open` was called twice where it must be called once. (2) The warm-attach collection branch returns before the local touch and had no provenance to carry, so a warm-attached session reported every clean Markdown file unconfirmed regardless of the tool-side fix; `WarmDiagnosticsResponse` now carries the incumbent touch's `confirmation` as an explicit enumerable DTO field (same doctrine as `inconclusive`), consumed as an AGGREGATE confirmation because the incumbent always touches `with-auxiliary` — an incumbent that omits the field stays unconfirmed. A confirmed touch whose findings are all filtered away by the requested severity floor now renders **clean at that severity** rather than consulting the empty-result classifier (which answered "unconfirmed" for a tier-3 silent server): the unconfirmed doctrine exists for a server that said NOTHING, not for one that demonstrably answered and had its findings filtered by the caller's own floor. The filtered diagnostics are still cached and widget-reconciled, and the tier-3 sync escape hatch is still reserved for a genuinely empty result. Covered end-to-end: a new suite drives the real `LSPService` through the real tool with only the server registry and client transport faked, so the confirmation has to actually flow rather than being asserted separately on each side of a mock; the incumbent-side DTO composition is pinned separately (a confirmed touch emits the field, an unconfirmed or inconclusive one OMITS the key rather than serializing `undefined`, which is what makes the absent-field degradation byte-identical to a pre-#1253 incumbent and the no-version-bump safe).
- **Turn-end madge resolved its command once per FILE, and a slow older pass could overwrite newer circular-dep state (refs [#766](https://github.com/apmantza/pi-lens/issues/766))** — `resolveMadge` ran inside `checkFilesBatch`'s per-file mapper, and its `findNodeToolBinary` miss path spawns uncached `npm config get prefix` / `pnpm bin -g` / `yarn global bin` probes, so a turn touching N import-changed files paid that whole chain N times before falling back to `npx madge` — never consulting the managed tools tree pi-lens installs madge into in the first place. Resolution now runs once per project root (memoized on the instance) and falls through local/global bin → `ensureTool("madge", { allowInstall: false })` → `npx madge`; the discovery step can never download, because installation stays owned by `ensureAvailable()`. That memo is deliberately not one-way, since re-probing per spawn is also what used to make resolution self-healing: a memoized absolute path is re-validated with one `existsSync` per resolution, so a madge uninstalled mid-session re-resolves instead of failing every remaining spawn, and the `npx` result is never pinned — it is the slow path being removed, and one transient installer failure would otherwise disable managed resolution for the whole session. The resolved command's `kind` is classified from the returned STRING (`local`/`managed`/`global`/`path`/`npx`) rather than from the step that produced it: `findNodeToolBinary` probes the npm/pnpm/yarn/bun GLOBAL bins as well as the project's `node_modules/.bin`, and the installer's discovery legitimately hands back a bare PATH name or an npm-global path as well as the managed one. `checkFilesBatch` now returns `{ results, stats }`, and turn-end attaches `MadgeBatchStats` — requested/missing/cacheHits/spawned/failed, `commandKind`, `resolveMs`, and per-target durations capped at 12 with `targetsTruncated` so the cap is never silent — as metadata on the existing `phase: "madge"` latency entry, which as one whole-phase duration could not distinguish many files from one slow file from command resolution. Separately, `lastCircular`/`circularFiles` are shared instance state that `checkFile`, `checkFilesBatch` and `scanProject` all overwrite wholesale while genuinely overlapping (turn-end against the background scans in `runtime-session.ts` and `project-diagnostics/fresh-fetch.ts`): each writer now takes a generation when it CLASSIFIES and publishes only while that generation is still the newest, so an operation that started against older file content can no longer resurrect that view over a newer one, whatever order the spawns finish in. The batch folds into a batch-local copy and publishes once at the end, so its returned map keeps the array-order sequential equivalence either way, and both guard tests were confirmed to fail against the unguarded publish rather than merely to pass after it. No result cache was added (#533, "when in doubt, re-run") — changed content still re-runs madge, and the guard only skips writes that a newest-wins sequential interleaving would also have discarded. A content-fingerprint single-flight map was considered and dropped as unreachable: `importsChanged` writes its `{timestamp, size}` entry synchronously during classification and neither caller has an interleaving await there, so a second overlapping operation on the SAME content always classifies as a cache hit and never reaches a spawn. Scope honesty: `isInCircular`/`getCircularForFile` have no production callers and turn-end only dbg-logs `hasCircular`, so the guard's payoff today is the correctness of future cache-hit results rather than user-visible output — which is input to the still-open step 4 of #766 (whether the turn-end madge pass should exist at all).
- **`duplicate-function-arg` no longer fires a false duplicate when a parameter's default-value expression references an earlier parameter name (closes [#1256](https://github.com/apmantza/pi-lens/issues/1256))** — the query bound `(required_parameter (identifier) @NAME)` with no depth restriction, so an identifier at **any depth** inside the second parameter's subtree — the default value (`root = node`) or a type annotation — captured as `@NAME`, and `same_param_name` compared it to the first parameter's name and fired. Dogfooded live: `containsYieldInFunctionBody(node, root: TreeSitterNode = node)` in `clients/tree-sitter-client.ts` reported a phantom duplicate on every dispatch. Both captures now bind through `pattern: (identifier)`, the field the grammar actually exposes (`name:` does not exist on `required_parameter` — it would silently kill the rule; `pattern:` is already used by the sibling `variable-shadowing.yml`), so only the parameter NAME can match. Regression tests: a default-value reference must NOT match, and genuine duplicates with a default-valued earlier parameter must still match.

- **Autofix surfaces now apply the same config as the lint surfaces that reported the diagnostics (closes [#1247](https://github.com/apmantza/pi-lens/issues/1247))** — `markdownlint-cli2 --fix`, `ruff check --fix` and `biome lint --write` ran with NO `--config`, so on projects without their own config the tool's default ruleset rewrote files per rules the lint surface never endorsed: markdownlint's default all-rules-on config produced whole-file rewrites of CHANGELOG.md/AGENTS.md and `#946` → `# 946` heading-spacing corruption (MD018), while ruff/biome applied default rule sets instead of the package-owned `core.toml`/`core.jsonc`. The lint runners and the autofix paths now consume ONE shared config-args seam (`markdownlintConfigArgs`/`ruffConfigArgs`/`biomeConfigArgs` in `clients/tool-policy.ts`) — package-owned fallback when the project has no config, the user's config when present — so the two surfaces cannot drift again. `biomeClient.fixFileAsync`/`ruffClient.fixFileAsync` gained an optional dispatch-cwd for discovery. Guarded by builder-level tests (fallback + user-config states for all three tools) and a pipeline-level test pinning the `--fix` invocation args.
- **Three hand-rolled `.tmp-<pid>` staging writers bypassed `atomic-write.ts` and never inherited the #1205 fix, and the fixed staging name still collided across worker threads (closes [#1205](https://github.com/apmantza/pi-lens/issues/1205), closes [#1217](https://github.com/apmantza/pi-lens/issues/1217), refs [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — #1205 made `writeFileAtomic`'s staging path unique per call, but only for writers that actually route through the module. `clients/instance-reaper.ts`'s `pruneDeadInstances` (machine-global `~/.pi-lens/instances.json`), `clients/gzip-stage-write.ts` (both persist workers' streamed-gzip stage write) and `clients/grammar-source.ts`'s `downloadGrammar` each interpolated their own per-PROCESS staging name, so all three still published torn files exactly as pre-#1205 code did. The reaper was the reachable one: `instances.json` is written fire-and-forget from `instance-registry.ts`'s `prunePids` as well, with nothing serializing the two, and `readInstanceRegistry` degrades a parse failure to empty — so a tear dropped **every** registered instance rather than the one entry being pruned. Reproduced against the pre-fix body at **22/40 unparseable on Linux**, the same scale as #1205's own 21/40. All three now source their staging name from `atomic-write.ts` (`writeFileAtomicAsync` for the reaper and `writeFileAtomic` for the grammar fetch, which also gains the failure-path staging cleanup neither hand-rolled copy had; `stagePathFor` alone for `gzip-stage-write.ts`, which needs its own streaming pipeline). `stagePathFor` itself gained `threadId`: its counter is per-THREAD (a worker gets its own module instance, so its own `_stageSeq` starting at 0) while `process.pid` is shared across every thread, so two threads each minted the identical `${target}.tmp-<pid>-0` — #1205's shared-inode tear reintroduced one axis over, and no longer hypothetical now that `gzip-stage-write.ts` is a real worker-side caller. The staging name is therefore `${target}.tmp-<pid>-<threadId>-<seq>`, covering the three ways two writes can overlap — pid across processes, thread id across threads, counter within one thread — and `STAGE_TMP_PATTERN` grew a third optional group so sweepers still collect leftovers in all three shapes on disk (`.tmp-<pid>`, `.tmp-<pid>-<seq>`, `.tmp-<pid>-<threadId>-<seq>`). `gzip-stage-write.ts`'s docstring claimed "the `stagePath` itself is only ever created by the atomic rename, so a crash mid-write can never leave a torn stage file behind" — true for a crash, false for concurrency, the same false-confidence pattern #1205 found in `atomic-write.ts`'s own docstring — and now scopes its guarantee to crash-safety and states that it provides no ordering and no isolation beyond tear-freedom. The repo-wide sweep #1217 asked for ran over every `rename` call site rather than a `.tmp-` grep, which is what surfaced `grammar-source.ts` (missed by the incidental discovery that filed #1217); the remaining renames are log rotation, installer moves, and LSP workspace edits, none of which stage. Every new guard was confirmed to fail against the pre-fix body on Linux, not merely to pass after (recurring defect shape 2/7): the reaper tear at 22/40, the gzip writers' renames at `ENOENT`, and two worker threads minting one byte-identical staging path.

- **The review graph's stale-stage sweep deleted OTHER modules' in-flight atomic-write staging files out of the shared project cache dir (refs [#1206](https://github.com/apmantza/pi-lens/issues/1206), [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — `sweepStaleStageFiles` (`clients/review-graph/builder.ts`) matched `entry.includes(".stage-") || /\.tmp-\d+$/` and `fs.rm`'d the result out of `getProjectDataDir(cwd)/cache`. The `.tmp-<pid>` alternative is the generic staging shape `clients/atomic-write.ts` produces for **every** durable store — diagnostic dispositions, cache-manager scanner data, project snapshot, call graph, codebase model, workspace/project diagnostics caches — not the review graph's property. A review-graph persist that readdir'd the cache dir while another process sat between its `writeFileSync` and its `renameSync` deleted that staging file, so the rename threw `ENOENT` (propagating out of `markDisposition` into the agent's mark tool for `bestEffort: false` stores) or silently dropped the update for `bestEffort: true` ones — one subsystem's garbage collection reaching into unrelated subsystems' transactions, and the inverse of the atomic-write contract. The predicate is now scoped to artifacts the review graph itself produces (basename starts with `review-graph.` **and** contains `.stage-`, covering `review-graph.json.gz.stage-<pid>-<gen>`, the checkpoint stage, and either's persist-worker `<stage>.tmp-<pid>`), and an entry whose embedded stage pid is still alive is skipped via the reaper's conservative `realIsPidAlive` (ESRCH-only-means-dead) rather than a second hand-rolled liveness probe — so a concurrent healthy owner's in-flight stage survives, and a recycled pid leaks one stage file rather than destroying a live one. Because the bare `.tmp-<pid>` shape is no longer matched at all, this is independent of the per-call-unique staging rename in #1205; the now-unowned orphaned-atomic-write-temp GC gap is tracked separately by #1228.

- **`writeFileAtomic` published torn files for concurrent same-process writes — the staging name was per-PROCESS, not per-CALL (refs [#1205](https://github.com/apmantza/pi-lens/issues/1205), [#1202](https://github.com/apmantza/pi-lens/issues/1202))** — `clients/atomic-write.ts` staged every write at `${target}.tmp-${process.pid}`, so two in-flight writes from one process to one target shared a staging inode: both `open(O_TRUNC)`ed it, the first `rename` published that inode to the target, and the second writer's still-open fd kept writing into the now-published file. The result was a torn hybrid at the target path — measured 35/40 in the issue report and reproduced by the new regression test against the pre-fix body on **both** OSes — 13/40 on a Windows dev box and **21/40 on ubuntu-latest CI** (verified by pushing the pre-fix body to a throwaway draft PR so the test would actually run on the CI OS, rather than assuming a Windows-only reproduction generalizes — recurring defect shape 2/7). The test carries deterministic structural assertions too (two concurrent `bestEffort: false` writers must report two different `err.path` staging files; `stagePathFor` must never repeat), so the guard does not depend on winning a scheduler race on any platform. The precondition is violated by design: `recordLspChild` (`lsp/client.ts`), `updateHeartbeat` (`runtime-turn.ts`), `prunePids` (`instance-registry.ts`) and `appendRecentTouches` (`bus-publish.ts`) are all deliberately fire-and-forget, and every reader in that family swallows parse errors to `{}`/`[]`, so a tear degraded to silent TOTAL loss of the store rather than partial loss. Fixed by routing both the sync and async paths through a new `stagePathFor()` that appends a monotonic per-process counter (`${target}.tmp-<pid>-<seq>`), making the staging path unique per call; the failure-path cleanup unlinks that same per-call path, so it still removes exactly the file the call created. A counter rather than `randomBytes` because it is allocation-free on these hot per-turn/per-touch paths and makes collision impossible by construction rather than merely improbable, with `process.pid` still supplying cross-process distinctness. The module docstring previously claimed "a concurrent reader always sees either the fully-old or fully-new file" — true cross-process, false same-process — and has been rewritten to state the real guarantee (crash-safe replacement + tear-free publication) alongside what the primitive explicitly does NOT provide: no read-modify-write isolation, no ordering, no mutual exclusion, no fsync durability, no orphan reaping (tracked in #1228), and a Windows-specific `bestEffort: true` loss mode when a reader holds the destination open without `FILE_SHARE_DELETE`. The naming scheme is now exported as `stagePathFor`/`STAGE_TMP_PATTERN` so orphan-staging-file sweepers consume it instead of re-deriving `.tmp-<pid>` (the pattern keeps the `<seq>` group optional so pre-#1205 leftovers on disk are still swept); `sweepStaleStageFiles` in `review-graph/builder.ts` is migrated to it separately under #1206, which must add its own pid-based exclusion rather than rely on that sweeper's existing `.stage-<pid>-` own-marker, which does not cover the `.tmp-` branch.

- **Dense word-index startup refresh no longer turns quadratic or monopolizes the TUI event loop (refs [#1197](https://github.com/apmantza/pi-lens/issues/1197))** — replacing one stale document is cheap, but the incremental path repeated posting-array filters even when almost the entire persisted index was stale: pi-lens refreshed 2,061 documents with zero reuse in 216.8s and recorded a genuine 22.5s event-loop block, while a linear full build of the same corpus took 7.5s. Refresh preflight now selects a separately-built full replacement before mutating the old index whenever the stale set's estimated WORK exceeds one full rebuild — density is not a bound, because per-document cost grows with the corpus: 800 documents with 239 stale (29.875%, under any 30% ratio) measured 90,577ms with a 39,553ms synchronous block against 1,029ms / 10.6ms for a full build, and at the 6,000-file cap up to 1,799 stale documents stayed on that path. The work model is calibrated against this repository's own 2,062-document corpus and its predicted crossover (23 stale documents) matches the measured one (~21). The >30% ratio, >30% file-set-churn and legacy-metadata transitions remain as-is. Every bulk path — the cooperative full builder and both refresh loops — now yields on an ~8ms monotonic budget OR'd with its item checkpoint rather than count-only, including within large documents and after any line of 4,096+ characters (a minified bundle previously got zero in-document yields). A build superseded mid-warmup returns gracefully instead of throwing out of the quick-mode warmup pass and skipping the #947 LSP pre-warm. Results remain byte-equivalent to the synchronous reference builder, superseded sessions never publish partial state, and sparse refreshes retain their unreadable-file retry semantics.

- **Windows bare-command resolution ignored the caller's child `PATH`/`PATHEXT` and could reuse another environment's cached answer (closes #1199)** — `safeSpawnAsync` and deprecated `safeSpawn` now derive case-insensitive Windows command resolution from the exact normalized environment passed to the child, so managed command shims such as Knip's `.cmd` are found even when only `options.env.Path`/`PATH` contains their directory. Cache identity includes the effective path and extension list; explicit/relative Windows paths use `path.win32`, and the same canonical absolute cwd used by drive-relative resolution is passed to the child. Positive cache hits revalidate the executable, negative entries expire after one second, and successful managed installs invalidate the cache immediately, while existing shell, UTF-8, timeout, abort, and tree-kill behavior remains unchanged.

- **`project_report`'s `toDisplayPath` hand-rolled relativization without the shape-aware `isWindowsPath` branch, mis-rendering a Windows-shaped path as the full absolute path on Linux CI (closes #1194, refs #1163 #1152 #1024)** — `clients/project-report.ts`'s `toDisplayPath` used host-default `path.isAbsolute`/`path.relative` directly, the exact defect `toProjectRelativePath` (`clients/path-utils.ts`) was fixed for in #1163 — its sibling `module-report.ts`'s `toDisplayPath` already delegates to that shape-aware helper, but this file reimplemented the same convention natively instead, so `path.isAbsolute("C:\\repo\\src\\x.ts")` returned `false` on Linux and short-circuited to the whole absolute path instead of `src/x.ts`. The #1163 sweep missed it because it grepped the `isWindowsPath` hot zone and this file never branched on it at all. Fixed by delegating to `toProjectRelativePath`, matching `module-report.ts`. Second-axis sweep of every other hand-rolled `path.relative`/`path.isAbsolute` relativizer in `clients/`/`tools/` found no further offenders: `call-graph.ts`'s `formatImpact` and `lens-map.ts`'s `toDisplayPath` already delegate to `toProjectRelativePath`; every other `path.relative`/`path.isAbsolute` hit resolves a live, same-process, same-OS path (dispatch context, LSP results, subprocess/runner output, filesystem walks) rather than a persisted/cross-OS review-graph path, so none share this class. Fail-then-pass verified directly against the pre-fix body under explicit `path.posix` semantics (not host-default, since a Windows dev box's bare `path` is already win32 and would pass vacuously) — it returned `"C:/repo/src/x.ts"` instead of `"src/x.ts"`, which the delegated implementation now corrects on any OS.
- **`trivy-config.test.ts` hardcoded a POSIX path literal (`/tmp/main.tf`), so it failed on a Windows dev machine while staying green on Linux CI (closes #1190, refs #1024)** — the terraform pass-through tests built `ctx.filePath`/`ctx.cwd` from the string literals `"/tmp/main.tf"`/`"/tmp"` and asserted the mocked `safeSpawnAsync` call against those same literals. The runner itself (`clients/dispatch/runners/trivy-config.ts`) is OS-agnostic — it resolves the scan path with `path.resolve(cwd, ctx.filePath)` — but on Windows that resolves `/tmp/main.tf` to a drive-relative `C:\tmp\main.tf`, so the hardcoded POSIX-form expectation no longer matched: the inverse of the #1024 class (a test only holding on one OS, this time POSIX-only rather than Windows-only). Test-only fix: the two cases now derive `tfCwd`/`tfFile` via `path.join(os.tmpdir(), ...)` and assert against those same derived values, so the expectation is computed the same way the runner computes its resolved path — real on both POSIX and Windows, with no hardcoded separator on either side.

- **Structural shape-5 hardening: LSP `touchFile` now returns a `{ diags, inconclusive, binding }` wrapper whose flags survive any copy by construction, plus a fail-closed graph-build-info guard (closes #1179, refs #1108 #1094 #1096)** —
	the #1108 audit found all five side-channel flags safe as-is (every consumer
	reads off the original), but the three LSP diagnostics flags stayed
	fragile-by-construction: a non-enumerable property hung on the returned
	diagnostics array, silently dropped by any `[...]`/`.map`/`.filter`/
	`structuredClone`/`JSON` copy between producer and consumer (the class that bit
	as #1094 `inconclusive` and #1096 `binding`). `touchFile` now resolves an
	explicit `TouchFileResult` wrapper (`clients/lsp/diagnostic-binding.ts`) with
	`inconclusive` and `binding` as ENUMERABLE fields alongside `.diags`, so a copy
	operates on `.diags` and can no longer drop them — the copy-loss is impossible
	by construction. Every flag-reading consumer was migrated in lockstep (the
	cascade `readInconclusive`/`readBoundToCurrentDisk`/`isConfirmedTouch` and its
	`.filter()` site in `clients/dispatch/integration.ts`, the dispatch LSP runner,
	the `lsp_diagnostics` tool, the workspace-sweep `lens_diagnostics mode=full`
	path, and the warm-attach IPC producer — which continues to re-surface
	`inconclusive` as an enumerable DTO field over the socket). Behavior is
	IDENTICAL — same flags, same values, same decisions; only the carriage
	changed. `getAllDiagnostics`'s per-entry `binding` is DELIBERATELY left as a
	lazy non-enumerable getter on the documented read-off-original contract (making
	it enumerable would fire the per-file disk stat+hash on any incidental spread —
	the stat storm its laziness exists to prevent). Separately closes a latent P3
	from the #1108 review: `getGraphBuildInfoForGraph`'s global-slot fallback could
	serve a SIBLING graph's build-info on a `_graphBuildInfoByGraph` identity miss,
	feeding the `graph_degraded` marker gate a (possibly healthy) sibling verdict —
	a new `graphBuildInfoIsTrustworthy` guard fails CLOSED so an unstamped/rehydrated
	graph surfaces an honest degraded/unknown advisory instead of a #533 false
	clean. Inert on the live path (every build stamps its returned graph before the
	cascade reads it). An ast-grep rule remains not viable (AGENTS.md shape 5 is
	semantic; cross-ref #1158).
- **Per-entry widget observation timestamps: a cross-file cascade merge no longer over-clears a whole footer record, dropping only the genuinely-stale entries (closes #1186, refs #1093 #1092 #1020)** —
	`reconcileCascadeNeighborLspErrors` → `commitDiagnostics` used to stamp the
	ENTIRE merged record's single `touchedAt` with the incoming `observedAt`
	(e.g. a passive snapshot's `entry.ts`, up to ~240s old), including PRESERVED
	entries observed more recently. If the neighbor's mtime later fell between
	that stale stamp and a preserved entry's true observation time,
	`reconcileStaleWidgetFiles` dropped the WHOLE record — losing the newer
	preserved findings (the residual documented at `clients/dispatch/integration.ts`).
	`WidgetDiagnostic` now carries a per-ENTRY `observedAt`: `normalizeDiagnostics`
	stamps each incoming entry at its observation time, the cascade merge keeps
	each preserved entry's own prior stamp (never re-aging a fresh finding to the
	incoming stamp), and `reconcileStaleWidgetFiles` gates per ENTRY — dropping
	only entries observed before the file's current mtime, keeping the record when
	any survive, and dropping the record only when empty (a clean, finding-less
	record still gates on `touchedAt`). `PersistedWidgetState` bumps v1→v2:
	`importWidgetState` accepts a v1 snapshot and migrates each stampless entry to
	inherit the record's `touchedAt` (a safe, over-conservative default; a
	missing/non-numeric version or a future version this build can't understand is
	still rejected — the pre-existing strictness is preserved). The sibling gates
	`dropStaleFiles`
	(session-state-store) and `reconcileProjectDiagnosticsSnapshot` are unchanged
	— both compare against a single save/scan time ≥ every entry's observation, so
	a whole-record drop there already equals dropping every entry. The
	`markDependentsUnverified`/seq-stamp half of #1093's sketch is a separate,
	non-contained follow-up (left open under #1093).
- **The `runtimeExitWindow` breaker map never dropped a stale/aged-out key, and its cooldown comment described the wrong call order (closes #1183, refs #1142 #1181)** — two P3 hygiene follow-ups from the #1181 review of the #1142 windowed-rate breaker in `clients/lsp/index.ts`. (1) `runtimeExitWindow` was only ever `delete`d on the currently-unreachable optional-trip path, so a key that crashed once then recovered kept a stale timestamp array forever (bounded by servers×roots, not a leak, but untidy). The "survived past the threshold" branch now also drops the key once every entry in its window has aged out, mirroring where `runtimeExitCounts` is already `delete`d there — anchored on the death's own `exitedAt` (not `Date.now()`) so it only ever fires for deaths `recordRuntimeExitWindow` declined to record (over the sleep-gap ceiling, or a missing `exitedAt`), never for a death that was just added to the window. (2) The `Math.max` cooldown comment claimed it "never shortens a longer cooldown the fast path may have just set," which has the ordering backwards — `state.broken` is `delete`d at the top of the method, so the window's `.get(key) ?? 0` always reads 0, and the fast path runs AFTER this block. Reworded to describe the actual ordering; harmless either way since the non-optional branch latches `permanentlyBroken` and the optional branch is currently unreachable. Comment-only for (2); pure map hygiene for (1) — trip logic, thresholds, and the fast path are unchanged.
- **`allowScripts` entries had drifted from what the declared dependency ranges actually resolve to, so npm v12's exact-version script-approval check saw stale pins (closes #1176)** —
	`package.json`'s `@ast-grep/cli` dependency range is `"^0.45.0"`, but the
	`allowScripts` map still keyed its approval on `"@ast-grep/cli@0.44.1"` — a
	version the range can no longer even resolve to. pi-lens has no
	`allowScripts`-enforcing tool of its own (no `@lavamoat/allow-scripts`, no
	`.npmrc` script policy), so the field does nothing for pi-lens's own
	`npm ci`; its only audience is an external consumer or vetting tool reading
	the *published* package. The published package ships **no lockfile**
	(`package-lock.json` is not in the `files` array and there's no
	`npm-shrinkwrap.json`), so that consumer fresh-resolves `"^0.45.0"` against
	the registry — which lands on `0.45.1` (registry `latest`; only `0.45.0`
	and `0.45.1` exist in the `0.45.x` line), exactly what the original report
	observed. The correct target is therefore the **freshest in-range /
	fresh-resolve version**, not the locally-committed lockfile's resolution —
	so the entry is `"@ast-grep/cli@0.45.1": true`, and the committed
	`package-lock.json` was also bumped (`npm install @ast-grep/cli@0.45.1`,
	an in-range patch bump — the `^0.45.0` dependency range is unchanged) so
	pi-lens's own dev tree and a fresh consumer resolve both land on the same
	version. `0.45.1` ships the same `postinstall` script `0.44.1` needed
	approval for. Swept every other `allowScripts` member for the same drift
	class (a pinned exact version no longer matching the freshest version its
	governing range resolves to): `@google/genai@1.52.0` (transitive via the
	`@earendil-works/pi-coding-agent` devDependency) already matched, unchanged;
	`protobufjs@7.6.4` (also transitive via `@earendil-works/pi-coding-agent`)
	had drifted the same way and moved to `"protobufjs@7.6.5": true` — the
	freshest in-range version, which here happens to match both the lockfile
	and a fresh resolve. `@ast-grep/napi` (also range `^0.45.0`) was re-checked
	for an install-time script at both `0.45.0` and `0.45.1` (`npm view
	@ast-grep/napi scripts`) and has none in either, so it correctly has no
	`allowScripts` entry. `fsevents@2.3.3` also has an install script
	(`hasInstallScript: true`) but is intentionally NOT listed: it's
	`dev:true, optional:true, os:["darwin"]`, so it's never part of a
	consumer's production install and out of scope for this allowlist — noting
	it here rather than leaving the omission silent. No other version-pinned
	metadata (`overrides`/`resolutions`/`pnpm.overrides`/`packageManager`)
	exists in `package.json` to drift, and a repo-wide grep for hardcoded
	`0.44.`/`0.45.` ast-grep version literals outside `package.json`/the
	lockfile turned up only historical comments in test files and one rule
	doc-comment describing ast-grep's behavior as of a past version — not pins
	that need to track the current range.
- **`switch-case-termination` false positives for returning try/catch and exhaustive conditionals (closes #1079)** — the `no_terminating_statement`
	post-filter only checked whether a case's last statement was a literal
	terminator, so a case ending in `try { return … } catch { return … }` (or an
	exhaustive `if/else`) was flagged even though every path returns. It now
	runs a bounded, fail-safe control-flow analysis: trailing blocks,
	`try/catch/finally` (a returning try completes after finally; a try body
	that completes normally only terminates via a finally), and exhaustive
	`if/else` (no else still falls through). It also honors an intentional
	`// fallthrough` marker comment (ESLint `no-fallthrough` convention),
	checked against comment nodes only so a `case "fallthrough":` value can't
	suppress a finding. Genuine fall-throughs are still reported. TS + JS
	regression fixtures cover grouped labels, try/finally, and intentional
	fallthrough.
- **Non-enumerable side-channel flags — the shape-5 copy-loss sweep (refs [#1108](https://github.com/apmantza/pi-lens/issues/1108))** — Audited every non-enumerable / identity-scoped side-channel flag hung on a returned diagnostics or graph value for the copy-loss class that bit as #1094 (`inconclusive`) and #1096 (`binding`): the `inconclusive` and `binding` flags on `touchFile` results, the lazy `binding` getter on `getAllDiagnostics` entries, and the `_graphBuildInfoByGraph` / `_graphImportChanges` WeakMaps keyed by `ReviewGraph` identity. All five are safe — every consumer reads the flag off the ORIGINAL producer object before any `.filter()`/`.map()`/spread, the one genuine JSON boundary (the warm-attach IPC socket) already re-surfaces `inconclusive` as an explicit enumerable response field, and both WeakMaps fail safe on a copy/rehydrate miss (a global-slot fallback for build-info; a full-rebuild + generation-gate for the import delta). Hardened the contract durably where it is not owned by an in-flight PR: documented the "read off the original, never a derived copy; re-surface as an enumerable field to cross a serialization boundary" carriage contract on the `DiagnosticBinding` type, and added an IPC-boundary guard test asserting the consumer still honors an inconclusive answer carried as an enumerable field across the JSON round-trip. The producer-side type annotations and the structural `{ diags, inconclusive, binding }` wrapper migration live in the LSP index (owned by the #1142 breaker work) and are deferred to a tracked follow-up ([#1179](https://github.com/apmantza/pi-lens/issues/1179)); an ast-grep rule is not viable (AGENTS.md shape 5 is semantic — good and bad uses are syntactically identical).
- **LSP runtime-exit breaker missed persistent low-frequency crash loops (closes #1142, refs #1127 #1139)** —
	#1139's `runtimeExitCounts` counts a client death toward the circuit breaker
	only when its lifetime (`exitedAt - spawnedAt`) is under
	`RUNTIME_EXIT_UPTIME_THRESHOLD_MS` (60s) — the hot crash loop (opengrep dies in
	seconds). A death OVER the threshold falls into the "survived → reset the
	streak" branch, so a server that reliably dies at ~65-90s after every spawn
	NEVER tripped: every death reset the streak and it churned forever, a slow loop
	the hard-cutoff design structurally can't detect. Added a SECOND, independent
	windowed-rate trip that COMPOSES with (does not replace) the fast path:
	`RUNTIME_EXIT_WINDOW_TRIP_COUNT` (5, = `BROKEN_PERMANENT_AFTER`) non-intentional
	deaths within a rolling `RUNTIME_EXIT_WINDOW_MS` (15 min) window trip the
	breaker regardless of each death's individual lifetime. N/M err against
	false-tripping: five clustered non-intentional deaths is definitionally a loop,
	and sparse benign crashes age out of the rolling window before five coincide.
	The fast path is byte-for-byte unchanged — a hot loop still trips at exactly 5
	consecutive early exits, never delayed by the window. Benign over-threshold
	"deaths" are excluded: intentional teardowns (user restart, config/workspace
	reload, session change, #743 eviction, generation handoffs) are gated out by the
	existing `!wasShutdownIntentional()` guard the windowed trip shares, and a
	death whose lifetime exceeds a 10-min ceiling — a genuinely long healthy run, or
	an `exitedAt - spawnedAt` inflated across a machine-sleep/Modern-Standby suspend
	(the #1122/#1139 death-timestamp lesson) — is not recorded, backed structurally
	by per-server-key windowing (one suspend kills at most one live client per key).
	The death-timestamp window is bounded on BOTH axes (prune aged-out entries +
	hard-cap the array at the trip count, drop-oldest) and needs no timer (ages by
	prune-on-check). Fail-then-pass regression tests cover all three cases: the slow
	~65-90s loop now converges (verified failing on pre-fix — the whole bug), the
	hot loop still trips fast (no regression), and a sleep-gap/long-run death,
	sparse aged-out crashes, and deliberate restarts do NOT trip.
- **Closed out the three #1134 P3 tails left after #1138's Svelte oxfmt support (closes #1134)** —
	1. **TOML line-match heuristic limits.** `hasOxfmtSvelteConfig`'s
	   `OXFMT_SVELTE_TOML_TRUE` regex required `svelte = true` to be
	   immediately followed by end-of-line/end-of-file, so a trailing inline
	   comment (`svelte = true  # enable`) false-negatived. Relaxed the
	   trailing match to tolerate an optional `#`-comment, and added a
	   docblock sentence documenting the heuristic's remaining known limits
	   (it is a line match, not a TOML parser — a `[table]`-sectioned or
	   multi-line-string `svelte = true` occurrence could still false-positive;
	   accepted since that only causes oxfmt to be offered, never a silent
	   formatter failure). Added a fail-then-pass regression test for the
	   `svelte = true  # comment` case (`tests/clients/tool-policy.test.ts`).
	2. **Monorepo asymmetry.** `hasOxfmtSvelteConfig`'s svelte-dependency check
	   stops at the nearest `package.json` (`hasNearestPackageJsonDependency`)
	   while its config walk goes all the way to the repo root
	   (`walkUpDirs`) — a root-level `svelte` dependency with a sub-package
	   `cwd` under-offers oxfmt for `.svelte` there. Documented via a
	   docblock sentence (no behavior change: this cell fails safe —
	   under-offering, never mis-offering a formatter that then errors at
	   runtime — and stays untested by design, matching the async
	   `.tflint.hcl` nearest-vs-root-walk note already in this file).
	3. **docs/language-coverage.md's Vue row** showed formatter `—` though
	   `.vue` has been wired to `prettier` (default) + `oxfmt` (appended via
	   `OXFMT_SUPPORTED_EXTENSIONS`) since #1138 landed. Corrected the row to
	   `prettier, oxfmt`.
- **Rule-cache disk cache could replay a stale compiled rule set on a same-mtime+same-size rule-file edit (closes #1118, refs #1105 #878)** —
	`clients/cache/rule-cache.ts`'s `computeRuleHash` fingerprinted every
	effective rule file over METADATA only (`mtimeMs:size`) — the review-graph
	first-filter without its content-hash CONFIRM step. A rule-file edit
	preserving both mtime and byte size (git-checkout timestamp restoration, a
	same-length tweak, a formatter that preserves mtime) replayed a stale
	compiled rule set from `<language>-rules-v6.json`, and because this is a
	PERSISTED disk cache, the stale set was re-persisted under the
	fresh-looking fingerprint, poisoning every future process (the #878
	`force`-reload failure mode, one axis over). Fixed by splitting the
	fingerprint on rule-file origin, mirroring the project/bundled split
	`yaml-rule-parser.ts`/`ast-grep-napi.ts` already use for ast-grep rules
	(#1105): the small, mutable PROJECT-LOCAL subset (under
	`<project>/rules/tree-sitter-queries/`) now gets a content-hash CONFIRM on
	top of the metadata fingerprint; the ~705 BUNDLED files (immutable within a
	process) stay metadata-only, so `RuleCache.get`'s per-edit tree-sitter
	runner hot-path cost is unchanged when a project has no rule overrides.
	`CACHE_VERSION` bumped v6→v7 so a v6 entry persisted under the old
	metadata-only formula (a potentially poisoned one) misses once on upgrade
	instead of being trusted. Fail-then-pass regression tests pin a rule
	file's mtime via `utimesSync`, edit its content while preserving byte size,
	and assert the cache invalidates for project-local files but still hits
	for bundled-only rule sets; a third test confirms a persisted
	metadata-only-fingerprinted entry is rejected post-fix.
- **Formatter definitions and `FORMATTER_POLICY_BY_EXTENSION` were unbound hand-maintained inverse lists; added a bidirectional drift guard and fixed two latent mismatches (closes #1135; refs #1086 #1134 #883)** —
	`clients/formatters.ts` (formatter → `extensions[]`) and `clients/tool-policy.ts`'s
	`FORMATTER_POLICY_BY_EXTENSION` (extension → `formatterNames[]`) are hand-maintained
	INVERSE mappings of the same relation with no test binding the two directions — the
	#883/#209 single-source-of-truth class, and the general shape under #1134's oxfmt/.svelte
	gap. Added `tests/clients/formatter-policy-consistency.test.ts`, which imports the real
	`ALL_FORMATTERS` definitions and the policy maps and asserts both directions: every
	definition extension is policy-included / a documented deliberate exclusion / a documented
	no-policy fallback (so a definition gaining a policy-gated extension can't be silently
	never-offered — #1134's exact symptom); and every policy `formatterName`/`defaultFormatter`
	is a real formatter whose definition claims that extension or filename (so a broken option
	can't be offered — including the terragrunt-hcl filename-keyed variant). Fixed two real
	latent drifts surfaced by the guard, both behavior-identical (the mismatched entries were
	already inert): the `.sass` policy listed `oxfmt` though oxfmt does not support `.sass`
	(absent from `OXFMT_SUPPORTED_EXTENSIONS`), and the `.fish` formatter policy named
	`fish-indent`, which has no `FormatterInfo` (it is a lint runner) — a dead, unsatisfiable
	entry, now removed. A structural derive (tool-policy importing the definitions) was rejected
	because `formatters.ts` already imports `tool-policy.ts`, so the reverse edge would create a
	module import cycle; the test-based guard binds both directions while keeping the dependency
	one-way. Also bound `AUTO_INSTALLABLE_DEFAULT_FORMATTERS` keys to real formatter definitions
	(same formatter-name-reference class). The guard reads four newly-exported read-only symbols
	(`ALL_FORMATTERS` plus the three policy maps `FORMATTER_POLICY_BY_EXTENSION`,
	`FORMATTER_POLICY_BY_FILENAME`, `AUTO_INSTALLABLE_DEFAULT_FORMATTERS`), and its own two
	allowlists (deliberate exclusions, no-policy fallbacks) each carry a minimality check so
	they cannot rot into blanket escape hatches.
- **Pull-diagnostics entries never bound to document content, so they never demoted (closes #1104, refs #1095 #1096 #1100)** —
	#1096 bound LSP diagnostics to a content fingerprint on the PUSH path
	(`publishDiagnostics` version echo + send-time hash); the PULL path
	(`textDocument/diagnostic`, `workspace/diagnostic`) recorded no fingerprint
	at all, so pull-served cache entries read binding `"unknown"` forever and
	the #1096 P2-1 service-sweep binding gate could never protect them. Fixed by
	threading the server's `resultId` and a request-time content hash through
	both pull requests: a `"full"` report is fingerprinted (the single-file path
	reuses the exact sent-content hash `recordSentContent` already captures on
	every didOpen/didChange — no extra read; the project-wide
	`workspace/diagnostic` pull hashes disk bytes at request time since files
	may not yet be open when it fires), and an `"unchanged"` report (now
	requested via `previousResultId`/`previousResultIds`) inherits the prior
	pull's diagnostics AND binding instead of being misread as a confirmed-clean
	`[]` (the #570/#571 false-clean shape). The workspace pull-sweep record site
	(`clients/lsp/index.ts`) now threads this `contentHash` into
	`workspaceDiagnosticsCacheCtx.record()`, closing the gap the site's own
	`#1095` doc comment used to document as intentional. Also closed an
	AGENTS.md shape-5 gap in the sibling per-file touch path: `processFile` read
	the `#1095` binding off the RAW `touchFile` diagnostics array before
	`applyAuxiliarySuppressions`' `.filter()` rebuilds it (a `.filter()` copy
	does not carry a source array's non-enumerable `.binding`), and
	`LSPService.mergeBinding` no longer gates a contributor's `contentHash` on
	that SAME contributor also carrying a `version` — pull bindings
	legitimately carry a hash with no version, and the old gate silently
	dropped it. Folded in two #1100-review P3s on the same surface: the
	cascade's degraded/fallback display paths and the `lsp_binding_rejected`
	indeterminate-advisory preamble (`clients/runtime-turn.ts`), which
	previously reused the graph-unavailable wording even when the review graph
	was fine and only the LSP display was withheld, now use a
	binding-rejection-specific frame. Fail-then-pass regression tests cover:
	the pull-sweep record site actually receiving a `contentHash`; a
	pull-recorded entry whose content changed under a matching mtime being
	demoted (not replayed) on the next sweep; the per-file touch path's binding
	surviving the suppression filter; and the `"unchanged"`-report inheritance
	on both the single-file and workspace pull protocols.
- **Config caches gated freshness on mtime alone — the mtime-only cache-freshness class sweep (closes #1105)** —
	Completed the shape-6 (freshness stamp that doesn't cover the data's real
	dependency) sweep the #1092→#1119 diagnostics/word-index arc deferred. Audited
	every persisted/derived cache with an mtime/TTL key: word-index (#1119),
	`rule-cache.ts` (`mtime:size`), `sgconfig.ts` (content-hashed), the
	`yaml-rule-parser` project/bundled split (project rules content-hashed via
	`loadYamlRulesFresh`, bundled dir-mtime is safe by process-lifetime
	immutability), `reverse-deps` (snapshot-seq/generation-coupled), installer
	`probe-cache` (existence re-validated every read + ast-grep version-family
	verify — mtime is a refresh hint, not the correctness gate), `TreeCache`
	(content-hash authoritative, #890), and the project-snapshot per-file
	`mtime:size` entries — all already-hardened or safe. The one gap: the
	`.pi-lens.json` config caches gated reuse on the config file's **mtime alone**,
	so an in-place edit that preserved mtime (git checkout timestamp restoration, a
	same-second rewrite) but changed the file's byte length replayed a stale parsed
	config / ignore matcher — a config that drives mutation, ignore, and rule
	policy. This spanned every freshness gate in the two files: the root parsed-
	config caches (`loadPiLensProjectConfig`/`loadPiLensConfigInDir` in
	`clients/project-lens-config.ts`), the root ignore-matcher cache, and — the
	member found in adversarial review — the **nested `.pi-lens.json`/`.gitignore`
	layering cache** (`patternsForDir`, #783) in `clients/file-utils.ts`, which
	short-circuits AHEAD of the root caches so a preserved-mtime, length-changing
	edit to a NESTED config replayed stale patterns for that subtree. Fixed by
	adding **`size` as the free second axis** of the review-graph `size:mtimeMs`
	signature to every gate — the root `.pi-lens.json` (threaded through
	`PiLensConfigMarker` in `clients/workspace-topology.ts`), the root `.gitignore`
	and global `~/.pi-lens/config.json` (via a shared `fileFreshnessSignature`
	helper), and both the nested `.pi-lens.json` and nested `.gitignore` axes. The
	same stat that yields mtime already reads size, so the cheap hit path stays
	cheap (no content hashing on the hot path). The residual (identical mtime AND
	identical size, changed content) matches the review-graph/word-index accepted
	residual by design. Fail-then-pass regression tests cover the root and nested
	gates (each proven to replay stale on the pre-fix mtime-only code); FS-agnostic
	(mtime pinned via `utimesSync`, size varied by content length) so they exercise
	the gates identically on Linux CI.

- **Windows drive-root & autofix-snapshot `readdirSync` blocked the event loop on slow cloud-backed dirs (refs #1137)** —
	the genuine synchronous event-loop-block tier from #1122 (the 1.6–10.6 s
	single-process blocks in `latency.log`, distinct from the machine-level
	Modern-Standby/commit-exhaustion artifacts dispositioned in #1122 and tagged
	`suspectSystemStall` by #1125). On a OneDrive/network-backed path a stalled
	synchronous directory read blocks the Node loop — and pi's TUI — for the whole
	stall. Converted the two clearly-live-path, low-risk offenders: (a) the Windows
	Ruby drive-root enumeration (`readdirSync(driveRoot)` scanning `C:\` for
	`ruby<N>` installer dirs), previously re-run **synchronously on every LSP spawn**
	in `buildAugmentedPath` (PATH build) and on every Ruby candidate build in
	`rubyBinCandidates` — now a shared `clients/lsp/ruby-drive-dirs.ts` module that
	**memoizes** the result once per process (O(1) amortized) and reads the drive
	root **off the loop** via `fs.promises.readdir` on the hot spawn path; and
	(b) the `tool_result` autofix side-effect snapshot walk (`snapshotDirInto`,
	`clients/pipeline.ts`), whose per-directory `readdirSync`/`statSync` are now
	`fs.promises.readdir`/`stat` (the walk was already async + chunk-yielding, but
	each synchronous per-dir read still blocked on a cloud stall). Same outputs,
	just non-blocking. `measureMaxSyncBlockMs` occupancy guards assert the converted
	drive-root path no longer holds the loop and fail against the pre-conversion
	sync shape. Remaining suspect sites (the shared `walkTreeStackAsync` per-dir
	read in `clients/source-walker.ts`; `hasProjectMarker`/`expandWorkspacePattern`
	deep in sync memoized/bounded chains; the `safe-spawn.ts` Windows `spawnSync`
	teardown/cached paths) are deferred as scoped follow-ups on #1137 — each ripples
	into a widely-called sync API or subprocess-teardown correctness.
- **`session_start_sequence_read` was an unbounded synchronous blocking read on the session_start hot path (closes #1162)** —
	`readLatestProjectSequence` called `fs.readFileSync` on the project
	change-log before `session_start_total` returned; normally ~2ms, but under
	host I/O pressure it had no escape hatch and was observed to balloon to
	2125ms in production latency.log. A `setTimeout`/`Promise.race` timeout
	cannot preempt a synchronous read (the thread only returns to the event
	loop once the OS call returns), so the fix adds an async twin
	(`readLatestProjectSequenceAsync`, `fs.promises.readFile`) and races it
	against a 250ms budget (`PI_LENS_SEQUENCE_READ_BUDGET_MS`-overridable) in
	both the quick-mode and full-mode session_start paths. On a healthy read
	(the common case) this adds ~zero overhead; on a stalled read, session_start
	proceeds immediately with the safe cold-start sequence (only gates
	snapshot freshness, never correctness) while the real read finishes in the
	background and re-seeds the runtime — skipped for a one-shot `pi --print`
	process via `isPrintMode()`, screening the #1154/#1153 one-shot
	referenced-handle retention class. The fallback is never silent: the
	`session_start_sequence_read` latency line now carries a `timedOut` flag,
	and a background reseed logs its own
	`session_start_sequence_read_deferred_reseed` phase. Fail-then-pass
	regression tests inject a controllable slow read and assert session_start
	returns within budget, falls back to cold-start with the flag set, and
	still seeds normally on the healthy path. Adversarial review (#1168) caught
	two P3s in this exact stall regime, both fixed in the same PR: (1) the
	background reseed's `isCurrentSession` guard caught a cross-session move-on
	but not a SAME-session advancement — an edit landing in the stall window
	could have its `bumpFileSeq` result clobbered by a late reseed of the
	pre-edit state; fixed with a `runtime.projectSeq > 0` guard (the cold seed
	always sets it to exactly 0, so `> 0` at reseed time can only mean an
	in-window bump happened). (2) The cold sentinel's `projectSeq: 0` was
	indistinguishable from a project's legitimate first-ever snapshot (also
	persisted at `seq === 0`), so a timed-out read could hydrate a stale seq-0
	snapshot as fresh; fixed with a dedicated `UNKNOWN_PROJECT_SEQ` (`-1`)
	sentinel fed only to the freshness check (never to `runtime.projectSeq`
	itself, keeping fix (1)'s guard valid). Both have their own fail-then-pass
	regression tests.
- **Resource-sampler Windows CIM spawns were not `.unref()`'d, the same one-shot-retention shape as the orphan reaper (refs #1155)** —
	`clients/resource-sampler.ts`'s two Windows-only `Get-CimInstance Win32_Process`
	spawns (`findDescendantPidsWindows`'s descendant-tree lookup and
	`sampleProcessesWindows`'s CPU/RSS query) used `stdio:["ignore","pipe","ignore"]`
	with a piped, `data`-listener-attached stdout and neither the child nor its
	stdout was ever `.unref()`'d — the same shape #1153/#1160 fixed for the orphan
	reaper (shape 4 of AGENTS.md's recurring-defect catalog: a referenced handle
	that outlives a one-shot settle). The sampler was empirically absent under a
	trivial `pi --print` prompt (its own `setInterval` was already unref'd, and it
	only runs bracketed to an awaited analyzer spawn), but was not safe by
	construction for a file-editing repro that does exercise it. Fixed by
	extracting the reaper's `unrefReaperChild` AND its `spawnCollectStdout`
	spawn→pipe-stdout→resolve-on-close plumbing into a shared, dependency-free
	`clients/child-unref.ts` (`unrefChildAndPipes`, `spawnCollectStdout`) and
	calling `spawnCollectStdout` at both sampler spawn sites — a single source
	of truth for both modules instead of a second hand-rolled copy (the
	promotion also resolved a SonarCloud new-code-duplication gate failure:
	adding an identical `unrefChildAndPipes(child)` line to both near-identical
	spawn blocks had pushed duplicated-line density over the 3% threshold;
	collapsing both blocks to parse-only call sites around the shared helper
	removed the duplication instead of adding to it). Unref only detaches this
	child ALONE from keeping a settled one-shot alive; in an interactive/
	long-lived session (or one bracketed to real analyzer work) the loop stays
	referenced for other reasons, so sampling is unaffected — the parse logic
	at both call sites is otherwise unchanged, so a spawn/error failure still
	resolves to the same empty/partial result as before. Fail-then-pass
	regression tests assert both spawn sites unref the child and its stdout, in
	both the sampler and (unchanged) the reaper.
- **`toProjectRelativePath` never relativized a Windows-shaped path off native Windows (closes #1163, refs #1150/#1152/#1161/#1024)** —
	shape-2 bug-class sweep of the `path.*`-on-cross-shaped-input hot zone.
	`clients/path-utils.ts:toProjectRelativePath` used the host-default
	`path.isAbsolute`/`path.relative` even when the input was Windows-shaped
	(drive letter or UNC). On Linux CI, `path.isAbsolute("C:\\repo\\src\\x.ts")`
	is `false` (no POSIX leading slash), so the function short-circuited and
	returned the whole absolute path instead of the project-relative `src/x.ts`
	it produces on Windows — a persisted call-graph symbol-key path or graph
	display path (via `module-report`/`lens-map`'s `toDisplayPath` and
	`call-graph`'s `formatImpact`) rendered as a full absolute path on Linux
	(green-locally / wrong-on-CI, the #1024 divergence class). Fixed
	shape-conditionally (`isWindowsPath(p) ? win32 : path`, the #1152 idiom):
	a Windows-shaped path is parsed with `win32.*` on ANY OS; native same-OS
	paths are unchanged. Fail-then-pass regression tests feed `C:\...`/UNC
	literals as INPUT and assert the relative result on any OS (meaningful on
	Linux CI). The rest of the swept hot zone
	(`widget-state`/`file-utils`/`call-graph`/`installer`/`elixir-check`, plus
	`file-role` #1152 and `resolveNonExisting` #1150 already fixed) was audited
	and cleared as native-by-design — inputs are real on-disk paths the running
	OS produced (cwd/project-roots/scanned files, `path.resolve`'d first) or
	already fold through `normalizeEphemeralMapKey`/`PathKeyedMap`/the
	regex-based `parseSymbolKey`.
- **`generated-artifacts.ts` used module-default `path.basename` on Windows-shaped paths, under-detecting lockfiles/declarations off native Windows (closes #1161, sibling of #1150/#1152)** —
	`hasStrongGeneratedArtifactPath` (lockfile match), `hasWeakGeneratedFileNamePattern`
	(name-pattern match), and `isDeclarationFile` (`.d.ts`/`.d.mts`/`.d.cts` match)
	all took the module-default `path.basename(filePath)` on shape-committed
	input. On Linux CI, `path.basename("C:\\proj\\package-lock.json")` finds no
	POSIX separator and returns the whole string unchanged, so
	`LOCKFILE_NAMES.has(...)` misses — a Windows-shaped lockfile or declaration
	path was silently treated as ordinary source. `generated-artifacts.ts` is
	imported by `file-role.ts`'s `"generated"` branch, so this residual sat
	within `detectFileRole`'s own call tree even after #1152 fixed the
	dir-segment/basename split there. Fixed with a shared `basenameForShape`
	helper that routes through `path.win32.basename` when `isWindowsPath`
	(exported by #1152) is true, mirroring `file-role.ts`'s fix exactly —
	shape-conditional, not shape-committed, so native-OS classification is
	unchanged. The strong directory-segment match (`pathSegments`, which splits
	on `[\\/]+`) was already shape-safe and untouched. Fail-then-pass regression
	tests cover a `C:\...`-shaped lockfile and `.d.ts` literal.
- **Quick-mode background warmup kept a one-shot `pi -p`/`--print` process alive (closes #1154)** —
	`handleSessionStart` forces **quick mode** for both a real `pi -p`/`--print`
	one-shot AND an interactive process's first session (to protect keystroke
	latency, then warm caches for the next `/new`). Quick mode scheduled a +2s
	background warmup that (1) armed a `setTimeout` that was **not** `.unref()`'d —
	violating the repo-wide convention that every background timer is unref'd
	(`clients/runtime-session.ts` previously had zero `.unref()` calls) — and
	(2) launched LSP-prewarm children + a language-profile source walk that
	outlive settle with **no `session_shutdown` abort**. In a one-shot (which
	exits right after the turn and has no future session to warm) both are pure
	waste AND referenced-handle keep-alives that hold the settled process open —
	the located **#1122 hypothesis-A** concern and a member of the
	one-shot-retained-by-a-referenced-handle class of #1097/#1110/#1148/#1149.
	Fixed by (a) skipping the warmup entirely in print mode via a new shared
	`isPrintMode()` helper (`clients/print-mode.ts`) — an interactive first
	session (quick but not print) still warms — and (b) `.unref()`'ing the warmup
	timer as defense-in-depth + convention conformity. Also unref'd the adjacent
	full-mode `scheduleStartupScans` deferred timers (same latent violation, not a
	one-shot member today). This resolves the #1122 hypothesis-A tail (cross-ref
	#1122). Fail-then-pass regression tests assert the warmup does not run in print
	mode and the scheduled warmup timer is unref'd.
- **`detectFileRole` misclassified a Windows-shaped path off native Windows (closes #1152, latent sibling of #1150/#1151)** —
	`clients/file-role.ts:detectFileRole` used the module-default `basename`/
	`dirname` (POSIX on Linux) even when the input path was Windows-shaped
	(drive letter or UNC prefix). A backslash-only `C:\...` path has no `/`
	for POSIX `dirname` to find, so it collapsed to `"."` and the dir-based
	role branches (`/tests/`, `/spec/`, generated-marker dirs) silently
	misclassified — the byte-identical forward-slash form already worked,
	since POSIX `dirname` handles `/` regardless of the leading drive
	letter. `detectFileRole` is platform-native by design, so the fix is
	shape-conditional rather than shape-committed (unlike #1151's
	`resolveNonExisting`, which is already win32-committed): a Windows-shaped
	path (per the now-exported `isWindowsPath`, `clients/path-utils.ts`) is
	parsed with `path.win32.basename`/`dirname` regardless of the running
	OS; a same-OS-native path is unaffected (native win32 already equals the
	module default there; native POSIX was never win32-shaped). Added
	regression coverage in `tests/clients/file-role.test.ts` asserting
	coherent `"test"`/`"init"` classification for `C:\...`, `C:/...`, and
	UNC-shaped inputs regardless of the running OS.
	`clients/lsp/server.ts:639`'s `normalizeSlashKey(path.dirname(path.resolve(file)))`
	(flagged in #1151's review as the same shape) was audited and verified
	**safe, not fixed**: `path.resolve(file)` runs before `dirname`, so by
	the time `dirname` sees it the value is already coerced to the running
	OS's native absolute-path shape — `file` here is always a real on-disk
	path produced by this process's own directory walking (extension-root
	resolution backed by real `existsSync`/`stat` probes, confirmed via
	`tests/clients/lsp/typescript-extension-root.test.ts`), never a
	persisted or cross-OS-supplied literal. Same "platform-native by
	design" exemption #1151 already applied to `path-utils.ts`'s
	`walkUpDirs`/`findNearestContaining` family.
- **Orphan-reaper fire-and-forget PowerShell/`ps` spawns kept a completed `pi --print` alive past settle (closes #1153)** —
	the orphan reaper (`clients/instance-reaper.ts`) is fired fire-and-forget from
	`session_start` (`index.ts` `sweepOrphans`/`sweepUntrackedOrphans`), not
	awaited and not gated out of one-shot/`--print` mode. Its OS-process-table
	enumeration spawns (`enumerateManagedProcesses`, `queryCommandLines`,
	`findPidsByMarkerWindows` — PowerShell on Windows, `ps` on POSIX) used
	`stdio:["ignore","pipe","ignore"]` with a `data` listener and **no `.unref()`
	anywhere in the file**. A piped, listener-attached stdout stream keeps the
	event loop REFERENCED until the child `close`s, and `sweepUntrackedOrphans`
	guarantees ≥1 such PowerShell on every Windows `session_start` — so a settled
	one-shot process could not exit until that PowerShell finished cold-starting
	(routinely 300 ms–2 s). This is the child-process member of the referenced-
	handle class (#1097/#1110 timers, #1148/#1149 worker ports). Fixed by
	`unref()`-ing every reaper child AND its stdio pipes (a `child.unref()` alone
	does not release a piped stdout that re-refs the loop) via a shared
	`unrefReaperChild` helper applied at all six spawn sites (the five enumeration
	spawns plus `killPidTree`'s `taskkill`). Unref, not a print-mode skip: the
	reaper is a machine-wide orphan backstop, not a next-session-only concern, so
	gating it out of `--print` would blind orphan cleanup on print-only machines
	(CI/automation/subagents — exactly where one-shots dominate and orphans
	accumulate); unref preserves the sweep in interactive sessions (the loop stays
	referenced for other reasons, so every child's `close` still fires and the
	sweep completes) while letting a genuinely-settled one-shot exit without
	waiting. Regression test spawns a fake child per spawn site and asserts the
	child + its stdout are unref'd (fails pre-fix, passes post-fix).
- **Persistence workers could keep completed one-shot processes alive ([#1148](https://github.com/apmantza/pi-lens/issues/1148))** — project-snapshot and review-graph workers called `unref()` before registering their `"message"` listeners, and Node re-referenced the public `MessagePort` when each listener was added. Both workers now install all lifecycle listeners before `unref()`, so persistence remains asynchronous without retaining an otherwise-finished `pi --print` or subprocess workflow. Real child-process regression tests require both persistence paths to finish writing and exit naturally.
- **`normalizeFilePath` mangled a Windows-shaped path on non-Windows OS (closes #1150)** —
	`normalizeFilePath` commits to its win32 branch by path *shape*
	(`isWindowsPath`), so a `C:/…`- or `C:\…`-shaped path enters that branch on
	ANY OS — but `resolveNonExisting`'s upward walk used the module-default
	`dirname` (POSIX on Linux) while the rest of the branch already used
	`win32.resolve`/`win32.normalize`. On Linux the POSIX `dirname` found no
	separator in the win32-resolved path, collapsed to `.`, stopped the walk at
	`process.cwd()`, and produced `<cwd>/file.ts` instead of the literal key — so
	a Windows-shaped path normalized to a DIFFERENT key on Linux than the
	byte-identical literal it is on Windows (the #1024 OS-divergence class,
	surfaced by #1139's green-on-Windows/red-on-Linux-CI). Fixed by using
	`win32.dirname` in the win32-committed branch, making the function internally
	coherent regardless of the running OS. Guarded by a both-OS-meaningful
	regression test (native win32 path on Windows; shape-committed win32 branch on
	Linux) and an AGENTS.md convention: tests must derive `normalizeMapKey`-keyed
	structure keys via `normalizeMapKey`, never hardcode a drive-letter literal.
- **Workspace-edit preflight: five contained P3 deferrals from #1085 (refs #1085)** —
	`clients/lsp/edits.ts`:
	- **P3-2** `mergeWorkspaceTextEditsByPriority`'s exact-duplicate dedup
	  collapsed identical zero-width inserts duplicated within ONE server's
	  own edit on the `renameFile` merge path, contradicting the documented
	  multiplicity invariant (several identical zero-width inserts at one
	  point are meaningful, same as `validateTextEdits` on the normal apply
	  path). The dedup now only applies to non-empty ranges; a genuine
	  cross-server exact duplicate (two servers proposing the identical
	  non-empty replace) still collapses to one.
	- **P3-3** an ordered edit `rename(b→c); create(b); textEdit(b)` was
	  falsely rejected in preflight ("text edit target does not exist"): a
	  `create` at a path vacated by an earlier rename in the same edit had
	  `resolveVirtualPath` return `undefined` (correct — the path is
	  virtually gone), so the created state was silently discarded instead
	  of recorded. Added a `virtualOverrides` overlay, keyed on the raw
	  query path, consulted only when `resolveVirtualPath` cannot resolve a
	  physical address, so a later op at that same path (e.g. the trailing
	  text edit) sees the re-established file.
	- **P3-4** `version: null` (LSP 3.17: "don't check") combined with a
	  numeric version for the SAME uri across two edit containers incorrectly
	  threw "conflicting text document versions". A numeric version is now
	  authoritative and adopted over a `null`/`undefined` counterpart; two
	  genuinely different numeric versions still conflict, and a numeric
	  version adopted from a `null` pairing is still checked against the live
	  document version (a stale adopted numeric is still rejected).
	- **P3-6** `fileDetails[].importsChanged` reported "the file contains any
	  import statement" (`/^import\s/m.test(updated)`) rather than "this edit
	  changed an import/re-export line", over-invalidating downstream
	  dependency-graph re-checks (`cache-manager.ts`'s `importsChanged`
	  filter, `lsp-mutation.ts`'s `addModifiedRange`) on every edit to a file
	  that merely already had imports. Now compares an import/re-export-from
	  line signature of the pre- and post-edit content; unchanged unless the
	  edit actually touched those lines. `create`/`rename`/`delete` keep
	  their existing conservative flags (a create writes an empty file;
	  rename/delete are structural, not diffable against pre-edit text).
	- **P3-8** an ordered edit that CREATEs `foo.txt` then case-renames it to
	  `Foo.txt` within the SAME edit fail-closed with "destination already
	  exists" on a case-insensitive FS: the alias check (`isSameFsEntry`)
	  lstats disk, but the just-created file exists only in the preflight's
	  virtual overlay, never on disk yet. The rename branch now also treats
	  a rename as an alias when the destination's cached `VirtualFile`
	  object is referentially identical to the source's (both `stateFor`
	  calls resolve through the same case-folded map key), before falling
	  back to the physical `isSameFsEntry` probe — which is unchanged for
	  genuinely-physical paths, so the #1024/#1120 ino-guard and
	  case-sensitivity invariants are untouched.

	**Deferred at the time (now fixed, see below):** P3-5 and P3-7 were
	rehomed to #1147; see the #1085 closing comment for that history.
- **Workspace-edit CRLF boundary class + rename close-failure plaintext reopen (closes #1147, refs #1085)** —
	the two P3 deferrals left open after #1146:
	- **P3-5** (`clients/lsp/edits.ts`) — #1120 fixed only the past-EOL clamp
	  member of the CRLF-boundary class. Two general members remained: (a) a
	  `newText` containing a bare `\n` was spliced verbatim into a CRLF
	  file's content, producing mixed line endings, because the LSP
	  workspace-edit apply path (unlike the host-edit path) never
	  EOL-normalized `newText`; (b) `utf16Position`'s past-EOL clamp only
	  triggered on `character > wireLength`, so an in-bounds,
	  caller-supplied `character === wireLength` on a line whose `\r` is
	  folded into `lineTextAt`'s with-`\r` length landed the position
	  squarely between `\r` and `\n`, splitting the pair on write. Fixed by
	  (a) normalizing every `newText` through the exact
	  `detectLineEnding`/`normalizeToLF`/`restoreLineEndings` contract
	  `clients/host-edit-normalize.ts` already uses for the host-edit path
	  (LF files are unaffected — `restoreLineEndings` is the identity for
	  `"\n"`), applied once in `normalizeTextEditsForContent` so it covers
	  both the preflight virtual-content chain and the final on-disk write;
	  (b) clamping on `character > clampedWireLength` (the `\r`-stripped
	  length) instead of the with-`\r` length, which subsumes and simplifies
	  #1120's original past-EOL clamp into the same branch. All of #1120's
	  existing invariants (single application-ordering sort, clamp, tie-break)
	  are unchanged and covered by the full `edits.test.ts` suite.
	- **P3-7** (`clients/lsp/index.ts`) — the rename close-failure recovery
	  path reopened the old document as a hardcoded `"plaintext"` languageId,
	  degrading that server's diagnostics until the next genuine open. Now
	  reopens with `getLanguageId(oldFilePath) ?? "plaintext"` — the same
	  resolver every genuine `notify.open` call in this file already uses —
	  so a recognized extension reopens with its real language ID and only a
	  genuinely unrecognized extension still falls back to `"plaintext"`.
- **Post-init runtime exits now count toward the LSP circuit breaker (closes #1127)**
	— `LSPService`'s (`clients/lsp/index.ts`) exponential-backoff breaker
	(`failureCounts` → cooldown → permanent-disable after
	`BROKEN_PERMANENT_AFTER`) only incremented on spawn/initialize failure.
	A server whose spawn SUCCEEDS but then exits shortly after (opengrep's
	post-init "Unhandled message" JSON-RPC crash, per #1122's Phase C
	corroborating-signal review — 37 respawns in one real session, never
	converging) hit the "dead client — needs respawn" path instead, which
	never touched the breaker: `failureCounts` was already cleared by the
	preceding successful spawn and the runtime exit itself was never counted.
	Fixed by adding a parallel `runtimeExitCounts` counter fed only by EARLY
	(lifetime < 60s) non-intentional exits, sharing the same cooldown formula
	and the same `state.broken`/`permanentlyBroken` maps as the existing
	breaker — tracked separately from `failureCounts` specifically because a
	successful respawn (which correctly resets the spawn/init failure streak)
	is not proof of health for a crash-loop server, so reusing that map would
	erase the streak on every respawn attempt (the #1127 bug). Deliberate
	teardowns (session reset, `#743` notify-backpressure eviction, generation
	handoffs) call `shutdown()` themselves before the process exits and set
	`shutdownRequested`; a new `wasShutdownIntentional()` accessor on
	`LSPClientInfo` (`clients/lsp/client.ts`) exposes that flag so the breaker
	distinguishes a genuine crash from a restart it initiated and never counts
	the latter. Adversarial review caught that lifetime was originally
	computed from the moment a dead client is lazily DETECTED (the next
	`getClientForFile` attach), not from when it actually died — #1127's
	documented pattern is attach-triggered respawns minutes to hours apart, so
	an early crash detected an hour later would misread as a long healthy run
	and never count. Fixed by stamping a real `exitedAt` on `LSPClientState`
	the moment the client's connection/process actually dies (first of
	`onError`/`onClose`/process `exit` to fire) and exposing it via a new
	`getExitedAt()` accessor; the respawn site computes lifetime as
	`exitedAt - spawnedAt`, falling back to the detection-time delta only when
	`exitedAt` is unexpectedly unset. New coverage in
	`tests/clients/lsp/service-runtime-exit-breaker.test.ts`: a crash-loop
	respawn sequence converges to permanent-disable instead of respawning
	forever (fails against pre-fix behavior), an early death with detection
	delayed by hours still counts (death time, not detection time, decides —
	fails against the pre-fix detection-time computation in both directions),
	a deliberate `shutdown()`-driven restart sequence never counts, a runtime
	exit past the lifetime threshold resets the streak instead of counting,
	and the REAL `#743` notify-write-backpressure eviction path (driven
	through `touchFile`, not a synthetic stand-in) never double-counts against
	this breaker. Full existing LSP suite (536 tests) stays green.

- **`terragrunt hcl fmt` reported success when it never ran (refs #1117)** —
	`formatFile` decided success from `result.error` alone, and a formatter that
	fails leaves the file byte-identical, which is indistinguishable from
	"already formatted". A terragrunt binary predating the `hcl` command group
	exits non-zero without touching the file, and pi-lens reported
	`success: true, changed: false`. `FormatterInfo` gained an opt-in
	`strictExitCode`, set on `terragrunt-hcl`, which folds the exit status into
	the check and surfaces the tool's own first stderr line as the error. It is
	opt-in rather than global because the lint-autofix formatters (`rubocop -a`,
	`ktlint -F`, `standardrb --fix`, `sqlfluff fix`) exit non-zero when offenses
	remain AFTER a successful rewrite; failing those would surface a formatter
	error on every file with an unfixable offense.
- **Runners reported a clean file on a non-zero exit with no output (refs #1117)** —
	seven runners gated their "the tool never ran" skip on
	`result.error` alone. `safeSpawnAsync` sets `error` only for spawn, timeout,
	signal and abort failures, and resolves a NORMAL exit with no `error` at any
	status (see `SpawnResult.failure`: "nonzero exit statuses are not spawn
	failures"). So an unknown subcommand, a rejected flag, or a config that fails
	to load — non-zero exit, message on stderr, empty stdout — fell straight
	through the guard, parsed `""` into zero diagnostics, and reported the file as
	clean. Terragrunt was the visible case: its docblock claimed a binary
	predating the `hcl` command group was classified SKIPPED, and it was not. The
	test now lives in `spawnFailedWithNoOutput`
	(`clients/dispatch/runners/utils/spawn-outcome.ts`) and folds `status` into
	the check; terragrunt, tflint, hadolint, taplo, trivy-config, htmlhint and
	detekt all use it. Runners that exit non-zero BECAUSE they found something are
	unaffected, since their findings are on the stream the guard tests. ktlint,
	prisma-validate, gleam-check, zig-check, dart-analyze and elixir-check already
	covered the case through their own non-zero follow-up branches and are
	unchanged.
- **Bare-binary GitHub release assets skip signature siblings** — the installer
	picked a release asset with `assets.find(a => a.name.includes(substring))`.
	Archive-based tools are unaffected (their substrings end in `.zip`/`.tar.gz`),
	but the three bare-binary entries (terragrunt, marksman, expert) resolve to
	the FULL asset name, which is a strict prefix of every `.asc`/`.sig`/`.sha256`
	sibling, so whichever the release listed first would be downloaded and marked
	executable as the binary. Selection now lives in an exported
	`pickReleaseAsset`: exact name first, then a substring match that excludes
	known sidecar suffixes.
- **Cascade fallback-display paths re-displayed bound-false LSP snapshots (refs #1104)** —
	#1100 gated the cascade's RECONCILE path (the footer/widget) onto content
	binding (`boundToCurrentDisk`), but two DEGRADED-fallback DISPLAY paths in
	`clients/dispatch/integration.ts` still re-read TTL-fresh `getAllDiagnostics()`
	snapshots without consulting binding at all: the touch-error fallback (a
	failed active LSP touch falling back to the passive snapshot) and
	`appendFallbackNeighbors` (the CR-3/A2 degraded-fallback path when no
	neighbor produced trustworthy LSP data). A bound-false snapshot — diagnostics
	computed against a DIFFERENT disk state than what's currently on disk, e.g. a
	pre-fix-edit read — could still reach cascade OUTPUT even though the widget
	was protected. Both sites now apply the same false/`"unknown"`/true contract
	#1095/#1100 already established for reconcile: `false` → skip the stale
	display (logged via the cascade channel with `bindingState`), `"unknown"` →
	unchanged (the pre-existing fallback contract), `true` → display. HONESTY
	fix: filtering a display candidate could otherwise make a genuinely degraded
	cascade look clean, so when every fallback candidate a run considered was
	binding-rejected and nothing else produced output, the run now carries the
	same `indeterminate` marker #1023 built for a degraded graph compute (new
	`CascadeIndeterminateReason: "lsp_binding_rejected"`), so the turn-end
	advisory still surfaces an honest note instead of silence. The `resultId`
	pull-diagnostics plumbing that #1104 also tracks remains open — this covers
	only the cascade display-binding gap (#1100 review P3-1).
- **`parseSymbolKey` mis-parsed LSP-fallback symbol kinds (refs #1088)** —
	the canonical-id parser whitelisted only the 7 kinds `buildSymbolId` mints
	directly, but `addLspFallbackSymbols` mints ids using the much larger
	`lspSymbolKindName` vocabulary (`enum`, `constant`, `struct`, `namespace`,
	`field`, `constructor`, `module`, the `lsp-symbol-<n>` catch-all, ...). Any
	of those ids failed the whitelist and fell through to the legacy
	last-colon split, shearing the id at the wrong boundary (e.g.
	`c:\p\a.kt:Color:enum:42` parsed as `filePath: "c:\p\a.kt:Color:enum"`,
	`symbolName: "42"`). `clients/review-graph/symbol-id.ts`'s
	`parseSymbolKey` now matches the trailing `:<kind-token>:<digits>` shape
	STRUCTURALLY (any lowercase, optionally hyphen-segmented token) instead of
	whitelisting specific kind strings, so it stays correct for the open-ended
	LSP-fallback vocabulary without needing a matching update per kind.
- **Java try-with-resources suppression was dead (refs #1089)** —
	`clients/tree-sitter-client.ts`'s `not_closed_or_try_with_resources`
	post-filter built its resource-word regex as `` new RegExp(`\b${resource}\b`) ``
	inside a template literal, where `\b` is the BACKSPACE control character
	(U+0008), not a regex word-boundary escape. The regex could never match,
	so the Java 9 short-form `try (resource) { ... }` was never recognized as
	closing its resource — only an explicit `.close()` call suppressed the
	"resource should be closed" finding. Fixed to `\\b` plus a regex-escaped
	identifier. No shipped Java fixture used try-with-resources syntax, so no
	existing expected-findings fixture changes with this fix.
- **Raw NUL byte in `review-graph/builder.ts` made the file look binary to grep (refs #1088)** — the checkpoint's `ignoredIds` join separator was a
	literal 0x00 byte embedded in source, which makes `ripgrep`/`grep` treat
	the whole 4,900+ line file as binary and stop scanning partway through —
	silently defeating the AGENTS.md-mandated repo-wide grep sweep for every
	commit that touched this file. Replaced the raw byte with the `\u0000`
	escape sequence, which is the byte-identical runtime string (same
	`hashIgnoredIds` function computes both the write-time and read-time
	hash, so no persisted checkpoint compatibility is affected).
- **`buildCallGraph` same-file evidence accounting hardened + dead code removed (refs #1089)** — added regression coverage for the audited
	same-file evidence class (divergent path forms for the same file must
	both be recognized as same-file AND counted exactly once per reference,
	keeping the coverage sum invariant `validatePersistedCallGraph` enforces
	on every load); the normalized-compare fix for this had already landed
	upstream, so this closes out the finding with a fail-then-pass regression
	test rather than a behavior change. Also removed `clients/call-graph.ts`'s
	dead duplicate `saveCallGraph` overload signature (byte-identical to the
	implementation signature) and the unreachable `{}`-spread branch on the
	required `identity` parameter.
- **JS destructured params dropped from `parameterCount` (refs #1089)** —
	`clients/dispatch/facts/function-facts.ts`'s `getParameters` recognized
	`required_parameter`/`optional_parameter` wrapper nodes (how TypeScript's
	grammar represents every parameter, destructured or not) but not the bare
	`object_pattern`/`array_pattern` nodes plain JavaScript's grammar uses
	for a top-level destructured parameter with no wrapper. `function
	f({a, b})` counted 0 parameters in a `.js` file while the TS-annotated
	equivalent counted correctly. Added JS-vs-TS parity tests.
- **`call-graph.ts`'s exported `CACHE_VERSION` renamed to `CALL_GRAPH_CACHE_VERSION`** — collided in name (not value — separate
	modules) with `clients/cache/rule-cache.ts`'s own `CACHE_VERSION` export;
	renamed to match the `<SUBSYSTEM>_CACHE_VERSION` convention already used
	by `WORKSPACE_DIAGNOSTICS_CACHE_VERSION` and
	`PROJECT_DIAGNOSTICS_CACHE_VERSION`. Updated the two test-file imports.
- **Dead SIGTERM→SIGKILL escalation guard on non-Windows kills (closes #1114)**
	— `clients/safe-spawn.ts`'s non-Windows `killTree` branch armed a 1s
	escalation timer gated on `if (!child.killed) child.kill("SIGKILL")`, but
	Node sets `ChildProcess#killed = true` the moment `kill()` successfully
	SENDS a signal — not when the child actually dies — so immediately after
	the `child.kill("SIGTERM")` one line above, the guard was always false
	and the SIGKILL escalation could never fire: a SIGTERM-ignoring child on
	Linux/macOS was never force-killed. Fixed by tracking OBSERVED death via a
	`closed` flag set synchronously (before any `await`) in the close/error
	handlers, gating the escalation on `!closed` instead — composes cleanly
	with the existing #1109/#1113 `escalationTimer` clear-on-close fix rather
	than switching to the LSP `killProcessTree` analog's unconditional-SIGKILL
	design, since `safeSpawnAsync` already has a real per-call close/error
	observation point to hang the flag off of. Proven with a new "child
	ignores SIGTERM → SIGKILL sent at the 1s mark" test
	(`tests/clients/safe-spawn-kill-escalation-timer.test.ts`) that fails
	against the pre-fix guard and passes post-fix; the existing #1109 timer-leak
	tests (escalation timer cleared when close/error DOES arrive) remain green.
	**Class sweep** of every `.killed` consumer under `clients/` and `scripts/`
	found two siblings of the same shape in `clients/lsp/client.ts`'s
	`killProcessTree`/`createLSPClient` and fixed both in this PR: (1) the
	`fast`-shutdown escalation timer checked `!proc.killed`, but the primary
	SIGTERM send there goes through the raw `process.kill(-pid, …)` process-group
	call (which never touches `proc.killed`), so the guard was either always-true
	(unconditional SIGKILL after the window on the common group-kill path) or
	dead (on the direct-child fallback path) — now tracked via a real `exit`
	listener set once up front, seeded from the same `exitCode`/`signalCode`
	pre-check the function's top-of-body early return already uses (a process
	that was already dead on entry — reachable when `options.processExiting`
	skips that early return — would otherwise miss its own "exit" event and
	still draw a redundant group SIGKILL at the escalation window); (2) the
	`initialize()`-timeout 2s SIGKILL backstop had the identical always-true
	`!lspProcess.process.killed` guard — switched to `lspProcess.process.exitCode
	=== null && lspProcess.process.signalCode === null` (both, not `exitCode`
	alone: a process killed BY a signal — the common case here, since
	`killProcessTree` above it signals rather than lets the process exit on its
	own — has `exitCode === null` forever and only `signalCode` set, so
	`exitCode` alone still re-armed the backstop's kill against an
	already-dead corpse; harmless in practice since `ChildProcess#kill()` on an
	exited handle is a swallowed no-op, but not an accurate "still alive"
	read). Other `.killed` reads audited and left as-is because they're
	liveness/status checks, not escalation-action gates: `isClientAlive`'s
	`!state.lspProcess.process.killed` (redundant with `isDestroyed`, already
	set from real exit/close handlers), `checkProcessAlive`'s informational
	"was killed" health-check string, `launch.ts`'s post-spawn
	immediate-failure check (`proc.killed` read before any kill was ever sent),
	`scripts/with-test-lock.mjs`'s `.once`-registered first-forward guard, and
	`scripts/smoke-tools.mjs`'s read of Node's own `execFileSync` timeout-kill
	flag on the caught error object.

	**Adversarial-review follow-up round:** the reviewer ran
	`kill-process-tree.test.ts` against PRE-fix `client.ts` and it passed 7/7 —
	the sibling fixes above had ZERO effective test coverage, because the
	"non-fast shutdown escalates" mock (and the other pre-existing mocks in
	that file) lacked `once`/never set `killed`, so BOTH the old dead guard and
	the new fix's guard were vacuously permissive against them (the #1106
	vacuous-mock class, recurring in mock form: a test's fixture is too weak to
	distinguish correct from broken behavior, so it passes either way). Fixed
	by: upgrading that test's mock to be `.once`-capable so it actually
	exercises the `exited`-flag logic; adding two new `fast`-shutdown tests with
	a real `.once`-capturing mock proving BOTH directions (exit observed before
	the 1.5s window → no group SIGKILL; no exit observed → group SIGKILL at the
	window) — the "no premature SIGKILL" direction fails against the pre-fix
	`!proc.killed` guard (proven by temporarily reverting the guard and
	re-running); and adding a real-subprocess POSIX-only test
	(`tests/clients/lsp/initialize-timeout-backstop.test.ts`, skipped on win32
	with an explicit reason — killProcessTree's Windows path is
	`taskkill`-based, not signal-based, and is already covered by the
	kill-process-tree suite) for the previously fully-untested `initialize()`
	2s backstop.
- **Micro-gap sweep: recorded coverage/observability/doc gaps (refs #1106, refs #1104)**
	- `session-state-store.ts`'s `loadSessionState` STATE_VERSION reject path
		(a wrong-version persisted snapshot is ignored, not rehydrated) had no
		test; `STATE_VERSION` is now exported so the new test can drive the
		mismatch off the real constant (`STATE_VERSION + 1`) rather than a
		hardcoded literal (#1116 pattern).
	- `tests/clients/cache/rule-cache.test.ts`'s deliberate `raw.version = "v2"`
		schema-mismatch override now pins `expect(CACHE_VERSION).not.toBe("v2")`
		alongside it (#1082/#1116 pattern), so the assertion can't vacuously pass
		if `CACHE_VERSION` ever became `"v2"`.
	- The cascade `neighbor_touch` log entry (`clients/dispatch/integration.ts`)
		now carries an `inconclusive` boolean in its metadata alongside
		`bindingState`, so the two independent unconfirmed-touch causes (notify/
		diagnostics wait lapsed vs. disk-diverged binding, #1093/#1095) are
		distinguishable from `cascade.log` alone, without cross-referencing
		`latency.log`.
	- `tools/lens-diagnostics.ts`'s `includeGenerated` param description now
		states it only takes effect with `mode=full refreshRunners=cheap/all`
		(it's silently a no-op under `cached`/`none`, since no project scan runs
		to apply it to) — a doc-only fix from PR #1115's review.
- **mtime-only cache freshness sweep (refs #1105)** — the #1092→#1096 arc bound
	LSP-diagnostics freshness to real content; this sweep audited the OTHER
	persisted/derived caches for the same "mtime unchanged ≠ content unchanged"
	class (git checkout timestamp restoration, formatters preserving mtime,
	same-second writes) and hardened the two CONTAINED members whose fix is free:
	- **word index** (`clients/word-index.ts`): the incremental session-warmup
		refresh gated re-read on mtime ALONE (`fileMtimes.get(file) !== mtimeMs`), so
		a mtime-preserving content change left stale identifiers served to
		`symbol_search`/`pilens_symbol_search`. It now stores a per-file byte `size`
		(the SAME `stat` the walk already runs to enforce the byte cap — zero extra
		I/O) and re-reads when mtime OR size differs, matching the review graph's
		gold-standard `size:mtimeMs` first filter. `size` is a new optional parallel
		array in the serialized snapshot (`SerializedWordIndex.fileSizes`); a
		pre-#1105 snapshot lacking it forces one self-healing full re-read on the
		next refresh (the safe direction) rather than a version bump.
	- **dependency checker** (`clients/dependency-checker.ts`):
		`DependencyChecker.importsChanged`'s mtime fast path (`cached.timestamp >=
		stat.mtimeMs`) skipped the madge circular-dep re-check on a mtime-preserving
		import edit; it now also requires `cached.size === stat.size`.

	The residual (same mtime AND same byte length, changed content) is left as the
	same accepted residual the review graph itself carries — closing it needs an
	unconditional per-read content hash, which the event-loop/hot-path discipline
	forbids. The sweep's full verdict table (word-index/dependency-checker fixed;
	project-snapshot seq, installer probe-cache, sgconfig, reverse-deps, TreeCache,
	yaml-rule-parser bundled cache all SAFE/already-hardened; rule-cache disk cache
	filed as a follow-up because a content-confirm there would read 700+ rule files
	on the per-edit hot path) is recorded in the PR.
- **Test-role files no longer leak into collateral cascade/impact surfaces (closes #1080)**
	— the review graph is already tests-free, but several
	collateral surfaces re-derived neighbors from OTHER sources that never saw
	that filter, so an UNIGNORED `*.test.*` / `tests/` file could still surface
	as cascade collateral. The existing `getProjectIgnoreMatcher` filtering only
	closed the ignored-file half of the gap (#297). A new shared predicate
	`clients/collateral-test-role.ts:isTestRoleCollateral` composes the SINGLE
	existing structural classifier (`clients/file-role.ts:detectFileRole`) — no
	second matcher, no private test-filename list — and is applied at every
	collateral producer boundary: (1) `computeCascadeForFile` filters
	`impact.directImporters` / `directCallers` / `neighborFiles` AFTER graph
	neighbors, reverse-deps, LSP reference expansion, and transitive expansion
	are merged, so a test neighbor is excluded from the active-touch/passive-
	snapshot set, the returned `impact`, AND the formatted header
	(`formatImpactCascade` reads `impact` verbatim for `Direct importers` /
	`Check next` counts and names) — module-level downstream files (added inside
	`computeImpactCascade`) are caught here at the consumption boundary rather
	than inside the shared graph query; (2) `appendFallbackNeighbors` drops an
	unignored test file from the passive-snapshot fallback (ignore filtering
	stays separate and unchanged); (3) `runtime-turn.ts` filters call-graph
	`impact()` results so a test caller appears in neither the turn-end advisory
	text nor the persisted delta; (4) `callGraphImpactToProjectDiagnostics`
	re-applies the predicate at the persistence producer boundary. Honesty
	preserved: a KNOWN `"test"` role is filtered, but a role-classification
	failure RETAINS the candidate (never a false clean), the ignore matcher's
	fail-open behavior is untouched, and LSP-unavailable/inconclusive cascade
	semantics are unchanged. Per-runner and auxiliary-profile `skipTestFiles`
	semantics, primary LSP diagnostics, intentional test-runner findings, and
	the generic project-diagnostics snapshot/delta display are all left as-is.

- **`no-bare-except` (Python, ast-grep) still flags dotted/qualified exception types as bare (closes [#1031](https://github.com/apmantza/pi-lens/issues/1031))** — the ast-grep twin of the #1026 tree-sitter fix never received its `attribute` alternative: `except asyncio.TimeoutError:` has no direct `identifier` child (the identifiers live inside the `attribute` node), so the `not: any:` check passed and the rule reported a false positive on every dotted exception type — live for binary-installed users, where the ast-grep LSP/CLI runs this rule on every Python file. The rule now accepts the same five exception-spec shapes as the tree-sitter filter's `bare_except_only`: identifier, attribute (dotted name), tuple, `parenthesized_expression`, and `as_pattern` — the parenthesized-single-name case is the same latent class and was swept in with it (verified firing pre-fix via the real CLI). Fixtures: dotted + parenthesized names added to `no-bare-except-test.yml` as valid cases; the bare `except:` invalid cases unchanged. Verified against the real ast-grep CLI: pre-fix fired on `except asyncio.TimeoutError:` and `except (ValueError):`, post-fix fires only on genuine bare `except:` among the identifier/attribute/tuple/parenthesized/as_pattern spec shapes (`subscript` specs like `except dict[str, int]:` remain a tracked gap on both surfaces); the full 251-case `ast-grep test` corpus passes.
- **The warm IPC side-channel re-dispatched the same request on stray bytes (closes [#1219](https://github.com/apmantza/pi-lens/issues/1219))** — the server's socket `data` handler (`mcp/server.ts` `startIpcServer`) buffered chunks but never consumed the request line, so any further `data` event on the connection — stray bytes without a newline still re-found the original line — re-ran the whole warm `analyzeFile` pass (three dispatches per connection in the pre-fix repro). The channel's clients write exactly one newline-delimited request and read one reply (`requestWarmAnalyze` ends the socket), so the handler is now strictly one-shot: `createWarmIpcLineReader` (`clients/mcp/ipc.ts`, re-exported through the lens-engine seam) accumulates chunks, dispatches the first complete line exactly once, and ignores everything after it. The stdio JSON-RPC loop already consumed its lines and is untouched. Regression tests drive the real reader: one request + stray bytes → exactly one dispatch (pre-fix: three), a second newline-terminated request is ignored, and a request split across chunks still assembles.

- **`npm run test:unit` runs again, and actually excludes the integration files** — the script passed `--ignore <file>`, which is not a vitest option, so every invocation died with `CACError: Unknown option --ignore` before a single test loaded (broken since the tier split was written; CI never caught it because CI runs `npm test`). The rename to the real flag, `--exclude`, fixes the crash but not the intent: `vitest.config.ts` defines per-project `exclude` lists, and a project's `exclude` REPLACES the root/CLI value rather than merging with it, so a command-line `--exclude` is silently dropped (verified with `vitest list`: both integration files still resolved). The exclusion now lives in the config, keyed off the script name npm exports (`npm_lifecycle_event`), which survives the `with-test-lock` wrapper identically on every OS — unlike an inline `FOO=1 …` prefix, which cmd.exe cannot parse. `npm run test:unit` is now 456 files, `npm run test:integration` the other 2, and `npm test` all 458.
- **`loop_block` no longer reports a machine sleep or paging stall as a pi-lens synchronous block (refs #1122, refs #1123 item 1)** — the event-loop occupancy probe (`clients/event-loop-monitor.ts`) wraps Node's native `monitorEventLoopDelay`, whose libuv timer-lag is measured against the monotonic clock (`uv_hrtime`/`QueryPerformanceCounter`). When the whole process is frozen — Windows Modern Standby, or paging thrash under commit-charge exhaustion — the next timer fires late by the entire wall-clock gap, and that gap was logged as a "block". Two distinct machine artifacts were confirmed against the Windows System event log: a 290,179 ms block lined up exactly with a 14:33:05Z→14:37:55Z Modern Standby window (Kernel-Power 506/507), reported byte-identically by two independent pids because the histogram is HDR-bucketed and quantizes ~290 s into one bucket; and a later silent host exit with **zero** sleep events but twelve Resource-Exhaustion-Detector (2004) events at 97% commit charge. `latency.log` also held multi-*hour* "blocks" that can only be overnight sleep. Comparing a wall clock to a monotonic clock does NOT catch this — on Windows both advance across Modern Standby — so the probe now uses **CPU accounting**: a genuine synchronous block of D ms burns ≈ D ms of main-thread CPU, so its window must have consumed ≥ ~D ms of CPU; a frozen/thrashing process consumes ~0 CPU across the gap. The histogram is now windowed **per turn** (its long-intended reset, previously never wired, so each block is attributable to its turn and its CPU budget is bounded), and a worst block above a 20 s floor that the window's CPU cannot account for is tagged `suspectSystemStall` in the `loop_block` metadata (alongside `windowCpuMs`/`windowWallMs` and the `lastPhase` attribution hint) rather than raising the genuine-block high-water — so a machine freeze can no longer permanently suppress logging of later real blocks, and `/lens-health` reports the worst *genuine* block. Sub-floor blocks are never auto-tagged, but the logged CPU-vs-wall ratio still exposes a shorter paging stall for a human. Pure `isSuspendSuspectedBlock` and `getLastLoggedPhase` seams are unit-tested without a real machine sleep.
- **`runtime-tool-result.ts`'s three tool-result-turn maps now key through `PathKeyedMap` (refs #1086, the #210/#1020/#1025 raw-path-key class)** — `inFlightPipelines`, `lastAnalyzedStateByFile`, and `debouncedPipelines` keyed directly on `filePath`, which is only `path.resolve`d from raw agent-supplied tool input (never case-folded, never realpath'd). A divergent Windows spelling of the same file (case, or an unresolved absolute path an agent supplies verbatim) bypassed the concurrent-state dedupe and debounce coalescing, producing duplicate pipeline runs, doubled LSP dispatch, and split telemetry participant counts; `flushDebouncedToolResults(filePath)` silently missed the pending entry it was meant to flush. All three now wrap `PathKeyedMap` with `normalizeEphemeralMapKey` — the cheap slash-fold + win32-lowercase normalizer (no `realpathSync`), matching the state's ephemeral, turn-scoped, single-process lifetime per AGENTS.md's normalizer-choice guidance (`normalizeMapKey`'s realpath cost is for long-lived cross-call-site state, not this). `inFlightPipelines`'s composite `${filePath}:${stateHash}` key is now a `PathKeyedMap<Map<string, InFlightPipeline>>` — normalized path outer, raw stateHash inner — rather than folding both halves through one string normalizer, so a genuine same-path content change still gets a distinct entry while divergent spellings of the same state collapse to one. Every read/write/delete/iteration site in the file was audited; `flushDebouncedToolResults`'s iteration and `scheduleDebounced`'s coalesce path both continue to see the map's preserved display path. No other raw path-keyed map exists in the file. New tests (`tests/clients/runtime-tool-result-debounce.test.ts`) prove, fail-then-pass, that (1) two concurrent `tool_result`s for the same file under a divergent (win32-normalizer-driven) spelling collapse into one pipeline run, and (2) `flushDebouncedToolResults` addressed by a divergent spelling still hits the entry scheduled under the other spelling (asserted by elapsed time, not just call count, so a silent miss falling back to the natural debounce timer can't vacuously pass); a third test proves the composite key's stateHash axis still keeps same-path-different-content calls distinct.
- **`pi --print --no-session` no longer hangs after `agent_settled` — uncleared race-loser timers (refs #1097, recurrence of #22's symptom via a different handle)** — a completed one-shot print-mode process kept running in the event loop instead of exiting. Root cause: `LSPService.getClientForFile` raced the client-wait against an inline `setTimeout(effectiveMaxWaitMs)` whose handle was never stored, so when the client resolved first (the common case) the losing timer stayed a **ref'd** pending timer for the full remaining wait budget. In a long-lived interactive session that is invisible (it fires later, resolves an orphan promise, is GC'd); in a `--print`/subagent process it keeps the loop alive for up to `effectiveMaxWaitMs` (15s in the report, minutes with a server whose `clientWaitTimeoutMs` is large) after the run settled — the `session_shutdown` handler runs and `resetLSPService` completes, but nothing owned or cancelled this timer. Proven via `process`-handle async-hooks tracing: the sole pi-lens-owned handle surviving into the hang was this timeout. The defect **shape** — *an operation raced/bounded by an independently-armed timer whose loser is never cancelled on the operation's resolution* — was swept repo-wide; two same-shape siblings were fixed in the same pass: the quiet-window heartbeat sample-timeout (`clients/quiet-window.ts`, 2s ref'd, kept a one-shot alive ~2s per settle) and the formatter timeout (`clients/format-service.ts`, 30s ref'd). All three now store the timer and `clearTimeout` it in a `finally`, matching the `withDeadline` helper contract the rest of the codebase already uses. Regression test (`tests/clients/lsp/client-wait-timer-cleanup.test.ts`) asserts no pending timer survives a fast client win — it fails on the pre-fix code (`expected 1 to be 0`). One further same-shape site — the cascade references race in `clients/dispatch/integration.ts` (750ms) — is tracked in #1109 for a follow-up as it is under concurrent modification.
- **Two more uncleared-race-timeout members closed out (closes #1109, sweep follow-up to #1097/#1110)** — the two class members #1110's adversarial review flagged rather than fixed. (1) `computeCascadeForFile`'s symbol-level blast-radius upgrade (`clients/dispatch/integration.ts`) races `lspService.references()` against an inline `setTimeout(() => reject(...), 750)` per changed symbol (up to 3 per file); when `references()` wins — the common case — the losing timer was never stored, so it stayed a REF'D pending timer for the remaining budget. Same `finally`-clear contract as the #1097 fix: the handle is now stored in a per-call `let` and cleared once the race settles. (2) `safeSpawnAsync`'s non-Windows `killTree` else-branch (`clients/safe-spawn.ts`) arms a REF'D 1s SIGTERM→SIGKILL escalation timer that was neither cleared on child exit nor unref'd (the main per-spawn `timeoutId` alongside it was already correctly cleared on both the `close` and `error` paths). Bounded to 1s and only on kill paths, so it can't reproduce #1097's hang by itself, but it is the same defect shape and now clears alongside `timeoutId` in both handlers. (Review-round finding: the escalation ACTION itself has always been dead code — `!child.killed` is false after a successful SIGTERM *send*, so SIGKILL never fires; this change fixes only the timer-handle leak and preserves the window; the dead guard is tracked in #1114.) Regression tests prove both fail on pre-fix code: `tests/clients/cascade-compute.test.ts` (`vi.getTimerCount()` after a fast `references()` win) and the new `tests/clients/safe-spawn-kill-escalation-timer.test.ts`, which mocks `process.platform` and `node:child_process`'s `spawn` (mirroring `tests/clients/lsp/kill-process-tree.test.ts`'s established technique) so the non-Windows branch is exercised deterministically on any host OS, including Windows dev machines — no vacuous platform-gated pass. A re-run of #1110's repo-wide `setTimeout`/`setInterval`/`Promise.race` grep over every file changed since that sweep (#1103, #1110, #1111) found no new members of the class.
- **`sg scan` exit-1-with-matches is no longer misclassified as a CLI failure (refs #1087)** — ast-grep's linter-style contract (verified first-hand against the bundled CLI: a `severity: error` rule that MATCHES exits **1** with valid JSON matches on stdout and stderr "Scan succeeded and found error level diagnostics"). `SgRunner.interpretScanResult()` / `exec()` only exempted `status===1` with *empty* output, so every real match under an error-severity rule was dropped as `failure: "cli-failure"` with `matches: []`. Now `status===1` + stdout that parses as valid JSON is treated as SUCCESS (matches parsed); stderr-with-unparseable-stdout stays a failure, and the historical status-1/no-output no-match convention is preserved. Invariant: **an exit code that means "scan succeeded with findings" must never be classified as a CLI failure.** This restores `ast_grep_search`/`pilens_ast_grep_search` raw error-severity rules, the `pilens_ast_grep_replace` preview/preCheck path, and `validateRule` for a matching error-severity rule.
- **"Silencing is not fixing" now covers the whole suppression class, not just the policy member (refs #1087)** — the delta baseline in `dispatcher.ts` was only re-filtered through the project rule policy before `filterDelta`, while the live set was additionally filtered by LSP/docker-overlap suppression, inline `pi-lens-ignore` comments, and agent/user dispositions. A finding persistently dropped by any of those three sibling filters stayed in the baseline's `after` view forever, so it counted in `resolvedCount` on EVERY dispatch and inflated `trackAgentFixed` unboundedly. The output filter pipeline (overlap → inline → disposition → policy) is now a single closure applied identically to the live set AND the delta baseline, so a persistently-suppressed finding is absent from both sides and never oscillates into `fixed`. The stored baseline remains the unfiltered deduped set (a policy/suppression edit never resets the user-authored delta baseline).
- **Inline `pi-lens-ignore` comment tokens are now normalized like the diagnostic id (refs #1087)** — the diagnostic side matched raw OR normalized, but the comment side stored only the raw token, so `// pi-lens-ignore: no-eval-js` failed to suppress a finding surfaced under the normalized `no-eval` even though the identical `disable: ["no-eval-js"]` config key worked. The comment token now also contributes its `normalizeRuleId` form, making the two suppression surfaces symmetric.
- **`"disable": []` / `"select": []` no longer warn spuriously (refs #1087)** — an explicitly empty rule-policy list is a well-formed no-op, not an error; it is now dropped silently. A NON-empty array whose entries are all blank/non-string is still a real authoring mistake and still warns ("must contain at least one non-empty string").
- **Test-runner subcommands no longer lost during binary resolution (closes #1098)** — `TestRunnerClient`'s `resolveExec` unconditionally dropped `args()[0]` whenever local/global binary resolution succeeded, an npx-wrapper-convention assumption (arg 0 names the binary, e.g. `npx vitest run …`) that only holds for wrapper-style runners. For direct runners whose args lead with a real subcommand it silently ate that subcommand instead — `cargo test --no-fail-fast` resolved to the argv-invalid `cargo --no-fail-fast` (a clap usage error), likewise for go/dotnet/maven/mix, misreported at turn-end as a false "1/1 failed". The new `stripWrapperArgs` helper strips a leading arg ONLY when it actually names the resolved binary (`[binName, ...]`, or the pytest-style `["-m", binName, ...]` pair) and leaves every other argv untouched, applied at both the local-bin and global-bin resolution sites. Also fixes the same defect's inverse: `rspec`'s config declares `command: "bundle"` with `args: ["exec", "rspec", file]`, but binName resolution defaulted to the runner key `"rspec"` instead of the real binary `"bundle"`; it now declares `binName: "bundle"` so local/global resolution looks up the right binary and `exec rspec <file>` reaches `bundle` intact. A generic regression matrix (`tests/clients/test-runner-client.test.ts`) iterates the exported `RUNNERS` table (not a hand-copied runner list) across local-bin/global-bin/fallback resolution paths, so a future runner is automatically covered. A repo-wide sweep for the same defect shape (positional argv stripping tied to binary resolution, and command/binName-resolution-name mismatches) found no other live instance — every other `findGlobalBinary` caller (biome-client, formatters, ast-grep runners, LSP launch) builds its argv as fresh literals per resolution branch rather than slicing a shared generator, so none share this defect's precondition. Three additional command/binName-mismatch members exist but are unreachable in practice (their binaries never land in the npm-family bin dirs findGlobalBinary probes) and now resolve MORE correctly if they ever did: gradle, maven, and minitest (command "ruby" vs default binName "minitest"); pytest's resolved-path argv was also silently broken pre-fix (slice(1) ate "-m") and is now correct.
- **Call-graph persisted-cache validator and canonical-freshness invariant now have real test coverage (refs #1089)** — two `call-graph.test.ts` "rejects…" tests hand-wrote `version: 4`, identity-less fixtures, so `loadCallGraph` returned `undefined` at the version/identity gate before the ~200-line semantic validator (`validatePersistedCallGraph`) ever ran; rewritten as `version: 5` fixtures with a valid canonical identity, each now isolating and proving out the ONE validator check it claims to cover (the inDegree/adjacency cross-check and the `complete && unsupportedEvidence > 0` honesty check). Also added the first tests anywhere that pass `expectedIdentity` to `loadCallGraph`, covering the freshness invariant edab5d5d introduced: a mismatched `reviewGraphVersion` or `reviewGraphSignature` rejects the cache (the #210/#1020 stale-replay class), while a matching identity loads normally.
- **`module_report`'s `callGraph: true` surface gains coverage for the `file-cap` and `identity-missing` unavailability reasons and the `provenance.callGraph` field (refs #1089; the Java try-with-resources regex and grammar co-load items remain open there)** — `cached-call-graph` / `unavailable:file-cap` / `none` / omitted-when-not-requested are now all asserted, so the tool description's own honesty contract ("unavailable cache state is never reported as zero calls") is enforced by tests, not just documented.
- **Review-graph cache-identity guard now detects a stale graph instance instead of a vacuous schema-tag compare (refs #1088; the `parseSymbolKey` LSP-fallback-kind item remains open there)** — `getReviewGraphCacheIdentity` compared `version`, the constant schema tag (e.g. `"v8"`, identical for every live graph), so it could never tell a caller's graph reference apart from a newer one that had since replaced it in the workspace cache — the concrete race is a session call-graph task computing a projection from an older `graph` instance while a concurrent cascade build races ahead and replaces `_workspaceGraphCache` before the identity lookup runs, letting a stale call-graph projection get persisted under a fresh-looking signature. Now compares the workspace-cache ENTRY's `buildGeneration` stamp (#459) — not the stored graph object's, which the reuse paths deliberately leave unstamped on their `cloneGraph` copies — so identity resolves for the graph a drift-reuse or disk-hit build just returned, while a stale or unstamped instance is still rejected.
- **Nested-link diagnostics no longer flag ordinary JSX wrappers (closes #1076)** — report only the outermost anchor in a directly or deeply nested chain, while allowing single and sibling links inside non-anchor containers across both ast-grep and Tree-Sitter dispatch paths.
- **LSP workspace edits apply safely in declared order (refs #1053)** — preserve text/resource operation ordering (including directory subtrees), stabilize same-position inserts, collapse identical replacement duplicates, prevalidate every text batch before writing, and close an open old URI before rename notifications.
- **LSP workspace-edit apply paths: reversed same-position inserts, win32 casing destruction, versioned-edit rejection, out-of-range clamping (closes #1085; P3-1 folded in, P3-2/3/4/5/6/7 deferred on #1085)** — four empirically-reproduced defects in the `clients/lsp/edits.ts` write pipeline, each with a fail-then-pass regression test.
  1. **Same-position inserts applied REVERSED through `applyWorkspaceEdit` (the #1066 invariant).** The tie-break sort was correct only for exactly one pass, but the apply pipeline sorted an even number of times (and differed by server position-encoding), so `["A","B"]` inserted at one position produced `"aBAbc"` instead of `"aABbc"` for UTF-16 servers (typescript-language-server, pyright — the majority). The application-ordering sort is now performed **exactly once**, at the single string-write site; `normalizeTextEditsForContent` returns validated edits in original array order (never pre-sorted), so the same-position order can no longer flip on an even number of passes. Invariant: same-position inserts apply in array order through every entry path (direct `applyTextEditsToString`, `applyWorkspaceEdit` `changes`, `documentChanges`) under all three position encodings — a full encoding×entry-path matrix now asserts it.
  2. **Windows case-preservation destroyed on create/rename; case-only renames rejected (invisible to Linux CI).** Disk mutations routed the URI through `uriToPath`→`normalizeFilePath`, which lowercases a nonexistent path tail on win32 — so `create NewFile.txt` wrote `newfile.txt`, `rename → MixedCase.txt` wrote `mixedcase.txt`, and a legitimate case-only rename (`foo.txt → Foo.txt`) was rejected as "source and destination must differ". Confinement/validation still run on normalized paths, but the actual `fs.writeFile`/`fs.rename`/`fs.readFile`/`fs.rm` calls now target the URI's decoded path via the new `uriToDiskPath`. The preflight "destination exists" check decides a case-only (or otherwise-aliased) rename by on-disk **FS identity** (matching `dev` and matching **nonzero** `ino` via `lstat({ bigint: true })`), not a platform-keyed path fold — so it works on ANY case-insensitive FS (win32 **and** macOS APFS/HFS+, per the #1024 "probe the FS, don't branch on platform" lesson) and closes the inverse edge where a case-sensitive FS holding both spellings would otherwise silently clobber. The nonzero-`ino` requirement (and BigInt comparison) is fail-closed: on ino-less filesystems that report `ino: 0` (FAT32/exFAT, some SMB redirectors) the identity check never matches, so the destination-exists check runs and a rename can only error, never silently clobber — a genuine case-only rename there needs an explicit `overwrite`. Tests are FS-probe-guarded (assert casing preservation on a case-insensitive FS, success on a case-sensitive FS) plus a direct unit test of the `ino: 0` guard — never a vacuous pass.
  3. **Versioned `TextDocumentEdit`s always rejected on the tool apply paths.** Rename `apply:true` (`tools/lsp-navigation.ts`) and code-action autofix (`clients/actionable-warnings.ts`) never pass `documentVersions`, while `client.rename`/`codeAction` validated versions against the live map and then PRESERVED them — so a numeric `textDocument.version` reached an apply site with no map and failed 100% for version-stamping servers (gopls). `normalizeClientWorkspaceEdit` now validates the version against the live map (unchanged) and then STRIPS it (spec `null` = don't check), so the tool paths succeed. The server-initiated `workspace/applyEdit` handler does NOT route through here (it applies `params.edit` with the live `documentVersions`), so its real version check is fully intact. The existing rejection test's key-mismatch vacuity (raw-path key vs `normalizeMapKey` lookup — the #1106 class) is fixed and a positive success-path test added.
  4. **Out-of-range positions threw instead of clamping (LSP 3.17 regression).** A line past EOF now clamps to end-of-document and a character past line end clamps to line length, so the `(0,0)-(9999,0)` whole-document-replace sentinel idiom works again; malformed shapes (negative/non-integer, `start > end`, mid-codepoint boundary) still throw. The character clamp is **CRLF-safe**: because `lineTextAt` keeps a trailing `\r`, the clamp targets the length BEFORE that `\r`, so a char-past-EOL insert lands at the CRLF boundary and the whole-line sentinel replace preserves the `\r\n` (it does not eat the `\r` or leave a stray one mid-line).
  P3-1 (an insert listed after a replace starting at the same position was spuriously rejected as overlapping — LSP-legal, VSCode applies it) is fixed as a byproduct of the sort rework via an end-descending tie-break, making the result independent of listing order. The general CRLF-boundary hazard (P3-5) and P3-2/3/4/6/7 remain deferred on #1085.

- **Read-guard did-you-mean no longer quotes NFKC-normalized text as "current lines"** (#1050) — the `Did you mean one of these current lines?` block (#505) rendered its rows from the normalized match space instead of the file's real characters, so for CJK markdown it folded `：`→`:`, `，`→`,`, `；`→`;`, `（）`→`()` and `—`→`-` and presented the result as ground truth about file content. That inverted #505's own stated invariant ("only for COMPARISON, never for the written content"). The damage compounded past a bad suggestion: an agent that copied the half-width row into `oldText` got past the guard, then the host's `fuzzyFindText` missed exact match, fell into fuzzy space, and `applyReplacementsPreservingUnchangedLines` rewrote the *touched* line from the normalized base — silently replacing full-width punctuation with ASCII while reporting success (untouched lines are safe per earendil-works/pi#5899; touched-line normalization is wontfix per earendil-works/pi#3554, so the suggestion is the only place this can be fixed). The failure mode is self-reinforcing: the agent re-reads the file next turn, sees changed punctuation, and concludes a markdown formatter is corrupting the file — a wrong root cause it then acts on. `findSimilarLines` now returns only `{ line, score }` and `formatSimilarLines` renders from the raw file, making it structurally impossible to leak normalized text into an error. The raw view is `normalizeToLF(stripBom(raw).text)` — the same STRUCTURAL normalization the match space applies, so line numbers cross-index 1:1 even on lone-CR or BOM files, but none of the CHARACTER folding, so full-width punctuation, smart quotes, and NBSP survive. A naive `\\r\\n`-only fold desyncs on lone-CR files and is covered by a regression test.
- **Experimental git guard correctness (refs #1063)** — `--lens-guard` now detects real commit/push invocations through the shared shell tokenizer, gates only structured blocking findings (including blocking test failures), and rejects stale, malformed, cross-session, or sequence-mismatched state conservatively. Aggregate per-file blocker state, clean-turn invalidation, and bounded decision telemetry prevent advisory findings or a later clean file from silently clearing an unresolved blocker.
- **Stale cross-file footer diagnostics now clear (refs #1093, refs #1092)** — a widget finding in file A that was *caused* by file B (a missing declaration, a changed export) is fixed by editing B, but every widget staleness gate compares A's OWN mtime to the record timestamp — and A's mtime never advances, so the resolved finding rendered forever (footer, `lens_diagnostics` replay, resume, fork adoption). Two minimal fixes close the reported shape. (1) The per-edit cascade already re-checks every edited file's dependents (`clients/dispatch/integration.ts`) but threw the result away as "display-only"; each **confirmed** neighbor result (a valid passive snapshot or a completed, non-inconclusive active touch — #571 semantics) now reconciles into widget state, **including the confirmed-clean `[]` case** that clears A's stale entry, keyed by the primary edit's `writeSeq` so a genuinely newer per-edit write still wins the `WriteOrderingGuard`. A touch that resolves `[]` while flagged `inconclusive` (its wait lapsed) is never treated as confirmed-clean — it neither reconciles nor seeds the recently-clean cache, so it cannot wipe a live finding (#533). Because the cascade only re-checks the *language server* and only for *errors*, the reconcile **merges** rather than replaces: it swaps out just the neighbor's existing LSP-error entries and preserves live biome/ruff/ast-grep findings and LSP *warnings* it never re-examined. Auxiliary-LSP findings (opengrep/ast-grep/zizmor/typos, identified by `findAuxiliaryProfileForSource`) are excluded from what the cascade writes — unlike the per-edit/scan writers it does not run `retagAuxiliaryDiagnostics`, so writing them would double-count the neighbor's own aux entry and escalate an advisory finding into a blocking `tool:"lsp"` error, bypassing aux policy. Tier-3-silent/recently-clean/within-turn-cache/rejected-touch results are never written. (2) `reconcileScanDiagnostics`/`recordDiagnostics` accept an observation timestamp; the `lsp_diagnostics` workspace-cache-hit branch (plus `lens_diagnostics mode=full`'s cache-served results) stamp `touchedAt` with when the diagnostics were *observed* (the cache entry's `scannedAt`), and the cascade's passive-snapshot reconcile stamps the snapshot's own publish time (`entry.ts`, up to `CASCADE_TTL_MS` old) — not `Date.now()` — so replaying an aging cached/snapshot view no longer keeps re-arming the mtime gate. The structural seq-stamping redesign (marking a changed file's reverse-dep set unverified up front) remains a follow-up under #1093.
- **Class sweep: test fixtures hand-writing persisted-format version literals no longer go vacuous on version bumps (closes #1106; class generalized from #1089/#1082, fresh-risk flagged from #1096)** — a fixture that hand-writes a persisted-format `version:`/schema-tag literal a runtime gate checks BEFORE the code-under-test runs silently orphans on a version bump: the gate now rejects the (unrelated-stale) fixture and the test stays green for the wrong reason. Repo-wide sweep of `tests/` for hardcoded `version:` numerics and `"v<digit>"` schema tags across every known persisted-cache surface. Converted to import the runtime constant (exporting it where it was previously a private literal): `clients/call-graph.ts`'s `CACHE_VERSION` (was unexported — `tests/clients/call-graph.test.ts`'s `validPersistedCallGraph()` and `tests/clients/module-report.test.ts`'s call-graph-cache fixture both now import it instead of hand-writing `version: 5`), `clients/read-guard.ts`'s `READ_GUARD_STATE_VERSION`, `clients/widget-state.ts`'s `WIDGET_STATE_VERSION`, `clients/review-graph/builder.ts`'s `REVIEW_GRAPH_VERSION`, and a newly-extracted `clients/word-index.ts` `WORD_INDEX_FORMAT_VERSION` (previously an inline `2` duplicated at both the write and gate call sites). `tests/clients/project-diagnostics/scanner-wasm-abort.test.ts`'s `priorSnapshot()` fixture now imports the already-exported `PROJECT_DIAGNOSTICS_CACHE_VERSION` instead of hardcoding `2`. Every test that deliberately persists an OLD version to exercise the rejection path itself (call-graph v4, project-diagnostics v0 ×2, word-index v1, review-graph v3/v4) is pinned per the #1082 pattern: an adjacent `expect(CURRENT_CONSTANT).not.toBe(oldLiteral)` so a future bump onto that exact old value fails loudly instead of silently un-exercising the rejection path. Spot-proved non-vacuous for the three highest-traffic surfaces (call-graph, project-diagnostics, and — via the existing #1089 validator-disable proof already recorded in `call-graph.test.ts` — the semantic validator itself) by locally bumping the runtime constant, reverting the fixture to its old hardcoded literal, and confirming a loud failure (not a silent pass) before restoring both. Surfaces confirmed already immune (import the constant / derive-then-override a live value, so version drift cannot silently orphan them): `workspace-diagnostics-cache` (`WORKSPACE_DIAGNOSTICS_CACHE_VERSION` + self-relative `+1` mismatch), `warm-attach`/`mcp/ipc` (`WARM_DIAGNOSTICS_SCHEMA_VERSION`), `rule-cache` (`CACHE_VERSION`), `project-snapshot` (`PROJECT_SNAPSHOT_VERSION` for valid fixtures; deliberate-bad-version sentinels use `999`, permanently distant from the real 1-3 range). Confirmed NOT members of this class (noted for the next sweep so they aren't re-litigated): LSP wire-protocol `textDocument.version` fields (staleness/ordering, unrelated to a persisted-cache gate), assertions on production-EMITTED event/latency-log payloads (`lens-events`, `index-integration`, `cache-observability` — a bump fails these loudly, not vacuously, since they assert output rather than feed a hand-written fixture through a gate), in-memory-only `ReviewGraph` object literals in `transitive-impact.test.ts`/`lens-map.test.ts` (never round-trip through the persisted-JSON version gate), and `tests/tools/lens-diagnostics*.test.ts` (the entire `project-diagnostics/cache.js` module is `vi.mock`'d, so the real gate never runs). The installer's `probe-cache.json` has no version field/schema gate at all (not a member). `session-state-store.ts`'s own `STATE_VERSION` gate has no dedicated fixture test at all — a coverage gap, not a vacuous-fixture bug, left as a follow-up rather than expanded into new-test-authoring scope. SHAPE-gated fixtures (a loader that rejects on missing/malformed fields before the code-under-test) were also noted where encountered — `project-snapshot`'s "missing seq" meta test, `read-guard`'s malformed-`reads`-shape tests (now additionally non-vacuous on the version axis via the `READ_GUARD_STATE_VERSION` conversion above), and `word-index`'s malformed-input test — all correctly exercise their shape gate today.
- **`no-bare-except` no longer flags subscripted exception specs (closes [#1244](https://github.com/apmantza/pi-lens/issues/1244))** — both detection surfaces (the ast-grep `no-bare-except` rule and the tree-sitter `bare-except` rule's `bare_except_only` post-filter) false-fired on valid subscript exception specs like `except dict[str, int]:` and PEP 654's canonical `except BaseExceptionGroup[TypeError, ValueError]:`. The #1031 sweep fixed the dotted-attribute sibling and stopped one member short: subscript was never in either surface's spec set, so neither surface suppressed it. The ast-grep `not: any:` and the tree-sitter filter's child-kind set both now accept `subscript`, keeping the two surfaces' shape spaces in lockstep (the same parity the #1026→#1031→#1243 arc enforced). Valid-fixture regression cases for both subscript forms fail against the pre-fix rule on both engines (2/2 false fires on the ast-grep CLI; 2/2 on the tree-sitter filter, confirmed by reverting the filter and re-running).

- **Spawn failures now distinguish missing tools from invalid working directories (closes #1214)** — Auto-install and reinstall paths run only when the executable itself is missing, preventing futile reinstall loops for cwd and permission failures.

- **Per-issue close-keyword CI validation** — Rejects comma-separated GitHub close lists and verifies merged PR close targets.

- **Recover stale git-guard blocker records (closes #1084)** — Clear persisted blocker content only when complete, well-typed blocker-file provenance proves the last represented blocker was revalidated clean; incomplete or forged provenance remains blocked.

- **Cascade module source-file walk is memoized on the existing freshness seams (refs [#1137](https://github.com/apmantza/pi-lens/issues/1137))** — `getModuleSourceFiles` (the #1318 slice-2 remnant) ran a recursive `readdirSync` walk per downstream module on every per-edit cascade in monorepos; it now memoizes per module root, revalidating on the visited directories' `mtimeMs` stamps plus ignore-matcher object identity (so `.gitignore`/`.pi-lens.json` edits re-walk), and clears with the module-graph cache.

- **Review-graph supersession cleanup is deterministic (refs #1318)** -- a superseded generation's staged write is reaped synchronously (and fault-tolerantly) before completion becomes observable, so CI waiters can no longer observe a leftover stage file; the supersession lock test moved to the quiet timing-sensitive phase.

- **In-pi extension logging no longer corrupts the pi TUI layout (closes #1333)** — pi owns the terminal, so the 26 ungated and 20 verbose-gated `console.error`/`console.warn` sites under `clients/` were landing raw bytes mid-frame and desyncing pi's screen model. Every site now writes to a `createNdjsonLogger` sink (the subsystem's own `tree-sitter.log`/`review-graph.log`/`latency.log` where one exists, otherwise the new `~/.pi-lens/extension.log`), user-facing degradations (invalid config, offline grammar fetch, WASM abort) additionally surface through `ctx.ui.notify`, and `index.ts` installs a defensive console reroute so a transitively loaded dependency cannot write to the frame either.

- **npx formatter fallback honors style-preservation gating (closes #1345)** — Files that must be skipped by `SKIP_FORMATTING` no longer invoke the static Biome or Prettier `npx` fallback when a primary formatter command is unavailable.

- **Formatter failures now appear in the diagnostics widget (closes #1346)** — Failed formatter runs render with a distinct error indication, remain deduplicated per formatter and file, and disappear after a successful retry.

- **Instance-registry liveness tests no longer depend on arbitrary runner PIDs (refs #1352)** — synthetic dead PIDs are classified through deterministic test seams, preserving coverage of footprint exclusion, registry pruning, and vanished-instance wiring without relying on the CI process table.

- **Trust-gated observability (refs #1334, #1358)** — LSP install refusals now log with warn-once deduplication, tree-sitter trust-blocked grammar toasts are deduplicated per grammar, trust transitions are recorded, and `~/.pi-lens/extension.log` is documented.

- **BLIND WRITE no longer fires on self-authored files (closes #1364)** — A `write`/`edit` of the same normalized path earlier in the blind-write window now counts as file knowledge, so the common author-then-iterate loop (create a file with `write`, refine it with successive `edit` calls) stays quiet. A genuinely stale edit of a file not touched in the window still warns, and the two-write threshold, 5-call window, and thrashing detection are unchanged. Thanks to @snrogers for the field report and the fix.

- **Edit counts now match normalized file paths (closes #1369)** — `AgentBehaviorClient.getEditCount` now uses the same path normalization as edit recording, so mixed-separator and case-variant lookups return the recorded count.

- **Bound warm word-index memory (closes #1370, refs #1332)** — idle/LRU per-root eviction now releases inactive indexes safely, while persisted snapshot postings no longer remain duplicated in long-lived in-memory caches.

- **Latency-log hygiene (closes #1371)** — Ast-grep unsupported-language telemetry now dedupes per language for each session and emits only a bounded rule-ID sample; the log analyzer excludes synthetic temp/scratchpad/heap-corpus rows by default, supports repeatable `--exclude` globs, and reports excluded-row counts.

- **Review-graph size gates stop at the cap sentinel (closes #1372)** — the shared cooperative source walk now stops at `maxFileCount + 1`, reports the partial count honestly as “more than N files,” and emits a distinct near-miss telemetry event within 5% of the cap.

- **Keep LSP roots inside the session project and coalesce nested marker clients (closes #1373, refs #1328)** — Clamp marker-selected roots at the declared cwd, and reuse same-server ancestor clients for config-only nested roots while preserving real manifest/lockfile sub-projects.

- **Telemetry phase pairing and attribution (closes #1374)** — Deferred formatting now emits counted terminal phases; LSP warm-up and unavailable events reconcile correctly, diagnostic timeouts identify their server, and startup timing preserves the console guard's import-time position.

- **Re-assert widget mounts after host UI replacement (closes #1381)** — The diagnostics widget remounts on the live UI at turn start when needed, while preserving mode and user visibility gates; unsupported widget hosts now emit a log-once diagnostic.

- **Widget and inter-extension bus updates recover after session replacement (closes #1383)** — every `session_start`, including the #473 guarded in-process subagent path, now reclaims activation-scoped bus, notifier, and widget-render wiring before returning. Stale bus failures are logged and ledgered once per failure occurrence, with a successful publish re-arming observability for a later channel death.

- **Deferred formatting uses bounded concurrency (closes #1387)** — the `agent_end` drain runs at most three formatter subprocesses in flight, applies results and synchronous bookkeeping in admission order with `setImmediate` yields between files, preserves per-file failure isolation, and requeues claimed records that were not started when the ambient turn aborts.

- **Hardened codebase-model cache identity (closes #1388)** — persisted models now carry a version and canonical review-graph identity, stale caches invalidate on mismatch, and model file selection uses shared role and artifact filtering.

- **Prevented consistency drift in language fixtures and extension handling (refs #1388)** — call-graph fixtures now cover every symbol-query language, JS/TS facts share the canonical extension policy, and bash file-access tracking derives source extensions from `KIND_EXTENSIONS`.

- **Canonicalized LSP edit merge buckets and call-graph path comparisons (refs #1388)** — equivalent URI spellings now share conflict/deduplication buckets while preserving the first display URI, and call-graph symbol/reference indexes normalize file keys consistently.

- **Bound and idle-evict review-graph workspaces and authoritative project snapshots (refs #1389).**

- Bound and evict rebuildable Tier 2 reverse-dependency, tree-sitter query, and workspace-topology caches while retaining unconsumed ReadGuard records until their edit or session end. ReadGuard now applies a high sanity cap with oldest-to-re-read eviction for read-only sessions (refs #1389; widget-state and Tier 3 remain deferred).

- **Bound Tier-3 process caches, prune expired negative LSP entries, decay widget/agent state, and close the cache audit (closes #1389).**

- **Scope session-start smell-tail diagnostics to the current session boundary (closes #1398)** — historical failures in the bounded bus-events and latency log tails are no longer reported as current; rows must have a parseable UTC timestamp at or after session start.

- **Unconfigured Python files are formatted again (refs #1144)** — `ruff format` rejects the `--indent-style`/`--indent-width` flags the style-preserving defaults were passing, exiting 2 without touching the file; because ruff was not treated as a strict-exit formatter, every unconfigured Python file silently reported "already formatted" and was never reformatted. Style is now pinned through ruff's inline TOML overrides, and a nonzero ruff exit is surfaced as a formatting failure instead of a clean no-op.
- **Ast-grep console-rule exclusions (closes [#965](https://github.com/apmantza/pi-lens/issues/965))** — stop flagging intentional console usage in CLI scripts and logger implementations.
- **Nullish equality exemptions (closes [#966](https://github.com/apmantza/pi-lens/issues/966))** — allow intentional `== null` / `!= null` checks while retaining other strict-equality findings.
- **Security-pin dependency handling (closes [#968](https://github.com/apmantza/pi-lens/issues/968))** — stop Knip from flagging dependencies used only through override/resolution security pins.
- **Unsafe-boundary fallback detection (closes [#969](https://github.com/apmantza/pi-lens/issues/969))** — recognize catch blocks that recover through fallback assignments.
- **Promise-returning async callbacks (closes [#970](https://github.com/apmantza/pi-lens/issues/970))** — avoid async-noise findings when an explicit Promise return type is required.
- **Native zizmor suppressions (closes [#971](https://github.com/apmantza/pi-lens/issues/971))** — honor inline `zizmor: ignore[...]` directives.
- **Named URL constants (closes [#964](https://github.com/apmantza/pi-lens/issues/964))** — avoid hardcoded-URL findings for deliberate `SCREAMING_SNAKE_CASE` endpoint constants.
- **Opengrep LSP compatibility (closes [#974](https://github.com/apmantza/pi-lens/issues/974))** — stop advertising unused work-done-progress support that could trigger client crash loops.
- **Workspace configuration responses (closes [#983](https://github.com/apmantza/pi-lens/issues/983))** — return one resolved value for each requested configuration section.
- **Same-file diagnostic ordering (closes [#1198](https://github.com/apmantza/pi-lens/issues/1198))** — prevent late diagnostic work from restoring stale pending results.
- **Bounded installer retries (refs [#1222](https://github.com/apmantza/pi-lens/issues/1222), [#1223](https://github.com/apmantza/pi-lens/issues/1223))** — cap failed install retries and preserve probe-cache updates across concurrent writes.
- **Stabilize LSP workspace-edit occupancy (closes [#1091](https://github.com/apmantza/pi-lens/issues/1091))** — keep workspace edits consistent under concurrent occupancy.
### Security

- **Git guard command-injection hardening (refs #1084, PR #1395)** — canonicalizes shell IFS parameter-expansion separators once, quote-aware, before verb classification and treats any non-leading guarded `git` invocation as suspicious, including arbitrary unknown wrappers and run flags; literal text consumers remain non-blocking.


- **`toPosix()` / `splitPathSegments()` path primitives ([#1193](https://github.com/apmantza/pi-lens/issues/1193))** — the sanctioned backslash→forward-slash fold and shape-safe segment split, the single canonical form of the `p.replace(/\\/g, "/")` / `p.split(/[\\/]+/)` idioms otherwise hand-rolled ~138× across the codebase. Pure separator normalization — no resolve/case/realpath (use `normalizeFilePath`/`normalizeMapKey`/`normalizeEphemeralMapKey` when a canonical key is needed). First step of the OS-agnostic transformation-layer consolidation: funnelling the scattered transform makes a shape-2 lint/ast-grep rule possible for the first time (today a bare inline `.replace(/\\/g, "/")` is byte-identical to a sanctioned use; once everything routes through `toPosix`, an un-migrated inline `.replace` becomes detectable — #1158). Adds the primitives + tests only; migrating the existing call sites is a tracked follow-up under #1193.

- **On-demand heap snapshots for retainer attribution ([#1126](https://github.com/apmantza/pi-lens/issues/1126))** — `PI_LENS_DEBUG_HEAP=1` makes `/lens-health` also write a V8 `.heapsnapshot` to `~/.pi-lens/` (plus a breadcrumb line in `heap-snapshots.log`), so the "which objects retain the bytes" follow-up to #1123's `memory_sample` trajectory is answerable on a live >1 GB instance without a fresh ad-hoc expedition. The flag is read once at startup and the writer mirrors `clients/debug-handles.ts`: zero cost + no file when unset, and the (synchronous, multi-second) snapshot is only ever triggered from the operator-invoked diagnostics command — never a hot path or timer. Snapshot files are pruned to the newest `SNAPSHOT_RETENTION` (3) after each write, bounding the growing on-disk axis (AGENTS.md shape 9). Auto-capture on an RSS threshold is a deliberately-deferred follow-up (it would reintroduce the pause onto an automatic path).

- **Guard hardening (refs #1084)** - git commit/push detection now covers normalized wrapper launchers (including path- and PATHEXT-qualified names), shell-escaped verbs, keyword, combined-flag, and continuation forms; shell substitutions are treated as execution even inside text consumers, while literal text remains allowed; bash read and ownership grants use quote-aware tokenization and are recorded only after successful tool results. Stale-record recovery remains deferred.

- **Honor the pi host's project-trust decision before installing or spawning anything (refs #1334)** — pi-lens now reads `ctx.isProjectTrusted()` at every `session_start`. When the host says a project is NOT trusted, tool auto-install degrades to discovery-only (nothing is downloaded or executed) and LSP servers are not spawned; in-process analysis, caches and tree-sitter continue unchanged. Hosts that expose no trust surface behave exactly as before.

### Contributors

- Apostolos Mantzaris (@apmantza)
- Nathan Cooke (@3choBoomer)
- Eli Stark (@white-hat)
- Mark Faga (@mjfaga)
- Marvin Aziz (@marvtub)
- Dominic (@aeturnal)
- Kenneth McCormick
- jiangmt

## [3.8.74] - 2026-08-01

### Added

- **🙏 Huge thanks to @AngriestBird (Kenny McCormick), this round's standout community contributor** — five merged PRs landed this release: a single source of truth for CLI flags (#1011), secret redaction at the log-write boundary (#954), new slash commands (#906), tree-cache performance improvements (#877), and a test harness for rule-dispatch behavior (#871). Sustained, high-quality work spanning perf, security, config, and test infrastructure — thank you.

- **Prompt-cache observability (refs #1018, closes #1018)** — two provider-independent
 signals now land in `~/.pi-lens/latency.log` as `type: "phase"` records.
 (1) Response-side: a defensively feature-detected `message_end` subscription
 (clients/agent-nudge.ts pattern — guarded, never throws on older hosts) logs one
 `cache_usage` record per assistant message that carries a `usage`, with
 `metadata: { provider, model, cacheRead, cacheWrite, input, output, cost }`
 (provider/model read straight off the assistant message; `cost` is the total).
 Messages with no usage (or non-assistant messages) are skipped rather than
 logged as zeros. (2) Request-side: the existing `context` handler now hashes
 `messages[0]` on every call and logs a `cache_prefix_break` record
 (`metadata: { turnIndex, previousHash, currentHash, sessionId, sessionRole }`,
 plus a baseline record on first observation) whenever that hash changes
 turn-over-turn — a regression guard that flags anything (pi-lens or otherwise)
 breaking the byte-stable prefix #1016 established. The baseline is keyed by the
 stable per-session id (`ctx.sessionManager.getSessionId()`) in a small bounded
 LRU, so a resume/reload keeps comparing against its own baseline while a
 new/fork or a concurrent in-process subagent (#473) gets an independent baseline
 instead of a spurious cross-session break; each record is tagged with the
 read-only #473 `sessionRole` classification, and a primary `session_shutdown`
 drops the ended session's entry. The hash is pure observation: it never changes
 the handler's injection behavior or return value, and runs even on non-injecting
 turns. New `clients/cache-observability.ts`; no new dependencies.

- **Word-index build/refresh/persist outcomes are now durably observable**
 (refs #958, #926, #533) — every word-index signal previously rode solely on
 the optional `dbg` callback, a documented no-op in the MCP host where
 `symbol_search` reads the index. The structured logger (`~/.pi-lens/
 word-index.log`, shared `createNdjsonLogger` infra) now records, independent
 of `dbg`: the full-rebuild-vs-incremental decision plus honest coverage
 (`indexedFileCount`/`truncated` and `refreshed`/`dropped`/`skipped`/`reused`
 counts) for both the session-start and cold-query (MCP) build paths; a
 swallowed snapshot **persist failure** as `persist_failed` (a silent stale
 index otherwise leaves no trace); and the safety refusal for a root at/above
 `$HOME`. The full-build collector (`collectWordIndexDocs`) now also returns a
 `skipped` count for files it enumerated but could not index (over the byte
 cap / unreadable), so a partial index is never reported as complete. The
 fragile string-parsing `dbg` adapter this replaces is removed.

- **Closed four observability gaps in recently-changed typos-config and project-snapshot code (refs #533)** — none of these change behavior, only
 what's now logged: (1) the typos LSP's inject-vs-step-aside decision
 (#967) now logs a `typos_config_resolved` phase in `latency.log` /
 `sessionstart.log` with `mode: "project_config" | "injected_default"` and
 the resolved `configPath`, so which typos config is actually active is no
 longer a guess; (2) a project-snapshot body persist that falls back to the
 synchronous main-thread gzip because the persist worker died/was
 unavailable now logs an explicit `project_snapshot_worker_fallback` phase
 with the `reason` (previously only visible via a test-only variable, or
 buried in an `offloaded:false` success line) — this is the +656MB-risk
 degraded path from #950; (3) a corrupt/truncated gzipped snapshot body
 (gunzip/JSON.parse failure) now logs `project_snapshot_body_corrupt`
 before failing open to a rebuild, instead of being indistinguishable from
 "no snapshot yet"; (4) dropping the in-process authoritative snapshot
 entry for an oversized (>24MB) body now logs
 `project_snapshot_authoritative_dropped_oversized` — low-priority, but no
 longer silent.

- **Review-graph checkpoint discards and write failures are now observable**
 (refs #936, #533) — a present resume checkpoint that's rejected now logs
 `checkpoint_discarded` with a `reason` (`corrupt`, `version_mismatch`,
 `not_in_progress`, `git_stamp_mismatch`, `ignored_ids_mismatch`,
 `removed_file`, `all_stale`) instead of silently falling back to a full cold
 rebuild, and a failed checkpoint write (worker error/death, promote failure,
 sync-write failure) logs `checkpoint_write_failed` — so "why isn't my
 checkpoint resuming / persisting?" is diagnosable from `review-graph.log`.

- **The review-graph full build is now resumable across sessions** (refs #936
 limit 2) — a cold full build (walk + tree-sitter parse of every source file)
 previously restarted from scratch every session, so on a large repo with
 short-lived sessions it could never finish. The extraction loop now
 periodically checkpoints the PRE-resolution graph plus the exact set of files
 already folded into it (with content hashes) to a dedicated
 `review-graph.checkpoint.json.gz`, and a later session resumes from it,
 re-walking only files that changed/appeared since. The checkpoint lives in
 its own file (never the authoritative `review-graph.json.gz`) and its
 hydrated graph carries `persistCoverage.inProgress` on top of `partial`, so
 no reader (`getCachedReviewGraph`, `loadPersistedGraph`) can ever serve or
 launder a mid-build checkpoint as a complete graph (honesty doctrine, #533).
 Resume equivalence to a cold build is guaranteed by `addFileToGraph`'s
 per-file contribution being order-independent (all cross-file linking is
 deferred to `resolveDeferredSymbolEdges`): content-changed processed files
 are evicted and re-walked, orphaned placeholder nodes are pruned, and any
 removed file, ignored-id-set change, version bump, or git-identity mismatch
 fails open to a cold build rather than risking a wrong graph. Checkpoint
 stride/interval are tunable via `PI_LENS_GRAPH_CHECKPOINT_EVERY_FILES` /
 `PI_LENS_GRAPH_CHECKPOINT_MIN_INTERVAL_MS`.

- **Bundled default typos allowlist for projects without their own config**
 (fixes #967) — the `typos` auxiliary LSP now injects a small pi-lens-shipped
 `_typos.toml` (`rules/typos/_typos.toml`, `[default.extend-identifiers]`
 only: `dito`, `unparseable`) via `initializationOptions.config` whenever a
 project has no `typos.toml`/`_typos.toml`/`.typos.toml` of its own. A
 project's own config still always wins outright: when one is found,
 pi-lens injects nothing at all (rather than letting typos-lsp merge ours
 on top and risk our defaults outranking the team's own allowlist on key
 collisions).

- **Session-start latency is attributable end to end** (refs #948) — latency
 telemetry now separates host boot from pi-lens evaluation and records quick
 and full session-start totals, pre-handler/bootstrap work, runtime reset,
 log cleanup, LSP reset, sequence/snapshot reads (including snapshot bytes),
 and delayed warmup scan/profile/index phases.

- **Review-graph LSP fallback nodes** (refs #307) — when tree-sitter yields
 zero declarations, the builder may use `documentSymbol` from an already-live,
 already-open capable server. Nodes carry `provenance: "lsp"`, hierarchical
 containment survives persistence (including flat native-TypeScript-7 results
 reconstructed through `containerName`), productive tree-sitter files never
 pay the request, and unavailable/failed fallback attempts degrade without
 opening or spawning while remaining visible in `review-graph.log`.

- **Standalone out-of-band review-graph build CLI** (refs #924) — `npx pi-lens
 build-graph [--cwd <dir>]` reuses the session builder and queued atomic
 persist path for CI/cron, forces the debounced snapshot write before exit,
 and prints file/node/edge/element counts, JSON bytes, and duration. Unsafe
 roots, build errors/skips, persist failures, and persist-cap trips exit
 non-zero with their reason instead of silently leaving no snapshot.

- **`/lens-perf` surfaces slow phases in-session** (closes #767) — the command
 shows independent top-five p50 and p99 rankings with sample counts for both
 the current process session and the machine-wide active `latency.log` window.
 It flushes pending writes, streams a tail bounded by the log rotation
 threshold (`PI_LENS_MAX_LOG_SIZE_MB`, 10MB by default), caps retained samples,
 and reports malformed/truncated input instead of silently reading it as clean.
 Session startup total and scan-context computation are now logged as phases so
 the startup regressions that motivated the command are visible there too.

### Changed

- **Session-start perf: bound the change-log replay + defer log cleanup (closes #1019)** —
 the interactive session-start path recomputed the project sequence by reading
 the ENTIRE append-only change log and folding every line — and each fold does a
 `normalizeMapKey`/`realpathSync.native()` syscall per historical entry, so the
 `session_start_sequence_read` phase (measured ~94ms, ~47% of a 200ms warm start)
 grew unbounded with total log length, not the working set. The project snapshot
 now embeds the DERIVED sequence index (`projectSeq` + per-file `fileSeqByPath`)
 as of its `seq` (`clients/project-snapshot.ts`), mirrored into the tiny
 `project-snapshot.meta.json` sidecar so session-start can hydrate it WITHOUT
 parsing the 40-112MB body (preserving the #947 skip-stale optimization).
 `readLatestProjectSequence` (`clients/project-changes.ts`) gained an optional
 base param: it hydrates that index (O(files-in-snapshot), keys already
 normalized → no per-key `realpath`) and folds ONLY entries with
 `seq > snapshot.seq` — O(changes-since-snapshot). The fold uses the SAME
 `Math.max`/`normalizeMapKey` the full replay does, so the result is
 byte-identical to a full replay (proven by an equivalence test suite covering
 no-new-entries, new/existing files, deletes, gaps/out-of-order, and the empty
 log) and order-independent. Correctness-first fallbacks to a full replay: a
 legacy/missing meta with no embedded index, a version-mismatched meta, and a
 snapshot whose `seq` is AHEAD of the log (truncation/rotation) — it never serves
 a wrong seq. Wired into BOTH the quick/interactive and the full session-start
 paths. Secondary: `log_cleanup` (~7ms) was moved off the synchronous critical
 path into a deferred `setImmediate` (it still runs every session and notifies
 async — nothing on the hot path consumed its result).

- **Config-consistency pass on the #166 flag registry (refs #166, #533)** — the
 #883 registry core (scope split, precedence, negation) was audited consistent
 and left untouched; the gaps were all in #533 malformed/unknown-input
 signalling, asymmetric between the global and project loaders. (1) The
 project loader (`.pi-lens.json`) now warns once on an unrecognized top-level
 key — a typo like `maxProjectFile` or `lps` previously did nothing silently —
 while tolerating the foreign namespaces the shared file legitimately carries
 for the LSP loader (`servers`, `serverOverrides`, `disabledServers`,
 `warmFiles`, plus `$schema`); a user-level-only lens key placed at project
 scope (e.g. `lsp`, `tests`, `delta`) gets a distinct "not honored at project
 scope" signal instead of being lumped in with typos. (2) The recognized-key
 catalogs are single-sourced (#883): `GLOBAL_NON_FLAG_CONFIG_SECTIONS` and
 `PROJECT_FOREIGN_CONFIG_NAMESPACES` are declared once beside `LENS_FLAGS`, and
 both loaders derive flag sections from the registry, so adding a namespace is
 a one-line edit and adding a flag needs none — replacing a drift-prone inline
 literal set in the global loader. (3) Three global scalars
 (`dispatch.runnerTimeoutFloorMs`, `widget.visible`, `format.mode`) that
 silently coerced a present-but-malformed value to `undefined` now warn on
 invalid input through the same path as `actionableWarnings.autoFix.maxFixes`,
 while staying silent when the key is absent (no false warnings). (4) Removed
 five dead global-only accessors that bypassed the precedence chain
 (`getGlobalAutoformatEnabled`, `getGlobalAutofixEnabled`,
 `getGlobalImmediateFormatDefault`, `getGlobalContextInjectionEnabled`,
 `getGlobalTurnSummaryEnabled`) — no non-test callers existed. (5) Updated
 `docs/globalconfig.md`, whose "unknown keys are ignored" claim was stale.

- **Normalized call-graph impact checks into structured `ProjectDiagnostic`s**
 (refs #179) — the last remaining adapter gap in #179's normalization sweep.
 `runtime-turn.ts`'s turn-end call-graph impact check (WillBreak/MayBreak
 callers of symbols edited this turn, via `impact()`/`call-graph.ts`) was
 advisory-text only, unlike the other 9 runners already normalized through
 `runner-adapters/*.ts`. Added `callGraphImpactToProjectDiagnostics`
 (`project-diagnostics/runner-adapters/call-graph-impact.ts`), a pure mapper
 attributing each diagnostic to the CALLER's file (no line — `impact()`'s
 `ImpactResult` never carries one, and the underlying call-graph symbol
 table only has a placeholder `line: 1`, so inventing one would be a fake
 location). Severity is deliberately capped below this codebase's
 "blocking" hard-stop tier for BOTH WillBreak and MayBreak (#533 honesty):
 `impact()` is a name-resolution heuristic with no type information, so a
 resolved caller is never a confirmed break. Wired at turn-end (not
 `fresh-fetch.ts`/`ANALYZER_IDS`) since impact analysis is inherently
 per-turn-edit, not a whole-project scan — same reasoning that keeps
 test-runner's own turn-end fire out of that registry. The existing
 human-readable advisory string is kept as-is (separate surface, same
 dual-surface pattern already used by knip's turn-end delta). The call-graph
 block now runs BEFORE the single `writeProjectDiagnosticsDeltaReport`
 serialization point (alongside knip's delta push) rather than after it —
 previously it pushed into `projectDiagnosticsDelta`/`projectDiagnosticsSources`
 past the one-shot write, so a call-graph-only turn persisted nothing and a
 mixed turn dropped the call-graph entries, leaving `lens_diagnostics` (a
 pure reader of the persisted report) unable to ever surface the findings
 (#533). Covered by a new `handleTurnEnd`-level regression test asserting the
 persisted report for both call-graph-only and mixed turns.

- **Parallelized the per-turn madge dependency check** (refs #766) — the
 turn-end circular-dependency pass previously ran one `await checkFile()`
 per import-changed file in a sequential `for…await` loop, serializing N
 madge subprocess spawns. `DependencyChecker.checkFilesBatch()` now runs
 those spawns concurrently (bounded to 6 in flight) via a new turn-end
 entry point. `DependencyChecker` keeps its circular-dep findings
 (`lastCircular`/`circularFiles`) as shared instance state that a single-file
 check overwrites wholesale, so naively parallelizing risked one file's
 spawn clobbering a sibling's write depending on subprocess completion
 order. The fix keeps every spawn's result local until all have settled,
 then folds them into the shared state in original file order — matching
 the sequential loop's file-by-file overwrite exactly, just without waiting
 for each subprocess before starting the next. Behavior-preserving: same
 per-file results, same final circular-dep state, same `dbg` logs — only
 wall-clock time changes.

- **Every runtime toggle is now settable from BOTH the CLI and `~/.pi-lens/config.json`, driven by one declarative registry (closes #166)** —
 the flag/config mapping used to live in two disconnected places (twelve
 `pi.registerFlag` calls in `index.ts` and an if/else chain in
 `resolvePiLensFlagWithSource`), which let coverage gaps open and persist.
 Seven flags were registered on the CLI but fell straight through the
 resolver, so config could never set them: `--no-lens`, `--no-lsp`,
 `--no-tests`, `--no-delta`, `--lens-guard`, `--no-opengrep`, and
 `--no-read-guard`. They now read `lens.enabled`, `lsp.enabled`,
 `tests.enabled`, `delta.enabled`, `guard.enabled`, `opengrep.enabled`, and
 `readGuard.enabled` (positive keys — a `--no-*` flag is the key set `false`).
 The four actionable-warning toggles went the other way: they were
 config-only and are now registered CLI flags too
 (`--lens-actionable-warnings`, `--lens-actionable-warning-actions`,
 `--lens-actionable-warning-autofix`, `--lens-actionable-warning-all`).
 `clients/lens-flag-registry.ts` is the single source both surfaces read, so
 they cannot diverge again; the wiring test derives its expected flag set from
 it rather than restating it (the old hand-written list had already drifted,
 missing `lens-turn-summary`).

- **`actionableWarnings.autoFix.maxFixes` is now actually read** (#166) —
 documented in `globalconfig.md` since #792 but no loader ever parsed it, so
 `agent_end` always used the hardcoded default of 5 no matter what the config
 said. Same defect class as the seven flags above: a documented config key
 with nothing wiring it to the code that consumes it. Accepts any
 non-negative whole number; `0` keeps the warning report while applying
 nothing.

- **Flag provenance gained an `env` tier and reports the global tier more accurately** (#166) — `PI_LENS_NO_CONTEXT_INJECTION` moved out of a
 hardcoded check in `index.ts` into the registry's `env` binding, so it
 resolves through the same chain as everything else (env → cli →
 nested-project → project → global → default) and reports `source: "env"`.
 The global tier now reports `source: "global"` whenever the key is present,
 not only when it happens to differ from the built-in default. Affects debug
 and mutation-skip log lines only, no resolved values change.

- **Unknown top-level keys in `~/.pi-lens/config.json` now warn once instead of being dropped silently** (#166, refs #533) — a typo like `lps` for `lsp`
 previously did nothing with no signal. The recognized-key set is derived from
 the flag registry (`LENS_FLAGS`) plus the non-flag sections (`ignore`,
 `dispatch`, `actionableWarnings`, `widget`) and `$schema`, so it stays in sync
 with the single source of truth; anything else logs one `[pi-lens] ignoring
 invalid global config …: unknown key "…"` line and is ignored.

- **The review-graph resume checkpoint (#936) now offloads its gzip to the shared persist worker (refs #958, #883)** — mid-build checkpoint writes
 previously ran a synchronous `gzipSync` of the growing graph on the event
 loop; they now stream the stringify+gzip through the same worker the
 authoritative snapshot uses (via the newly-shared `writeGzipStageFile` core),
 generation-gated so a slow write can't clobber a newer checkpoint or
 resurrect one after the build completes and retires it, and falling back to a
 synchronous write when the worker is unavailable. Best-effort as before — a
 lost checkpoint only costs a cold rebuild.

- **Project snapshot body is now written gzipped by a worker thread** (refs
 #958 item 2) — the snapshot body (40-112MB observed) is persisted as
 `project-snapshot.json.gz`, with the `JSON.stringify` + gzip run on a worker
 thread off the save path, mirroring the review graph's
 `persist-worker.ts`/generation-gated-promotion pattern (gzip measured 5-10x
 on top of the #957 compaction win; the review-graph's own measurement was
 60MB → 1.4MB). A slow worker write for generation N is discarded rather than
 promoted over a newer generation N+1 already on disk, and the loader still
 reads the previous uncompressed `project-snapshot.json` for one compatibility
 release so an upgrade never loses a snapshot. The save path deliberately does
 NOT sync-gzip (the #950 review measured a naïve sync gzip regressing host
 memory by +656MB); when the worker is unavailable/dies the pending body falls
 back to a synchronous main-thread gzip write, surfaced via the logger rather
 than silently presented as saved (#533). Read-your-writes across the async
 promotion is preserved by an in-process authoritative "latest write" that
 `loadProjectSnapshot` consults before disk, so the merge-write callers
 (`saveRuntimeProjectSnapshot`, word index, reverse deps) never observe a
 stale body in the promotion window.

- **Session warmup refreshes the word index incrementally** (refs #958) —
 the persisted serializer now carries per-file mtimes. Startup still performs
 the bounded source walk, but reuses unchanged postings, re-tokenizes only
 stale/new files, and drops deleted files; legacy indexes, refresh failures,
 and file-set churn above 30% fall back to a full rebuild. The current derived
 file cap and `truncated` state are re-evaluated on every refresh, and
 `warmup_word_index` telemetry records mode/refreshed/dropped/reused counts.

- **Project scans feed compact structural IR into review-graph builds** (refs
 #939) — each fully completed scanner file publishes content-hash-bound
 imports, reexports, function summaries, symbols, and references. A following
 or overlapping graph build reuses only exact-hash, successful entries;
 stale, failed, absent, and cold one-shot paths parse normally. The handoff
 retains neither source content nor WASM trees, and cancelled scans expose
 only files completed before cancellation.

- **Session-start performance: LSP pre-warm, snapshot meta-gate, compact cached snapshot** (refs #947) — three
 measured startup wins: (1) the dominant-language LSP pre-warm was gated on
 full startup mode that the first-session heuristic never allows (82 quick
 vs 0 full starts in 31k dogfood log lines), so it now runs inside the
 quick-mode +2s background warmup pass — once per process,
 generation-guarded, honoring subagent light mode, warm-attach, the
 `no-lsp` flag, and the `canWarmCaches` guard, with a `warmup_lsp_prewarm`
 phase record; (2) session start no longer sync-parses the whole
 `project-snapshot.json` body (110-130ms at 40MB, ~0.5s at 112MB) before
 checking freshness — the tiny `project-snapshot.meta.json` sidecar is read
 first and a stale seq/version skips the body parse entirely
 (`session_start_snapshot_load` records `skippedStale: true`), with
 missing-meta installs falling back to the legacy parse; (3) the snapshot
 is serialized compactly (~30% smaller) and its parsed body is cached
 in-process per (path, mtime), so `saveRuntimeProjectSnapshot` and the
 session-start/word-index/scan-context consumers no longer re-parse a file
 pi-lens itself wrote seconds earlier.

- **Warm LSP names enrich tree-sitter read expansion** (refs #158) — partial
 read expansion keeps tree-sitter's line boundaries authoritative, but an
 already-open document with an already-active LSP can now replace the display
 name/kind from `documentSymbol` (including `Class.method` ancestry) within a
 150 ms best-effort ceiling. Cold, closed, unsupported, timed-out, or failed
 servers retain the tree-sitter identity, and `ts_range_expanded` records
 whether enrichment succeeded.

- **Installer subprocesses are lifetime-coupled** (refs #945) — npm, pip, gem,
 and archive extraction now use the shared safe-spawn path, await full Windows
 process-tree termination on timeout, and synchronously clean registered
 installer children during parent exit/signals.

- **Managed tool installs are cross-process serialized** (refs #945) — a
 dependency-free atomic lock protects the shared tools tree, verifies owners
 before stale recovery, bounds lock waits with an honest error, and rechecks
 discovery after acquisition to avoid duplicate package-manager runs.

- **Ordinary tests never install managed tools** (refs #945) — Vitest sets
 `PI_LENS_DISABLE_TOOL_INSTALL=1`, its prewarm step creates a local synthetic
 oxlint probe-cache entry without networking, and one-shot analysis explicitly
 awaits probe-cache persistence before exit.

- **Installer orphan/locking regressions are process-tested** (refs #945) —
 fake package-manager coverage verifies Windows timeout tree-kill, exactly one
 install across concurrent processes, explicit install-disable refusal, and
 Vitest's default no-install environment.

- **Downgrade TypeScript `unsafe-regex` to advisory and suppress escaped-before- assignment false positives (refs #932)** — the coarse dynamic `RegExp`
  heuristic no longer blocks edits and recognizes escape/replace calls in a
  same-file identifier initializer; structural ReDoS detection remains with
  the `redos-nested-quantifier` ast-grep rule.

- **Review-graph persistence no longer serializes or compresses on the event loop (refs #939)** — debounced snapshots are materialized in one lazy,
 unref'd worker and streamed through gzip into the new canonical
 `review-graph.json.gz` cache. The main thread promotes only the current
 generation's atomic staged file, so the synchronous CLI/exit flush can
 supersede an in-flight worker without a stale overwrite. Loads retain one
 release of fallback support for legacy uncompressed `review-graph.json`
 snapshots; worker failures are logged and degrade to a synchronous persist.
 Persist telemetry now records element count, raw/gzip bytes, serialization
 and write time, and whether the work was offloaded.

- **Raise and make the review-graph persist ceiling useful** (refs #936) — the
 default `GRAPH_PERSIST_MAX_ELEMENTS` cap is now 500,000 (still overrideable
 through `PI_LENS_GRAPH_PERSIST_MAX_ELEMENTS`), matching measured startup
 load/reindex costs and allowing the ~208,000-element #919 repository to
 persist completely. Above the cap, persistence retains whole-file node groups
 in existing reverse-dependency-centrality order plus induced edges that fit,
 instead of dropping the snapshot. Exact total-versus-persisted node/edge
 counts are stored and surfaced; read-only queries may use the partial graph,
 while incremental builds reject it as a complete base.

- **Logger hot paths now coalesce queued lines and rotate during long sessions** (refs #935) — contiguous NDJSON entries drain through one append up to each truncate boundary while retaining peek-then-remove exit safety and one-write cross-process atomicity. `sessionstart.log` now uses one shared asynchronous writer for ordinary diagnostics (with the crash-adjacent LSP launch write intentionally synchronous), and latency/cascade/tree-sitter/bus-event logs enforce the existing 10 MB cap in process.

- **Incremental review-graph updates avoid redundant whole-graph copies and index rebuilds (refs #939)** — file re-extraction now rebuilds derived indexes
 once, immutable edges are array-copied without cloning every edge object, the
 updated graph itself becomes the workspace snapshot, and debounced persistence
 defers its O(graph) array materialization until the quiet-window flush.

- **Reverse-dependency indexes update at import-edge granularity** (refs #939)
 instead of rebuilding from every graph edge after a one-file edit. Body-only
 edits reuse the cached index without rewriting the project snapshot; import
 changes patch only the touched `imports` and `importedBy` buckets.

- **Review-graph file-cap degradation is now explicit and count-honest** (refs
 #921) — `project_report` says a capped project has “more than N files” instead
 of presenting the cap+1 early-exit sentinel as an exact count. `module_report`
 now marks graph-backed `usedBy`, blast-radius provenance, and
 `semantic.source` as `unavailable:file-cap` and emits an actionable warning
 with the cap plus both `.pi-lens.json#maxProjectFiles` and
 `PI_LENS_REVIEW_GRAPH_MAX_FILES` controls, keeping disabled data distinct from
 a genuinely empty/cold graph.

- **Project scans release every scan-local fact after each file** (refs #886,
 #939) instead of retaining source content, imports, summaries, and other
 derived per-file facts until the scan ends. The live dispatch store remains
 untouched; only the scanner-owned store is cleared.

- Repair eight non-compiling Java, C++, CSS and PHP tree-sitter rules (refs #884).

- Repair four non-compiling Go, Rust, and Kotlin tree-sitter rules (refs #884).

- **Project diagnostics now use one file-major scan pass** (refs #896) —
 tree-sitter rules, fact rules, and bundled ast-grep share each eligible
 file's content read while retaining their individual extension/size gates,
 diagnostic ordering, cancellation behavior, and latency telemetry. Full
 review-graph builds likewise hash the bytes already read for extraction
 instead of rereading every file after the graph is built.

- **A project scan runs its rule set in one tree walk, not one walk per rule**
 (refs #675) — `runQueriesOnFile` compiles a language's rules into a single
 multi-pattern query and maps matches back to the owning rule, keeping per-rule
 metavars, predicates, post-filters, caps and ordering. Measured at 3.3× on
 identical matches; the scan's tree-sitter phase went **14.4s → 3.6s** and a
 full `mode=full` scan of pi-lens **24.4s → 12.2s**.

- **Project scans parse each file once** (refs #675) — the tree-sitter rules and
 the fact rules both tree-sitter-parse the same files, and running them as two
 whole-project sweeps meant the second one re-parsed every file the first had
 already parsed and evicted (357 of 357 first touches missed on capacity in a
 500-file `mode=full` scan). They now share one file-major pass: **722 parses →
 367, 892ms → 437ms of parse time, zero capacity misses**, same diagnostics in
 the same order. The 50-entry cache is unchanged and was never the problem —
 within a phase it already ran at a 96.8% hit rate, any capacity short of the
 full working set saves nothing, and one big enough to hold it would cost
 150-300MB of resident WASM trees.

- **Tree-sitter cache pressure is measurable** (refs #675): every production
 consumer, including partial-read expansion, uses the shared process-wide
 client, and scope-isolated cache counters plus parser timing are emitted for
 project scans and full review-graph builds.

- **Dead incremental-parse cache API removed.** `TreeCache.incrementalUpdate`,
 `calculateEdit`, and `invalidate` had no callers; the header's "10-100×
 speedup on edits" was advertising a path nothing ever took.

- **Faster LSP teardown and tool probes** (refs #448) — `killProcessTree`
 resolves on the child's exit event instead of sleeping the full 1.5s
 SIGTERM→SIGKILL escalation window (saves ~1.5s per graceful client
 shutdown); tool availability checks skip `--version` spawns for binaries
 that aren't on disk and no longer re-verify a probe-cache-validated managed
 install on every analyze.

### Fixed

- **Read-before-edit guard now survives a session resume (#1041)** — the
 guard's read-set was in-memory only, so a `pi --session <id>` resume (which
 resets the runtime to a fresh empty guard) falsely `zero_read`-blocked the
 first edit of any file read in the prior session. The read-set now rides the
 same #190 `PersistedSessionState` save/load path that widget diagnostics use:
 `ReadGuard.exportState()`/`importState()` persist and rehydrate `reads`,
 reconciling each read against current disk (line-hash verified) so a resume
 drops any read whose file changed or vanished — a rehydrated read never masks
 a real staleness. Forked sessions adopt the parent's read-set via the same
 in-memory hand-off as widget state. Backward-compatible: the field is optional
 and pre-#1041 persisted sessions load cleanly as "no prior reads".

- **`turn_summary_emit` quiet-window task no longer fails on a stale pi ctx (the #483 quiet window's most frequent live-dogfood error — 55×)** —
 the `agent_settled` quiet-window task `turn_summary_emit` reads the
 `lens-turn-summary` flag through `pi.getFlag()` (via `getLensFlag`). The task
 holder's `pi` is refreshed on every activation, but the quiet window is fired
 fire-and-forget from `agent_settled`, so an interim session
 replacement/reload (`ctx.newSession/fork/switchSession/reload` — common in the
 in-process subagent flow #473) invalidates that captured `pi`; the very next
 `pi.getFlag()` then throws the SDK's stale-ctx guard. That throw sat OUTSIDE
 the existing `sendMessage` try/catch, so it escaped the task and the scheduler
 logged `quiet_window: task "turn_summary_emit" failed: … stale after session
 replacement or reload` — 55 times across two weeks of dogfooding, the single
 most frequent error in `~/.pi-lens/sessionstart.log`, and it fired even with
 the feature OFF (the flag READ throws before the flag value is known). Fix:
 the task now recognises the stale-ctx throw (new `isStaleExtensionCtxError`
 message matcher) at both pi touch points (flag read + emit) and degrades to a
 logged no-op — the session that run's summary belonged to is gone, so there is
 nothing to emit into — while a genuine error still propagates to the scheduler
 (recorded `ok:false`). User impact was minimal (a lost summary for the exact
 settle that raced a session swap, plus log noise), not silent feature
 degradation: in steady state the task runs `ok:true`. Separately, the
 quiet-window runner now logs the failing task's **stack** (not just `${err}`'s
 message) so the next failure is diagnosable from the log alone; it remains
 strictly non-fatal (per-task try/catch, `runQuietWindow` never rethrows, and
 `agent_settled` invokes it fire-and-forget — a failing task can never break the
 turn). Regression test drives the registered task against a `pi` whose
 `getFlag` throws the stale-ctx error and asserts a no-op (no throw, no emit);
 it fails pre-fix.

- **Word index no longer serves stale postings after a case/separator-divergent edit (refs #1025, closes #1025 item #2)** —
 the word index's `docLengths`/`forward`/`fileMtimes` maps are keyed on file
 paths, but the full/incremental BUILD keyed on the on-disk casing the
 `readdirSync` walk reports while the per-edit UPDATE seams
 (`clients/dispatch/integration.ts`, `clients/mcp/analyze.ts`) key via
 `path.resolve()` on the raw tool-input path. When those forms differed only by
 separator (`\` vs `/`, always) or case (on a case-insensitive FS), the plain-`Map`
 keying made `forward.has(doc.path)` MISS, so the stale build-form entry was never
 removed — a duplicate doc entry with stale postings served until a full rebuild,
 silently degrading `symbol_search` relevance (the path-key divergence class,
 #1020/#210). Fix: a new typed **`PathKeyedMap<V>`** primitive
 (`clients/path-keyed-map.ts`) folds every key through a caller-supplied
 normalizer INTERNALLY, so keying a raw path is structurally impossible; the word
 index now routes all three path maps through it using the shared, exported
 `wordIndexKey` (= `normalizeEphemeralMapKey` — cheap slash-fold + win32-lowercase,
 NO `realpathSync`, so the hot BM25 path adds no filesystem I/O), and the
 token-keyed `postings` cleanup compares `WordHit.file` through the same
 normalizer, and the incremental-refresh set-difference
 (`refreshWordIndexIncrementally`) now computes its `current`/`oldSet` keys in
 that same normalized space (previously raw-keyed — a file whose stored display
 path became the edit form would have been double-counted as churn and
 dropped-then-re-added, defeating the incremental path and opening a
 drop-before-readd regression window). `PathKeyedMap` stores each value's
 ORIGINAL display path, so `symbol_search`/rendering surfaces still see the
 on-disk form, not the folded key. Build-form, edit-form, and refresh keys now
 converge on ONE entry regardless of casing/separator. The runtime-coordinator suspect maps (#1025 item #4) are left
 for a follow-up (unproven trigger, and their correct normalizer is the
 realpath-based `normalizeMapKey` matching the file's own `_fileSeq` convention —
 a larger, higher-risk change across the concurrent-session drain logic).

- **Cascade honesty: degraded/uncomputed impact no longer renders as clean (refs #1023, closes #1023)** —
 the cascade impact subsystem previously emitted an all-clear that was
 indistinguishable from "genuinely nothing impacted" whenever it could NOT
 compute impact: the review graph was size-skipped (`too_many_files` over
 `PI_LENS_REVIEW_GRAPH_MAX_FILES`) or root-skipped (`unsafe_root`), the changed
 file had no graph node, or the deferred compute threw — a silent under-report
 exactly where downstream breakage is most likely (large/over-cap monorepos)
 (#533). Impact results now carry a first-class `indeterminate` marker
 (`clients/review-graph/types.ts`) set ONLY on a degraded/cold/errored/
 missing-node compute; the already-known degraded state is threaded from
 `getLastGraphBuildInfo().mode`/`skipReason` at the compute call site (no
 re-derivation), a `missing_node` marker comes straight from
 `computeImpactCascade`, and a thrown compute is tagged in the pipeline catch. A
 new `indeterminate` `CascadeSkipReason` flows to the EXISTING turn-end seam
 (`clients/runtime-turn.ts`), which renders a short honest note in the ADVISORY
 tier ("Cascade could not compute downstream impact … a clean cascade result
 does not cover them") instead of silence. The advisory tier (not the blocker
 tier) is deliberate: an over-cap monorepo skips the graph on every edit, so a
 blocker would fire hard and never clear turn state every turn (over-escalation,
 the mirror of the silent-all-clear bug). A HEALTHY graph with a genuinely empty
 dependent set stays silent (keyed strictly off the degraded mode/marker, never
 off `neighbors.length === 0` alone — no crying wolf). Companion: the Tier-3 quiet-window reconcile
 (`clients/lsp/cascade-tier.ts`) now RE-INJECTS a `resolved-found` cold-neighbor
 error through the same neighbor→turn-end formatting via an `onResolvedFound`
 callback wired in `index.ts`, instead of leaving it logs-only. The
 neighbor-touch/freshness pipeline (neighborTouchCache, recentlyCleanNeighborCache,
 the 40-neighbor budget, tier-aware touch model, deferred cascade flow) is
 untouched.

- **Disposition marks under a nested monorepo language root are no longer silently dropped (refs #1030, closes #1030; pairs with #1024)** —
 a `false-positive`/`flagged`/`defer` mark recorded via `lens_diagnostic_mark`
 was invisible to the per-edit dispatch filter for every file living under a
 nested language-root marker (`packages/app/tsconfig.json`, a nested
 `package.json`/`pyproject.toml`/`go.mod`/etc.) in a monorepo. The mark tool
 writes dispositions keyed on the **project root** (`runtime.projectRoot`), but
 `dispatcher.ts`'s `applyDispositions` read keyed on `ctx.cwd`, which
 `createDispatchContext` resolves to the nearest **language root** via
 `resolveLanguageRootForFile` (a dispatch-internal tool/config-resolution
 detail). Because both the anchor AND the persisted store location
 (`getProjectDataDir`, keyed on `cwd`) derive from that base, write and read
 computed different anchors AND opened entirely different
 `diagnostic-dispositions.json` files — a total, deterministic failure (not
 probabilistic) for every file under a nested marker. The tool returned success
 while dispatch never saw the mark (a #533 honesty failure); `suppress` survived
 only via its independent inline `pi-lens-ignore` comment. Fixed by reading the
 disposition layer from the project root (`ctx.projectRoot`, already computed in
 `createDispatchContext`), matching the write side — anchor base and store base
 now agree on both sides. `resolveLanguageRootForFile` is untouched and still
 drives actual dispatch/tool-root resolution. Orthogonal to #1024 (which fixes
 the path *form* of the anchor); the two compose for a monorepo-stable anchor.
 No new dependencies.

- **Disposition anchor path-form stability (refs #1024, closes #1024)** — the
 `dd:`/`ddw:` disposition anchors (clients/diagnostic-dispositions.ts) are now
 derived from a single canonical path form, closing a #533-adjacent
 dropped-signal bug (sibling of #210/#1020). `relativeFile` — the chokepoint
 both `computeStrictAnchor` and `computeWeakAnchor` route through — computed
 `path.relative` on whichever raw path form the caller happened to hold: the
 mark tool (tools/lens-diagnostic-mark.ts) passes a RAW `cwd` /
 `path.resolve(cwd, arg)`, while the dispatch read side
 (clients/dispatch/dispatcher.ts `createDispatchContext`) passes
 `normalizeMapKey`-canonicalized (realpath'd) cwd/filePath. When the two forms
 diverged (Windows drive/segment case, symlink/realpath), the agent's own
 `false-positive`/`flagged` mark anchored under one id and the later
 `applyDispositions` lookup under another, so the mark was invisible and the
 "resolved" diagnostic kept re-firing / re-blocking every turn. Fix routes
 BOTH `cwd` and `filePath` through `normalizeMapKey` inside `relativeFile`
 (the same normalizer the read side already relies on — the #210 "every guard
 map keys through `normalizeFilePath`" invariant), so write and read derive
 identical anchors regardless of the form the caller passed. Anchor semantics
 are unchanged: strict (`dd:`, line-hash) vs weak (`ddw:`) split, the prefixes,
 and the `..`-escape fallback all behave exactly as before — only the path
 component is now form-stable. Existing on-disk marks written under the old raw
 form re-anchor to the canonical id; a stale orphan is re-derivable and simply
 reappears once for the agent to re-mark (identical to today's pre-fix
 behavior), so no read-time legacy fallback is needed.

- **`lens_diagnostics mode=all` no longer replays a resolved blocker (refs #1020, closes #1020)** —
 the widget-state `files` map was keyed by the raw, non-normalized path string,
 so the SAME file could land under two different key forms in one session:
 the forward-slash form (`C:/…/x.ts`) the LSP client + cascade fold produce via
 `normalizeFilePath`, and the backslash form (`C:\…\x.ts`) that `mode=full`'s
 clean reconcile (`result.filePath`) and `path.resolve`/event inputs produce on
 Windows. A stale blocking entry and the fresh clean entry then coexisted as two
 separate map entries. `mode=full` rendered clean because its merge re-keys every
 summary through `path.resolve`, but `mode=all`'s `formatAllMode` reads
 `getFileDiagnosticSummaries()` verbatim with no dedup, so it saw both and
 rendered the stale entry's `blocking:1` as a 🔴 — a resolved state that replayed
 as still-broken on every `mode=all` (worst on Windows + resumed sessions), and a
 #533 honesty failure since the tool prompt tells agents to use `mode=all` to
 verify no blockers remain. Fixed at source: every write/read seam on the `files`
 map (and the `diagnosticsWriteGuard`) now folds its key through one normalizer,
 `normalizeEphemeralMapKey` (slash-fold + win32-lowercase, no filesystem I/O —
 chosen over the `realpathSync`-backed `normalizeMapKey` because this is a hot
 write path), and `importWidgetState` folds persisted keys on rehydrate so a
 `/`-key snapshot and a fresh `\`-key write collapse across a resumed session.
 The human-readable display path on each record is preserved verbatim (rendering
 and path-relative math unchanged). No new dependencies.

- **Cache-friendly ephemeral context injection (refs #1016, closes #1016)** — the
 `context` event handler in `index.ts` now splices pi-lens's ephemeral turn-end
 findings in **immediately before the final message** instead of prepending them
 at message index 0. Prepending flipped `messages[0]` every turn, which
 invalidated the entire prompt-cache prefix on every prefix-caching provider
 (Anthropic, Bedrock, and OpenAI — all key the cache on the exact token prefix).
 Inserting before the last message keeps `messages[0]` (the real first user turn)
 byte-stable so the prior conversation stays cached, while still keeping the real
 user prompt as the trailing message — preserving the trailing-`user` cache
 breakpoint and the historical `fe0ed5da` guarantee that input is never empty (the
 existing transcript is always preserved, never dropped; an empty transcript falls
 back to the prior prepend-only behavior). Because the `context` event fires
 before **every** provider call (not just at turn boundaries), a trailing-role
 guard (`isPlainUserPrompt`) only splices before the last message when it is a
 plain user prompt; when the tail is a mid-loop `tool_result` (or assistant/tool
 message) the findings are **appended** after the whole transcript instead — a
 pure append that both preserves the `tool_use`↔`tool_result` adjacency all three
 providers require (splicing between them is an HTTP 400) and keeps the entire
 prior transcript as an untouched cache prefix. Changes shipped `dist` behavior
 (the handler lives in the bundled dist path).

- **Fixed: `ts-ssrf` no longer flags fixed/const endpoint URLs built with `new URL(...)` as SSRF sinks, while still catching tainted URLs (closes #1000, refs #533, #963)** — a project-wide pi-free scan flagged fixed outbound
 OAuth/profile endpoints as SSRF: `fetch(authUrl.toString())` where
 `authUrl = new URL("auth/authorize", \`${BASE_URL_CLINE}/\`)` with a fixed
 (module-const or imported) base. The `ts_ssrf_sink` post-filter already
 exempted a `fetch()` of a file-local `const` literal string (#963) but had no
 awareness of the `new URL(literalPath, fixedBase)`shape, so
 `url.toString()`/`url.href` fell through to the broad taint heuristic. Extended
 the post-filter to also exempt `fetch(u.toString())`/`fetch(u.href)` when `u`
resolves (same file) to a clean `const u = new URL(<literalPath>, <fixedBase>)`
— literal path plus a base that is a literal, a file-local literal `const`, or
 an import binding (the`URL` ctor may be aliased, e.g. `NodeURL`). Query params
 added later via`searchParams.set(...)` don't control origin/path and never
 taint the destination. Still fires (regression fixtures added) when the base
 or path comes from a function parameter, `process.env`/config (including a
 const initialized from`process.env`), request data, or a parsed
 redirect/`location`value — a missed SSRF stays far worse than one FP.
 Adversarial review then closed two bypasses in that exemption: (1) a
 post-construction origin mutation (`u.host = req.x` / `u.href = …`/
 `u["host"] = …`, incl. augmented assignments) now re-taints the receiver and
 fails closed — only query-only writes (`u.search`/`u.searchParams`) stay
 exempt; (2) a request-tainted function *parameter* base is no longer exempted
 just because an unrelated same-named module-level`const` literal exists
 (scope-aware shadow check; imported bases stay trusted).

- **Fixed: `no-javascript-url`/`no-javascript-url-js` no longer flag defensive `javascript:`-URL filters (refs #533)** — a dogfood run flagged code that was
 *rejecting* `javascript:` links (e.g. `url.startsWith("javascript:")` inside a
 guard) as if it were introducing one. The rule matched the `javascript:`
 string literal alone with no way to tell "used as a sink" from "used to
 detect/block it". Narrowed both rules to also exclude the literal when it's
 the needle in `.startsWith`/`.endsWith`/`.includes`/`.indexOf`/`.search`/
 `.match`, or one side of an `===`/`!==`/`==`/`!=` comparison — deliberately
 NOT `.replace`/`.replaceAll`, since the literal there could be the malicious
 replacement argument rather than the defensive search argument, and
 excluding the whole call would hide that true positive. Both new exclusions
 deliberately avoid `stopBy: end` (unlike the pre-existing test-literal
 exclusion, which needs it) so the literal must be a DIRECT argument/operand
 of the defensive call/comparison, not merely somewhere in its subtree — an
 adversarial review caught that `stopBy: end` let a real sink smuggled
 inside the call/comparison (e.g. `candidates.includes(link.href =
 "javascript:...")`, a comma-operator sink, or `(a.href = "javascript:...")
 === expected`) go unflagged. Added valid fixtures reproducing the filter FP
 and invalid fixtures for the replace-based sink and all three smuggled-sink
 bypasses, confirmed against the real ast-grep 0.45.0 binary.

- **Contained spawn/callback failures that could crash the host (pidusage bug class) (refs #533)** — a best-effort telemetry sampler recently killed a live
 pi host with `Error: spawn UNKNOWN` (uncaughtException) because a bundled
 dep's `child_process.spawn(...)` threw SYNCHRONOUSLY with no try/catch, from
 a detached context the caller's `try/await/catch` could not contain. Audited
 every spawn/detached-callback site in `clients/` and hardened the at-risk
 best-effort/background ones to the repo's existing guard shape (try/catch
 around the synchronous `spawn` + resolve gracefully, matching the async
 `'error'`-listener path): `safeSpawnAsync`'s core spawn (the shared wrapper
 dozens of best-effort callers rely on to NEVER reject), its Windows
 `killPidTreeSync` (runs from `process` exit/signal handlers, where a throw
 becomes an uncaughtException during shutdown; the POSIX branch was already
 guarded), `SgRunner.exec`'s three ast-grep spawn branches, all six installer
 tool-discovery/verify probes, the PSScriptAnalyzer runner's `spawnPs`, and
 the MCP `analyzeFileFresh` worker fork. A synchronous spawn throw now
 resolves the operation's normal failure value instead of escaping as an
 unhandledRejection/uncaughtException. Behavior-preserving otherwise.

- **Fixed: opengrep security findings now surface project-wide in `lens_diagnostics mode=full` (refs #585, #584, #533)** — opengrep's
 whole-tree CLI scan ran at session-start and cached its findings, and the
 cache-only extractor registry (`project-diagnostics/extractors.ts`) even
 registered an `"opengrep"` row — but #585 replaced that registry in
 production with `fetchFreshProjectDiagnostics`
 (`project-diagnostics/fresh-fetch.ts`), whose `ANALYZER_IDS` list omitted
 opengrep. Net: opengrep scanned + cached, yet nothing production read it
 back, so semgrep-grade security findings (ERROR→blocking, CWE-tagged) never
 reached the agent for unedited/project-wide files — a scan-and-orphan
 honesty gap (#533). `fetchFreshProjectDiagnostics` now runs opengrep the
 same way it runs gitleaks/trivy: an availability probe (opengrep is
 structurally always-on, so no static project-type gate), then a fresh scan
 that JOINS the session-start scan of the same root via
 `SecurityScanClient.dedupeScan` rather than double-spawning a heavy scan,
 with the result written back to cache and adapted to project diagnostics.
 The per-edit aux-LSP push path (`clientScope: with-auxiliary`,
 `AUXILIARY_LSP_PROFILES`) was verified already-working and is untouched;
 opengrep stays excluded from the push-only LSP workspace sweep (#584). The
 now-dead cache-only `extractCachedProjectDiagnostics` reader — the parallel
 registry that shadowed `ANALYZER_IDS` and let opengrep silently diverge — was
 removed so a single source of truth (#883) remains.

- **Fixed: test-runner findings now surface project-wide in `lens_diagnostics mode=full` (closes #1004, refs #585, #533)** — the same `ANALYZER_IDS`
 omission #585/#1003 fixed for opengrep also orphaned test-runner: the
 per-edit turn_end test fire (`runtime-turn.ts`) caches failures under
 `"test-runner-findings"`, but `fetchFreshProjectDiagnostics`
 (`project-diagnostics/fresh-fetch.ts`) never read that cache back, so test
 failures never reached the agent for unedited/project-wide `mode=full`
 calls. Unlike opengrep/gitleaks/trivy, this is wired as a CACHE-READ (not a
 fresh scan): test-runner has no "whole project" run to trigger — turn_end
 only ever runs the targeted, cascade-aware test files touched by that
 turn's edits — so `fetchFreshProjectDiagnostics` now peeks at the same
 `"test-runner-findings"` cache key (never re-running the suite, never
 writing back) and adapts it via the existing
 `testRunnerFindingsToProjectDiagnostics`. Added a coverage guardrail
 (`tests/clients/project-diagnostics/analyzer-coverage.test.ts`) that greps
 the real session-start (`runHeavyweightTask`) and EVERY turn_end
 `cacheManager.writeCache` call site in `runtime-turn.ts` (excluding a
 short, explicit bookkeeping exclude-list — `errorDebt`/`turn-end-findings`/
 `turn-end-findings-last` — that isn't analyzer-shaped) and asserts every
 remaining id is a member of `ANALYZER_IDS`, so this whole #585 bug class
 can't silently regress again for a future analyzer added to EITHER writer.
 Adversarial review also caught two honesty/fidelity gaps in the initial
 fix: (1) test-runner is cache-read, edit-scoped (the targeted test files
 touched by the most recent turn's edits), unlike every other analyzer's
 fresh whole-project scan — `mode=full` now emits an explicit "coverage is
 edit-scoped, NOT a full-project run" caveat whenever test-runner
 contributed findings (not only when cold), and a `stale` cached result
 (the turn advanced before the test run finished) is prefixed
 `[stale — from a prior turn]` in its diagnostic message, matching the
 one-shot turn-context message's own stale wording; (2) `TestFailure.location`
 (`"file.ts:42"`, set by the vitest/jest JSON parser in
 `test-runner-client.ts`) never reached `ProjectDiagnostic.line`, so every
 test-runner finding rendered as `L?:` — `runner-adapters/runner-findings.ts`
 now parses a numeric trailing `:line[:col]` out of `location` (left alone
 for pytest/mix's non-numeric `location` strings, which carry a test name or
 module, not a line).

- **Guarded Windows CPU/RSS resource sampling so a best-effort sampler can no longer crash the pi host (refs #620, #533)** — `clients/resource-sampler.ts`
 sampled CPU%/RSS via `pidusage`, whose Windows path shells out to `gwmi`
 through an internal `spawn(..., { shell: "powershell.exe" })` that has no
 try/catch and runs from inside a ChildProcess `close` callback. Under real
 Windows handle/commit pressure that spawn can throw `spawn UNKNOWN`
 (errno -4094) **synchronously in that detached callback**, which the call
 site's `try { await pidusage() } catch {}` cannot catch → `uncaughtException`
 → the whole host process dies (observed live). pidusage 4.0.1 offers no
 option to avoid the gwmi path. The sampler now runs its OWN fully guarded
 `Get-CimInstance Win32_Process` query on Windows (never calling `pidusage`
 there), mirroring the existing `findDescendantPidsWindows` guard: a
 synchronous spawn throw, a child `error` event, and a non-zero/garbage exit
 all resolve to a partial/empty map — the sampler can now only ever lose a
 data point, never throw into the heartbeat/spawn path. RSS comes from
 `WorkingSetSize`; CPU% is preserved via the same KernelModeTime/UserModeTime
 delta-over-elapsed-wall-time computation gwmi uses (a small per-pid history
 tracks the prior cumulative CPU time). Linux/macOS keep using `pidusage`
 (procfs/`ps` — not the crash vector) unchanged. A pid absent from the
 returned map still means "unsampled this tick", never zero.

- **Stabilized the two remaining flaky/environment-sensitive tests tracked in #902.** LSP workspace-diagnostics sweep flush-count assertions
 (`workspace-diagnostics-sweep-batch-open.test.ts`,
 `workspace-diagnostics-sweep-preopen-chunk.test.ts`) pinned an exact flush
 count, but the pre-open pass's real `fs.promises.readFile` genuinely races
 the real 100ms `WatchedFilesQueue` debounce timer — under full-suite
 scheduler contention that can legitimately fragment or merge a chunk's
 flush by one. Replaced the exact-count assertions with invariant checks
 (coalescing happened; nowhere near one flush per file; no lost/duplicated
 URIs) that tolerate that jitter without losing the #608/#621 regression
 coverage — a test-robustness fix, not a product bug. Separately, two
 ast-grep dispatch test harnesses
 (`ast-grep-rule-tests.test.ts`, `ast-grep-catalog-rules.test.ts`) shelled
 out to the real `ast-grep` CLI via a raw `execFileSync(..., { shell: true
 })` per call — an uncached cmd.exe wrapper on Windows whose own exit code
 can mask the real child's and which can intermittently fail to spawn at
 all under the process-creation pressure of a full parallel test run.
 Switched both to the already-hardened `safeSpawn` (`clients/safe-spawn.ts`,
 #817) — cached PATH+PATHEXT resolution, direct `.exe`/`.com` spawn with no
 shell involved — the same deterministic resolution production dispatch
 already relies on. The third tracked item
 (`startup-overhead.test.ts`'s "quick mode self-reports within 100ms")
 was already reworked to a generous fixed budget + `retry: 2` in an earlier
 pass; confirmed still adequate, no further change needed.

- **`pi-lens build-graph` honestly reports a capped, over-the-cap persist**
 (closes #924, refs #936 limit 3) — the CLI's build-attempt check no longer
 mistakes the benign "succeeded, but persisted a partial subgraph" reason
 (#960's over-cap circuit-breaker) for a hard failure: it now exits 0 and
 prints a `PARTIAL persist` line with `persistedNodes=X/Y`,
 `persistedEdges=X/Y`, and the cap that was hit, instead of either silently
 reporting "done" or failing loudly on a build/persist that actually
 succeeded. `flushReviewGraphPersist()` now returns the persisted
 `coverage` so any standalone caller can make the same distinction.

- **`ts-ssrf` no longer trusts naming convention as proof of a fixed URL**
 (fixes #963) — the post-filter now resolves a bare `fetch(IDENT)` argument
 against the file's AST and exempts it only when `IDENT` provably resolves
 to a single `const` declarator initialized with a string literal (or a
 template literal with no `${...}` substitutions). SCREAMING_SNAKE_CASE
 naming is no longer sufficient by itself: `const TARGET_URL = req.query.url`
 still flags, since the identifier's *initializer* — not its name — is
 what's checked. Ambiguous/shadowed declarations, `let`/`var` bindings,
 reassigned identifiers, and unresolved identifiers all fall through to the
 existing broad heuristic and keep being flagged.

- **Remaining test-suite spawns routed off `shell:true` to close the Windows-spawn flake class** (refs #902) —
 `tests/clients/ast-grep-rule-precedence-followups.test.ts` (CLI probe + `runCli`),
 `tests/clients/coderabbit-ast-grep-rules.test.ts` (vendored-catalog smoke),
 `tests/clients/dispatch/runners/ast-grep-playground-verify.test.ts` (async `runVerify`),
 `tests/clients/dead-code-client.test.ts` (vulture probe), and
 `tests/clients/lsp/clangd-lazy-indexing.test.ts` (clangd `where`/`which` probe) now spawn
 through the hardened `safeSpawn`/`safeSpawnAsync` (`clients/safe-spawn.ts`, #817) instead of
 a raw `execFileSync`/`spawnSync`/`spawn`/`execSync` with a fresh, uncached `cmd.exe` wrapper
 per call — the documented intermittent ENOENT/EAGAIN source under windows-latest CI's
 parallel process-creation pressure. Completes the sweep PR #993 started for the two
 ast-grep runner test files; `git` spawns are left as-is (no `.cmd` shim, so out of the flake
 class) and the `mcp/*` and MCP harness spawns that need bidirectional stdin streaming are
 left as-is (`safeSpawnAsync` only exposes a close-based result, not a live stdin/stdout pipe).

- **Tree-sitter post-filters no longer leave silently dead rules** (refs #879) —
 the 25 unknown filter references were resolved by implementing eight bounded,
 fail-open-on-filter-error AST checks, expressing two conditions directly in
 their queries, and removing fifteen rules whose promised semantic/framework
 analysis could not be supported honestly. Coarse resource, assertion, and
 sensitive-`memset` heuristics are advisory rather than blocking.

- **Capped word indexes disclose partial coverage** (refs #928) — snapshots now
 persist indexed-file count and truncation state, and both `symbol_search`
 surfaces report coverage instead of presenting capped zero-hit results as
 authoritative.

- **`pilens_health` keeps disabled LSP servers visible** (refs #927) —
 permanently broken server/root pairs now render with their failure count, and
 temporary circuit-breaker cooldowns expose their retry deadline.

- **Cold `symbol_search` failures are now observable and honest** (refs #926) —
 unavailable results distinguish an active build, a safety refusal, and the
 last build's failure, while background build/persist errors reach a persistent
 NDJSON log.

- **Failed heavyweight analyzers no longer masquerade as clean runs** (refs #925) —
 unsuccessful results are reported distinctly, omitted from cache so the next
 session retries, and valid fix-worklog records survive neighboring corrupt lines.

- **The footer refreshes as LSP servers come online during a cold `lens_diagnostics mode=full` sweep (refs #798)**, instead of showing
 `LSP Inactive` until turn end. The repaint captures UI methods during the
 active tool event, so async warm-up never touches a stale session context.

- **Tree-sitter WASM aborts are now visible instead of silently disabling structural analysis for the rest of the process (refs #915)**. The shared
 runtime records a process-wide, timestamped `restart_required` health state,
 logs a one-time actionable error, exposes it through `pilens_health`, and
 marks project-scan responses with `treeSitterStatus`. A poisoned scan remains
 truncated and never replaces the last complete snapshot. In-process retry is
 deliberately unsafe: every new client imports the same cached `web-tree-sitter`
 ES module and therefore reuses its corrupted Emscripten heap; restarting the
 host is the isolation boundary.

- **`pilens_rebuild` can no longer destroy an npm-installed pi-lens** (refs
 #920) — rebuilds are refused before spawning a package script unless the
 package is a source checkout with `tsconfig.dist.json` outside
 `node_modules`; installed servers also omit the tool from `tools/list`, so
 subagent allowlists cannot discover it.

- **Review-graph background failures are no longer silent** (refs #919) —
 `project_report`/`pilens_project_report` now surface the most recent build
 attempt and its terminal skip/failure reason, including the persistence
 element circuit-breaker. A dedicated `~/.pi-lens/review-graph.log` records
 build and persistence starts, successes, skips, and failures, so a rejected
 fire-and-forget build or an over-cap graph cannot look perpetually in progress.

- Resolve nested C# and F# project roots for dotnet builds (refs #895).

- **A mid-scan tree-sitter WASM abort no longer replaces the authoritative project-diagnostics snapshot with a silently truncated result (refs #891)**.
 The partial scan is returned with `scanTruncated`, logs its completed/total
 file counts and abort point, and leaves the previous complete cache intact.

- **Block-wrapped switch cases no longer report false fall-through errors**
 (refs #910) — `switch-case-termination` now follows trailing statement
 blocks to recognize a nested `break`, `return`, `throw`, or `continue`, while
 still flagging empty and non-terminating blocks.

- **Project-wide enumeration now covers every registered file kind** (refs
 #894) — `ALL_SCANNABLE_EXTENSIONS`, `WARMUP_SOURCE_EXTS`, and
 `SUPPORTED_FILE_KINDS` now derive from the single `KIND_EXTENSIONS`
 authority instead of three drifting language lists. TODO scans, symbol
 search indexing, dominant-language LSP warmup, and language-profile
 detection can now see Java, Swift, C/C++, PHP, and every other supported
 kind. Code kinds keep priority over data/doc kinds (json/yaml/markdown/…)
 inside the existing caps: the dominant-language LSP warm ranks code kinds
 first, and the capped warmup/word-index walks fill code files before
 non-code files, so a locale/fixture pile can't starve real languages.
 Package-manager lockfiles (package-lock.json, pnpm-lock.yaml, …) are
 filtered as generated artifacts, and the TODO scanner caps per-file reads
 at 512 KiB. A coverage guard makes a newly registered kind automatically
 enumerable — and classified as code or non-code — on both project-wide
 paths.

- **CMake files now reach a real LSP server** (refs #892) — the CMake policy's
 previous `lsp` fallback had no registered server and silently produced no
 diagnostics. `cmake-language-server` now covers both `.cmake` files and the
 canonical `CMakeLists.txt` basename, with managed pip installation.

- **Fish LSP policy is no longer dead wiring** (refs #893) — `fish-lsp` is now
 registered for `.fish` files and auto-installed through npm; `fish_indent`
 continues to run alongside it.

- **Editing an inherited tree-sitter rule now invalidates the inheriting language's RuleCache** (refs #878) — the cache key fingerprinted only the language's OWN rules directory, but `tsx` also runs the `typescript` rule set (`queriesForLanguage`), so a typescript-rule edit left the tsx entry's hash unchanged and stale compiled rules kept being served from the on-disk cache until a tsx rule happened to change. The fingerprint now covers the full effective rule set via `ruleFilesForLanguage`, a new loader-owned seam that derives from the same rule-source composition as rule selection, so the cache key can't drift from what the runner actually runs. The runner's cache-miss path also forces the query loader past its in-memory memo (`loadQueries(root, { force: true })`) — a correct key alone wasn't enough: the memo ignores rule-file mtimes, so within one process a miss re-persisted the PRE-edit rules under the fresh fingerprint and the staleness then survived restarts. The client's compiled-batch cache is likewise now keyed on rule content instead of rule ids — ids are stable across edits, so the shared client kept serving the pre-edit compiled patterns (and messages) for the process lifetime even after the reload. `CACHE_VERSION` bumped to `v6`.

- **Small edits no longer pay the entity-extraction cost** (refs #885) — the
 <5-line skip threshold only guarded the zero-diagnostics early return; a
 second `extractEntitySnapshot` block ran unconditionally, so trivial edits
 still spent ~500-800ms per dispatch and, on unsaved buffers, parsed stale
 disk content (thrashing the parse-cache entry). Extraction is now one
 threshold-guarded block that receives the same `file.content` override the
 diagnostics phase used.

- **The per-edit tree-sitter runner walks the tree once, not once per rule**
 (refs #888) — the dispatch hot path ran ~30-40 `runQueryOnFile` walks per
 edit behind a concurrency limiter that could not parallelize synchronous
 WASM. It now calls `runQueriesOnFile` once (#675 batching) and distributes
 the per-rule results; the per-rule `maxResults(10)` cap and modified-ranges
 gating are unchanged.

- **A transient grammar-load failure no longer disables batched rule scans for the process lifetime** (refs #889) — `compileQueryBatch` cached `null` on ANY `build()` failure, including a transient `loadLanguage()` miss (offline lazy grammar fetch, mid-scan load error), so every later scan fell back to the per-rule path and paid ~3.3× forever. Load failures are now distinguished from genuine batch-compile failures: they are not cached (the next scan retries the load and recovers), with retries bounded at 3 consecutive load failures per rule set before the miss is cached, so a grammar that never loads doesn't hot-loop. Deterministic compile failures still cache `null` permanently. The query/batch cache keys also switched from a collision-prone 32-bit hash to the full pattern text.

- **module-report parses plain JS under the correct tree-sitter grammar** (closes
 #887) — `tsLangForFile` hand-rolled a local extension map that sent
 `.js`/`.mjs`/`.cjs` to the typescript grammar and `.jsx` to tsx, while every
 other tree-sitter consumer resolves those extensions to `javascript` via the
 shared `EXT_TO_LANG` (`clients/tree-sitter-shared.ts`), so each plain-JS file
 was parsed and cached twice under two grammars (TreeCache keys are
 `languageId:path`) and ran TS-grammar symbol queries on JS trees. It now
 routes the extension-split kinds (jsts, and the c-vs-cpp split for cxx)
 through the shared `resolveTreeSitterLanguage`, keeping the historical kind
 default only for extensions the shared map does not cover (`.svelte`/`.vue`,
 the C++ module-interface/Objective-C tail). The symbol extractor gains a
 dedicated `javascript` defs/refs/import query set: the TypeScript symbol
 queries do NOT compile against the javascript grammar
 (`interface_declaration`/`type_alias_declaration`/`type_identifier` do not
 exist there — a query naming them fails with "Bad node name"), so the
 javascript set is the same queries minus the type-only patterns, with class
 names matched as `(identifier)` instead of `(type_identifier)`.

- **A rule whose query fails to compile now warns once instead of silently reporting nothing** (refs #884) — both compile paths (`compileQueryBatch`'s per-rule drop and `compileRawQuery`, which every per-edit `runQueryOnFile` call falls back on) previously either `dbg()`-logged (invisible without verbose mode) or returned `null`/`[]` with no trail at all. They now call a shared `reportQueryCompileFailure`, mirroring the existing unimplemented-`post_filter` warning: one `console.error` per broken rule id, not per file. A new compile-guard test (`tests/clients/tree-sitter-rule-compile-guard.test.ts`) compiles every non-disabled shipped rule against its real grammar and caught the 32 rules #884 reports as currently broken (tracked there in a shrink-only `KNOWN_BROKEN` allowlist so follow-up fix PRs are forced to remove their entries, and the list can't grow or go stale unnoticed).

- **TreeCache mtime false misses and FIFO eviction** (closes #890) — a content
 hash match is now authoritative: a save-without-change (same bytes, newer
 mtime) is a cache hit that refreshes the entry's stat metadata instead of
 invalidating and re-parsing, and `get()` re-inserts hit entries so eviction
 is true LRU — hot per-edit files are no longer evicted by scan traffic.

- **Eight enabled typescript/javascript tree-sitter rules whose queries never compiled (refs #884)** — each had been silently dead since authoring because
 its query failed to compile against the real grammar, so it matched nothing
 and reported no diagnostics. Repaired against the actual node/field names and
 verified end-to-end (matches the bug, leaves correct code alone):
 `empty-switch-case`, `switch-case-termination`, `switch-case-termination-js`
 (switch cases carry their statements as direct `body:` children, not a
 `consequence: (statement_block)`); `infinite-loop` (`while (true)` wraps the
 condition in a `parenthesized_expression`; `for (;;)` has an `empty_statement`
 condition, not `(null)`); `duplicate-function-arg` (typescript parameters are
 `required_parameter`, not bare `(identifier)`; now also catches non-adjacent
 duplicates); `mixed-async-styles` (no `async_modifier` node — match the `async`
 token); `switch-non-case-labels` (JS) (a `labeled_statement` lives inside a
 `switch_case`, not directly under `switch_body`); and `ts-insecure-random`
 (the inline `(?i)` regex flag is invalid in JS `RegExp` — dropped the
 redundant name predicate and let the post-filter do the case-insensitive
 check, walking up from the `Math.random()` call so chained forms like
 `Math.random().toString(36)` are still attributed to their binding). Also
 implemented the four post-filters these rules referenced but that were never
 defined (`is_empty_block`, `no_break_or_return_in_body`, `same_param_name`,
 `no_terminating_statement`), which the batch runner had been failing closed on.

- **A column-0 comment after a `query: |` block no longer breaks the rule**
 (refs #884) — the query-block extractor kept every line more-indented than the
 key (to preserve `#eq?`/`#match?` predicate lines) but did not stop at a
 document-level `# …` comment sitting between the block and the next key, so the
 comment was appended to the query and made it fail to compile. This is what
 kept `mixed-async-styles` dead even after its query was otherwise correct.

- **Seven ruby security rules never compiled and never produced a finding** (refs
 #884) — `ruby-command-injection`, `ruby-eval`, `ruby-insecure-deserialization`,
 `ruby-insecure-random`, `ruby-open-struct`, `ruby-string-eval` and
 `ruby-weak-hash` were authored against JavaScript-grammar node names
 (`call_expression`, `method_call`, `command`, `interpolated_string`) that do not
 exist in tree-sitter-ruby, so every one failed to compile with a "Bad node name"
 error and was silently skipped. Each query is rewritten against the real ruby
 grammar (`call` with `method:`/`receiver:` fields, `scope_resolution` receivers
 for `Digest::MD5`, `string` for interpolated literals), with `#match?`/`#eq?`
 predicates moved inside the pattern's outermost parens. Per-rule positive and
 negative fixtures (`system` vs `File.read`, `Marshal.load` vs `YAML.safe_load`,
 `rand` vs `SecureRandom`, `OpenStruct.new` vs `Struct.new`, `Digest::MD5` vs
 `Digest::SHA256`, string vs block `class_eval`) pin the security intent.

- **Six python rules never compiled or never fired** (refs #884) —
 `python-empty-except` used a `body:` field that doesn't exist on
 `except_clause`; `in-operator-unsupported` used bare `"in"`/`"not"` `"in"`
 tokens the grammar doesn't expose that way (`not in` is a single token) and
 only matched `identifier` targets, so `x in None` never matched at all;
 `no-super-torchscript` looked for a `(call function: (identifier))` inside the
 decorator (`@torch.jit.script` has no call — it's a bare `attribute`) and
 expected the decorator directly on the method rather than the class;
 `notimplemented-boolean-context` used `("and" | "or")` (not valid tree-sitter
 query syntax — alternation is `[...]`) inside a non-existent `binary_operator`
 form and `unary_operator operator: ("not")`, when python's logical `not` is
 its own `not_operator` node with an `argument:` field;
 `yield-return-outside-function` referenced a `yield_expression` node type that
 doesn't exist in tree-sitter-python. All five queries are rewritten against
 the real grammar (verified via AST dumps against `tree-sitter-python.wasm`);
 `no-super-torchscript` and `in-operator-unsupported` gained
 `torchscript_super_call` / `check_in_operator_types` post-filters
 (`clients/tree-sitter-client.ts`) since neither had a working implementation
 behind their declared `post_filter` name. `exit-signature-check` compiled
 fine but its `@PARAM1?`/`@PARAM2?`/`@PARAM3?` captures never matched anything
 — the `?` quantifier was written after the capture name instead of the node,
 so the captured names were literally `PARAM1?`/`PARAM2?`/`PARAM3?` while the
 post_filter read `captures.PARAM1`; fixed to capture the whole `parameters`
 node and count named children instead (per-slot optional quantifiers turned
 out to match every valid sub-alignment, not just the maximal one, producing
 spurious duplicate matches for a fully-correct signature).

- **Project scans run tree-sitter rules for every supported language, not just 10 extensions** (closes #882, refs #877, #880) — the scanner's `TREE_SITTER_EXT_TO_LANG` covered only ts/tsx/js/py/go/rs/rb, so files whose grammars and non-disabled rule dirs already exist (c, cpp, csharp, css, php, java, kotlin) were silently skipped by the tree-sitter phase of project scans. It now derives the shared per-edit resolver (`EXT_TO_LANG`) so c/cpp/csharp/php/css and the `.tsx`→tsx / `.jsx`→javascript nuances can't drift from the per-edit path, and layers java/kotlin on top (grammars + rule dirs exist but no per-edit `appliesTo`). A regression test asserts the map covers every non-disabled rule dir whose grammar is loadable.

- **`.dart` files are now included in project-wide source enumeration** (closes #880, refs #876) — `ALL_SCANNABLE_EXTENSIONS` (`clients/source-filter.ts`) and `WARMUP_SOURCE_EXTS` (`clients/language-profile.ts`) were missing `.dart`, so Dart projects were fully supported per-edit (LSP, `dart-analyze`, `dart format`, autofix) but skipped by project-wide scans and cold-start language-profile warmup.

- **Tree-sitter rules were compiled against the wrong grammar** — a compiled
 query is bound to the language it compiled against, and running it on a tree
 from another grammar returns zero matches silently. Rules now compile against
 the language the file is parsed as. This is why the javascript→typescript rule
 merge never fired a single diagnostic in its life. That merge stays off — the
 rules are written against typescript node types and misfire on JS — but the
 same fix is what makes `.tsx` work.

- **Project scans ran rules from `-disabled/` directories** — the per-edit runner
 excluded them, the scanner read the raw loader map and ran them anyway.
 **1,936 of a 500-file scan's 2,590 tree-sitter findings came from rules that
 were explicitly switched off.** Rule-set selection now goes through one seam,
 `queriesForLanguage`.

- **`.tsx` parsed with the typescript grammar**, which produces ERROR nodes on
 JSX, and under a different language id than the fact providers use — so every
 `.tsx` file was parsed twice, once wrongly. It now parses as `tsx` and
 inherits the typescript rule set, which is rule-for-rule identical on both
 grammars. `.tsx` also gets those rules in the per-edit runner now, where it
 previously saw only its own two JSX rules.

- **An unimplemented `post_filter` reported every raw match instead of none.**
 40 of the 84 filters rules reference have no implementation; the default now
 drops the match and warns once. `duplicate-function-arg` (whose
 `same_param_name` filter was never implemented) was reporting 59 phantom
 duplicates across 60 files.

- **YAML scalars kept their trailing comments** in the rule loader's hand-rolled
 parser — `post_filter: not_in_test_block  # skip test blocks` carried the
 comment as part of the filter name, so the filter silently never applied.

- **tree-sitter runner no longer double-increments already 1-indexed line/column (refs #448)** — dispositions recorded against the old off-by-one
 are anchored (`clients/diagnostic-dispositions.ts`) to the wrong physical
 line's content and will resurface once after upgrading; this is expected and
 one-time, not a regression.

### Security

- **Debug logs redact credential-shaped text before it reaches disk** (closes
 #327). The shared NDJSON boundary scrubs private keys, provider tokens, AWS
 access keys, and JWT/JWE compact tokens with deterministic linear scanners.
 The synchronous crash-adjacent LSP launch diagnostic uses the same redactor.

## [3.8.73] - 2026-07-28

### Added

### Changed

- **`@types/js-yaml` removed; js-yaml 5 migration complete** (closes #391) — with
 js-yaml 5.2.2 in (#843) the package ships its own declarations, so the stub
 types dev-dependency is gone and the lockfile regenerated. This completes the
 v5 migration started with the namespace-import compatibility pass below.

- **`js-yaml` imports are compatible with v5** (refs #391, #843) — use the
 package's namespace exports so the central accessor and YAML rule tooling work
 with both the current v4 dependency and the upcoming v5 release. `@types/js-yaml`
 remains temporarily because js-yaml 4.3.0 needs its declarations; remove it
 with the v5 Dependabot upgrade once the bundled declarations are active.

- **`build:dist` now compiles with TypeScript 7.0.2** (refs #809) — the dist emit pin moves from `typescript@6` to the same major as the dev dependency (#600); emitted output was verified byte-identical across all 328 files before the bump.

### Fixed

- **ast-grep Gate-B now follows LSP liveness and launcher resolution** (refs #868) — the NAPI fallback no longer runs beside a healthy ast-grep LSP when the bundled native binary is resolvable but `ast-grep` is absent from PATH; it still engages when neither resolution path nor a live client is available.

- **NAPI fallback unsupported-rule skips no longer spam the session log** (refs #282) — the per-rule terminal lines (one for each of the ~30 non-jsts catalog rules) are replaced by a single aggregated `astgrep_napi_unsupported_rules_skipped` entry in `latency.log` carrying per-language counts and rule ids.

- **Missing tree-sitter defect-class metadata is now covered** (refs #863) — loop-termination and `finally` control-flow rules now declare their taxonomy classes, keeping the rule audit complete.

- **MCP smoke deadlines scale under CI load (refs #860)** — the shared MCP
 test harness keeps its 20s local default, accepts the
 `PI_LENS_TEST_TIMEOUT_SCALE` multiplier, and the analyze-graph smoke pays
 server startup through a throwaway health call before its assertions.

- **TypeScript 7 compatibility fixes (refs #809)** — narrowed ast-grep search test details and made the LSP `workspace/applyEdit` response overload-compatible, unblocking Dependabot PR #600.

- **ast-grep NAPI fallback now runs TSX-tagged rules and reports unsupported-language skips** (refs #282) — `.tsx` files use the `tsx` grammar for rule-language scoping, preserving the exact TypeScript/JavaScript twin behavior from #657; rules for languages the fallback cannot evaluate are logged once per rule instead of disappearing as zero matches.

- **CSS server root-policy coverage is hermetic on Windows** (refs #855) — the
 server-policy fixture now pins its nearest `package.json` inside the temporary
 workspace, so a marker in the host user's profile cannot change the result.

- **Multi-client LSP shutdown no longer serializes the grace-period tail** (#851) — retiring clients now tear down concurrently while generation handoff still waits for every client to settle; instance-registry child removals serialize their read-modify-write without serializing process kills.

- **LSP singleton resets no longer overlap live server generations** (closes #850) — `resetLSPService()` still returns synchronously and may allocate a replacement service immediately, but that replacement's first spawn now waits for every older generation's asynchronous teardown to settle. Repeated resets destroy an intermediate waiting generation before it can spawn, and post-root/post-client-cleanup guards prevent a retired service from starting a late process outside `state.inFlight`. The handoff is cleared after its first wait, preserving normal within-generation warm reuse and its hot path; concurrent-secondary sessions still skip destructive reset through the existing ownership guard. New deterministic coverage holds the old client's shutdown open and proves current `master`'s pre-fix second spawn/three-generation accumulation cannot recur.

## [3.8.72] - 2026-07-26

### Added

- **Subagent identity in the instance registry** (#822) — subagent sessions now persist their marker, agent type, parent PID, and run ID in `instances.json` for concurrency-profile analysis; primary and legacy entries remain unchanged.

- **Same-workspace warm LSP attach (opt-in soak)** (#822) — with `PI_LENS_WARM_ATTACH=1`, a new session discovers a PID-confirmed, heartbeat-fresh incumbent for the same workspace root and routes per-edit and project-sweep LSP diagnostic touches through its PID-scoped, versioned IPC endpoint instead of spawning a duplicate fleet. Attached dispatch diagnostics now also request bounded code actions from the incumbent, preserving quickfix enrichment without creating a local client; enrichment failures skip quickfixes without promoting because the authoritative diagnostics already succeeded. Sweep warm-up, pre-open, and workspace-pull paths stay dormant while attached; a mid-sweep attach failure permanently promotes the session and resumes the remaining files locally. Content hashes and deadlines guard freshness; any IPC, schema, freshness, timeout, or incumbent-liveness failure permanently promotes the session to today's local LSP path. Decisions and per-file routing sources are logged as `lsp_warm_attach`.

- **Resource-aware cross-process LSP budget policy** (closes #821) — budget pressure now shortens the current session's idle reset, lets pull-capable servers rely on pull diagnostics without a push-warm fallback, and can supplement the live-server ceiling with an opt-in aggregate RSS ceiling (`PI_LENS_LSP_BUDGET_RSS_MB`). Missing or stale resource samples fail open to count-only behavior; the existing kill switch disables every policy.

- **Review-graph JavaScript/TypeScript import resolution now follows tsconfig project references** (#819) — a bare package import declared through `"references": [{"path": "../lib"}]` resolves to the referenced package's source entry after `paths` aliases and before workspace-package fallback, including transitive references with cycle protection and directory or direct-config targets.

### Fixed

- **Markdownlint no longer passes the invalid `--disable MD013` CLI option** (closes #833) — unconfigured Markdown projects now use the package-owned default config, which disables MD013 and scopes MD024 to sibling headings so intentional repeated changelog category headings are allowed; project markdownlint-cli2 configs are preserved unchanged, including the CLI's JSONC/YAML/CJS/MJS filename variants.

- **`safeSpawnAsync` (and the deprecated sync `safeSpawn`) no longer build a cmd.exe command line from caller-tainted input on Windows** (closes #817, CodeQL alerts #17/#18/#19, CWE-78) — after #815/#816 replaced `shell:true` with an explicit `cmd.exe /d /s /c <hand-built string>` invocation, CodeQL re-flagged the same spawn: the escaping was genuinely unsound (`%VAR%` expands even inside double quotes and cannot be escaped on a `/c` line; cmd's quote-state toggling can defeat `""` doubling), not just a taint false positive. `clients/safe-spawn.ts` now resolves `command` itself via a cached PATH+PATHEXT walk (`resolveWindowsCommand`, a `where`-equivalent, keyed by `command`+`PATH`+`cwd`; reset hook `resetSafeSpawnWindowsCommandCache()` wired into `runtime-session.ts`'s `handleSessionStart` alongside the repo's other session-lived caches). `.exe`/`.com` resolutions — the overwhelming majority of spawns (sg, biome, ruff, git, node, `where.exe`...) — now spawn directly with `shell:false` and a real args array: no command line is ever built, so there's nothing for cmd.exe to reinterpret. `.cmd`/`.bat` shims (npm-installed tool wrappers, the common case) keep the cmd.exe wrapper since they can't run without it, but the interpreter is pinned to `%SystemRoot%\System32\cmd.exe` (never `ComSpec`/`COMSPEC`, addressing alert #18 — the same pin `killTree` already used for `taskkill`) and args are VALIDATED instead of escaped: a value containing `"`, `%`, `!`, or CR/LF is rejected with a clear "Refusing to spawn... via cmd.exe" `SpawnResult.error` rather than spawned with an unsound escape — validated against the *resolved* path (what's actually interpolated into the `/c` line), not just the caller's original command string, so a resolved path itself containing `%`/`!` can't slip through unvalidated either. An unresolvable command now returns a synthesized `ENOENT`-shaped error (message/`code`/`syscall`/`path`) instead of letting cmd.exe report "not recognized" from inside a shell, preserving existing `err.message.includes("ENOENT")`/`err.code === "ENOENT"` tool-not-installed detection at call sites like `sg-runner.ts`. The `chcp 65001` UTF-8 console-code-page mutation is preserved for the tools that needed it (sg/biome/ruff CP850/CP936/CP932 mis-decoding): the `.cmd`/`.bat` wrapper keeps its `chcp` prefix as-is, and a new one-shot `ensureUtf8ConsoleCodePageOnce()` runs the same static, hardcoded `chcp 65001` via a pinned-cmd.exe spawn exactly once per process before the first direct `.exe` spawn (the console code-page mutation persists for the console's lifetime, so once suffices — a strict improvement over today's per-spawn re-run, not a regression). A code-review pass on the first version of this fix found the deprecated synchronous `safeSpawn` (today's only caller: `test-runner-client.ts`'s sync pytest-on-PATH probe) still had the identical `buildWindowsCommand`/`shell:true` sink — its Windows branch (and the now-dead `escapeWindowsArg`/`buildWindowsCommand` helpers it alone used) is reimplemented against the exact same seams (`resolveWindowsCommand`/`findCmdUnsafeValue`/`buildWindowsShellCommand`/`synthesizeEnoentError`) with `spawnSync(..., { shell: false })` throughout; `test-runner-client.ts`'s call site is unchanged (its `detectRunner`/`findTestFile` call chain is synchronous with many sync callers/tests — migrating to async wasn't a trivial, in-scope change here). New coverage in `tests/clients/safe-spawn-windows-command.test.ts` (Windows-conditional, real spawns), mirrored for both the async and sync entry points: direct `.exe` PATH resolution + spawn, a `.cmd` fixture round-tripping an arg with spaces through the pinned wrapper, `%`/`"`-bearing args rejected against a `.cmd` shim with nothing spawned, the same dangerous args passing through literally to a direct `.exe` spawn, an unresolvable command producing the ENOENT-shaped error, and (async-only) resolver-cache reuse/reset. `buildWindowsShellCommand` remains exported (existing #214 regression tests unchanged). Verified end-to-end against the built compiled output for BOTH entry points: a `.cmd` shim (`npx.cmd --version`) and a bare `.exe` (`git --version`) spawn correctly, a `%`-bearing arg to a `.cmd` shim is rejected with nothing spawned, and an unresolvable command yields the ENOENT-shaped error. Related pre-existing cmd.exe-command-line-building patterns found outside `safe-spawn.ts` (`clients/lsp/client.ts`'s PS1→PowerShell fallback shell path, `clients/sg-runner.ts`'s non-bash Windows fallback) are out of scope for this issue (#817 is scoped to `safe-spawn.ts`) and are noted for a follow-up rather than folded in here.

- **Multi-server `clientScope: "all"` diagnostic touches now get the same silent-clean confirm #799 gave the single-server hot path** (closes #814) — `lens_diagnostics` mode=full's per-file sweep and `lsp_diagnostics`' `serverScope: "all"` both use `touchFile`'s `clientScope: "all"` path, which #799's clean-confirm gate never covered (it's scoped to `clientScope === "primary" && spawned.length === 1`), so a clean markdown file with marksman (push-only, `silentOnClean`) alongside other servers that all answered still burned marksman's own budget and reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut`, blocking clean-caching and `demonstratedReady`. New `classifyServerWaitTier(serverId, snapshot)` in `clients/lsp/cascade-tier.ts` extracts the per-server classification rule `classifyCascadeWaitTier` (#458) already used for the file's primary server, so both share one classification, not two. `touchFile` (`clients/lsp/index.ts`) now runs a capability-aware aggregate gate after a `clientScope: "all"` touch's diagnostics wait times out: a spawned server counts as "still outstanding" when nothing landed in its per-file diagnostics cache for this touch (`getAllDiagnostics()` — cleared by this touch's own didOpen/didChange, so a present entry can only be a fresh answer, reusing the same #240 doctrine signal `cascade-tier.ts`'s Tier-3 reconcile already trusts); the touch is only resolved as CONFIRMED clean (0 diagnostics from the silent server(s), `diagnosticsTimedOut` cleared, not `inconclusive`) when EVERY still-outstanding server classifies `tier3-silent` (push-only + `silentOnClean`) AND its notify write actually succeeded. One ordinary push-only straggler (not marked `silentOnClean`) or a failed notify write keeps the touch cautious, matching today's behavior exactly. Fails safe (today's timeout/inconclusive behavior) on any classification error. Logs a distinguishable `lsp_silent_clean_confirm` phase with `aggregate: true` and the resolved `serverIds`. The pre-existing single-server `clientScope === "primary"` gate is left unchanged (a documented special case of this more general rule — a future cleanup could fold the two together once both have soaked). New coverage in `tests/clients/lsp/silent-clean-confirm.test.ts`'s "touchFile capability-aware AGGREGATE wait (#814)" describe block: one publisher + one silent server resolves early as confirmed clean with the publisher's findings kept; a failed notify write on the silent server still times out; an ordinary (non-`silentOnClean`) push-only straggler still runs to cap and stays inconclusive; every spawned server silent resolves at the max of their budgets as confirmed clean; and a classification-throwing failure falls back to today's timeout behavior.

### Added

- **`getWorkspaceTopology` — shared, memoized workspace-topology marker index** (closes #806) — new `clients/workspace-topology.ts` consolidates the per-subsystem per-directory marker walks (`.pi-lens.json`/`pi-lens.json`, `tsconfig.json`, and the workspace-manifest markers `workspace-modules.ts` cares about — `package.json`, `pnpm-workspace.yaml`, `Cargo.toml`, `go.work`) into one shared seam: `getDirectoryMarkers(dir)` collects ALL markers for a directory in a single `readdir` pass, cached and invalidated per-directory by the directory's own mtime; `findNearestDirWithMarker` layers the shared upward-walk discipline on top (`walkUpDirs` + `isAtOrAboveHomeDir` ceiling, an explicit 64-deep cap that logs a `workspace-topology-walk-cap` latency phase instead of truncating silently). Three consumers migrated with zero resolution-semantics change: `project-lens-config.ts`'s `findPiLensConfigInDir` (the #804 nested mutation-control walk's per-dir discovery), `review-graph/tsconfig-paths.ts`'s governing-tsconfig discovery, and `review-graph/workspace-modules.ts`'s `detectWorkspaceType` manifest-presence probes (manifest CONTENT parsing is unchanged/unmoved). New `resetWorkspaceTopology()` is wired into `runtime-session.ts`'s `handleSessionStart` alongside `tsconfig-paths.ts`'s `clearTsconfigPathsCache()` — an added behavior improvement: a `tsconfig.json` edited mid-session is now picked up on the next session/reset instead of never (the matcher cache was previously session-lived with no reset hook at all, noted on #805). Root/language-root resolution is explicitly NOT migrated (#806 lists it as a later follow-up). New `tests/clients/workspace-topology.test.ts` (marker index correctness incl. both `.pi-lens.json` basenames, single-`readdir`-pass collection, mtime invalidation, the reset hook, the home-dir ceiling, and cap-trip logging).

- **Root/language-root resolution migrated onto the workspace-topology index — second wave** (closes #807) — `language-profile.ts`'s `resolveLanguageRootForFile` now discovers its per-language-kind root marker (e.g. `tsconfig.json`/`package.json` for `jsts`, `Cargo.toml` for `rust`) via a new `findNearestDirWithAnyBasename(startDir, basenames, homeDir?)` in `clients/workspace-topology.ts` — the generalized, home-guarded/depth-capped/walk-cached counterpart of `findNearestDirWithMarker` for a per-call marker LIST rather than one of `DirectoryMarkers`' typed fields; each `DirectoryMarkers` entry now also carries a raw `entryNames` set (from the same single `readdir`) for this kind of type-agnostic, non-typed-field presence check. `resolveLanguageRootForFile`'s OWN policy is unchanged: it still clamps any discovered root back to `workspaceRoot` via its existing stricter workspace-relative check — the topology service supplies discovery only. `startup-scan.ts`'s `findNearestProjectRoot` now reads per-directory marker presence through `getDirectoryMarkers(dir).entryNames` (one cached `readdir` per directory instead of `PROJECT_ROOT_MARKERS.length` separate `existsSync` stats), but deliberately KEEPS its own hand-written, non-home-guarded walk-UP loop — its callers (`computeStartupScanContext`/`resolveStartupScanContextAsync`) need the actual marker location even at/above `$HOME` to distinguish the `"home-dir"` verdict from `"no-project-root"` (pinned by `tests/clients/startup-scan-home-ceiling.test.ts`); a home-guarded walker would silently misreport that case. A repo-root micro-benchmark (N=1000 resolutions over a synthetic 12-package, 8-levels-deep monorepo fixture) measured the migration as a net win on the hot per-edit path: ~68–75% faster spread across many files, ~79–82% faster for a repeated small working set (the realistic edit-loop pattern) versus the pre-migration hand-rolled `existsSync` loop. Sweep for remaining hand-rolled upward marker probes found several NOT migrated in this pass, each for a documented reason (see PR #807 description): `tool-policy.ts`'s `has*Config` family (already a documented exception, different shape — content-sniffing booleans); `dispatch/runners/{oxlint,vale,shellcheck,shfmt}.ts`, `dispatch/runners/utils/runner-helpers.ts`, `review-graph/git-identity.ts`, `file-utils.ts`, `metrics-history.ts`, `lsp/{config,server}.ts`, and `review-graph/import-resolvers.ts` (all currently un-home-guarded tool-config/VCS-root probes; routing them through the topology service would newly add a home-guard, a behavior change out of scope for an exact-semantics migration); `pipeline.ts`'s/`dispatch/runners/rust-clippy.ts`'s `findNearestContaining` calls for `Cargo.toml`/`pubspec.yaml` (already on the shared `path-utils.ts` primitive, same reasoning); `knip-client.ts`/`dead-code-client.ts` (already on the shared, guarded `findNearestMarkerRoot` primitive); `jscpd-client.ts` (explicitly does not resolve a project root, per its own comment); `package-root.ts` (resolves pi-lens's OWN installed package root from a module URL, a different domain than user-workspace discovery). `clients/project-scale.ts` was left untouched per this PR's constraints (concurrent work) and, on inspection, contains no such probe. Existing tests (`tests/clients/language-profile.test.ts`, `tests/clients/startup-scan-*.test.ts`) pass unmodified; new coverage lives in `tests/clients/workspace-topology.test.ts` for the added `entryNames` field/`findNearestDirWithAnyBasename` surface.

- **Empirical monorepo test harness — Track B of the #775 root-resolution audit** (refs #775) — a reusable `makeMonorepo(opts)` fixture builder (`tests/monorepo/fixture.ts`) that builds a temp npm-workspaces repo (root `package.json#workspaces`, N packages with names/deps/files, optional nested `.gitignore`/`.pi-lens.json`, optional file-count padding) plus eight committed regression files (`tests/monorepo/*.test.ts`, 29 tests) covering the audit's test matrix: cross-package graph edges post-#777 (entry + subpath specifiers, non-workspace bare specifiers stay external, `module_report`'s `usedBy`/blast-radius and `computeImpactCascade`'s module-level downstream expansion actually see them); size-cliff behavior (startup-scan's verdict flips exactly at `maxSourceFiles`, sync+async; review-graph bails above its cap and — answering the audit's open question — `getCachedReviewGraph` returns `undefined` after a fresh size-skipped build with no prior warm graph, but keeps serving a now-STALE prior graph once one already existed, and `module_report` degrades identically to a cold cache either way); the post-#779 `maxProjectFiles` scale knob changing effective startup-scan/review-graph budgets end-to-end via `.pi-lens.json`; session-in-package-subdir root-resolution scoping; nested `.gitignore` + `.pi-lens.json` layering (root+package-local gitignore both honored; a package-local `.pi-lens.json`'s `maxProjectFiles` wins for a package-scoped cwd, but its `ignore` field is never consulted — only the root's is, since the ignore matcher always re-anchors to the git root first); diagnostics-scanner `scanTruncated` reaching `lens-engine.ts`'s `projectScan` seam unmodified; `computeImpactCascade`'s module-graph fallback requiring an explicit `cwd` (present on the primary per-edit cascade path, absent — though available in scope — on `runBlastRadiusInBackground`'s call site); and a pnpm-store-shaped symlink layout (junction-based, Windows-safe) extending #777's symlink-safety pinning to `source-filter.ts`'s walker, including `followSymlinks:true` being observably inert for a Windows junction. Two `KNOWN GAP (#775)`-marked tests pin current (unfixed) behavior rather than asserting an ideal outcome. Tests-only — no production code changed.

- **New `project_report` registered tool (+ MCP mirror `pilens_project_report`)** (closes #773) — the top of the discovery funnel (`project_report` → `module_report` → `read_symbol`), answering "orient me in this project" purely from data the review graph already computes: a trust header (graph freshness, file coverage vs. the persisted project snapshot's file count, and the exact/import/receiver-type/name-only edge-resolution-quality mix, reusing `module_report`'s existing `resolution` tags), hubs (top fan-in files with `blastRadius` and a one-line role from their most-imported exported symbols), entry points (near-zero fan-in / high fan-out files), a directory-level subsystem map (first-path-segment clustering that collapses to a deeper segment only when one segment dominates the file set, directory-to-directory import edge counts, Tarjan-SCC cycle detection worst-first, and minority-direction layering violations for any directory pair with edges both ways), risk hotspots (fan-in × max per-symbol cyclomatic complexity), and suspected dead weight (zero-importer non-entry-point files, always shipped with a low-confidence disclaimer for dynamic-import/runtime-registration/test-only-reachability false positives). Every ranked list is capped by a single `limit` knob and re-rankable via an optional `focus` hint (module_report's `recommendedReads` ranking pattern). Read-only over the cached graph, mirroring `module_report`'s #256 no-build contract and `symbol_search`'s #348 cold-cache contract: a cold graph kicks off a single deduped background build (`buildOrUpdateGraph`, home-ceiling-guarded) and returns `available: false` with an actionable retry hint, never blocking the call. `view: "compact"` renders a line-oriented text summary instead of JSON, matching `module_report`'s convention. New engine module `clients/project-report.ts` (never imports from `tools/`, per the repo's layering rule) plus a thin `tools/project-report.ts` wrapper; registered as an always-active tool in `index.ts` alongside `module_report`/`symbol_search` (`tests/index-wiring.test.ts` updated), and mirrored in `mcp/server.ts` as `pilens_project_report` (schema/registration covered by `tests/mcp/server.smoke.test.ts`). Middle-man detection is deliberately NOT surfaced (a documented v1 deviation): the review graph doesn't persist that signal on symbol nodes today — `module_report` computes it per-file, on demand, from raw file content — and re-deriving it project-wide would mean re-scanning every class-bearing file on this never-blocks read path. New `tests/clients/project-report.test.ts` (cold path non-blocking + background build, all six warm-path section shapes, the `limit` knob scaling every cap, a synthetic-fixture directory cycle via Tarjan SCC, a synthetic-fixture minority-direction layering violation, the dead-weight disclaimer always present, and `focus` re-ranking hub order) and `tests/tools/project-report.test.ts` (cold/warm/compact-view wrapper behavior).

### Changed

- **Explicit `lsp_diagnostics` file batches now expose a consolidated contract** (refs #837) — runtime path lists are normalized and rejected above the 100-file cap (never silently truncated), preserve input order, enforce bounded per-file deadlines and abort-aware completion, and return honest per-file outcomes (`clean`, `findings`, `unsupported`, `unavailable`, `failed`, `inconclusive`) with aggregate counts. Primary-language diagnostics remain separated from auxiliary findings, and incomplete batches are rendered as unconfirmed.

- **Incumbent LSP wait policy now has one process-neutral module boundary** (#822) — per-server strategies, tier classification, and capability-snapshot types moved under `clients/lsp/wait-policy/` with compatibility re-exports at the old paths; this is an internal refactor with no behavior change.

- **Review-graph file budget now tapers instead of scaling flatly or cliff-dropping above 1,000 files** (refs #775 R1) — `project-scale.ts`'s `getReviewGraphMaxFilesDerived` keeps today's exact 0.5× `maxProjectFiles` ratio (1,000 files at the 2,000 default base) unchanged for every project at or below a 4,000-file `maxProjectFiles` base, then tapers growth asymptotically toward a 5,000-file hard ceiling for larger bases instead of continuing linearly forever. The ceiling is bound FIRST by the review graph's own disk-persist circuit-breaker (`GRAPH_PERSIST_MAX_ELEMENTS_DEFAULT` = 200,000 graph elements in `review-graph/builder.ts` — above it the graph is never persisted, logged + skipped, so a repo over that line pays a full cold build every session instead of once): at ~30 elements/file for a symbol-dense repo, 5,000 files ≈ 150,000 elements, a ~25% margin below the persist cap (a 6,000-file ceiling considered during review left only ~10% margin — too little for a denser-than-average repo). SECOND and independently, it's consistent with the graph's measured per-file build cost (CHANGELOG 3.8.43: "2-4 s" full cold build at the then-1,000-file cap, ~2-4 ms/file; 5,000 files ≈ 10-20 s worst-case, one-time and disk-cached thereafter). New `.pi-lens.json#reviewGraph.maxFiles` knob (R2) lets a monorepo opt into a bigger graph explicitly, bypassing the taper (tolerant numeric parse, clamped to `[100, 20_000]`, warn-once on invalid — mirrors `maxProjectFiles`'s parsing); the pre-existing `PI_LENS_REVIEW_GRAPH_MAX_FILES` env override still wins outright over both. Truncation (files found over the cap) now always logs a `review_graph_size_skip` latency phase directly from `review-graph/builder.ts`'s skip branch (R3), independent of which caller triggered the build, instead of relying on `dispatch/integration.ts`'s cascade-only `graph_build` log — closing a silent-cap gap for the other four `buildOrUpdateGraph` call sites (`lens-map.ts`, `project-report.ts`, `mcp/analyze.ts`, `runtime-session.ts`, `dispatch/runners/tree-sitter.ts`). Documented in `docs/globalconfig.md`'s project-config section. New tests: `taperedReviewGraphMaxFiles` shape (below/at/above the linear boundary, half-gap-at-taper-scale property, ceiling asymptote, monotonicity), the config knob's parse/clamp/warn-once behavior, and the new latency phase.

- **Review-graph JS/TS imports now honor `compilerOptions.baseUrl` and `paths` aliases, including relative `extends` chains** (refs #775) — aliased imports resolve to real workspace files before workspace-package/external fallback, with longest-prefix matching and existing extension/index probing.

- **Nested mutation controls now layer per edited file** (refs #792) — monorepo package `.pi-lens.json` files use closest-wins resolution independently for `format.enabled`, `autofix.enabled`, and `actionableWarnings.autoFix.enabled`, with explicit CLI disables still highest precedence and mtime-keyed per-directory config caching.

- **`symbol_search`/`pilens_symbol_search` gained `paths`/`lang` filters, per-hit `suggestedNext` discovery hints, and warm-review-graph annotations** (closes #771) — three additive ergonomics improvements over the registered tool (`tools/symbol-search.ts` → `clients/lens-engine.ts`'s `symbolSearch`, mirrored in `mcp/server.ts`), all reusing existing machinery: (1) optional `paths` (glob array, same shape/semantics as `ast_grep_search`'s `paths` — a bare directory/file entry scopes its subtree) and `lang` (same identifiers as `ast_grep_search`'s `lang`, mapped to extensions) scope the word index BEFORE ranking via a new `RankOptions.fileFilter` on `searchWordIndex` (`clients/word-index.ts`), so a surviving file's BM25/priors/centrality score is identical to an unfiltered run — omitting both params reproduces prior output byte-for-byte; (2) every hit now carries a `suggestedNext: { tool: "module_report", path }` hint (mirrors `ast-grep-search.ts`'s `suggestedDump` pattern), making the symbol_search → module_report → read_symbol discovery funnel machine-actionable instead of prose-only; (3) hits gain an optional `annotations: { fanIn, complexity }` — `fanIn` reuses the SAME reverse-dependency centrality value already computed for the ranking boost, `complexity` is the file's highest recorded per-symbol cyclomatic complexity from the review graph — populated only when `getCachedReviewGraph` (`clients/review-graph/builder.ts`, dynamic-imported to keep it off the hot path when unused) finds an already-warm graph; this accessor is READ-ONLY and never builds/blocks, so a cold graph simply omits `annotations` with no added latency. `symbolSearch` is now `async` (the dynamic import) — an additive signature change; both call sites (`tools/symbol-search.ts`, `mcp/server.ts`) now `await` it. New/extended coverage: `tests/clients/word-index.test.ts` (`fileFilter` score-preservation + omitted-filter equivalence), `tests/tools/symbol-search.test.ts` (paths/lang filtering, no-filter parity, per-hit `suggestedNext`, graph-cold no-annotation-no-build, graph-warm annotation presence), and `tests/index-wiring.test.ts` (asserts `paths`/`lang` are actually registered on the tool's parameter schema).

- **New `lazyEnvNumber(envName, fallback)` factory in `clients/env-utils.ts`** (closes #763) collapses the repeated "lazy + memoized, `toPositiveFinite(process.env[name])`-or-fallback" getter triple — each instance was a module-level memo variable plus a `getX()`/`_resetXForTests()` pair, ~20 lines apiece — into one line per call site while keeping every exported name/signature unchanged so callers and tests don't churn: `clients/startup-scan.ts`'s `getStartupScanVerdictTtlMs`/`_resetStartupScanVerdictTtlForTests` and `getStartupScanMaxEntries`/`_resetStartupScanMaxEntriesForTests` now re-export `lazyEnvNumber(...).get`/`._resetForTests`. `runtime-config.ts`'s `getRunnerTimeoutFloorMs` (also reads a config-file value and takes `Math.max` of the two sources), `slow-fs.ts`'s threshold resolver (memoized per-cwd probe result, not per env-value, and recomputed on every probe rather than cached standalone), and `subagent-mode.ts`'s classification cache (boolean/string shape, not a number) were left as-is — none match the number-only lazy-memoized-getter shape the factory targets. No behavior change. New `describe("lazyEnvNumber", ...)` block in `tests/clients/env-utils.test.ts` covers default/override/non-finite-fallback/memoization-until-reset/no-env-read-at-construction/independent-instance-memo, mirroring `tests/clients/startup-scan-entry-budget.test.ts`'s existing getter tests (left unmodified and still passing).

### Fixed

- **Windows subprocess arguments no longer pass through Node's `shell:true` concatenation** (CodeQL alert #17 / CWE-78) — `safeSpawnAsync` now invokes `cmd.exe` explicitly with `shell:false` and `windowsVerbatimArguments` so its manually quoted `/c` payload is not escaped a second time by Node. User-controlled paths and arguments remain arguments instead of becoming additional commands; regression coverage includes a shell-metacharacter argument and Windows command paths with spaces.

- **CI drift alerts no longer interpolate third-party output into shell code** — compat-smoke passes layer outcomes and versions through environment variables, and install-smoke disables persistent checkout credentials before running package-install tests. Intentional pinned ad-hoc package installs are documented with targeted zizmor suppressions.

- **Windows temp-dir teardown no longer fails tests with EPERM/ENOTEMPTY** (closes #810) — root cause of the deterministic `tests/clients/runtime-session.test.ts` failures (2 of 12, reproduced on clean master on this machine): in "full" mode with a `package.json` present, `scheduleStartupScans` (`clients/runtime-session.ts`) fires `todo` and `word-index` as fire-and-forget background tasks (`setImmediate`) that do REAL, unmocked fs reads against the test's temp project dir — unlike knip/jscpd/ast-grep-exports/etc, which the test stubs out entirely. The test helper's `runSessionStart` returned as soon as `handleSessionStart`'s own promise resolved, so the caller's `finally { env.cleanup() }` could run while those two tasks were still mid-read, racing the recursive `rm` against an open file handle inside the temp dir (deterministic here, not a load flake). Fixed by tracking the real in-flight-scan set (via `markStartupScanInFlight`/`clearStartupScanInFlight`, the same primitive production code uses) and having the returned `cleanup` wait for `todo`/`word-index` to settle before deleting the directory — `call-graph`/`codebase-model` are deliberately excluded since they're staggered 5+ seconds out by design and a stale/missing analysisRoot by the time they fire is a harmless no-op. Also added ONE shared hardened temp-dir cleanup helper, `removeTempDirSync` (`tests/clients/test-utils.ts`), implementing the #793 pattern (`maxRetries: 5, retryDelay: 200`) wrapped so a final failure warns via `console.warn` instead of failing the suite — a leftover temp dir is harmless, a red run from teardown is not — and swept every ad-hoc `fs.rmSync(dir, { recursive: true, force: true })` directory teardown across `tests/` onto it (~104 files), including two bespoke retry-and-swallow sites (`tests/clients/lens-map.test.ts`, `tests/clients/ast-grep-rule-precedence-followups.test.ts`) that had hand-rolled the same pattern locally. Single-file (non-recursive) `fs.rmSync` calls that delete one fixture file mid-test were left as-is (bespoke, not directory teardown).

- **`lens_diagnostic_mark` no longer trusts a stale caller-supplied `line` verbatim — it verifies/reanchors against current diagnostics before writing anything** (closes #802) — the tool handler (`tools/lens-diagnostic-mark.ts`) previously hashed/wrote at exactly the `line` the agent passed, with no check that the file's CURRENT content still put that finding there. A stale line silently rotted `false-positive`'s strict content-hash anchor (it never matched the real finding again) and, worse, made `suppress` write the inline `pi-lens-ignore` comment above the WRONG statement — the self-inflicted case being a top-down batch-suppress in one file, where each inserted comment shifts every later line down by one and invalidates the batch's own remaining coordinates. New `verifyLine` cross-checks the caller's tool/rule/normalizedMessage against `clients/widget-state.ts`'s live per-file diagnostics (`getFileDiagnostics`, reusing `diagnostic-dispositions.ts`'s own now-exported `normalizeMessage` — not a second normalizer) and reanchors to the diagnostic's CURRENT recorded line when it differs (multiple same-rule/message matches on different lines pick the one closest to the caller's line, the conservative choice for suppress). When widget-state has nothing for this file/finding (e.g. a fresh session), a fuzzy fallback accepts the caller's line if it's in-bounds and non-blank, else searches ± 5 lines for the nearest non-blank line; if nothing plausible is found, the tool returns `isError` instead of guessing. `verifyLine` is internally defensive (never throws), and any unexpected failure degrades safely per-disposition: `false-positive`/`defer`/`flagged` fall back to trusting the caller's line (today's pre-fix worst case — a rotted anchor, not a corrupted write), while `suppress` refuses to write at an unverified position rather than risk a misplaced comment. The tool's description now documents line verification and recommends bottom-up ordering for multi-suppress in one file. New coverage in `tests/tools/lens-diagnostic-mark.test.ts`: stale-line-with-widget-match reanchoring (both for `false-positive`'s stored anchor and `suppress`'s comment placement), unchanged behavior when no widget state exists, out-of-bounds/all-blank-nearby lines erroring without writing, and a top-down two-finding batch-suppress in one file landing both comments correctly via reanchoring.

- **Silent-clean workspace-indexing LSP warm-ups no longer pay the generic 20-second cold floor** (refs #832) — `ensureWarmForSweep` now uses the configured short strategy wait when live capability classification identifies a workspace-indexing push-only server that is silent on clean files, while TypeScript-like cold-indexing servers retain the longer budget; ambiguous capability data fails closed.

- **Cold `lens_diagnostics` full sweeps no longer burn 2x20s on marksman for a clean markdown project, and no longer re-pay a failed warm-up on every retry sweep** (closes #799) — root cause was two compounding gaps: (1) marksman is push-only and publishes NOTHING on a clean file, so `ensureWarmForSweep`'s `warmupOverride` floor (`clients/lsp/index.ts`) guaranteed the full 20s warm-up budget on both the initial attempt AND its #744 retry, with zero diagnostics either time; (2) that inconclusive result never marked the server `demonstratedReady`, so a follow-up sweep in the same session re-paid the same 2x20s all over again. Fixed both: marksman now carries `silentOnClean: true` (`clients/lsp/server-strategies.ts`), and `touchFile`'s diagnostics wait gained a generic push-only clean-confirm fallback — reusing the existing `classifyCascadeWaitTier` (#458) capability-snapshot classification rather than typescript's TS-specific sync-command race — so a single-server primary-scope touch that times out with a successful notify write and a `silentOnClean` push-only server is now CONFIRMED clean (and marked `demonstratedReady`) instead of inconclusive. `ensureWarmForSweep`'s `warmupOverride` floor is now also scoped to the FIRST attempt only (`LSPTouchFileOptions.warmupAttempt`) — a retry no longer re-floors to the full cold-start budget, respecting the server's own (much shorter) strategy wait instead. Second: a new session-scoped negative cache (`LSPState.demonstratedCold`, keyed identically to `demonstratedReady`) records a server whose warm-up genuinely stayed cold after both attempts; a later sweep this session sees the cache and skips straight to the existing #744 group-skip accounting instead of re-running the warm-up round trip, logging a distinguishable `lsp_sweep_warmup_cached_cold` phase (and `skippedFromCache` metadata on the group-skip log) so this reads differently from a fresh failure. The cache clears automatically the moment a server demonstrates readiness through any path, and is discarded wholesale on `resetLSPService` (a fresh `LSPService` instance starts with an empty set) so a new session always retries fresh. New coverage in `tests/clients/lsp/sweep-warmup.test.ts` and a new `tests/clients/lsp/silent-clean-confirm.test.ts`.

- mutation-control config follow-ups (#792): global `autofix.enabled` now honored (docs promised it, code ignored it), the MCP LSP-navigation tool now resolves project config from the request cwd, global+project config share one `{enabled}` parser with warn-once on invalid values, and mutation-skip debug lines name the deciding config source

- deferred auto-format queue: records are now owned by the queuing session/turn — a read-only or concurrent-secondary subagent turn no longer flushes and formats another turn's pending files (#791)

- compat-smoke behavioral harness: scratch-dir cleanup crash (ENOTEMPTY race) no longer discards assertion results and falsely reports Layer B contract drift (#785)

- **A review graph no longer keeps serving a stale pre-cap snapshot once a repo grows past the review-graph file cap, and `project_report` now says so honestly** (closes #782) — the #775 harness pinned a `KNOWN GAP`: once a repo crossed `maxProjectFiles`-derived cap (`clients/project-scale.ts`)/`PI_LENS_REVIEW_GRAPH_MAX_FILES`, `getCachedReviewGraph` (`clients/review-graph/builder.ts`) kept indefinitely returning any graph cached/persisted from BEFORE the crossing — `usedBy`/`blastRadius`/fan-in silently reflected an old, smaller repo — and every consumer (`module_report`, `symbol_search` annotations, `project_report`) had no way to tell "graph disabled because the repo is too big" apart from "cold cache, build in progress"; `project_report`'s cold-path hint told the caller to "retry shortly", which is actively wrong when the build will never succeed at the current cap. The `too_many_files` skip branch now records a 15-minute-TTL'd size-skip verdict per cwd (`getReviewGraphSizeSkipVerdict`, TTL configurable via `PI_LENS_REVIEW_GRAPH_SIZE_SKIP_TTL_MS`, matching `project-report.ts`'s existing `STALE_THRESHOLD_MS`) — chosen over persisting the verdict to disk because every real caller either rebuilds within a session or restarts the process, unlike startup-scan's `too-many-source-files` verdict which is cached across process starts specifically to skip a slow walk. `getCachedReviewGraph` now consults that verdict before serving either cache tier, so a repo confirmed over cap gets a cold read instead of a silently stale one; a repo shrink or a raised cap clears the verdict immediately on the next successful build (or lets it lapse on TTL). `project_report`'s cold-path branches on the verdict to return `"review graph disabled: project has N files, cap is M — raise maxProjectFiles in .pi-lens.json or set PI_LENS_REVIEW_GRAPH_MAX_FILES"` instead of the generic retry hint, and skips kicking off another background build that would just re-hit the same cap. `tests/monorepo/size-cliff.test.ts`'s `KNOWN GAP (#775)` pinning test is flipped to assert the fixed behavior, plus new tests for verdict TTL expiry and the immediate clear-on-success path.

- **Package-local `.pi-lens.json` `ignore` patterns are now honored — layered the same way nested `.gitignore`s are** (closes #783) — the walk-wide ignore matcher (`clients/file-utils.ts`'s `getProjectIgnoreMatcher`) re-anchors to the git root before loading `.pi-lens.json`, so a package-local config's `ignore` field was silently never consulted, even for files inside that package (found empirically by the #775 Track B harness and pinned as a KNOWN GAP in `tests/monorepo/nested-ignore-config.test.ts`). `buildProjectIgnoreMatcher`'s per-ancestor-directory pattern collection (`patternsForDir`, the same layering that already merges nested `.gitignore`s) now also loads a `.pi-lens.json`/`pi-lens.json` found directly in each ancestor directory between the git root and the target — via new no-upward-walk helpers `findPiLensConfigInDir`/`loadPiLensConfigInDir` in `clients/project-lens-config.ts` that reuse the existing path+mtime config cache, so no JSON is re-read per file — and merges its `ignore` patterns anchored relative to that config's own directory (same anchoring semantics as a `.gitignore` there), tagged with the `pilens` layer so #703's "tracked files are never rescued from pi-lens-native intent" rule applies unchanged. The per-directory nested cache key now includes the config file's mtime alongside the `.gitignore` mtime, so editing a nested config takes effect without a restart. Root-config `ignore` behavior, precedence (`global` → `gitignore` → `pilens`, closer directory wins), and `maxProjectFiles`' upward-walk discovery are all unchanged; a nested config's patterns never apply outside its own directory subtree. The pinning test is flipped to assert package-local patterns ARE honored, with new coverage for root-config patterns still applying project-wide and for a nested config's identically-named pattern NOT leaking into sibling packages or the repo root; `docs/globalconfig.md`'s `ignore` section documents the layering.

- **Two Track B (#775) follow-ups from the empirical monorepo test harness** (closes #781, closes #784): (1) `runBlastRadiusInBackground` (`clients/dispatch/runners/tree-sitter.ts`) now passes `cwd` through to `computeImpactCascade` — `cwd` was already in scope (used the line above for `buildOrUpdateGraph`) but dropped at this call site, so the background blast-radius path's workspace module-graph fallback could never engage in a monorepo, unlike the primary per-edit cascade path (`clients/dispatch/integration.ts`) which already passed it correctly; the `tests/monorepo/impact-cascade-cwd.test.ts` pinning test that previously marked this a `KNOWN GAP` now asserts the fallback engages. (2) `scanTruncated` (the #760 scanner-budget flag) reached `lens-engine.ts`'s `projectScan` seam intact but nothing rendered it, so a capped scan read as a complete clean sweep to the agent/user — new `scanTruncationNotice(snapshot)` helper in `clients/lens-engine.ts` renders a one-line notice ("scan truncated at N file(s) — results are partial; raise maxProjectFiles in .pi-lens.json to scan fully.", matching the #777 warm-skip notify's style) that `mcp/server.ts`'s `pilens_project_scan` tool and `tools/lens-diagnostics.ts`'s `mode=full` renderer now both append; `tests/monorepo/diagnostics-truncation.test.ts` gained a test asserting the notice renders once truncated and stays silent otherwise.

- **Three monorepo quick-wins from the #775 root-resolution audit** (refs #775): (1) a size-skipped warm pipeline (`too-many-source-files`/`too-many-entries` startup-scan verdict, `clients/runtime-session.ts`) now fires a single visible `notify` — mirroring the existing slow-FS notice's shape/severity — instead of only a debug-log line, naming the `PI_LENS_STARTUP_SCAN_MAX_ENTRIES` override when that's the tripped budget; fires once per session even though heavy scans, the TODO scan, and the dominant-language LSP pre-warm all skip off the same verdict. (2) the audit's suspected symlink-cycle hang in the startup-scan counting walk (`clients/startup-scan.ts`) is verified a NON-issue and pinned by tests instead of "fixed": `fs.Dirent` reports a symlink-to-directory as `isSymbolicLink()`/not-`isDirectory()` (junctions included), so the visitor's directory branch never traverses symlinked dirs at all and a cycle cannot hang the walk — the misleading "always follows symlinks" doc claim is corrected and new tests pin both boundedness and the symlinked-subtree-not-counted behavior so a future stat-through-symlinks refactor can't silently reintroduce the risk. (3) `resolveJsTs` (`clients/review-graph/import-resolvers.ts`) and the warm graph builder's `localImportToFile` (`clients/review-graph/builder.ts`) now resolve a bare specifier (`@scope/pkg` or `@scope/pkg/subpath`) that names a sibling workspace package — detected via the existing `workspace-modules.ts` npm/pnpm/cargo/go.work scanner, memoized per cwd by `buildModuleGraph` so the workspace scan costs one filesystem pass per graph build, not one per import edge — to that package's entry file (`package.json` `main`/`module`/`exports`-main, falling back to `index.ts`/`.js`/`src/index.ts`) or, for a subpath import, a file within the package resolved with the same source-twin-preferring extension probe the relative resolver uses; a specifier matching no known workspace package still returns `[]` (external), unchanged. New coverage: `tests/clients/runtime-session-warm-skip-notify.test.ts` (notify fires once on an over-budget seeded verdict, names the entries override, stays silent on a normal small project), `tests/clients/startup-scan-symlink-cycle.test.ts` (self-loop and mutual-symlink fixtures complete instead of hanging, sync + async), and workspace-package fixtures in `tests/clients/review-graph/import-resolvers.test.ts` and `tests/clients/review-graph.service.test.ts` (cross-package entry/subpath edges, non-workspace bare specifiers stay external).

- **`collectSourceFiles`/`collectSourceFilesAsync` (`clients/source-filter.ts`) now bound the walk by directory entries VISITED, not just source files FOUND** (closes #760 — the #758 escape class at the remaining non-startup-verdict call sites) — the walks' only hard cap was the `maxFiles` RESULTS cap (#250), so a mixed tree with few source files but a huge pile of non-source files (the #758 reporter's shape: ~300 scripts among ~84k game-mod data files) never tripped it and still got a full-tree walk, dominated by one `ignoreMatcher.isIgnored()` call per entry — hitting the review-graph rebuild's per-edit hot path (`clients/review-graph/builder.ts`), the `lens_diagnostics` project scan (`clients/project-diagnostics/scanner.ts`), the SYNC caller-blocking todo scan (`clients/todo-scanner.ts`), and word-index build paths not gated by the #758 startup verdict (`clients/word-index.ts`). Fix mirrors #759's startup-scan shape and `jscpd-client.ts`'s `MAX_ENTRIES` prior art: a new `maxScanEntries` option (named consistently with startup-scan) charges one tick per entry TOUCHED — including ignored/skipped ones, since the per-entry ignore probe is the dominant cost — with a generous `DEFAULT_MAX_SCAN_ENTRIES` (200k: bounds the pathological misrooted/data-dominated case to seconds instead of an unbounded walk, while staying an order of magnitude above any healthy project's entry count so real repos never see truncation); when it trips the walk stops and returns the best-effort list collected so far. The existing `maxFiles` cap is unchanged and independent, exactly as in #758. Exposed non-invasively via new `collectSourceFilesWithBudget`/`collectSourceFilesWithBudgetAsync` cores returning `{ files, entryBudgetExceeded }` that the existing collectors wrap — the `string[]` contract of every current caller is untouched, and the default budget covers ALL callers automatically. Truncation awareness threaded per call site: the review-graph build logs a `review_graph_source_walk_entry_budget` latency phase (a truncated graph degrades like `maxFiles` trimming, so log-only); the project-diagnostics snapshot carries a new `scanTruncated` flag (surfaced like #747/#749's `unsafeRoot` but deliberately NOT a refusal — a truncated analysis is still useful, the flag only stops callers reading a partial result as a complete clean sweep) plus a `maxScanEntries` scan option; todo-scanner and word-index accept the default-bounded truncated list (comment-documented, no behavior change needed). New `tests/source-filter-entry-budget.test.ts` covers budget trip on a deterministic mixed tree, generous-budget completeness (no false positives), invalid-budget coercion to the finite default, sync/async flag agreement (and set-identical complete walks), `maxFiles` independence, and plain-wrapper equivalence; `tests/clients/project-diagnostics/scanner.test.ts` covers `scanTruncated` set-without-refusing and omitted-when-clean.

- **The startup source-file count walk now stops after a bounded number of directory entries instead of traversing the entire project tree** (closes #758) — `computeStartupScanContext`/`resolveStartupScanContextAsync` (`clients/startup-scan.ts`) count source files under the project root to decide `canWarmCaches`, and the walk early-exited ONLY once it had seen more than `MAX_STARTUP_SOURCE_FILES` (2000) `.ts/.js/.py/.go/.rs/.rb` files. A repo with FEW source files but a huge pile of non-source files never trips that exit — the reporter's case was a Hearts of Iron IV mod with ~300 scripts among ~84k data/`.txt` entries — so the walk enumerated the WHOLE tree, dominated by one `ignoreMatcher.isIgnored()` call per entry, blocking `session_start` for seconds on the cold-start path. The walk now carries a second, independent bound: a `MAX_STARTUP_SCAN_ENTRIES` (50000, env-tunable via `PI_LENS_STARTUP_SCAN_MAX_ENTRIES`) total-entry ceiling — the same finite-walk-cap pattern already guarding `jscpd-client.ts`'s `hasSourceFilesRecursive`. When a walk hits that ceiling before crossing the source-file limit, the tree is large and dominated by non-source files, so it returns a new `too-many-entries` verdict (`canWarmCaches: false`) rather than trusting the partial count and warming caches on (then re-walking) an enormous tree. `too-many-entries` is TTL'd by `isStartupScanVerdictFresh` exactly like `too-many-source-files` (#699) so a shrunk repo auto-recovers on the next session. The public `countSourceFilesWithinLimit`/`countSourceFilesWithinLimitAsync` wrappers keep their pre-#758 contract (only the source-file limit bounds them); the entry ceiling applies solely to the startup-scan verdict path via the shared `walkSourceCount`/`walkSourceCountAsync` cores. New `tests/clients/startup-scan-entry-budget.test.ts` drives the `too-many-entries` verdict deterministically with a tiny mixed-repo fixture and a small `maxScanEntries`, covers the env-tunable getter, and pins the TTL behavior on both the sync and async paths. `runtime-session.ts`'s `useScanRootForSignals` treats the new verdict exactly like its `too-many-source-files` sibling — both found a real project root, so session signals stay anchored there rather than falling back to cwd — and the sync/async walk loops share a single `visitCountEntry` per-entry step so the two bounds live in one place.

- **`writeState` in `clients/diagnostic-dispositions.ts` now writes the disposition store via tmp+rename instead of a plain `fs.writeFileSync`** (#690's rescope — a prerequisite for the cross-process review CLI, which will read/write this store from a second process) — a concurrent reader (`readState`) could previously observe a half-written file mid-`writeFileSync`, fail `JSON.parse`, and have its catch treat that as empty state, silently losing every disposition on that read. Now matches the repo's established `${target}.tmp-${pid}` atomic-write shape (`clients/instance-registry.ts`, `clients/recent-touches.ts`, `clients/review-graph/builder.ts`): `renameSync` replaces the destination atomically on both POSIX and Windows (libuv's `MOVEFILE_REPLACE_EXISTING`), so a reader now always sees either the fully-old or fully-new JSON, never a torn write. Unlike those best-effort writers, a rename failure here still throws (after best-effort tmp-file cleanup) rather than being swallowed — this store's writes are functionally load-bearing (a lost mark is a correctness bug, not a dropped observability sample), matching the pre-existing `writeFileSync`'s own no-catch behavior. No retry loop was added for transient Windows `EPERM`/antivirus contention on rename: none of this repo's other atomic writers retry either, and `renameSync`'s replace semantics already succeed in the common case. `readState`'s corrupt-JSON catch is now genuinely rare (a torn read can no longer trigger it) and its comment was updated to explain why caching `{}` against that stat is still safe rather than a permanent trap. New tests in `tests/clients/diagnostic-dispositions.test.ts` assert no `.tmp-*` file is left behind after a mark, that state round-trips through a cache reset (simulating a fresh process reading purely from disk), and that a 200-entry write is always fully parseable off disk.

- **`lens_diagnostic_mark` dispositions now apply to the cached `lens_diagnostics` delta/all modes immediately, without waiting for the file to be re-dispatched** (closes #755) — the #690 disposition filter ran in exactly two places: the per-edit dispatcher (`clients/dispatch/dispatcher.ts`, filtering BEFORE results are cached) and `mode=full`'s merge (`applyInlineSuppressionsToSummaries`, `tools/lens-diagnostics.ts`). But `mode=delta` (the default) and `mode=all` re-served the `actionable-warnings`/`code-quality-warnings` reports, the project-diagnostics delta report, and the widget summaries VERBATIM from dispatch time — before any mark existed — and `lens_diagnostic_mark`'s suppress writes its `pi-lens-ignore` comment via raw `fs.writeFile`, not an agent edit, so no `tool_result` hook fires and no re-dispatch happens: a suppressed/deferred finding reappeared on the next `lens_diagnostics` call until the file was next edited. `formatDeltaMode` and `formatAllMode` now re-apply the disposition filter at read time. A new `applyWeakDispositions` (`clients/diagnostic-dispositions.ts`) drops only the WEAK-anchored dispositions (`suppress`, `defer` — `file|tool|rule|message`, no line-content hash), which by construction needs ZERO file I/O, so the "instant" cache-only modes stay instant: no unconditional reads are added. `false-positive` is deliberately NOT filtered in these modes — it is STRICT-anchored (needs the flagged line's own content to re-derive its hash) and still filters on the next per-edit dispatch and in `mode=full`, both of which already have file content in hand; the store's weak `suppress` entry mirrors every inline comment the mark tool writes, so store filtering already covers marks made through the tool (a hand-written inline-only comment on an un-redispatched file remains the documented corner case, converging at next dispatch / in `mode=full`). Project-delta findings are anchored via `projectDiagnosticToWidget` so the `(tool, rule)` a mark binds to matches `mode=full`'s own project-runner filter. No mark-time cache purge was added: read-time filtering is the single robust choke point that converges every `lens_diagnostics` consumer, whereas purging the two report-cache shapes plus the in-memory widget state at mark time would duplicate that logic across fragile shapes and risk drift. New end-to-end coverage (`tests/tools/lens-diagnostics.test.ts`) marks suppress/defer via the real tool and asserts the finding is gone from delta and all with no re-dispatch (and that `mode=full` is unaffected); unit coverage of `applyWeakDispositions` (`tests/clients/diagnostic-dispositions.test.ts`) proves it drops suppress/defer with no content passed, keeps `flagged`, and leaves `false-positive` for the content-based filter.

- **Remaining cwd-rooted scan roots now carry the same `isAtOrAboveHomeDir` ceiling, and the source-file walk is capped structurally** (closes #751) — follow-up to #749, closing out the lower-exposure members of the #250/#253/#462/#747 escape class so no walker or scanner spawn can enumerate `$HOME` from a misrooted cwd. (1) `scanProjectDiagnostics` (`clients/project-diagnostics/scanner.ts`) — the cheap-tier project scan, reached from `lens_diagnostics mode=full` and MCP `pilens_project_scan` (cwd straight from tool args) — refuses to walk a cwd at/above home, returning an honest `unsafeRoot` snapshot (0 files, not "clean") that both callers render as a skip rather than a clean verdict. (2) `clients/source-filter.ts` `resolveCollectionConfig` no longer defaults an omitted `maxFiles` to `Infinity`: the new `DEFAULT_MAX_SOURCE_FILES` (20000) finite cap means no caller (`todo-scanner`, `scan-utils` pass-throughs, warm-path language ranking) can trigger an unbounded collection just by omitting the cap. (3) `ast_grep_search` (`tools/ast-grep-search.ts`) refuses only the *defaulted*-cwd case (`paths` omitted) when that cwd is at/above home — explicit user `paths` are never second-guessed. (4) the LSP workspace sweep (`collectWorkspaceDiagnosticFiles`, `clients/lsp/index.ts`) adds the home ceiling on top of its existing 5000-file cap, with `tools/lens-diagnostics.ts` rendering the refused sweep as a skip. (5) `JscpdClient.scan` (`clients/jscpd-client.ts`) gains an internal `isAtOrAboveHomeDir` guard (belt-and-braces — it resolves no marker root, so previously every caller had to guard for it). (6) `rules-scanner.ts` `findMarkdownFiles` gains a depth-8/500-file cap (it only ever walks `.claude/rules`/`.agents/rules` subtrees, so a depth/file bound is sufficient without the full home ceiling).

- **A sweep warm-up that times out now retries once and, if still cold, skips that server's files for the rest of the sweep instead of letting a wedged server drag the whole run** (closes #744) — live dogfooding of a 119-file `lens_diagnostics mode=full` sweep found `ensureWarmForSweep` (`clients/lsp/index.ts`) doing a single one-shot warm-up `touchFile` per representative file bounded by `warmupTimeoutMs()` (20s floor); when that warm-up timed out (observed: marksman, a `workspaceIndexing` server, burned the full 20s and stayed cold) the server was left cold with NO re-warm, NO backoff, and NO skip, so every subsequent per-file touch in the sweep re-paid a full per-file budget against it and timed out again — the sweep launched ~59s after an `lsp_service_reset`, straight into freshly-relaunched servers, exactly the state where warm-up failure is most likely. **What/why/how:** (1) `ensureWarmForSweep` now measures per-non-auxiliary-server warm-up success via the SAME `demonstratedReady` signal `touchFile` already marks on a confirmed (non-inconclusive) diagnostics round trip — a server whose key is still absent afterward never warmed. On a failed first attempt it retries exactly ONCE after a short `warmupRetryBackoffMs` breather (`PI_LENS_LSP_WARMUP_RETRY_BACKOFF_MS`, default 500ms, abort-aware — the failure state is usually a server mid-relaunch/index that just needs a moment, not a second full budget), and returns any still-cold server ids in a new `failedServerIds`. (2) `runWorkspaceDiagnostics`' per-group worker skips a group whose primary server is in `failedServerIds` (the sweep groups by primary server, so that primary is precisely the one whose per-file touches would drag), recording each skipped file as UNCONFIRMED (`timedOut: true` + a new `skippedWarmupFailure: true` result field) — never as confirmed-clean `[]`, so the existing confirmed/unconfirmed reporting (#630/#646, `tools/lens-diagnostics.ts`) and the per-primary-server breakdown surface these files honestly as "NOT the same as 0 diagnostics" rather than "ran clean", with a dedicated `lsp_sweep_warmup_failed`/`lsp_sweep_group_skipped_warmup` latency-log trail in `~/.pi-lens`. **Design notes:** the skip is deliberately SWEEP-SCOPED (the caller discards `failedServerIds` when the call ends), NOT the global `broken` cooldown map — a server that is merely still indexing is not broken and must not be cooldown-banned across the whole session; the `broken` map's 15s-base/exponential cooldown is for genuine spawn/initialize failures, a different failure class. Warm-up stays `clientScope:"primary"` (not the sweep's `"all"`) on purpose: `"all"` would additionally spawn the sweep-EXCLUDED auxiliaries (`WORKSPACE_SWEEP_EXCLUDED_SERVER_IDS`), and because `touchFile`'s `inconclusive` flag is touch-wide, one slow advisory auxiliary would then suppress the `demonstratedReady` marking of a perfectly healthy primary and falsely condemn it — recording per-primary-server outcomes covers the servers the sweep actually gates on without that regression. The issue's optional third bullet (gating/delaying a sweep that starts shortly after an `lsp_service_reset`) is left as a follow-up: it needs a reset-timestamp seam the sweep doesn't have today and didn't fall out of this change. New tests in `tests/clients/lsp/sweep-warmup.test.ts` cover warm-up timeout → one retry; retry-still-failing → server reported in `failedServerIds` and its files skipped as `skippedWarmupFailure`/unconfirmed (not clean); a server that warms on the retry participates normally; and the already-warm no-op path is unchanged.

- **A single backpressured LSP server no longer times out the didOpen/didChange notify write for an entire `clientScope:"all"` file touch, zeroing every co-touched healthy server's diagnostics** (closes #743 — observed live during a `lens_diagnostics mode=full` sweep on a 119-file project: writes split 20 ok / 16 timed out, the sweep appeared stuck at 20%, and touched files reported `notifyWriteTimedOut:true collectedDiagnostics:0` even for healthy servers). `LSPService.touchFile` (`clients/lsp/index.ts`) fanned the notify write across ALL spawned servers under one `Promise.all` bounded by a single `notifyWriteBudgetMs()` (2000ms) deadline, so one server that had stopped draining its stdin (backpressure — e.g. tsserver mid-recheck under load) timed out the write for the WHOLE file, flipping the touch to `notifyWriteTimedOut → inconclusive` for every server including perfectly healthy ones — contributing no diagnostics and marking nobody `demonstratedReady`. Fix, mirroring how the diagnostics wait already went per-server (#242/#573 via `perServerTimeout`): (1) each server's notify write now gets its OWN `notifyWriteBudgetMs()` deadline instead of one shared `Promise.all` deadline; (2) `notifyWriteTimedOut` is now per-server — a new `notifyWriteTimedOutServerIds` list records exactly which servers' writes did not land, the file-level `notifyWriteTimedOut` flag means "at least one server timed out" (kept for the conservative touch-wide `inconclusive` merge, since `collected` still merges across all servers), and the `demonstratedReady` gate now skips ONLY the servers whose own write timed out rather than gating the whole loop on the file-level flag — so a healthy server whose sibling's write stalled still becomes `demonstratedReady`; (3) a server whose notify write times out `NOTIFY_BACKPRESSURE_BROKEN_AFTER` (3) times in a row is demoted through the EXISTING `broken`-cooldown map (`recordNotifyWriteBackpressure` sets a `BROKEN_BASE_COOLDOWN_MS` cooldown and evicts the wedged client — an alive client is reused before the broken check in `ensureClientForServer`, so the cooldown only bites once the stale client is gone) so subsequent sweeps stop re-paying its notify budget on every file; a single successful write resets the consecutive-timeout streak. A write that REJECTS (not backpressure) still marks that server inconclusive but does not count toward the demotion streak. New `tests/clients/lsp/service-notify-per-server.test.ts` covers: a stalled sibling write not marking the healthy server timed out while the healthy server still reaches `demonstratedReady`; repeated timeouts tripping the broken cooldown and evicting the client; and a successful write resetting the streak so demotion needs a fresh consecutive run.

- **`lens_diagnostics mode=full` no longer launches heavyweight analyzers from `$HOME` or above** (closes #747) — `fetchFreshProjectDiagnostics` (`clients/project-diagnostics/fresh-fetch.ts`) resolved the session cwd as `analysisRoot` with no ceiling, so a `mode=full refreshRunners=cheap` call issued while pi's cwd was the home directory started a whole-home `jscpd .`/knip/madge/gitleaks/govulncheck/trivy/dead-code walk (the explicit `paths` scope only filters *reported* results, never what the analyzers walk). Observed failure: a jscpd scan from a WSL `$HOME` reached 44 GB RSS, exhausted swap, and OOM-killed the entire WSL instance. The fresh fetch now applies the same `isAtOrAboveHomeDir` ceiling as `startup-scan.ts`/`resolveSnapshotRoot`/the review-graph builder (the #250/#253 escape class) and refuses before spawning anything, reporting a dedicated "heavyweight analyzers skipped" note plus a machine-readable `analyzersUnsafeRoot` details flag instead of seven misleading per-analyzer "not applicable" reasons.

- **`symbol_search`'s cold word-index build no longer walks an unbounded tree from `$HOME`** (#747 hardening) — `triggerBackgroundWordIndexBuild` (`clients/word-index.ts`) was the one word-index build path with no `canWarmCaches` gate in front of it (cold in-process queries and MCP `pilens_symbol_search`, where cwd can be a raw tool argument), and `collectWordIndexDocs` ran `collectSourceFilesAsync` with no `maxFiles`, so the `WORD_INDEX_MAX_FILES` (6000) cap only trimmed results AFTER the entire tree had been enumerated — the same unbounded-walk-plus-in-memory-accumulation shape as the #747 jscpd OOM. The trigger now applies the `isAtOrAboveHomeDir` ceiling, and the cap is passed into the walk itself so every build path is bounded.
- **`hasOxlintConfig` now detects `.oxlintrc.jsonc`, `oxlint.config.ts`, and `oxlint.config.mts`** (closes #745) — previously only `.oxlintrc.json`/`oxlint.json` were recognized, so projects using oxlint's TS-based config format (`oxlint.config.ts` + `defineConfig`) or the `.oxlintrc.jsonc` variant were treated as having no oxlint config at all, causing `getPreferredJstsLintRunners` to fall back to `["oxlint", "biome-check-json"]` instead of `["oxlint"]` and run `biome-check-json` as an uninvited fallback linter. New `OXLINT_CONFIGS` list (mirroring the existing `ESLINT_CONFIGS` pattern) covers all four oxlint-documented auto-discovery filenames alongside the legacy `oxlint.json` name.

### Changed

- **Introduced a shared `writeFileAtomic`/`writeFileAtomicAsync` helper (`clients/atomic-write.ts`) and migrated four of the five hand-rolled `${target}.tmp-${pid}` + `renameSync`/`rename` writers onto it** (closes #762) — `instance-registry.ts`, `session-state-store.ts`, `recent-touches.ts`, `diagnostic-dispositions.ts`'s `writeState`, and `review-graph/builder.ts`'s sync exit-hook persist all independently hand-rolled the same tmp+rename shape, inviting drift (e.g. forgetting the tmp-cleanup-on-failure step); the helper takes a `bestEffort` option (default `true`, matching the majority: swallow write/rename failures after best-effort tmp cleanup) versus `bestEffort: false` (rethrow after the same cleanup — `diagnostic-dispositions.ts`'s #757 load-bearing policy, since a silently lost disposition mark is a correctness bug, not a dropped observability sample, preserved exactly). `session-state-store.ts`'s tmp file is now named `${file}.tmp-${pid}` instead of its former one-off `${file}.${pid}.tmp` — no observable behavior change, since nothing reads the intermediate tmp filename. `review-graph/builder.ts`'s OTHER writer (`writePending`, the debounced async persist) is deliberately left unmigrated: its callback-style `fs.mkdir`/`fs.writeFile`/`fs.rename` logs a distinct `console.error` per failing stage (mkdir vs write vs rename) that the shared helper's single swallow/rethrow outcome can't express without losing that diagnostic granularity or turning a non-blocking fire-and-forget write into an awaited promise chain. New `tests/clients/atomic-write.test.ts` covers both variants directly: successful atomic replace, no tmp file left behind on success or on a swallowed failure, `bestEffort: true` swallowing a rename failure, and `bestEffort: false` rethrowing it — rename failures are induced via a real filesystem obstruction (renaming onto an existing non-empty directory) rather than mocking `fs`, since this module's `import * as fs` style doesn't reliably re-bind under `vi.spyOn`. Existing suites (`tests/clients/instance-registry.test.ts`, `recent-touches.test.ts`, `session-state-store.test.ts`, and `diagnostic-dispositions.test.ts`'s no-tmp-left-behind/torn-read assertions) pass unmodified.

- **Consolidated the repo's recursive tree walkers onto a shared bounded-walk engine in `clients/source-walker.ts`** (closes #761) — every walker hardening episode was costing O(number-of-walkers) because each walker re-implemented the same traversal skeleton: the home-ceiling class took #747 then #751/#754 (seven sites), the entry-budget class took #758/#759 then #760/#764 (five consumers), each hand-rolling the `readdir` + for-entry loop, the sync-recursion / async-stack shape, and the `setImmediate` yield cadence. `source-walker.ts` already held the shared `readDirEntriesSafe` + `shouldRecurseIntoDir`; it now also owns the LOOP via three drivers — `walkTreeStackSync`/`walkTreeStackAsync` (depth-first stack, with an optional per-directory `shouldStop` loop guard and, on the async twin, a `yieldEvery` `setImmediate` cadence + a `beforeWalk` hook for `ensureTrackedIndex` priming) and `walkTreeRecursiveSync` (immediate-descent recursion, preserving result-array order) — each parameterized by a pluggable per-entry `WalkVisitor` returning `"recurse"`/`"skip"`/`"stop"`, generalized from #759's `visitCountEntry` and #764's `chargeEntryBudget`/`classifyEntry`. Phase 1 migrates the two count walks (`walkSourceCount`/`walkSourceCountAsync` in `clients/startup-scan.ts`) onto the stack drivers via a shared `makeSourceCountVisitor`, `hasSourceFilesRecursive` in `clients/jscpd-client.ts` (its `MAX_ENTRIES` 6000 budget expressed as the per-directory `shouldStop` guard, its skip-all-symlinks/multi-language-regex policy kept caller-side), and the `collectSourceFilesWithBudget`/`...Async` cores in `clients/source-filter.ts` (sync onto the recursive driver, async onto the stack driver) — every public contract byte-identical: the `string[]` wrappers, `SourceCollectionResult`, `maxFiles`/`maxScanEntries` semantics, the slow-FS sync clamp, the artifact-probe cache, and #703 tracked-index priming. Classification/policy stays pluggable per walker BY DESIGN — the bounds arithmetic genuinely differs by an off-by-one between the count family (post-classify `visited >= max`, the maxEntries-th entry counted) and the collect family (pre-classify `visited > max`, the tripping entry refused), so the budget/results caps stay in each walker's visitor rather than being forced onto one shared comparison that would shift a cliff by one entry; `lsp/index.ts`, `rules-scanner.ts`, review-graph, `language-profile.ts`, and word-index are deliberately NOT migrated (deferred to opportunistic follow-ups per the issue). The existing equivalence suites are the safety net and pass unmodified — `tests/clients/source-walker-equivalence.test.ts`, `tests/source-filter.test.ts`, `tests/source-filter-entry-budget.test.ts`, and the startup-scan entry-budget/home-ceiling/verdict-cache and jscpd suites — plus a new `tests/clients/source-walker-engine.test.ts` pinning the three drivers' traversal order, `stop`/`shouldStop` halting, and async `setImmediate` yield cadence directly.

- **Unified the five independently-hardcoded project-size budgets behind one configurable `maxProjectFiles` scale knob** (closes #776) — the project-diagnostics scanner (500 files), review graph (1,000 files), startup scan (2,000 source files), jscpd (6,000 dir entries), and word index (6,000 files) each degraded silently and separately as a repo grew, with no single lever to say "this repo is big; believe in it." New `clients/project-scale.ts` centralizes a `maxProjectFiles` base (default `2,000`) and a `PROJECT_SCALE_RATIOS` table (0.25×/0.5×/1×/3×/3× respectively) that reproduces today's five defaults exactly at the default base — a behavior-neutral refactor, no default value changed. Resolution order: each subsystem's PRE-EXISTING per-subsystem env override (`PI_LENS_REVIEW_GRAPH_MAX_FILES`, `PI_LENS_STARTUP_SCAN_MAX_ENTRIES`, etc.) still wins outright; below that, `getProjectScaleBase(cwd)` resolves `maxProjectFiles` from the project's `.pi-lens.json` (new field, extended schema in `clients/project-lens-config.ts`) → the new `PI_LENS_MAX_PROJECT_FILES` env var (via the house `lazyEnvNumber` pattern, #763) → the 2,000 default. Every call site with a `cwd`/`rootDir` in hand threads it through so a per-project override actually scales that subsystem (`project-diagnostics/scanner.ts`, `review-graph/builder.ts`, `startup-scan.ts`, `jscpd-client.ts`, `word-index.ts`); the old exported constants (`DEFAULT_MAX_FILES`, `MAX_STARTUP_SOURCE_FILES`, `WORD_INDEX_MAX_FILES`, `RUNTIME_CONFIG.reviewGraph.maxFiles`) are kept, deprecated in comments, for callers/tests that still reference the literals. Two entry-COUNT budgets (`startup-scan.ts`'s `MAX_STARTUP_SCAN_ENTRIES`, `source-filter.ts`'s `DEFAULT_MAX_SCAN_ENTRIES`) were deliberately left standalone rather than derived — they bound directory entries VISITED (a raw tree-walk safety valve), a fundamentally different quantity from source files KEPT whose healthy ratio varies wildly by project shape, and coupling them risked changing behavior on exactly the pathological-tree shapes those ceilings protect. New `tests/clients/project-scale.test.ts` covers the ratio table reproducing all five defaults at the default base, a `.pi-lens.json` override scaling all five derived budgets proportionally, per-subsystem env still beating the derived value, and `PI_LENS_MAX_PROJECT_FILES` beating the default but losing to both `.pi-lens.json` and per-subsystem env. Documented in `docs/globalconfig.md`.

## [3.8.71] - 2026-07-20

### Added

- **`ty` (astral-sh/ty) support as an alternative Python language server** (closes #717) — `PythonServer.spawn()` (`clients/lsp/server.ts`) now tries a bare `ty server` on `PATH` after the existing local `pyright-langserver`/`basedpyright-langserver` candidates fail, and before pyright's managed/auto-install fallback. Strictly opt-in and PATH-only: ty is deliberately NOT added to the installer registry (`clients/installer/index.ts`)/`ensureTool`, and the `resolveAndLaunch` call hardcodes `allowInstall: false`, so ty is only ever picked up when a user has already installed it themselves (`uv tool install ty` / `pipx install ty` / `pip install ty`) — it never displaces an already-installed pyright/basedpyright and never becomes the silently auto-installed Python LSP default (that stays pyright). ty's CLI invocation shape differs from pyright/basedpyright's shared `--stdio` flag — it's launched via the `server` subcommand instead — so it gets its own `resolveAndLaunch` call rather than joining the existing `localCandidates` array. No `initializationOptions` payload is sent to ty: it has no stable `pythonPath`-equivalent init option yet (astral-sh/ty#2032) and instead auto-discovers `.venv`/`VIRTUAL_ENV` relative to `cwd`. New tests in `tests/clients/lsp/server-policy.test.ts` cover the ty fallback tier (launched when pyright/basedpyright aren't found locally, `ty server` args, never triggers install, and pyright still wins when found locally).
- **`/lens-map` command: a human-facing, self-contained interactive HTML project map rendered from the review graph** (closes #679) — a new `clients/lens-map.ts` aggregates the (symbol-level) review graph up to FILE-level nodes and deduped, weighted file→file edges, computes a deterministic force-directed layout (pairwise repulsion + edge-spring attraction + center gravity, seeded from a hash of each file's own path so re-running on an unchanged project reproduces the same map), and renders it to a zero-dependency HTML page — embedded JSON payload + vanilla JS/SVG, no CDN script, no npm deps. Interactions: pan (drag background), zoom (wheel), hover (highlights direct neighbors, dims the rest), click (fixed detail panel: path, symbol count, in/out degree, transitive dependents). Node radius scales with transitive dependents; fill is a neutral brand-blue intensity scale by dependents only (light `#2563eb` family / dark `#60a5fa` family) — complexity-aware coloring is explicitly deferred to #306, noted inline. "external" kind nodes (third-party/stdlib import targets) are excluded from the map; their count is surfaced in the header instead. Test files (per `detectFileRole` from `clients/file-role.ts` — `.test.`/`.spec.`/`__tests__/`/etc. path patterns) are ALSO excluded, along with their edges, and computed BEFORE degree/dependents/truncation ranking so they can never inflate a file's rank or count toward the node cap; their count is surfaced in the header too (`testFileCount`). Compiled twins are merged, not double-rendered: in compile-in-place projects the review graph's import resolution often lands on the compiled `X.js` sibling of an `X.ts` source, so when both exist the compiled node's symbols and ALL its edges remap onto the source node (`.js`→`.ts`/`.tsx`, `.mjs`→`.mts`, `.cjs`→`.cts`; edges that become self-referential after the merge collapse like other same-file edges; compiled files with no source twin — vendored deps, pure-JS projects — are untouched), with the merge count surfaced as `compiledTwinCount` in the header and command output. Untracked-gitignored files are excluded too — the set is computed by asking git itself (`ls-files --others --ignored --exclude-standard`, honoring git's semantic that a TRACKED file is never ignored, so committed vendored files matching an ignore pattern stay on the map; outside a git repo the filter degrades to a no-op), with twin-merge taking precedence over exclusion (an ignored compiled file whose source twin survives is merged, edges preserved, rather than dropped) and the dropped count surfaced as `ignoredFileCount`. The viewer ships four pure client-side interactions (no payload/schema changes, no CSP exceptions): search-to-highlight (case-insensitive substring over file paths, ring + dim-the-rest), a min-edge-weight slider (sub-threshold edges hide; nodes left with zero visible edges fade in place — positions never shift), label culling (labels render only when zoomed past 1.5× or for the top-25 files by transitive dependents), and path tracing (select a node, shift+click — or arm the "trace path" toggle and click — a second node to BFS their connection over UNDIRECTED edges, highlighting the chain; Esc/background-click clears). All four compose through one `recomputeVisibility` pass with documented precedence (trace > search > weight for opacity; culling only affects labels). A graph exceeding `PI_LENS_MAP_MAX_NODES` (default 500) files keeps only the highest-degree ones, with a visible truncation note in the page header. Security: every graph-derived string (file paths) reaches the page ONLY via a `<script type="application/json">` block (with `<`/`>`/`&` escaped to their unicode forms, so no substring can ever break out of the tag) read back and rendered through DOM `textContent`/`createElementNS` client-side — never string-concatenated into HTML markup (the #504-spike XSS-from-repo-content mitigation, since repo file names are attacker-influenceable in cloned repos). Written to `<project data dir>/reports/lens-map.html` (`getProjectDataDir`, never a hardcoded `.pi-lens`), notified via `ctx.ui.notify` with the absolute path — no browser auto-open. Unlike `module_report`'s read-only #256 contract, `/lens-map` DOES build the review graph on a cold cache (`buildOrUpdateGraph`) since the user explicitly asked for a map and a few seconds' build cost is acceptable for this non-hot-path command. Human-only: no agent tool, no MCP mirror. New `tests/clients/lens-map.test.ts` covers aggregation (symbol→file collapse + edge dedup/weighting, external-node exclusion+count, transitive-dependents BFS, degree-ranked truncation), layout (determinism, all-finite coordinates, empty-graph), rendering (script-tag-breakout escaping of a malicious file name, CSP meta + both theme accents present), and an end-to-end `generateLensMap` write against a temp `PILENS_DATA_DIR`.
- `lens_diagnostic_mark` tool (situational, activate via `pi_lens_activate_tools`): record a disposition for a diagnostics finding — `false-positive` / `suppress` (writes an inline `pi-lens-ignore` comment) / `defer` (session-only) / `flagged` (to fix, tagged in `lens_diagnostics mode=full`). Anchored by content, with the binding strength chosen per disposition — `false-positive` uses a site-specific anchor (rule + normalized message + the flagged line's own content hash, so it re-fires if that line is rewritten); `suppress`/`defer`/`flagged` use an intent-level anchor (rule + normalized message, no line hash) that survives edits elsewhere in the file. Honored by both the per-edit dispatch path and `lens_diagnostics mode=full`. Every mark is also appended to a machine-global NDJSON telemetry log (`~/.pi-lens/dispositions.log`, size-capped via `PI_LENS_DISPOSITION_LOG_MAX_BYTES`, records `previousDisposition` on re-marks and `defer` marks whose in-memory state has no other durable trace — the #181 false-positive rule-tuning signal) and published on pi's shared event bus as `pilens:diagnostic:disposition` (`v: 1`, fire-and-forget, honors the `PI_LENS_BUS_PUBLISH` kill switch) (#690).
- **`pilens:autofix:start` bus event, symmetric to `pilens:format:start`, so the same in-process listener can also see the `agent_end` actionable-warnings autofix batch before it mutates files** (#684) — follow-up to #673/#674: `clients/runtime-agent-end.ts`'s actionable-warnings autofix batch (gated by `getFlag("lens-actionable-warning-autofix")`) has the identical "runs later, unpredictably" timing shape as deferred formatting (reads a cached report, applies fixes as a batch well after the edit that produced the warnings), but only ever emitted `pilens:files:touched` (`reason: "autofix"`) AFTER the fact. `pilens:autofix:start` (`clients/format-events-publish.ts`, `{v, source, cwd, paths, fileCount, eligibleCount}`) now fires once per batch, right before `applyConservativeActionableWarningFixes` runs, but ONLY when the cached report is confirmed fresh (`checkActionableWarningsReportFresh`) AND has at least one autofix-eligible warning — mirroring `pilens:format:start`'s "only when there's genuine work" gate exactly, so a no-op batch never signals a start. `pipeline.ts`'s synchronous per-edit `runAutofix` (biome/ruff/eslint, awaited inside the `tool_result` hook) isn't deferred and doesn't have this race, so it doesn't get a `start` event.
- **Two new `pi.events` bus signals so an in-process extension can observe pi-lens's deferred-format queue before it mutates files, not just after** (#673) — `pilens:files:touched` (#482) only reports what changed AFTER deferred formatting at `agent_end` completes; there was no way for a same-process listener (e.g. a review/snapshot controller deriving an immutable candidate tree mid-turn) to know a file was queued for a formatter that hasn't run yet. `pilens:format:queued` (`clients/format-events-publish.ts`) fires once when a file NEWLY enters `RuntimeCoordinator`'s deferred-format pending queue (`{v, source, filePath, cwd, tool}`) — a second edit to an already-queued file before `agent_end` does not re-fire, avoiding spam. `pilens:format:start` fires once at the same moment as the existing `agent_end_deferred_format_start` latency-log phase, with the full pending file list (`{v, source, cwd, paths, fileCount}`), only when there's at least one file queued. Both follow the exact `pilens:files:touched` conventions: versioned frozen-additive payloads, fire-and-forget (bus failures swallowed, `dbg` invoked at most once per event type), logged via `logBusEvent`. This is visibility only — no synchronous flush/barrier API; that remains a separate future feature. `RuntimeCoordinator.deferFormat()` now returns a boolean (new-entry vs re-touch) so its caller (`clients/runtime-tool-result.ts`) knows when to publish.
- **`lens_diagnostics mode=full` and `lsp_diagnostics`'s batch/directory sweep now cache confirmed per-file LSP results across calls** (#671) — previously `runWorkspaceDiagnostics` re-touched EVERY file through the language server(s) on every call, with zero memory of a prior sweep: calling `mode=full` twice back-to-back with no edits in between paid the full LSP round-trip cost both times. A new persisted cache (`clients/lsp/workspace-diagnostics-cache.ts`, `<project-data-dir>/cache/lsp-workspace-diagnostics.json`) now lets a repeat sweep skip any file that hasn't changed since its last CONFIRMED touch, reusing the same per-file result instead. Invalidation is deliberately layered: (1) the file's own mtime must be unchanged; (2) when the project has a persisted reverse-dependency index (`clients/reverse-deps.ts`, already built by the cascade path), every file it imports must also be unchanged since the entry was recorded — closing the cross-file blind spot where file A's diagnostics can change purely because a dependency B's exported shape changed, with zero edits to A itself (no index available this session falls back to mtime-only, matching the existing cheap-tier `project-diagnostics.json` cache's accepted blind spot). An inconclusive/timed-out/errored touch is NEVER persisted as cacheable — reusing an unconfirmed result as "cached clean" would reintroduce the exact false-clean bug class #571/#630 already fixed for the live path. `runWorkspaceDiagnostics` and `tools/lsp-diagnostics.ts`'s batch/directory sweep (`mapWithConcurrency`) share ONE cache store, keyed by a `scopeKey` fingerprint of what each touch actually covers (client scope + excluded servers) so a file swept by either tool benefits the other's next sweep, without ever cross-serving an entry recorded under a different scope (the workspace sweep excludes opengrep via a separate CLI extractor; `lsp_diagnostics` doesn't). Survives a partial/aborted sweep gracefully — already-completed files' fresh results are still cached, and a corrupt/missing cache file fails open to "touch everything".

### Changed

- **`release.yml` publishes to npm via OIDC trusted publishing instead of a long-lived `NPM_TOKEN`, and `package.json` declares an `allowScripts` allowlist for npm v12's install-time script review** (closes #737) — follow-up to <https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/>. The `publish-npm` job now requests a GitHub Actions OIDC token (`permissions.id-token: write`) and runs a bare `npm publish`, no `NODE_AUTH_TOKEN`/`secrets.NPM_TOKEN` involved — the trusted publisher (repo `apmantza/pi-lens`, workflow `release.yml`) was already registered on npmjs.com. The workflow's `has_npm_token` gate is gone entirely (OIDC needs no secret to check for); release/dry-run/smoke-load steps now gate purely on the existing version-tag-missing check. The publish job pins `npm@11.18.0` before installing (`npm install -g npm@11.18.0 --ignore-scripts`) since OIDC trusted publishing needs npm >= 11.5.1 and the Node 22 runner's bundled npm isn't guaranteed new enough — pinned to the latest 11.x rather than 12.x, whose stricter `engines.node` (`^22.22.2 || ^24.15.0 || >=26.0.0`) isn't guaranteed by the workflow's `node-version: 22` pin. Separately, `package.json` gains an `allowScripts` field (the field npm v12's `npm approve-scripts` manages, verified by reading npm's own `docs/content/commands/npm-approve-scripts.md` and `lib/utils/allow-scripts-writer.js` locally rather than guessed) approving the runtime/dev dependencies confirmed (via `npm view <pkg> scripts` and a real `npm approve-scripts --all` run) to have install-time lifecycle scripts: `@ast-grep/cli` (`postinstall`, a direct runtime dependency) plus `@google/genai`/`protobufjs` (transitive through the `@earendil-works/pi-coding-agent` devDependency, needed so the repo's own CI installs — which build `dist/` via `prepare` — don't get flagged). `@ast-grep/napi`, `web-tree-sitter`, `pidusage`, and `@earendil-works/pi-tui` were also audited and have no install-time scripts, so nothing was added for them. README's install section gains a short note on `npm approve-scripts` and git-dependency approval for npm v12 consumers.
- **Consolidated the hand-duplicated "walk up directories looking for one of a list of config filenames" loop across 4 sites** (closes #680) — `clients/opengrep-config.ts`'s `findLocalOpengrepConfig`, `clients/typos-config.ts`'s `findLocalTyposConfig`, `clients/zizmor-config.ts`'s `findLocalZizmorConfig`, and `clients/sgconfig.ts`'s `findLocalSgconfig` each independently hand-rolled the identical `walkUpDirs` + nested-filename-check loop. New `findLocalToolConfig(startDir, names)` in `clients/path-utils.ts`, alongside the file's other shared walk-up primitives (`walkUpDirs`, `findNearestContaining`, `findNearestMarkerRoot`), is the single source of truth; all 4 call sites are now one-line wrappers over it with their own filename list, keeping their existing public name/signature (so no caller elsewhere needed to change). Pure internal refactor, zero behavior change — deliberately does NOT touch `clients/tool-policy.ts`'s `has*Config` family (different shape: `boolean` return, tool-specific content-sniffing) or `clients/project-lens-config.ts`'s `discoverPiLensProjectConfig` (adds mtime caching), both out of scope per the issue. New unit tests for `findLocalToolConfig` in `tests/clients/path-utils.test.ts`; all 4 migrated sites' existing test suites pass unmodified.
- **Extracted `index.ts`'s `tool_call` handler (read-guard preflight: LSP auto-touch, read-range tracking/expansion, oldText autopatch, edit-guard block/relocation, pre-write duplicate-export detection) into `clients/runtime-tool-call.ts`** (closes #678) — pure refactor, no behavior change. `tool_call` was the one lifecycle hook whose logic still lived inline in the entry point while its three siblings (`session_start` → `clients/runtime-session.ts`, `tool_result` → `clients/runtime-tool-result.ts`, `turn_end`/`agent_end` → `clients/runtime-turn.ts`/`clients/runtime-agent-end.ts`) had already been extracted; at ~800 lines it was also the largest, and the most safety-critical (it mutates the agent's own edits — relocation/autopatch — before they reach disk), living in the file that's hardest to unit-test directly. New `handleToolCall(deps)` follows the exact same dependency-injection shape as its siblings; `index.ts`'s `pi.on("tool_call", ...)` registration is now a thin call site. Verified line-by-line against the original (diff of the extracted body vs. the pre-extraction source shows only the necessary renames/import-path fixes, no logic changes) plus the full existing test suite (including the dedicated `tests/index-integration.test.ts` `tool_call` coverage) passing unmodified. New `tests/clients/runtime-tool-call.test.ts` unit-tests `handleToolCall` directly against mocked deps (lensEnabled short-circuit, read-guard read recording + LSP auto-touch, zero-read edit block, write-then-edit read-guard interaction, duplicate-export write block).
- **Consolidated the hand-duplicated "load a JSON cache file, try/catch, version/shape-validate" boilerplate across 7 sites** (closes #676) — `clients/project-diagnostics/cache.ts` (two load functions), `clients/lsp/workspace-diagnostics-cache.ts`, `clients/cache-manager.ts` (three load sites: `readCache`, `isCacheFresh`, `readTurnState`), `clients/cache/rule-cache.ts`, `clients/project-snapshot.ts`, and `clients/session-state-store.ts` each independently wrote the same `JSON.parse(fs.readFileSync(path, "utf-8"))` → structural/version guard → `undefined`/`null`-on-any-throw shape. New `clients/json-cache-read.ts` extracts only the read/parse/try-catch mechanics into `readJsonCache`/`readJsonCacheAsync` (the latter for `session-state-store.ts`'s `fs.promises` call site), taking a `validate(parsed): T | undefined` callback plus an optional `onError` hook for the one site (`CacheManager.readCache`) that logs a read failure rather than silently swallowing it. Every site's own validation logic — including `rule-cache.ts`'s extra `ruleHash` field check, alongside the version check every other site has — is preserved exactly as its `validate` callback; this is pure boilerplate deduplication, not a behavior change. (`clients/generated-artifacts.ts`, listed in the issue, turned out on inspection to have no JSON-cache-read pattern at all — no code changed there.) New `tests/clients/json-cache-read.test.ts` unit-tests the shared helper directly (valid payload, corrupt JSON, valid-but-wrong-shape, missing file, `validate`/`onError` throwing); all pre-existing tests for the migrated sites pass unmodified.

### Fixed

- **`pilens_health`'s resource footprint no longer reports dead instances from stale instance-registry entries** (closes #735 — observed live during dogfooding: after a `pi` process was hard-killed, `pilens_health` kept reporting its pid as a live instance at 233MB RSS) — `computeResourceFootprint`/`getResourceFootprint` (`clients/instance-registry.ts`) aggregated whatever `~/.pi-lens/instances.json` entries existed, trusting each entry's heartbeat-cached `rssBytes`/`cpuPercent` with no pid-liveness check at all, so a killed/crashed instance kept inflating the headline instance count/RSS/CPU numbers until its entry aged out (up to `STALE_HEARTBEAT_MS`, 6h). Fix: `computeResourceFootprint` now takes an optional `isPidAlive` predicate and drops any instance whose pid it reports dead BEFORE aggregation (both `perInstance` and the totals — `pilens_health`'s text summary derives directly from `totalRssBytes`/`totalCpuPercent`/`instanceCount`, so no separate text-layer fix was needed); `getResourceFootprint` defaults that predicate to the #449/#525 reaper's own `realIsPidAlive` (`clients/instance-reaper.ts`) — reused rather than reimplemented, per that function's own docstring precedent (`clients/lsp-budget.ts`) — and opportunistically (fire-and-forget, best-effort) prunes newly-dead pids' entries from the on-disk registry too, a bonus cleanup layered on top of, not a replacement for, the scheduled reaper sweep. Dropped rather than flagged `stale: true`: unlike heartbeat staleness (which the reaper deliberately treats as "maybe idle-but-alive" and never uses to hide/remove anything), a pid-confirmed-dead instance is unambiguous, and every existing caller already expects a footprint of currently-live instances. No pid-reuse identity check (unlike the reaper's child-pid `matchProcess`): `InstanceEntry` never recorded the host's own command line to verify against (the same gap #525 documented for the reaper's PARENT pid and deliberately left unfixed there), and a health-report false positive is a far smaller blast radius than the reaper's own mistaken-kill risk. Both parameters are optional and default to prior (unfiltered) pure behavior when omitted, so every pre-#735 caller/test is unaffected. New tests in `tests/clients/instance-registry.test.ts`: `computeResourceFootprint` drops a dead-pid instance from both `perInstance` and the totals when `isPidAlive` is supplied, and applies no filtering when it's omitted; `getResourceFootprint` excludes a dead-pid instance end-to-end and opportunistically prunes it from the registry file, while leaving the registry untouched when every pid is alive.
- **The shared project ignore matcher is now tracked-aware, so a git-tracked file whose name happens to match a `.gitignore`/global ignore pattern is no longer invisible to every scan and to the review graph** (closes #703 — the residual documented-but-out-of-scope gap left by #694/#701) — `getProjectIgnoreMatcher` (`clients/file-utils.ts`) applied ignore *patterns* with no knowledge of what git actually tracks, but git's own rule is that ignore rules only ever apply to UNTRACKED files. Concretely: pi-lens's own tracked `clients/test-runner-client.ts` matches `.gitignore`'s `test-*.ts` pattern (meant for scratch test files) and was silently dropped from every walk — the review graph never parsed it and instead materialized a phantom `clients/test-runner-client.js` node (0 symbols, no content hash) standing in for its 5 real importers, blacking out `who-uses-this`/`blastRadius` for that file. Fix: each `GitignorePattern` is now tagged with the layer it came from (`global`/`gitignore`/`pilens`, precedence order unchanged). A winning positive match from the `global` or `gitignore` layers — both of which emulate git — now inherits git's "a tracked file is never ignored" rule and is rescued via a new async, `git ls-files`-backed tracked-files set (`clients/git-tracked-ignore.ts`'s new `collectTrackedFiles`, mirroring #701's `collectUntrackedIgnoredIds` shape exactly: memoized per-root with a 30s TTL, fail-open to `undefined` on no-git/spawn-failure/timeout). A winning match from the `pilens` layer (`.pi-lens.json`'s `ignore` field) is pi-lens-native intent, not a git emulation, and keeps excluding regardless of tracked status. Since `isIgnored` must stay synchronous and hot (called per file in every walk), the tracked-set lookup is a synchronous read of a snapshot the async fetch populates as a side effect; a new `ProjectIgnoreMatcher.ensureTrackedIndex(): Promise<void>` lets an async walker prime that snapshot ONCE per walk (not per file) before its loop starts. Wired into every async walk entry point with a real loop: `startup-scan.ts` (source-file counting), `language-profile.ts` (warmup language detection), `source-filter.ts`'s `collectSourceFilesAsync` (the shared collector behind the word-index build, `getSourceFilesAsync`, and most background scanners), `pipeline.ts`'s `snapshotProjectFiles` (autofix change detection), and `lsp/index.ts`'s workspace-diagnostics sweep walk. Sync-only consumers (`collectSourceFiles`, `jscpd-client.ts`, `tree-sitter-client.ts`'s structural search, `review-graph/workspace-modules.ts`, `project-scan-policy.ts`, `dispatch/integration.ts`'s per-edit cascade-neighbor check) are left unprimed and degrade to today's pattern-only behavior by design — the seam is opt-in, never silently forced onto a hot synchronous path. `REVIEW_GRAPH_VERSION` bumped `v5` → `v6`: a persisted v5 graph is missing every tracked-but-pattern-matched file's real node and instead carries a phantom compiled-artifact node in its place, so it's rejected and rebuilt clean rather than merged with newly-walked v6 nodes. New `tests/clients/tracked-aware-ignore-matcher.test.ts` (real-git-repo fixtures, following #701's pattern): a tracked file matching a `.gitignore` pattern is rescued once primed and stays excluded when never primed (documents the fail-open default); a `.pi-lens.json` match still excludes a tracked file; an untracked file matching `.gitignore` is unaffected; negation still wins last-match-wins across layers; a non-git directory degrades to pattern-only without throwing; cross-separator (`/` vs `\`) paths resolve to the same verdict; and an end-to-end `collectSourceFilesAsync` walk over a fixture repo includes the tracked pattern-matched file.
- **The startup scan-context walk no longer re-runs and discards itself on every single `pi` process start in a large monorepo** (closes #699 — bisected and reported by @aaronkyriesenbach, who traced a consistent ~17s-per-startup cost via a package-toggle bisection down to `resolveStartupScanContextAsync()`) — `resolveStartupScanContext(Async)` (`clients/startup-scan.ts`) already early-exited its counting walk as soon as the running count passed `MAX_STARTUP_SOURCE_FILES` (2000; `if (count > limit) return count;`, predating this issue), so the 17s wasn't a missing early exit — it was that nothing ever survived the walk to inform the NEXT process. The interactive full-mode path (`clients/runtime-session.ts`) only reused a `too-many-source-files` verdict when `project-snapshot.json`'s seq-based freshness matched, but the background warmup timer used by every `-p`/`--print` (quick-mode) invocation — the ONLY place a one-shot process ever reaches scan-context, since quick mode returns before the synchronous path touches it — never persisted the verdict at all, for either outcome; a one-shot process exits right after computing it, so the walk (and, per the reporter's bisection, the ~2s warmup-delay `setTimeout` that keeps the process alive to run it) recomputed and discarded the result on every single startup, `--no-lens` included (that flag never reaches the quick-mode gate). Fix: (1) the background warmup now checks `project-snapshot.json` for a still-fresh persisted verdict BEFORE walking, and unconditionally persists whatever it computes (`canWarmCaches` true or false) afterward via the existing `saveRuntimeProjectSnapshot`/`startupScan` field, mirroring the interactive path instead of duplicating a parallel cache file; (2) a new `computedAt` timestamp on `StartupScanContext` plus `isStartupScanVerdictFresh` (TTL-gated via `PI_LENS_STARTUP_SCAN_VERDICT_TTL_MS`, default 24h, lazy-memoized) covers the one case the existing seq-based freshness check can't: a `too-many-source-files` verdict never advances the snapshot's seq on its own (pi-lens writes nothing while `canWarmCaches` is false), so without a TTL a repo that later shrinks below the threshold would stay stuck on a stale negative verdict forever — the interactive full-mode path now applies the same TTL gate. Corrupt/missing cache files fail open via the existing `readJsonCache` plumbing (re-walk, never throw); cache writes are best-effort via `saveRuntimeProjectSnapshot`'s own try/catch, and never block startup. Measured on this repo (742 source files, `canWarmCaches:true`, so the same walk+persist code path the `too-many-source-files` branch shares): a cold in-process walk costs ~3.7s; a second in-process call hits the existing per-process memo at ~0ms; a genuinely separate process reading the persisted verdict off disk (simulating the next `pi` startup) costs ~0.7ms — the 17s monorepo repro itself wasn't reproduced here (no monorepo of that shape available), but the mechanism is identical. New `tests/clients/startup-scan-verdict-cache.test.ts` (TTL getter + env override + memoization, `isStartupScanVerdictFresh` fresh/expired/missing-`computedAt`/non-TTL'd-reasons, `computedAt` stamping, and a pinning test for the pre-existing early-exit-at-limit+1 behavior) and `tests/clients/runtime-session-scan-cache.test.ts` (full `handleSessionStart` integration: cold walk persists and a second call reuses it without re-invoking `resolveStartupScanContext`, an expired verdict re-walks, a corrupt snapshot fails open).
- **Review graph import resolution now prefers `.ts`/`.tsx` (and `.mts`/`.cts`) source twins over compiled `.js`/`.mjs`/`.cjs` siblings, and node creation is gated against untracked-AND-gitignored files** (closes #694) — on a compile-in-place repo (pi-lens's own build included) EVERY cross-file import edge was landing on the compiled artifact node instead of the source: measured on pi-lens's own graph, `clients/dispatch/types` had 90 import edges / 84 distinct importer files all on `types.js` and **zero** on `types.ts`, blacking out `module_report`'s `blastRadius`/`usedBy`, the impact cascade, and word-index priors for the dispatch system's central type hub. Root cause: both `resolveJsTs` (`clients/review-graph/import-resolvers.ts`, the cold `module_report` path) and `localImportToFile` (`clients/review-graph/builder.ts`, the warm graph-build path) tried the LITERAL specifier path (`./foo.js`) as their FIRST existence-checked candidate — since Node's ESM resolver requires the runtime `.js` extension even for a `.ts` source, that candidate almost always existed on disk (the compiled sibling) and won before the `.ts`/`.tsx` candidates further down the list were ever tried. Fix: a new shared `jsTsCandidatePaths` (import-resolvers.ts) tries the source twin (`.mts` for an `.mjs` specifier, `.cts` for `.cjs`, else `.ts`/`.tsx`) BEFORE the literal/compiled extension, used by both resolvers so the cold and warm paths can never diverge on which twin wins; falls back to the compiled file unchanged when no source twin exists (pure-JS projects, vendored `.js`). Separately, import-resolution-created file nodes (`addJsTsFile`'s local-import target, the tree-sitter `resolveImportToFiles` loop, and cxx `#include` targets) previously had NO ignore-awareness at all — an untracked-AND-gitignored build artifact with no surviving source twin still became a first-class graph node. New `clients/git-tracked-ignore.ts` (extracted from #679's `/lens-map`, which now re-uses it too) computes the untracked-AND-ignored id set via `git ls-files --others --ignored --exclude-standard` — respecting THE critical git semantic that a tracked file is never "ignored" even when a `.gitignore` pattern matches it (pi-lens's own tracked `clients/deps/*.js` vendored sources match the repo's `*.js` pattern and must stay graph nodes) — memoized per-cwd with a 30s time bound so the hot per-edit/cascade rebuild path shares one `git` spawn instead of paying for one per file/per edit; any resolution failure (non-git cwd, git absent) degrades to `undefined` and every caller skips the filter rather than guessing via a tracked-unaware pattern matcher. `REVIEW_GRAPH_VERSION` bumped `"v4"` → `"v5"` (same safe-rebuild pattern as the v2→v3/#260 and v3→v4/#655 bumps): a persisted v4 graph from a compile-in-place project has edges materialized on compiled artifact nodes throughout, so it's rejected and rebuilt clean rather than merged with newly-built v5 edges. Residual, documented but not fixed here: the INITIAL file walk (`getGraphSourceFiles` → `collectProjectSourceFilesAsync` → the pattern-only `getProjectIgnoreMatcher`) is still tracked-unaware and can wrongly exclude a tracked file whose name matches an ignore pattern (e.g. a tracked `test-*.ts` file matching a `test-*.ts` ignore rule) — fixing that needs the same async tracked-check threaded through a widely-shared, currently-synchronous matcher, which is out of scope for this change. Verified on pi-lens's own graph: `clients/dispatch/types.ts` now carries all 90 import edges / 84 importers (was 0), `types.js` carries 0 (was 90), and `computeTransitiveImpact` from `types.ts` now returns 130 dependents (was an empty/blacked-out result). New tests: `tests/clients/review-graph/import-resolvers.test.ts` (twin preference across `.js`/`.mjs`/`.cjs`, fallback-when-no-twin, extensionless specifiers); `tests/clients/git-tracked-ignore.test.ts` (tracked-vs-ignored set computation, no-git degrade, TTL memoization); `tests/clients/review-graph.service.test.ts` (real-git-repo ignore-gated node creation — an ignored untracked target never becomes a node while a tracked file matching the same pattern does; the v4→v5 migration rebuild).
- **Scan-provenance labels no longer poison a diagnostic's dedup identity, and the workspace sweep now honors ast-grep's `skipTestFiles` gate like the per-edit path does** (closes #692) — found via live dogfooding (`pi-drykiss`): a `lens_diagnostics mode=full` run reported 726 warnings, largely the same findings twice. Root cause: `convertLspDiagnostics` (`clients/dispatch/utils/lsp-diagnostics.ts`) baked a caller-supplied `source` option straight into `rule` (`${source}:${code}`) — three "reconcile this scan's fresh results into widget state" call sites (`tools/lens-diagnostics.ts`'s footer reconcile, `tools/lsp-diagnostics.ts`'s footer reconcile) passed a scan-provenance label (`"lens_diagnostics_full"`, `"lsp_diagnostics"`) there, silently clobbering the diagnostic's REAL source (e.g. `ast-grep`) in the field that defines its identity. A per-edit write of the same finding kept rule `ast-grep:<id>`, so `normalizeRuleForDedup`'s prefix strip (which only knows about `ast-grep:`) matched one write but not the other, producing two divergent dedup keys for one finding — and the same corruption broke inline `pi-lens-ignore: <id>` suppression for scan-written entries and lost their tool provenance (`tool` stayed `"lsp"` instead of e.g. `"ast-grep"`, since the per-edit-only aux re-tag loop never ran on the scan path). Fix: the `source` override is gone from `rule` derivation entirely (`rule` now always comes from the diagnostic's own `d.source ?? tool`); scan provenance, where still wanted, now lives in a new dedicated `Diagnostic.scanOrigin` field that is purely informational and never read by identity/dedup/suppression logic. The per-edit dispatch runner's (`clients/dispatch/runners/lsp.ts`) aux re-tag logic (tool id + semantic policy + defect class + native-suppression + `skipTestFiles`) is now a shared helper, `retagAuxiliaryDiagnostics` (`clients/dispatch/auxiliary-lsp.ts`), used by both the per-edit path and the two scan-reconcile call sites, so a scan-written aux finding gets identical treatment instead of keeping tool `"lsp"`. Separately, `clients/lsp/index.ts`'s workspace sweep applied `applyAuxiliarySuppressions` to every file's results but never passed a `fileRole`, so ast-grep's `skipTestFiles` gate (#687/#688 — added only to the per-edit merge loop) never applied to a `mode=full` sweep, re-surfacing every ast-grep finding on `*.test.ts` files wholesale; `applyAuxiliarySuppressions` now takes an optional `{fileRole}` third argument (all existing 2-arg callers unaffected), wired at the sweep's call site via `detectFileRole`, and the same gap was found and fixed in `tools/lsp-diagnostics.ts`'s own standalone-query suppression call via grep-siblings. Three `convertLspDiagnostics(..., {source: "cascade"})` call sites in `clients/dispatch/integration.ts` (cascade neighbor diagnostics — ephemeral display output, never reconciled into persisted widget/dedup state) had the override dropped rather than migrated, since it had no remaining purpose once identity no longer reads it. New tests: `tests/clients/dispatch/utils/lsp-diagnostics.test.ts` (dedup-key/rule equality between per-edit and scan-origin conversions of the same finding, `scanOrigin` present only on the scan path and never leaking into `rule`/`id`); `tests/clients/dispatch/auxiliary-lsp.test.ts` gains `retagAuxiliaryDiagnostics` coverage (matches the per-edit runner's prior behavior exactly) and `applyAuxiliarySuppressions`'s new `fileRole` gate; `tests/clients/dispatch/inline-suppressions.test.ts` gains a round-trip suppression test for a scan-written entry.
- **`oxlint` no longer skips test files** (refs #576) — its `skipTestFiles: true` (bare, no confirmed-FP rationale attached — the original comment was a generic "test files often use patterns that trigger false positives" assumption, unlike the confirmed-FP class that justifies ast-grep's skip, see #687) was inconsistent with every other correctness-oriented runner in the dispatch set: type checkers (mypy/phpstan/detekt/dart-analyze/dotnet-build/javac/etc.) and the primary language servers (typescript-lsp/pyright-lsp/rust-lsp) never skip test files, since a real correctness bug in a test is still a bug. oxlint catches genuine correctness issues, not just style, so it now follows that precedent. New test in `tests/clients/dispatch/runners/oxlint.test.ts` asserts `skipTestFiles: false`.
- **`no-javascript-url` ast-grep rule no longer flags a `javascript:` string literal used as adversarial test input, and ast-grep's own `skipTestFiles` policy now survives when its LSP surface (not just the in-process napi runner) is active** (closes #687) — two related false-positive fixes found via the same live dogfooding pass on `pi-free` that surfaced #686. (1) `rules/ast-grep-rules/rules/no-javascript-url(-js).yml` matched any string literal starting with `"javascript:`, with no check on how it was used — `expect(openBrowser("javascript:alert(1)")).toBe(false)` (a test asserting the guard REJECTS the URL) matched identically to a real `a.href = "javascript:..."` sink. Fix: exclude literals whose nearest enclosing call is `expect`/`it`/`test`/`describe` (or a `.only`/`.skip`/`.each` variant) — the existing bare `const u = "javascript:..."` invalid fixture (flagged regardless of downstream use) is intentionally preserved, so this narrows only the test-framework-wrapped case, not the rule's broader intent. (2) `clients/dispatch/runners/ast-grep-napi.ts` already declares `skipTestFiles: true`, but that only applies to the in-process napi fallback runner — when the ast-grep LSP binary is installed (the common path), that runner skips entirely in favor of `clients/dispatch/runners/lsp.ts`'s auxiliary-diagnostic merge (`clients/dispatch/auxiliary-lsp.ts`'s `AUXILIARY_LSP_PROFILES`), which had no test-file gating at all — confirmed live: `cline-xml-bridge.test.ts`/`open-browser.test.ts` (pi-free) both got ast-grep findings despite being test files. New `AuxiliaryLspProfile.skipTestFiles` field, set only on the `ast-grep` profile (opengrep/zizmor/typos keep running on test files — a hardcoded secret or typo in a test fixture is still worth flagging), gated in `lsp.ts`'s merge loop on `ctx.fileRole === "test"`. New/updated tests: `no-javascript-url(-js)-test.yml` fixtures for the `expect`/`it`/`test.each` cases; `tests/clients/dispatch/auxiliary-lsp.test.ts` asserts only the ast-grep profile sets `skipTestFiles`; `tests/clients/dispatch/runners/runner-status-semantics.test.ts` covers the merge loop dropping ast-grep (but not opengrep) findings on a `fileRole: "test"` file, and keeping them on a regular source file.
- **`find-import-file-without-extension` ast-grep rule no longer flags bare/scoped package specifiers with a subpath** — found via live dogfooding on 2026-07-16 (no separate tracking issue; a contained rule fix). The rule's regex only checked whether a string's final path segment lacked a file extension; it never checked whether the string was actually a RELATIVE import (`./`/`../`) at all. A package specifier with a subpath — `@earendil-works/pi-ai/compat`, `lodash/debounce` — has a `/` before its final segment too, so it matched the identical shape as a genuinely broken relative import like `./utils`. The existing test fixture only covered a slash-less bare specifier (`"package"`), never a scoped/deep one, so the gap went unnoticed. Fix: `rules/ast-grep-rules/rules/find-import-file-without-extension.yml` now requires the string to start with `./` or `../` (`^\.\.?/`) before applying the missing-extension check. New fixtures cover scoped (`@scope/pkg/sub`), deep bare (`lodash/debounce`), and parent-relative (`../sibling/util`) cases; all 251 ast-grep rule-test fixtures still pass.
- **Cold LSP servers now actually get their full warm-up budget instead of being silently capped at their normal per-edit timeout** — found via live dogfooding on 2026-07-15, immediately after #667/#668's `ensureWarmForSweep` shipped (no separate tracking issue filed; this is a direct regression-in-effect follow-up to that PR, referenced here rather than under a new issue number). `ensureWarmForSweep` (`clients/lsp/index.ts`) is supposed to give a genuinely cold server (mid-launch, no index built yet) a generous one-time budget to become ready — it defaults to `warmupTimeoutMs()` (20000ms) and passes that as `touchFile`'s `maxClientWaitMs`/`maxDiagnosticsWaitMs`. But `touchFile`'s `perServerTimeout` resolves that caller cap as a CEILING (`Math.min(callerCap, strategy.aggregateWaitMs)`) — deliberate and correct for the normal per-edit dispatch path (#242, so a slow strategy can't blow past the pipeline's cap), but exactly backwards for a cold warm-up call, which needs MORE time than the server's normal warm-state budget, not less. The effective warm-up deadline silently collapsed to whatever the server's steady-state `aggregateWaitMs` is (e.g. 1000ms for typescript) regardless of the 20000ms actually requested — confirmed against live logs (`lsp_sweep_warmup_start timeoutMs:20000` immediately followed by `lsp_diagnostics_timeout ... timeoutMs:1000 durationMs:1015`), defeating the warm-up feature entirely for any server whose normal per-edit budget is short. Fix: a new `LSPTouchFileOptions.warmupOverride` flag, set only by `ensureWarmForSweep`'s own `touchFile` call, makes `perServerTimeout` treat the caller cap as a FLOOR instead (`Math.max(callerCap, strategyWait)`) — every other caller (per-edit dispatch, cascade touches) leaves it unset and keeps the exact pre-existing ceiling-only semantics. New regression test in `tests/clients/lsp/sweep-warmup.test.ts` asserts a cold server's `waitForDiagnostics` call actually receives the full requested 20000ms budget, not typescript's 1000ms `aggregateWaitMs`.

## [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).
- **Turn-end inline blockers now reconcile against disk — a blocker recorded for a file that has since been deleted can never be cleared (clearInlineBlockers only fires on a later dispatch of the same path, which a deleted file never gets), so it re-surfaced as an "Unresolved from this turn" blocker every turn and could keep the #1063 git guard conservative for the whole session (closes #1245, refs #279/#297/#298 reconcile-on-display discipline)** — RuntimeCoordinator._pendingInlineBlockers (a PathKeyedMap) is now reconciled against disk at every read: getInlineBlockersSnapshot() drops entries whose file no longer exists (the map is tiny and reads are bounded — once per turn_end / tool_result — so the probe cost is negligible), and the git guard's aggregate _gitGuardHasBlockers check reads the reconciled snapshot instead of the raw map size, so a deleted file's blocker no longer counts. The empty-vs-stale distinction is preserved: a blocker for a live file still gates exactly as before. The reconcile rebuilds the map from existence-checked survivors instead of deleting in place — delete-time key normalization lowercases a gone file's tail on Windows while set-time realpaths it, so a delete-based reconcile missed mixed-case filenames (a verified live gap, covered by a dedicated regression test). Regression tests record a blocker for a temp file (lowercase and mixed-case), delete the file, and assert the snapshot and git-guard status both clear (fail pre-fix).

- **`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
