pi-lens 3.8.47 → 3.8.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +37 -6
  3. package/clients/actionable-warnings.ts +129 -18
  4. package/clients/agent-behavior-client.ts +12 -3
  5. package/clients/ast-grep-client.ts +210 -3
  6. package/clients/ast-grep-tool-logger.ts +174 -0
  7. package/clients/ast-grep-types.ts +15 -0
  8. package/clients/ast-grep-yaml-synth.ts +122 -0
  9. package/clients/bash-file-access.ts +220 -0
  10. package/clients/bootstrap.ts +10 -0
  11. package/clients/call-graph.ts +444 -0
  12. package/clients/codebase-model.ts +194 -0
  13. package/clients/dispatch/dispatcher.ts +4 -5
  14. package/clients/dispatch/facts/import-facts.ts +155 -5
  15. package/clients/dispatch/plan.ts +0 -2
  16. package/clients/dispatch/priorities.ts +0 -1
  17. package/clients/dispatch/runners/biome.ts +15 -7
  18. package/clients/dispatch/runners/credo.ts +13 -6
  19. package/clients/dispatch/runners/elixir-check.ts +9 -7
  20. package/clients/dispatch/runners/eslint.ts +29 -7
  21. package/clients/dispatch/runners/fact-rules.ts +1 -1
  22. package/clients/dispatch/runners/index.ts +0 -5
  23. package/clients/dispatch/runners/rust-clippy.ts +33 -10
  24. package/clients/dispatch/runners/tree-sitter.ts +8 -1
  25. package/clients/dispatch/runners/utils/runner-helpers.ts +26 -0
  26. package/clients/dispatch/tool-profile.ts +0 -1
  27. package/clients/formatters.ts +12 -1
  28. package/clients/gitleaks-client.ts +363 -0
  29. package/clients/govulncheck-client.ts +476 -0
  30. package/clients/installer/index.ts +24 -0
  31. package/clients/jscpd-client.ts +14 -1
  32. package/clients/lens-config.ts +32 -0
  33. package/clients/log-cleanup.ts +8 -6
  34. package/clients/lsp/client.ts +123 -9
  35. package/clients/lsp/edits.ts +66 -1
  36. package/clients/lsp/index.ts +220 -6
  37. package/clients/oldtext-autopatch.ts +46 -0
  38. package/clients/pipeline.ts +1 -3
  39. package/clients/project-snapshot.ts +0 -12
  40. package/clients/read-expansion.ts +87 -1
  41. package/clients/read-guard-tool-lines.ts +33 -12
  42. package/clients/review-graph/builder.ts +89 -11
  43. package/clients/runtime-coordinator.ts +2 -11
  44. package/clients/runtime-session.ts +163 -53
  45. package/clients/runtime-tool-result.ts +49 -0
  46. package/clients/runtime-turn.ts +76 -0
  47. package/clients/safe-spawn.ts +5 -1
  48. package/clients/search-read-registration.ts +89 -0
  49. package/clients/sg-runner.ts +216 -7
  50. package/clients/tool-event.ts +24 -0
  51. package/clients/tool-policy.ts +91 -50
  52. package/clients/tree-sitter-client.ts +13 -0
  53. package/clients/tree-sitter-symbol-extractor.ts +249 -0
  54. package/clients/ts-service.ts +2 -2
  55. package/clients/widget-state.ts +146 -26
  56. package/commands/booboo.ts +0 -123
  57. package/index.ts +162 -96
  58. package/package.json +18 -9
  59. package/rules/ast-grep-rules/rules/no-mutable-export.yml +13 -0
  60. package/rules/ast-grep-rules/rules/no-octal-literal.yml +14 -0
  61. package/rules/ast-grep-rules/rules/no-sort-without-comparator.yml +14 -0
  62. package/rules/ast-grep-rules/rules/redos-nested-quantifier.yml +23 -0
  63. package/rules/ast-grep-rules/rules/switch-without-default.yml +16 -0
  64. package/rules/tree-sitter-queries/typescript/no-equality-in-for-condition.yml +40 -0
  65. package/rules/tree-sitter-queries/typescript/no-jump-in-finally.yml +47 -0
  66. package/scripts/download-grammars.js +15 -0
  67. package/skills/ast-grep/SKILL.md +71 -2
  68. package/skills/write-ast-grep-rule/SKILL.md +38 -0
  69. package/skills/write-tree-sitter-rule/SKILL.md +36 -0
  70. package/tools/ast-dump.js +62 -0
  71. package/tools/ast-dump.ts +79 -0
  72. package/tools/ast-grep-replace.js +95 -6
  73. package/tools/ast-grep-replace.ts +137 -16
  74. package/tools/ast-grep-search.js +161 -9
  75. package/tools/ast-grep-search.ts +218 -19
  76. package/tools/lens-diagnostics.js +194 -0
  77. package/tools/lens-diagnostics.ts +248 -0
  78. package/tools/lsp-navigation.js +277 -9
  79. package/tools/lsp-navigation.ts +377 -9
  80. package/clients/amain-types.ts +0 -165
  81. package/clients/dispatch/runners/similarity.ts +0 -510
  82. package/clients/dispatch/runners/type-safety.ts +0 -197
  83. package/clients/project-index.ts +0 -403
  84. package/clients/state-matrix.ts +0 -202
  85. package/clients/type-safety-client.ts +0 -193
  86. /package/rules/tree-sitter-queries/{abap → abap-disabled}/delete-where.yml +0 -0
  87. /package/rules/tree-sitter-queries/{cobol → cobol-disabled}/alter-statement.yml +0 -0
  88. /package/rules/tree-sitter-queries/{cobol → cobol-disabled}/lock-table-cobol.yml +0 -0
  89. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/delete-update-where.yml +0 -0
  90. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/end-loop-semicolon.yml +0 -0
  91. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/fetch-bulk-collect-limit.yml +0 -0
  92. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/forallsave-exceptions.yml +0 -0
  93. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/lock-table.yml +0 -0
  94. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/nchar-nvarchar2-bytes.yml +0 -0
  95. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/no-synchronize.yml +0 -0
  96. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/not-null-initialization.yml +0 -0
  97. /package/rules/tree-sitter-queries/{plsql → plsql-disabled}/raise-application-error-codes.yml +0 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,136 @@ All notable changes to pi-lens will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.8.50] - 2026-06-07
