@herbertgao/pi-extensions 2026.8.5 → 2026.8.6

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 (155) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  7. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +10 -0
  8. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  9. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +5 -2
  10. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  11. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  16. package/node_modules/@narumitw/pi-btw/README.md +24 -17
  17. package/node_modules/@narumitw/pi-btw/package.json +5 -5
  18. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  19. package/node_modules/@narumitw/pi-btw/src/menu.ts +33 -13
  20. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  21. package/node_modules/pi-lens/CHANGELOG.md +95 -0
  22. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  23. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  24. package/node_modules/pi-lens/dist/clients/biome-client.js +121 -13
  25. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  26. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  27. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  28. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  29. package/node_modules/pi-lens/dist/clients/dead-code-client.js +135 -30
  30. package/node_modules/pi-lens/dist/clients/dependency-checker.js +19 -7
  31. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +6 -4
  32. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  33. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +114 -10
  34. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +101 -16
  35. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -4
  36. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  37. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  38. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +230 -50
  39. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  40. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  41. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  42. package/node_modules/pi-lens/dist/clients/extension-log.js +296 -3
  43. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  44. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  45. package/node_modules/pi-lens/dist/clients/git-guard.js +18 -21
  46. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  47. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +116 -7
  48. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  49. package/node_modules/pi-lens/dist/clients/installer/index.js +5 -1
  50. package/node_modules/pi-lens/dist/clients/jscpd-client.js +4 -9
  51. package/node_modules/pi-lens/dist/clients/knip-client.js +51 -14
  52. package/node_modules/pi-lens/dist/clients/latency-logger.js +50 -1
  53. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  54. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  55. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  56. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  57. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +58 -13
  58. package/node_modules/pi-lens/dist/clients/lsp/client.js +169 -8
  59. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +29 -0
  60. package/node_modules/pi-lens/dist/clients/lsp/index.js +264 -66
  61. package/node_modules/pi-lens/dist/clients/lsp/server.js +247 -60
  62. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  63. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  64. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  65. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  66. package/node_modules/pi-lens/dist/clients/mcp/session.js +30 -17
  67. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  68. package/node_modules/pi-lens/dist/clients/pipeline.js +29 -2
  69. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  70. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  71. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  72. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  73. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +153 -7
  74. package/node_modules/pi-lens/dist/clients/runtime-context.js +104 -11
  75. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +170 -22
  76. package/node_modules/pi-lens/dist/clients/runtime-session.js +28 -5
  77. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +124 -10
  78. package/node_modules/pi-lens/dist/clients/runtime-turn.js +388 -35
  79. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  80. package/node_modules/pi-lens/dist/clients/security-scan-client.js +88 -5
  81. package/node_modules/pi-lens/dist/clients/sg-runner.js +141 -23
  82. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  83. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  84. package/node_modules/pi-lens/dist/clients/test-runner-client.js +431 -24
  85. package/node_modules/pi-lens/dist/clients/tool-policy.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  87. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  89. package/node_modules/pi-lens/dist/index.js +4827 -1633
  90. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  91. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  92. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  93. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  94. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +3 -2
  95. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +62 -7
  96. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  97. package/node_modules/pi-lens/docs/agent-guide.md +38 -13
  98. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +11 -3
  99. package/node_modules/pi-lens/docs/features.md +14 -1
  100. package/node_modules/pi-lens/docs/globalconfig.md +11 -0
  101. package/node_modules/pi-lens/docs/servercapabilities.md +1 -1
  102. package/node_modules/pi-lens/docs/settings.md +6 -0
  103. package/node_modules/pi-lens/docs/usage.md +23 -5
  104. package/node_modules/pi-lens/docs/word-index.md +35 -0
  105. package/node_modules/pi-lens/package.json +1 -1
  106. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  107. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  108. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  109. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  110. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  111. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  112. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  113. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  114. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  115. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  116. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  117. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  118. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  119. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  120. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  121. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  122. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +79 -0
  123. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  124. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  125. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  126. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  127. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  128. package/node_modules/pi-mcp-adapter/CHANGELOG.md +13 -0
  129. package/node_modules/pi-mcp-adapter/README.md +4 -1
  130. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  131. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  132. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  133. package/node_modules/pi-mcp-adapter/direct-tools.ts +18 -15
  134. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  135. package/node_modules/pi-mcp-adapter/metadata-cache.ts +18 -8
  136. package/node_modules/pi-mcp-adapter/package.json +2 -1
  137. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  138. package/node_modules/pi-mcp-adapter/server-manager.ts +5 -0
  139. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  140. package/node_modules/pi-mcp-adapter/types.ts +93 -10
  141. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  142. package/node_modules/pi-web-access/README.md +18 -12
  143. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  144. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  145. package/node_modules/pi-web-access/curator-page.ts +5 -3
  146. package/node_modules/pi-web-access/curator-server.ts +2 -1
  147. package/node_modules/pi-web-access/extract.ts +106 -34
  148. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  149. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  150. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  151. package/node_modules/pi-web-access/index.ts +120 -48
  152. package/node_modules/pi-web-access/package.json +2 -2
  153. package/node_modules/pi-web-access/summary-review.ts +11 -5
  154. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  155. package/package.json +9 -9