8
+
9
+ ### Added
10
+
11
+ - **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.
12
+
13
+ - **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.
14
+
15
+ - **`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.
16
+
17
+ - **`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.)
18
+
19
+ - **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.
20
+
21
+ ### Fixed
22
+
23
+ - **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.
24
+
25
+ - **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.
26
+
27
+ - **`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.
28
+
29
+ - **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`).
30
+
31
+ - **`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.
32
+
33
+ - **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.
34
+
35
+ ### Changed
36
+
37
+ - **`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`).
38
+
39
+ ### Added
40
+
41
+ - **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.
42
+
43
+ - **`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).
44
+
45
+ - Extended oxfmt formatter to CSS, SCSS, Less, HTML, JSON, YAML, Markdown, MDX, GraphQL, TOML, Vue files. Updated tool-policy entries and added unit tests.
46
+
47
+ - **`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.
48
+
49
+ - **`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.
50
+
51
+ - **`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.
52
+ - **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.
53
+
54
+ - **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.
55
+
56
+ ### Fixed
57
+
58
+ - **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.
59
+
60
+ - **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.
61
+
62
+ - **Regex S5852 backtracking eliminated** — replaced `(.*?)` with `([^(]*)` and `/\r?\n/` with `/\r\n|\n/` in ast-grep-client and lsp-navigation.
63
+
64
+ - **`@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.
65
+
66
+ ### Performance
67
+
68
+ - **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.
69
+
70
+ ## [3.8.48] - 2026-06-05
71
+
72
+ ### Added
73
+
74
+ - **`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.
75
+
76
+ - **`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.
77
+
78
+ - **`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.
79
+
80
+ - **`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.
81
+
82
+ - **`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.
83
+
84
+ - **`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.
85
+
86
+ - **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`.
87
+
88
+ - **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.
89
+
90
+ - **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.
91
+
92
+ - **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.
93
+
94
+ - **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.
95
+
96
+ - **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).
97
+
98
+ - **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.
99
+ - **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?
100
+
101
+ ### Performance
102
+
103
+ - **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.
104
+
105
+ ### Fixed
106
+
107
+ - **`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.
108
+
109
+ - **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.
110
+
111
+ - **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`.
112
+
113
+ - **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.
114
+
115
+ - **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.
116
+
117
+ - **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.
118
+
119
+ - **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.
120
+
121
+ - **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.
122
+
123
+ - **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`.
124
+
125
+ - **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.
126
+
127
+ *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.
128
+
129
+ - **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.
130
+
131
+ ### Removed
132
+
133
+ - **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.
134
+ - **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).
135
+ - **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.
136
+
7
137
  ## [3.8.47] - 2026-06-01
8
138
 
9
139
  ### Added
package/README.md CHANGED
@@ -88,7 +88,12 @@ pi-lens includes **37 language server definitions**. LSP is **enabled by default
88
88
  { "warmFiles": ["src/main.cpp", "src/lib.cpp"] }
89
89
  ```
90
90
 
91
- **Agent LSP tools:** `lsp_diagnostics` can check one file, a directory, or an explicit `filePaths` batch with bounded concurrency. `lsp_navigation` provides definitions, references, hover, workspace symbols, call hierarchy, rename edits, and `findSymbol` for filtered document-symbol lookup. Rename accepts `apply: true` to write the returned workspace edits to disk immediately, with per-file LSP re-sync after application.
91
+ **Agent LSP tools:** `lsp_diagnostics` can check one file, a directory, or an explicit `filePaths` batch with bounded concurrency. `lsp_navigation` provides definitions, references, hover, workspace symbols, call hierarchy, rename edits, and `findSymbol` for filtered document-symbol lookup. Key operations:
92
+
93
+ - **`rename`** — renames a symbol across all references; `apply: true` writes workspace edits to disk with per-file LSP re-sync.
94
+ - **`rename_file`** — LSP-aware file rename: sends `workspace/willRenameFiles` to collect import-path rewrites, applies them, renames the file on disk, and notifies servers via `workspace/didRenameFiles`. `apply: false` previews the workspace edits without touching the filesystem.
95
+ - **`capabilities`** — shows which operations are supported by the active LSP server(s) for a file, read directly from the cached `initialize` response (no round-trip).
96
+ - **Symbol column resolution** — passing `symbol: "myFunc"` instead of an exact `character` position resolves the correct column automatically. Use `symbol: "foo#2"` for the second occurrence of `foo` on the line.
92
97
 
93
98
  LSP servers for: TypeScript, Deno, Python (pyright/basedpyright + jedi), Go, Rust, Ruby (ruby-lsp + solargraph), PHP, C# (omnisharp), F#, Java, Kotlin, Swift, Dart, Lua, C/C++, Zig, Haskell, Elixir, Gleam, OCaml, Clojure, Terraform, Nix, Bash, Docker, YAML, JSON, HTML, TOML, Prisma, Vue, Svelte, ESLint, CSS.
94
99
 
@@ -153,12 +158,22 @@ When `actionableWarnings.autoFix.enabled` is set in global config (or `--lens-ac
153
158
 