@@ -7,6 +7,7 @@ import type { Component, TUI } from "@earendil-works/pi-tui";
7
7
  import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
8
8
  import {
9
9
  type BtwSettings,
10
+ type BtwSettingsPatch,
10
11
  btwSettingsPath,
11
12
  effectiveRememberThinkingLevelChanges,
12
13
  readBtwSettings,
@@ -35,7 +36,7 @@ export interface ShowBtwCommandMenuOptions {
35
36
  settingsPath?: string;
36
37
  readSettings?: typeof readBtwSettings;
37
38
  updateSettings?: (
38
- patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
39
+ patch: BtwSettingsPatch,
39
40
  options: UpdateBtwSettingsOptions,
40
41
  ) => Promise<BtwSettings>;
41
42
  }
@@ -44,6 +45,7 @@ export type BtwCommandMenuResult = "start" | "closed" | { kind: "resume"; thread
44
45
 
45
46
  type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
46
47
  type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
48
+ const SAME_AS_MAIN_THREAD = "Same as main thread";
47
49
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
48
50
 
49
51
  type BtwCustomFactory<T> = (
@@ -79,8 +81,22 @@ export async function showBtwCommandMenu(
79
81
  }
80
82
  return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
81
83
  };
82
- const displayThinkingLevel = (settings: BtwSettings): BtwThinkingLevel =>
83
- clampToAvailableThinkingLevel(settings.thinkingLevel ?? options.currentThinkingLevel, levels);
84
+ const currentMainThinkingLevel = clampToAvailableThinkingLevel(
85
+ options.currentThinkingLevel,
86
+ levels,
87
+ );
88
+ const displayThinkingLevel = (settings: BtwSettings): string =>
89
+ settings.thinkingLevel === undefined
90
+ ? SAME_AS_MAIN_THREAD
91
+ : clampToAvailableThinkingLevel(settings.thinkingLevel, levels);
92
+ const displayThinkingSummary = (settings: BtwSettings): string =>
93
+ settings.thinkingLevel === undefined
94
+ ? `${SAME_AS_MAIN_THREAD} (currently ${currentMainThinkingLevel})`
95
+ : displayThinkingLevel(settings);
96
+ const displayRememberSummary = (settings: BtwSettings): string => {
97
+ const value = effectiveRememberThinkingLevelChanges(settings) ? "On" : "Off";
98
+ return settings.thinkingLevel === undefined ? `${value} (fixed levels only)` : value;
99
+ };
84
100
 
85
101
  const menu = defineMenu<BtwMenuState, BtwMenuScreen, BtwMenuAction, MenuContext>({
86
102
  start: "main",
@@ -89,7 +105,7 @@ export async function showBtwCommandMenu(
89
105
  kind: "actions",
90
106
  title: "Pi BTW",
91
107
  lines: [
92
- `Thinking: ${displayThinkingLevel(state.settings)} · Remember changes: ${effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off"}`,
108
+ `Thinking: ${displayThinkingSummary(state.settings)} · Remember changes: ${displayRememberSummary(state.settings)}`,
93
109
  ],
94
110
  items: [
95
111
  {
@@ -111,7 +127,7 @@ export async function showBtwCommandMenu(
111
127
  {
112
128
  id: "settings",
113
129
  label: "Settings",
114
- description: "Choose pi-btw thinking and whether shortcut changes are remembered",
130
+ description: "Choose pi-btw thinking level and fixed-level shortcut memory",
115
131
  to: state.kind === "invalid" ? "invalid" : "settings",
116
132
  },
117
133
  ],
@@ -138,15 +154,15 @@ export async function showBtwCommandMenu(
138
154
  {
139
155
  id: "thinkingLevel",
140
156
  label: "Thinking level",
141
- description: "Set the starting level for future pi-btw side threads.",
157
+ description: `Set the starting level for future pi-btw side threads. Currently ${currentMainThinkingLevel}.`,
142
158
  currentValue: displayThinkingLevel(state.settings),
143
- values: levels,
159
+ values: [SAME_AS_MAIN_THREAD, ...levels],
144
160
  action: "set-thinking",
145
161
  },
146
162
  {
147
163
  id: "rememberThinkingLevelChanges",
148
164
  label: "Remember thinking level changes",
149
- description: "Save side-thread shortcut changes to pi-btw.json for next time.",
165
+ description: "Save shortcut changes for fixed thinking levels to pi-btw.json.",
150
166
  currentValue: effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off",
151
167
  values: ["On", "Off"],
152
168
  action: "set-remember",
@@ -176,12 +192,16 @@ export async function showBtwCommandMenu(
176
192
  return { kind: "close" } as const;
177
193
  },
178
194
  "set-thinking": async ({ value, signal }) => {
179
- if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
195
+ if (!value) return { kind: "rejected" };
196
+ const patch =
197
+ value === SAME_AS_MAIN_THREAD
198
+ ? ({ thinkingLevel: undefined } satisfies BtwSettingsPatch)
199
+ : levels.includes(value as BtwThinkingLevel)
200
+ ? ({ thinkingLevel: value as BtwThinkingLevel } satisfies BtwSettingsPatch)
201
+ : undefined;
202
+ if (!patch) return { kind: "rejected" };
180
203
  try {
181
- await updateSettings(
182
- { thinkingLevel: value as BtwThinkingLevel },
183
- { settingsPath, signal },
184
- );
204
+ await updateSettings(patch, { settingsPath, signal });
185
205
  if (signal.aborted) return { kind: "rejected" };
186
206
  notifySafely(ctx, `Pi BTW thinking level: ${value}.`, "info");
187
207
  return { kind: "stay" };
@@ -20,6 +20,11 @@ export type BtwSettingsLoadResult =
20
20
  | { kind: "invalid"; reason: string }
21
21
  | { kind: "loaded"; settings: BtwSettings };
22
22
 
23
+ export interface BtwSettingsPatch {
24
+ thinkingLevel?: BtwThinkingLevel;
25
+ rememberThinkingLevelChanges?: boolean;
26
+ }
27
+
23
28
  export interface UpdateBtwSettingsOptions {
24
29
  settingsPath?: string;
25
30
  signal?: AbortSignal;
@@ -77,14 +82,14 @@ export async function readBtwSettings(
77
82
  }
78
83
 
79
84
  export function updateBtwSettings(
80
- patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
85
+ patch: BtwSettingsPatch,
81
86
  options: UpdateBtwSettingsOptions = {},
82
87
  ): Promise<BtwSettings> {
83
88
  const settingsPath = options.settingsPath ?? btwSettingsPath();
84
89
  return enqueueMutation(settingsPath, async () => {
85
90
  options.signal?.throwIfAborted();
86
91
  const current = await readSettingsDocumentForUpdate(settingsPath);
87
- const updated: SettingsDocument = { ...current, ...patch };
92
+ const updated = applyBtwSettingsPatch(current, patch);
88
93
  const settings = normalizeBtwSettings(updated);
89
94
  if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
90
95
  await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
@@ -216,6 +221,21 @@ async function publishSettings(
216
221
  }
217
222
  }
218
223
 
224
+ function applyBtwSettingsPatch(
225
+ current: SettingsDocument,
226
+ patch: BtwSettingsPatch,
227
+ ): SettingsDocument {
228
+ const updated: SettingsDocument = { ...current };
229
+ if (Object.hasOwn(patch, "thinkingLevel")) {
230
+ if (patch.thinkingLevel === undefined) delete updated.thinkingLevel;
231
+ else updated.thinkingLevel = patch.thinkingLevel;
232
+ }
233
+ if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
234
+ updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
235
+ }
236
+ return updated;
237
+ }
238
+
219
239
  function isSettingsDocument(value: unknown): value is SettingsDocument {
220
240
  return typeof value === "object" && value !== null && !Array.isArray(value);
221
241
  }
@@ -16,6 +16,101 @@ All notable changes to pi-lens will be documented in this file.
16
16
 
17
17
  ### Security
18
18
 
19
+ ## [4.0.1] - 2026-08-16
20
+
21
+ ### Added
22
+
23
+ - **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.
24
+
25
+ - **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.
26
+
27
+ - **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`.
28
+
29
+ - **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.
30
+
31
+ - **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.
32
+
33
+ - **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.
34
+
35
+ ### Changed
36
+
37
+ - **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`.
38
+
39
+ - **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.
40
+
41
+ - **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.
42
+
43
+ - **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.
44
+
45
+ - **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.
46
+
47
+ ### Fixed
48
+
49
+ - **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.
50
+
51
+ - **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.
52
+
53
+ - **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.
54
+
55
+ - **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.
56
+
57
+ - **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.
58
+
59
+ - **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.
60
+
61
+ - **`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.
62
+
63
+ - **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.
64
+
65
+ - **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.
66
+
67
+ - **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.
68
+
69
+ - **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.
70
+
71
+ - **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.
72
+
73
+ - **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).
74
+
75
+ - **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.
76
+
77
+ - **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.
78
+
79
+ - **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.
80
+
81
+ - **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.
82
+
83
+ - **A cut-off auxiliary no longer reads as confirmed clean (closes #1470)** — When
84
+ the aux grace timer cut off a scanner such as opengrep, the touch still reported
85
+ an unqualified confirmation, so a hung security scanner produced a result that
86
+ read as a clean bill of health. The confirmation is now narrowed rather than
87
+ discarded: it names the servers it does not speak for, so `lsp_diagnostics` says
88
+ which coverage is missing, the cascade no longer wipes a live finding on that
89
+ evidence, the workspace sweep stops caching a partially covered result, and the
90
+ per-edit lane stops reporting an empty result as checked — on both the
91
+ incumbent-touch route and the warm-attach route a per-edit check takes in a
92
+ live session. A primary that
93
+ answered stays trustworthy — its findings still reach you. A related gap stays
94
+ open: a scanner that answers within its budget but publishes nothing still reads
95
+ as clean (#1493).
96
+
97
+ - **A slow first probe no longer disables an installed tool (closes #1476)** —
98
+ biome, ast-grep, Go, cargo and govulncheck now tell a probe timeout apart from
99
+ a missing install, the way knip and madge already did. Before this, one stalled
100
+ second at session warm-up latched "tool is not installed" for the life of the
101
+ process, and biome and ast-grep also paid for an auto-install nobody needed. A
102
+ transient verdict now expires and the tool comes back without a restart, its
103
+ cooldown escalates on a host that stays sick, and every verdict is recorded in
104
+ `latency.log` as `availability_decision` with its cause, timing and retry
105
+ schedule. A structural coverage test parses `clients/` and fails when a new
106
+ version probe parks its verdict outside the shared policy; the hand-rolled
107
+ latches that predate it are listed in the test as named, shrink-only gaps
108
+ rather than being quietly excluded.
109
+
110
+ - **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.
111
+
112
+ - **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.
113
+
19
114
  ## [4.0.0] - 2026-08-14
20
115
 
21
116
  ### Added
@@ -0,0 +1,314 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { logLatency } from "./latency-logger.js";
5
+ import { normalizeMapKey, toProjectRelativePath } from "./path-utils.js";
6
+ import { resolveRunnerPath } from "./dispatch/runner-context.js";
7
+ export const MAX_ADVISORY_AFFECTED_FILES = 256;
8
+ export function advisoryPathKey(filePath, cwd) {
9
+ return normalizeMapKey(path.resolve(cwd, filePath));
10
+ }
11
+ /** Shared with git guard: there is one SHA-256 implementation for advisories. */
12
+ export function advisoryFileHash(filePath) {
13
+ try {
14
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
15
+ }
16
+ catch (error) {
17
+ const code = error.code ?? "unknown";
18
+ return code === "ENOENT" ? "missing" : `unreadable:${code}`;
19
+ }
20
+ }
21
+ function snapshotOne(filePath, cwd, role) {
22
+ const resolved = path.resolve(cwd, filePath);
23
+ try {
24
+ const stat = fs.statSync(resolved);
25
+ return {
26
+ path: resolved,
27
+ role,
28
+ mtimeMs: stat.mtimeMs,
29
+ size: stat.size,
30
+ sha256: advisoryFileHash(resolved),
31
+ };
32
+ }
33
+ catch (error) {
34
+ const code = error.code ?? "unknown";
35
+ return {
36
+ path: resolved,
37
+ role,
38
+ mtimeMs: -1,
39
+ size: -1,
40
+ sha256: code === "ENOENT" ? "missing" : `unreadable:${code}`,
41
+ };
42
+ }
43
+ }
44
+ export function snapshotAdvisoryProvenance(args) {
45
+ const seen = new Set();
46
+ const files = [];
47
+ for (const file of args.files) {
48
+ const key = advisoryPathKey(file.path, args.cwd);
49
+ if (seen.has(key))
50
+ continue;
51
+ seen.add(key);
52
+ files.push(snapshotOne(file.path, args.cwd, file.role));
53
+ }
54
+ return {
55
+ revision: {
56
+ sessionId: args.runtime.telemetrySessionId,
57
+ projectSeq: args.runtime.projectSeq,
58
+ turnIndex: args.runtime.turnIndex,
59
+ generation: args.generation,
60
+ capturedAt: args.capturedAt ?? Date.now(),
61
+ },
62
+ files,
63
+ ...(args.truncated ? { truncated: true } : {}),
64
+ };
65
+ }
66
+ function isFiniteNumber(value) {
67
+ return typeof value === "number" && Number.isFinite(value);
68
+ }
69
+ function isCapturedHash(value) {
70
+ return typeof value === "string" &&
71
+ (/^[a-f0-9]{64}$/.test(value) || value === "missing" || value.startsWith("unreadable:"));
72
+ }
73
+ function isWellFormed(value) {
74
+ if (!value || typeof value !== "object")
75
+ return false;
76
+ const record = value;
77
+ const revision = record.revision;
78
+ return !!revision && typeof revision.sessionId === "string" &&
79
+ isFiniteNumber(revision.projectSeq) && isFiniteNumber(revision.turnIndex) &&
80
+ isFiniteNumber(revision.generation) && isFiniteNumber(revision.capturedAt) &&
81
+ Array.isArray(record.files) && record.files.length > 0 && record.files.every((file) => !!file && typeof file.path === "string" &&
82
+ (file.role === "source" || file.role === "test" || file.role === "affected") &&
83
+ isFiniteNumber(file.mtimeMs) && isFiniteNumber(file.size) &&
84
+ isCapturedHash(file.sha256));
85
+ }
86
+ export function validateAdvisoryProvenance(record, cwd, runtime) {
87
+ if (!isWellFormed(record.provenance)) {
88
+ return { status: "unknown", reasons: ["malformed-or-legacy-provenance"], allFilesDeleted: false, changedPathCount: 0 };
89
+ }
90
+ const provenance = record.provenance;
91
+ const reasons = [];
92
+ let unknown = provenance.truncated === true;
93
+ if (unknown)
94
+ reasons.push("truncated-provenance");
95
+ if (runtime) {
96
+ if (provenance.revision.sessionId !== runtime.telemetrySessionId)
97
+ reasons.push("session-mismatch");
98
+ }
99
+ let deletedFiles = 0;
100
+ const changedPaths = new Set();
101
+ for (const captured of provenance.files) {
102
+ const resolved = path.resolve(cwd, captured.path);
103
+ const reasonsBefore = reasons.length;
104
+ let stat;
105
+ try {
106
+ stat = fs.statSync(resolved);
107
+ }
108
+ catch (error) {
109
+ const code = error.code ?? "unknown";
110
+ if (code === "ENOENT") {
111
+ if (captured.sha256 !== "missing") {
112
+ deletedFiles += 1;
113
+ reasons.push(`missing:${advisoryPathKey(resolved, cwd)}`);
114
+ changedPaths.add(advisoryPathKey(resolved, cwd));
115
+ }
116
+ }
117
+ else {
118
+ unknown = true;
119
+ reasons.push(`unreadable:${advisoryPathKey(resolved, cwd)}:${code}`);
120
+ changedPaths.add(advisoryPathKey(resolved, cwd));
121
+ }
122
+ continue;
123
+ }
124
+ if (captured.sha256.startsWith("unreadable:")) {
125
+ unknown = true;
126
+ reasons.push(`capture-unreadable:${advisoryPathKey(resolved, cwd)}`);
127
+ changedPaths.add(advisoryPathKey(resolved, cwd));
128
+ continue;
129
+ }
130
+ if (captured.sha256 === "missing") {
131
+ reasons.push(`created:${advisoryPathKey(resolved, cwd)}`);
132
+ changedPaths.add(advisoryPathKey(resolved, cwd));
133
+ continue;
134
+ }
135
+ if (stat.mtimeMs !== captured.mtimeMs || stat.size !== captured.size) {
136
+ reasons.push(`metadata-changed:${advisoryPathKey(resolved, cwd)}`);
137
+ }
138
+ const currentHash = advisoryFileHash(resolved);
139
+ if (currentHash.startsWith("unreadable:")) {
140
+ unknown = true;
141
+ reasons.push(`${currentHash}:${advisoryPathKey(resolved, cwd)}`);
142
+ }
143
+ else if (currentHash !== captured.sha256) {
144
+ reasons.push(`content-changed:${advisoryPathKey(resolved, cwd)}`);
145
+ }
146
+ if (reasons.length > reasonsBefore)
147
+ changedPaths.add(advisoryPathKey(resolved, cwd));
148
+ }
149
+ const allFilesDeleted = deletedFiles === provenance.files.length;
150
+ if (unknown)
151
+ return { status: "unknown", reasons, allFilesDeleted, changedPathCount: changedPaths.size };
152
+ return reasons.length > 0
153
+ ? { status: "superseded", reasons, allFilesDeleted, changedPathCount: changedPaths.size }
154
+ : { status: "current", reasons: [], allFilesDeleted, changedPathCount: 0 };
155
+ }
156
+ // ── Finding-cited path existence (#1461 slice 1) ──────────────────────────────
157
+ //
158
+ // `validateAdvisoryProvenance` above answers "did the files the agent EDITED
159
+ // change since capture?". A cached scanner finding asks a different question:
160
+ // "does the file this finding NAMES still exist?". #1460's live case is the gap
161
+ // between them — a gitleaks blocker for a directory deleted eleven minutes
162
+ // earlier shipped as `current` seven consecutive times, because the edited
163
+ // files were all intact and the cited path was never in the envelope.
164
+ //
165
+ // CONTRACT (the five remaining #1461 slices reuse this verbatim):
166
+ // - Input is any finding shape; the caller supplies `citedPath`, so nothing
167
+ // here knows about gitleaks, trivy, govulncheck, vulture, or delta mode.
168
+ // - A finding whose cited path is absent (ENOENT/ENOTDIR) is DROPPED, not
169
+ // demoted. There is no remediation for a file that is gone — the agent
170
+ // cannot rotate a credential in it or delete a line from it. Content drift
171
+ // on a SURVIVING file stays `validateAdvisoryProvenance`'s job, and that
172
+ // one demotes.
173
+ // - Fail open, never closed: a finding with no cited path, an unreadable
174
+ // path (EACCES/EPERM/EBUSY), or a path past the stat budget is DELIVERED.
175
+ // Unreadable is not absent; a missed drop is noise, a wrong drop is a lost
176
+ // secret.
177
+ // - Bounded cost: findings are deduped by their RAW cited string first —
178
+ // zero filesystem work — before anything pays for `resolveRunnerPath`'s
179
+ // ancestor walk or `normalizeMapKey`'s realpath. A first cut that deduped
180
+ // by the resolved/canonical key instead still ran that expensive step
181
+ // once per FINDING, not once per unique path, because the dedup lookup
182
+ // came after the cost it was meant to avoid: #1461's live 126-finding
183
+ // record measured 72.7ms in that shape, all of it before the first
184
+ // `statSync`. Deduping the raw string first drops the same record to a
185
+ // ~1.6ms median (this repo, Windows, 10-sample bench) — the remaining
186
+ // cost is one canonicalization + one stat per distinct cited string,
187
+ // capped at `MAX_FINDING_PATH_STATS`.
188
+ // - Path identity uses the guard normalizer (`resolveRunnerPath` →
189
+ // `normalizeMapKey`) for the canonical-key dedup and the shape-aware
190
+ // `toProjectRelativePath` for display, so Windows spellings of one path
191
+ // collapse to one stat and one log entry (defect shapes 1 and 2). This
192
+ // helper has no zero-I/O contract of its own to protect — it stats.
193
+ // Slice 3 (delta mode) must find and name its OWN seam before reusing
194
+ // this shape; `formatDeltaMode` (tools/lens-diagnostics.ts) reads only
195
+ // actionable-warnings, code-quality-warnings, and the delta report, none
196
+ // of which any #1461 slice writes yet, so nothing here currently touches
197
+ // it.
198
+ /** Stat budget: one per unique cited path, sharing the envelope's own cap. */
199
+ export const MAX_FINDING_PATH_STATS = MAX_ADVISORY_AFFECTED_FILES;
200
+ /** How many dead paths a single drop record names before it stops. */
201
+ const MAX_LOGGED_DEAD_PATHS = 3;
202
+ export function findingPathExistence(resolvedPath) {
203
+ try {
204
+ fs.statSync(resolvedPath);
205
+ return "live";
206
+ }
207
+ catch (error) {
208
+ const code = error.code ?? "unknown";
209
+ // ENOTDIR: an ancestor component is no longer a directory — the cited
210
+ // path cannot exist either, same as ENOENT.
211
+ return code === "ENOENT" || code === "ENOTDIR" ? "missing" : "unknown";
212
+ }
213
+ }
214
+ /**
215
+ * Partition findings into `{live, dropped}` by whether the path each one names
216
+ * still exists. Pure apart from the `fs.statSync` probe, which is injectable so
217
+ * the partition rules are unit-testable without a filesystem.
218
+ */
219
+ export function partitionFindingsByCitedPath(args) {
220
+ const limit = args.maxUniquePaths ?? MAX_FINDING_PATH_STATS;
221
+ const probe = args.existence ?? findingPathExistence;
222
+ // Two dedup layers, cheapest first. `rawVerdicts` collapses findings that
223
+ // cite the IDENTICAL string with zero filesystem work — the dominant case
224
+ // (#1460's live record: 126 findings over a handful of distinct `file`
225
+ // strings). Only a raw string not seen before pays for
226
+ // `resolveRunnerPath`/`advisoryPathKey`, which folds FS-confirmed spelling
227
+ // variants (case, separators, ancestor walk-up) into `verdicts`, the
228
+ // canonical-key map that `statCount` reports. Keying the expensive work by
229
+ // the RESOLVED path instead of the raw one (the pre-#1461-HIGH-2 shape)
230
+ // still called it once per finding, since the dedup lookup came after the
231
+ // cost it was meant to dedupe.
232
+ const rawVerdicts = new Map();
233
+ const verdicts = new Map();
234
+ const live = [];
235
+ const dropped = [];
236
+ const deadPaths = [];
237
+ let truncated = false;
238
+ for (const finding of args.findings) {
239
+ const cited = args.citedPath(finding);
240
+ if (!cited) {
241
+ live.push(finding);
242
+ continue;
243
+ }
244
+ let verdict = rawVerdicts.get(cited);
245
+ if (verdict === undefined) {
246
+ // Ancestor-tolerant, same as `toRunnerDisplayPath`'s resolution in
247
+ // runtime-turn.ts — a bare `path.resolve` here would decide the drop
248
+ // against a different root than the one used to render the survivor,
249
+ // dropping findings the display path would have shown correctly
250
+ // (#1461 HIGH-1). `resolveRunnerPath` already runs its result through
251
+ // `normalizeMapKey`, so the resolved path IS the canonical key — a
252
+ // second `advisoryPathKey` pass would re-pay the same realpath cost.
253
+ const resolved = resolveRunnerPath(args.cwd, cited);
254
+ const key = resolved;
255
+ verdict = verdicts.get(key);
256
+ if (verdict === undefined) {
257
+ if (verdicts.size >= limit) {
258
+ // Budget spent on paths we have not seen before — deliver rather
259
+ // than guess. Already-probed paths keep their cached verdict.
260
+ truncated = true;
261
+ verdict = "live";
262
+ }
263
+ else {
264
+ verdict = probe(resolved);
265
+ verdicts.set(key, verdict);
266
+ if (verdict === "missing")
267
+ deadPaths.push(resolved);
268
+ }
269
+ }
270
+ rawVerdicts.set(cited, verdict);
271
+ }
272
+ if (verdict === "missing")
273
+ dropped.push(finding);
274
+ else
275
+ live.push(finding);
276
+ }
277
+ return { live, dropped, deadPaths, statCount: verdicts.size, truncated };
278
+ }
279
+ /**
280
+ * Delivery-seam wrapper: partition, then emit one bounded `finding_dead_path_drop`
281
+ * record when anything was dropped, and return only what is safe to deliver.
282
+ *
283
+ * The record is the #1432 Gap 1 principle applied here — an eviction that logs
284
+ * nothing is only confirmable by the absence of complaints. One record per
285
+ * store per delivery, with a capped path sample; never one per finding.
286
+ */
287
+ export function dropFindingsForMissingPaths(args) {
288
+ const partition = partitionFindingsByCitedPath(args);
289
+ if (partition.dropped.length === 0)
290
+ return partition.live;
291
+ logLatency({
292
+ type: "phase",
293
+ phase: "finding_dead_path_drop",
294
+ filePath: args.cwd,
295
+ durationMs: 0,
296
+ metadata: {
297
+ store: args.store,
298
+ droppedDeadPaths: partition.dropped.length,
299
+ deadPathCount: partition.deadPaths.length,
300
+ deliveredCount: partition.live.length,
301
+ statCount: partition.statCount,
302
+ samplePaths: partition.deadPaths
303
+ .slice(0, MAX_LOGGED_DEAD_PATHS)
304
+ .map((deadPath) => toProjectRelativePath(deadPath, args.cwd)),
305
+ ...(partition.truncated ? { truncated: true } : {}),
306
+ },
307
+ });
308
+ return partition.live;
309
+ }
310
+ export function provenanceStamp(provenance) {
311
+ if (!isWellFormed(provenance))
312
+ return "session unknown / turn unknown / generation unknown";
313
+ return `session ${provenance.revision.sessionId} / turn ${provenance.revision.turnIndex} / generation ${provenance.revision.generation}`;
314
+ }
@@ -169,12 +169,19 @@ export function wireAgentNudgeSubscriber(args) {
169
169
  * - all local → "after your last turn" (the original #485 wording,
170
170
  * unchanged — verified by the pre-existing #485
171
171
  * tests);
172
- * - all cross-process → "by another pi-lens instance (e.g. a subagent's)";
173
- * - mixed → "after your last turn (N of them by another
174
- * pi-lens instance)" — the base framing stays local
172
+ * - all cross-process → "by an automatic run outside your turn";
173
+ * - mixed → "after your last turn (N of them by an automatic
174
+ * run outside it)" — the base framing stays local
175
175
  * and the cross-process portion is counted out
176
- * precisely, so no local file is ever misattributed
177
- * to another instance.
176
+ * precisely, so no local file is ever misattributed.
177
+ *
178
+ * The cross-process wording names the ACTION, not the process identity. It
179
+ * used to read "by another pi-lens instance (e.g. a subagent's)", which sent
180
+ * agents investigating what other instance was running — the exact behavior
181
+ * this nudge exists to prevent. Which process formatted the file is an
182
+ * implementation detail the reading agent cannot act on; that it was an
183
+ * automatic run outside their turn is the whole actionable content. The
184
+ * origin split stays exact in the telemetry for human debugging.
178
185
  *
179
186
  * Clears the accumulator ONLY here, on actual injection — never on
180
187
  * agent_end/agent_settled/turn_start. Files formatted at the last turn_end of
@@ -222,10 +229,10 @@ export function consumeAgentNudge(dbg) {
222
229
  // file to another instance — a mixed batch keeps the local base framing
223
230
  // and calls out the cross-process portion by exact count.
224
231
  const attribution = localCount === 0
225
- ? "by another pi-lens instance (e.g. a subagent's)"
232
+ ? "by an automatic run outside your turn"
226
233
  : crossProcessCount === 0
227
234
  ? "after your last turn"
228
- : `after your last turn (${crossProcessCount} of them by another pi-lens instance)`;
235
+ : `after your last turn (${crossProcessCount} of them by an automatic run outside it)`;
229
236
  const message = `pi-lens: ${filesTotal} file(s) were ${verbLabel} ${attribution}: ${nameList} — working-tree changes to these are expected; re-read before editing.`;
230
237
  logLatency({
231
238
  type: "phase",