154
159
  When the agent reads a small slice of a file (≤ 60 lines), pi-lens transparently expands the read to the full enclosing symbol (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 that symbol pass without requiring the agent to have read every line individually. Expansion runs within a 200 ms budget and falls back silently on unsupported file types or parse failures.
155
160
 
156
- Supported: TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Ruby.
161
+ Supported: TypeScript, TSX, JavaScript, JSX, Python, Go, Rust, Ruby, Java, Kotlin, Dart, Elixir, C, C++, C#, PHP, Swift, Lua, OCaml, Zig, Bash.
157
162
 
158
163
  ### Fact Rules Pipeline
159
164
 
160
165
  Covers JavaScript/TypeScript, Python, Go, Rust, Ruby, Shell, and CMake. A TypeScript AST-based fact-rule engine extracts function-level metrics and evaluates quality and security rules inline. Blocking rules surface immediately at write time; advisory rules are available via `/lens-booboo`.
161
166
 
167
+ ### AST Search and Replace
168
+
169
+ `ast_grep_search` and `ast_grep_replace` provide AST-aware pattern matching across 40+ languages via the `sg` CLI. Key capabilities:
170
+
171
+ - **Metavariable captures** — named captures (`$VAR`, `$$$ARGS`) appear below each match: `$VAR=x $$$ARGS=a,b,c`.
172
+ - **Strictness modes** — `strictness: "relaxed"` ignores optional punctuation (trailing commas, semicolons) that causes zero matches in `smart` mode. Also supports `ast`, `cst`, `signature`, `template`.
173
+ - **Pagination** — `skip: N` offsets into large result sets; truncated results include a next-page hint.
174
+ - **Stale-preview detection** — `ast_grep_replace` re-validates the pattern before writing; returns a clear error if files changed since the preview instead of applying against wrong content.
175
+ - **`ast_dump`** — dumps the full tree-sitter AST for a source snippet. Use this when a pattern returns zero matches and the correct node kind or field name is unknown.
176
+
162
177
  ### Tree-sitter Rules
163
178
 
164
179
  Structural rules organized by language in `rules/tree-sitter-queries/<language>/`. Rules marked **🔴** block the agent inline at write time (only for lines in the current edit); others are advisory.
@@ -211,6 +226,7 @@ pi
211
226
 
212
227
  # Optional switches
213
228
  pi --no-lens # Start pi-lens disabled for this session; /lens-toggle can re-enable
229
+ pi --no-lens-context # Disable automatic context injection only (tools/LSP/read-guard/format stay on); /lens-context-toggle
214
230
  pi --no-lsp # Disable unified LSP diagnostics
215
231
  pi --no-autoformat # Skip auto-formatting entirely
216
232
  pi --immediate-format # Format immediately after each edit instead of deferring to agent_end
@@ -245,29 +261,44 @@ Hide the diagnostics widget by default, run formatting immediately after write/e
245
261
  "enabled": false,
246
262
  "maxFixes": 5
247
263
  }
264
+ },
265
+ "contextInjection": {
266
+ "enabled": false
248
267
  }
249
268
  }
250
269
  ```
251
270
 
252
271
  `format.mode` can be `"deferred"` (default) or `"immediate"`. Set `format.enabled` to `false` to match `--no-autoformat`. `/lens-widget-toggle` still works as a session-only override.
253
272
 
273
+ `contextInjection.enabled` (default `true`) controls whether pi-lens prepends automatic findings — session-start guidance, turn-end findings, and test findings — into the next model turn. Set it to `false` (or use `--no-lens-context` / `PI_LENS_NO_CONTEXT_INJECTION=1` / `/lens-context-toggle`) to keep tools, LSP, read-guard, and formatting running while avoiding the prompt-cache invalidation that injected messages cause in long, cache-sensitive sessions. Findings are still cached, so `lens_diagnostics` and `/lens-health` keep working.
274
+
254
275
  `actionableWarnings.enabled` gates the turn_end report. `includeLspCodeActions` fetches LSP code actions for each warning (requires an active language server). `deltaOnly` (default `true`) limits the report to lines touched in the current turn. `autoFix.enabled` applies conservative LSP quickfixes at `agent_end`; `autoFix.maxFixes` caps the number applied per turn (default `5`).
255
276
 
256
277
  ## Environment Variables
257
278
 
258
279
  - `PILENS_DATA_DIR` — redirect per-project state (scanner caches,
259
- turn-state.json) out of the project directory. By default pi-lens writes
260
- `<cwd>/.pi-lens/`; if set, it writes to
261
- `<PILENS_DATA_DIR>/<sanitized-cwd-slug>/` instead. Useful for keeping repos
262
- clean or for mounted/ephemeral setups. Tool binaries always live in
280
+ turn-state.json) to a base directory outside the project. By default
281
+ pi-lens writes to `~/.pi-lens/projects/<sanitized-cwd-slug>/`. The one
282
+ exception is a legacy `<cwd>/.pi-lens/` directory: if that already exists
283
+ in the project, pi-lens continues to use it. Set `PILENS_DATA_DIR` to
284
+ permanently override both cases and write to
285
+ `<PILENS_DATA_DIR>/<sanitized-cwd-slug>/` instead. Particularly useful
286
+ when running pi with a local model server (llama.cpp, Ollama, etc.) that
287
+ monitors the project directory — cache-file churn inside the workspace can
288
+ disrupt the model's context scoring. Tool binaries always live in
263
289
  `~/.pi-lens/bin/` regardless.
264
290
  - `PI_LENS_STARTUP_MODE` — `full` | `minimal` | `quick`. Override the
265
291
  auto-selected startup path. One-shot `pi --print` sessions auto-use `quick`
266
292
  to reduce latency.
293
+ - `PI_LENS_NO_CONTEXT_INJECTION` — set to `1` to disable automatic context
294
+ injection (equivalent to `--no-lens-context` / `contextInjection.enabled:
295
+ false`). Tools, LSP, read-guard, and formatting stay active; findings are
296
+ still cached for `lens_diagnostics` and `/lens-health`.
267
297
 
268
298
  ## Key Commands
269
299
 
270
300
  - `/lens-toggle` — toggle pi-lens on/off for the current session without restarting
301
+ - `/lens-context-toggle` — toggle automatic context injection on/off for the session (tools/LSP/read-guard/formatting stay active)
271
302
  - `/lens-widget-toggle` — show/hide the pi-lens diagnostics widget below the editor
272
303
  - `/lens-booboo` — full quality report for current project state
273
304
  - `/lens-health` — runtime health, latency, and diagnostic telemetry
@@ -357,24 +357,36 @@ export async function buildActionableWarningsReport(args: {
357
357
  });
358
358
  continue;
359
359
  }
360
- let diags: LSPDiagnostic[] = [];
361
- try {
362
- const content = fs.existsSync(filePath)
363
- ? fs.readFileSync(filePath, "utf-8")
364
- : undefined;
365
- if (content) await lspService.openFile(filePath, content);
366
- diags = await lspService.getDiagnostics(filePath);
367
- } catch (err) {
368
- args.dbg?.(
369
- `actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`,
370
- );
371
- logActionableWarningsEvent({
372
- event: "lsp_file_skipped",
373
- sessionId: args.sessionId,
374
- filePath,
375
- metadata: { reason: "lsp_error", error: String(err) },
376
- });
377
- continue;
360
+ // Prefer the cache primed by the dispatch pipeline (touchFile already
361
+ // ran in this turn for every modified file). A second open + wait
362
+ // here costs ~1 s/file with the LSP cold and produces an identical
363
+ // result. Fall through to the slow path only when the cache is
364
+ // missing — that means dispatch didn't see this file in this turn.
365
+ let diags: LSPDiagnostic[] | undefined;
366
+ let lspSource: "cache" | "fresh" = "cache";
367
+ const cached = lspService.getLastKnownDiagnostics(filePath);
368
+ if (cached !== undefined) {
369
+ diags = cached;
370
+ } else {
371
+ try {
372
+ const content = fs.existsSync(filePath)
373
+ ? fs.readFileSync(filePath, "utf-8")
374
+ : undefined;
375
+ if (content) await lspService.openFile(filePath, content);
376
+ diags = await lspService.getDiagnostics(filePath);
377
+ lspSource = "fresh";
378
+ } catch (err) {
379
+ args.dbg?.(
380
+ `actionable_warnings: LSP diagnostics failed for ${filePath}: ${err}`,
381
+ );
382
+ logActionableWarningsEvent({
383
+ event: "lsp_file_skipped",
384
+ sessionId: args.sessionId,
385
+ filePath,
386
+ metadata: { reason: "lsp_error", error: String(err) },
387
+ });
388
+ continue;
389
+ }
378
390
  }
379
391
  const ranges =
380
392
  args.modifiedRangesByFile.get(normalizeMapKey(filePath)) ?? [];
@@ -417,6 +429,7 @@ export async function buildActionableWarningsReport(args: {
417
429
  deltaFiltered,
418
430
  enriched,
419
431
  modifiedRangesCount: ranges.length,
432
+ lspSource,
420
433
  },
421
434
  });
422
435
  }
@@ -475,6 +488,104 @@ export function writeActionableWarningsReport(
475
488
  cacheManager.writeCache("actionable-warnings", report, cwd);
476
489
  }
477
490
 
491
+ export interface ActionableWarningsHistoryEntry {
492
+ timestamp: string;
493
+ sessionId: string;
494
+ turnIndex: number;
495
+ projectSeq?: number;
496
+ filePath: string;
497
+ displayPath: string;
498
+ fileSeq?: number;
499
+ line?: number;
500
+ column?: number;
501
+ severity: ActionableWarningRecord["severity"];
502
+ tool: string;
503
+ source?: string;
504
+ rule?: string;
505
+ code?: string;
506
+ message: string;
507
+ fixKind?: string;
508
+ autoFixAvailable?: boolean;
509
+ actionCount: number;
510
+ autoFixEligibleActionCount: number;
511
+ suppressed: boolean;
512
+ suppressionReason?: string;
513
+ origin: ActionableWarningRecord["origin"];
514
+ warningId: string;
515
+ }
516
+
517
+ export function getActionableWarningsHistoryPath(cwd: string): string {
518
+ return path.join(getProjectDataDir(cwd), "actionable-warnings.jsonl");
519
+ }
520
+
521
+ /**
522
+ * Append every actionable warning from this turn to the project's rolling
523
+ * NDJSON history. Mirrors `appendCodeQualityWarningsHistory` so the two
524
+ * advisory families have the same shape of cross-turn persistence:
525
+ *
526
+ * - One line per warning (not per turn).
527
+ * - Carries the stable `aw:<hash>` id so callers can correlate the same
528
+ * warning across turns / sessions.
529
+ * - Captures suppression state at write time so historical analyses can
530
+ * reconstruct what the agent actually saw.
531
+ * - Captures action counts (and autoFixEligible counts) — the LSP code-
532
+ * action enrichment is the actionable-warnings-only signal; preserving
533
+ * it lets later analyses ask "which warnings ship with an autofix?".
534
+ *
535
+ * Skips the write entirely when no warnings exist — matching the code-
536
+ * quality history's no-op-on-empty behaviour and keeping the file from
537
+ * accumulating 0-warning noise.
538
+ */
539
+ export function appendActionableWarningsHistory(
540
+ cwd: string,
541
+ report: ActionableWarningsReport,
542
+ ): void {
543
+ const entries: ActionableWarningsHistoryEntry[] = [];
544
+ for (const file of report.files) {
545
+ for (const warning of file.warnings) {
546
+ entries.push({
547
+ timestamp: report.generatedAt,
548
+ sessionId: report.sessionId,
549
+ turnIndex: report.turnIndex,
550
+ projectSeq: report.projectSeqEnd,
551
+ filePath: warning.filePath,
552
+ displayPath: warning.displayPath,
553
+ fileSeq: file.fileSeq,
554
+ line: warning.line,
555
+ column: warning.column,
556
+ severity: warning.severity,
557
+ tool: warning.tool,
558
+ source: warning.source,
559
+ rule: warning.rule,
560
+ code: warning.code,
561
+ message: warning.message,
562
+ fixKind: warning.fixKind,
563
+ autoFixAvailable: warning.autoFixAvailable,
564
+ actionCount: warning.actions.length,
565
+ autoFixEligibleActionCount: warning.actions.filter(
566
+ (action) => action.autoFixEligible,
567
+ ).length,
568
+ suppressed: warning.suppressed,
569
+ suppressionReason: warning.suppressionReason,
570
+ origin: warning.origin,
571
+ warningId: warning.id,
572
+ });
573
+ }
574
+ }
575
+ if (entries.length === 0) return;
576
+ const historyPath = getActionableWarningsHistoryPath(cwd);
577
+ try {
578
+ fs.mkdirSync(path.dirname(historyPath), { recursive: true });
579
+ fs.appendFileSync(
580
+ historyPath,
581
+ `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
582
+ "utf8",
583
+ );
584
+ } catch {
585
+ // Non-fatal — history write failure must never surface to the agent.
586
+ }
587
+ }
588
+
478
589
  export interface ActionableWarningsAutofixSummary {
479
590
  considered: number;
480
591
  applied: number;
@@ -36,7 +36,7 @@ const WRITE_OPS = new Set(["edit", "write", "multiedit"]);
36
36
  const READ_OPS = new Set(["read", "bash", "grep", "glob", "find", "rg"]);
37
37
 
38
38
  const BLIND_WRITE_WINDOW = 5; // Check last N tool calls for a read
39
- const THRASH_THRESHOLD = 3; // Flag after N consecutive identical tools
39
+ const THRASH_THRESHOLD = 3; // Flag after N consecutive identical tool+file pairs
40
40
  const THRASH_TIMEOUT_MS = 30_000; // Reset thrash counter if gap > 30s
41
41
 
42
42
  // --- Client ---
@@ -45,6 +45,7 @@ export class AgentBehaviorClient {
45
45
  private toolHistory: ToolCallRecord[] = [];
46
46
  private consecutiveCount = 0;
47
47
  private lastToolName: string | null = null;
48
+ private lastToolFilePath: string | null = null;
48
49
  private lastToolTimestamp = 0;
49
50
 
50
51
  // Per-file tracking
@@ -58,9 +59,14 @@ export class AgentBehaviorClient {
58
59
  const warnings: BehaviorWarning[] = [];
59
60
  const now = Date.now();
60
61
 
61
- // Track consecutive identical tools (thrashing)
62
+ // Track consecutive identical tool+file pairs (thrashing).
63
+ // Editing different files in sequence is normal agent behaviour — only flag
64
+ // when the same tool is called on the same file N times without making
65
+ // progress on anything else.
66
+ const normalizedPath = filePath ? normalizeMapKey(filePath) : null;
62
67
  if (
63
68
  toolName === this.lastToolName &&
69
+ normalizedPath === this.lastToolFilePath &&
64
70
  now - this.lastToolTimestamp < THRASH_TIMEOUT_MS
65
71
  ) {
66
72
  this.consecutiveCount++;
@@ -68,16 +74,19 @@ export class AgentBehaviorClient {
68
74
  this.consecutiveCount = 1;
69
75
  }
70
76
  this.lastToolName = toolName;
77
+ this.lastToolFilePath = normalizedPath;
71
78
  this.lastToolTimestamp = now;
72
79
 
73
80
  // Check for thrashing
74
81
  if (this.consecutiveCount === THRASH_THRESHOLD) {
82
+ const fileLabel = filePath ? ` on \`${filePath}\`` : "";
75
83
  warnings.push({
76
84
  type: "thrashing",
77
- message: `🔴 THRASHING — ${THRASH_THRESHOLD} consecutive \`${toolName}\` calls with no other action. Consider fixing the root cause instead of re-running.`,
85
+ message: `🔴 THRASHING — ${THRASH_THRESHOLD} consecutive \`${toolName}\`${fileLabel} calls with no progress. Consider fixing the root cause instead of re-running.`,
78
86
  severity: "error",
79
87
  details: {
80
88
  toolName,
89
+ filePath,
81
90
  callCount: this.consecutiveCount,
82
91
  },
83
92
  });