@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
@@ -25,25 +25,38 @@ import { CacheManager, MCP_TURN_STATE_OWNER_ID, } from "../cache-manager.js";
25
25
  import { resetDispatchBaselines } from "../dispatch/integration.js";
26
26
  import { resetFormatService } from "../format-service.js";
27
27
  import { getLSPService, resetLSPService } from "../lsp/index.js";
28
- import { consumeSessionStartGuidance, consumeTestFindings, consumeTurnEndFindings, peekTestFindings, peekTurnEndFindings, } from "../runtime-context.js";
28
+ import { acknowledgeTestFindings, acknowledgeTurnEndFindings, consumeSessionStartGuidance, consumeTestFindings, consumeTurnEndFindings, peekTestFindings, peekTurnEndFindings, } from "../runtime-context.js";
29
29
  import { RuntimeCoordinator } from "../runtime-coordinator.js";
30
30
  import { handleSessionStart } from "../runtime-session.js";
31
31
  import { handleTurnEnd } from "../runtime-turn.js";
32
32
  import { createMcpHost } from "./host-shim.js";
33
33
  let contextPromise;
34
+ let resolvedRuntime;
34
35
  /** Lazily build (once) the persistent session context shared across MCP calls. */
35
36
  export function getMcpSessionContext() {
36
- contextPromise ??= (async () => ({
37
- runtime: new RuntimeCoordinator(),
38
- cacheManager: new CacheManager(),
39
- astGrepClient: new AstGrepClient(),
40
- clients: await loadBootstrapClients(),
41
- }))();
37
+ contextPromise ??= (async () => {
38
+ const context = {
39
+ runtime: new RuntimeCoordinator(),
40
+ cacheManager: new CacheManager(),
41
+ astGrepClient: new AstGrepClient(),
42
+ clients: await loadBootstrapClients(),
43
+ };
44
+ resolvedRuntime = context.runtime;
45
+ return context;
46
+ })();
42
47
  return contextPromise;
43
48
  }
49
+ /** Synchronous peek at the session runtime, for construction sites that cannot
50
+ * await (e.g. the MCP server's tool wiring). Returns undefined until a session
51
+ * context has actually been built — advisory validation then honestly skips
52
+ * the session-identity check instead of comparing against a phantom session. */
53
+ export function peekMcpSessionRuntime() {
54
+ return resolvedRuntime;
55
+ }
44
56
  /** Test hook — drop the cached context so a fresh one is built next call. */
45
57
  export function _resetMcpSessionContext() {
46
58
  contextPromise = undefined;
59
+ resolvedRuntime = undefined;
47
60
  }
48
61
  const noop = () => { };
49
62
  function joinMessages(consumed) {
@@ -219,13 +232,13 @@ async function runTurnEndNow(cwd, files = [], deferredDelivery = false) {
219
232
  });
220
233
  const outcome = deferredDelivery
221
234
  ? {
222
- turnEnd: joinMessages(peekTurnEndFindings(ctx.cacheManager, cwd)),
223
- tests: joinMessages(peekTestFindings(ctx.cacheManager, cwd)),
235
+ turnEnd: joinMessages(peekTurnEndFindings(ctx.cacheManager, cwd, ctx.runtime, true)),
236
+ tests: joinMessages(peekTestFindings(ctx.cacheManager, cwd, ctx.runtime, true)),
224
237
  filesRegistered: registered,
225
238
  }
226
239
  : {
227
- turnEnd: joinMessages(consumeTurnEndFindings(ctx.cacheManager, cwd)),
228
- tests: joinMessages(consumeTestFindings(ctx.cacheManager, cwd)),
240
+ turnEnd: joinMessages(consumeTurnEndFindings(ctx.cacheManager, cwd, ctx.runtime)),
241
+ tests: joinMessages(consumeTestFindings(ctx.cacheManager, cwd, ctx.runtime)),
229
242
  filesRegistered: registered,
230
243
  };
231
244
  return {
@@ -233,8 +246,8 @@ async function runTurnEndNow(cwd, files = [], deferredDelivery = false) {
233
246
  commit: () => {
234
247
  if (!deferredDelivery)
235
248
  return;
236
- consumeTurnEndFindings(ctx.cacheManager, cwd);
237
- consumeTestFindings(ctx.cacheManager, cwd);
249
+ acknowledgeTurnEndFindings(ctx.cacheManager, cwd);
250
+ acknowledgeTestFindings(ctx.cacheManager, cwd);
238
251
  },
239
252
  };
240
253
  }
@@ -306,16 +319,16 @@ function runTurnEndForIpcNow(cwd) {
306
319
  }
307
320
  const ctx = await getMcpSessionContext();
308
321
  const cachedOutcome = {
309
- turnEnd: joinMessages(peekTurnEndFindings(ctx.cacheManager, cwd)),
310
- tests: joinMessages(peekTestFindings(ctx.cacheManager, cwd)),
322
+ turnEnd: joinMessages(peekTurnEndFindings(ctx.cacheManager, cwd, ctx.runtime, true)),
323
+ tests: joinMessages(peekTestFindings(ctx.cacheManager, cwd, ctx.runtime, true)),
311
324
  filesRegistered: 0,
312
325
  };
313
326
  const transaction = hasTurnEndFindings(cachedOutcome)
314
327
  ? {
315
328
  outcome: cachedOutcome,
316
329
  commit: () => {
317
- consumeTurnEndFindings(ctx.cacheManager, cwd);
318
- consumeTestFindings(ctx.cacheManager, cwd);
330
+ acknowledgeTurnEndFindings(ctx.cacheManager, cwd);
331
+ acknowledgeTestFindings(ctx.cacheManager, cwd);
319
332
  },
320
333
  }
321
334
  : await runTurnEndNow(cwd, [], true);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Provider derivation for worklog/disposition attribution (#1448).
3
+ *
4
+ * The host reports `provider` and `model` as separate fields on its telemetry
5
+ * events (see `updateRuntimeIdentityFromEvent` in index.ts and the
6
+ * `tool_result` handler in clients/runtime-tool-result.ts) MOST of the time —
7
+ * so the common case needs no parsing at all. This parser exists only for the
8
+ * fallback: a model id supplied without an explicit provider.
9
+ *
10
+ * Derivation rule (conservative — returns "" rather than guess):
11
+ * 1. A single `/` or `:` separator splits the id into `<provider>/<rest>`
12
+ * or `<provider>:<rest>` — e.g. "anthropic/claude-sonnet-4-5",
13
+ * "openai:gpt-5". The left segment is lowercased and returned AS-IS
14
+ * (no allowlist check) since an explicit separator is deliberate host
15
+ * structure, not a guess.
16
+ * 2. No separator: match a known model-id PREFIX against a small table of
17
+ * vendor naming conventions (claude- -> anthropic; gpt-, o1-, o3- ->
18
+ * openai; gemini- -> google; llama- -> meta; mistral-, mixtral- ->
19
+ * mistral; command- -> cohere). First match wins.
20
+ * 3. Anything else (unrecognized prefix, multiple separators, empty
21
+ * string) is ambiguous -> "".
22
+ */
23
+ const KNOWN_PREFIXES = [
24
+ ["claude-", "anthropic"],
25
+ ["gpt-", "openai"],
26
+ ["o1-", "openai"],
27
+ ["o3-", "openai"],
28
+ ["gemini-", "google"],
29
+ ["llama-", "meta"],
30
+ ["mistral-", "mistral"],
31
+ ["mixtral-", "mistral"],
32
+ ["command-", "cohere"],
33
+ ];
34
+ export function deriveProviderFromModelId(modelId) {
35
+ const id = modelId?.trim().toLowerCase();
36
+ if (!id)
37
+ return "";
38
+ const separators = [...id].filter((c) => c === "/" || c === ":").length;
39
+ if (separators === 1) {
40
+ const sep = id.includes("/") ? "/" : ":";
41
+ const [providerPart, ...rest] = id.split(sep);
42
+ if (providerPart && rest.join(sep).length > 0)
43
+ return providerPart;
44
+ return "";
45
+ }
46
+ if (separators > 1)
47
+ return "";
48
+ for (const [prefix, provider] of KNOWN_PREFIXES) {
49
+ if (id.startsWith(prefix))
50
+ return provider;
51
+ }
52
+ return "";
53
+ }
@@ -843,7 +843,25 @@ export async function runPipeline(ctx, deps) {
843
843
  });
844
844
  // --- 3. Auto-fix ---
845
845
  phase.start("autofix");
846
- const { fixedCount, autofixTools, attemptedTools, changedFiles: autofixChangedFiles, needsContentRefresh: fixRefresh, skipReason: autofixSkipReason, } = await runAutofix(filePath, cwd, getFlag, dbg, deps, getFlagSource);
846
+ let fixedCount = 0;
847
+ let autofixTools = [];
848
+ let attemptedTools = [];
849
+ let autofixChangedFiles = [];
850
+ let fixRefresh = false;
851
+ let autofixSkipReason;
852
+ if (ctx.autofixMode === "deferred") {
853
+ autofixSkipReason = "deferred_to_agent_end";
854
+ dbg(`autofix: deferred until agent_end for ${filePath}`);
855
+ }
856
+ else
857
+ ({
858
+ fixedCount,
859
+ autofixTools,
860
+ attemptedTools,
861
+ changedFiles: autofixChangedFiles,
862
+ needsContentRefresh: fixRefresh,
863
+ skipReason: autofixSkipReason,
864
+ } = await runAutofix(filePath, cwd, getFlag, dbg, deps, getFlagSource));
847
865
  for (const changedFile of autofixChangedFiles) {
848
866
  piChangedFiles.add(path.resolve(changedFile));
849
867
  }
@@ -903,6 +921,8 @@ export async function runPipeline(ctx, deps) {
903
921
  }, {
904
922
  projectRoot: ctx.projectRoot,
905
923
  writeIndex: ctx.telemetry?.writeIndex,
924
+ telemetryModel: ctx.telemetry?.modelId,
925
+ telemetryProvider: ctx.telemetry?.provider,
906
926
  });
907
927
  recordDiagnostics(filePath, dispatchResult.diagnostics, ctx.telemetry?.writeIndex);
908
928
  // #502: emit the write batch's FINAL diagnostic state immediately after
@@ -999,7 +1019,11 @@ export async function runPipeline(ctx, deps) {
999
1019
  const fileList = changedList.length
1000
1020
  ? "\nModified files:\n" + topFiles + overflow
1001
1021
  : "";
1002
- output += `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
1022
+ const targetHasAuthoritativeAttachment = ctx.autofixMode !== "deferred" &&
1023
+ autofixChangedFiles.some((changedFile) => path.resolve(changedFile) === path.resolve(filePath));
1024
+ output += targetHasAuthoritativeAttachment
1025
+ ? `\n\n⚠️ **The attached full content for ${toRunnerDisplayPath(cwd, filePath)} is authoritative after autofix. You MUST re-read any other modified side-effect files before editing them.**${fileList}`
1026
+ : `\n\n⚠️ **File was modified by auto-format/fix. You MUST re-read modified file(s) before making any further edits — the content on disk has changed (whitespace, indentation, quotes, or code). Editing from memory will produce mismatches.**${fileList}`;
1003
1027
  }
1004
1028
  phase.end("dispatch_lint", {
1005
1029
  hasOutput: !!dispatchResult.output,
@@ -1088,5 +1112,8 @@ export async function runPipeline(ctx, deps) {
1088
1112
  formattersUsed,
1089
1113
  fixedCount,
1090
1114
  autofixTools,
1115
+ postMutation: ctx.autofixMode !== "deferred" && autofixChangedFiles.some((f) => path.resolve(f) === path.resolve(filePath)) && fileContent !== undefined
1116
+ ? { filePath: path.resolve(filePath), content: fileContent, source: "autofix" }
1117
+ : undefined,
1091
1118
  };
1092
1119
  }
@@ -383,7 +383,7 @@ export async function fetchFreshProjectDiagnostics(cacheManager, cwd, clients, s
383
383
  cold.push("test-runner");
384
384
  return;
385
385
  }
386
- record("test-runner", testRunnerFindingsToProjectDiagnostics(cached.data), Date.now() - startMs);
386
+ record("test-runner", testRunnerFindingsToProjectDiagnostics(cached.data, analysisRoot, options.runtime), Date.now() - startMs);
387
387
  }),
388
388
  ];
389
389
  // Swallow any later rejection so an aborted-and-abandoned task can never
@@ -1,3 +1,6 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { advisoryPathKey, validateAdvisoryProvenance, } from "../../advisory-provenance.js";
1
4
  function failureMessage(failure) {
2
5
  const firstLine = failure.message.split("\n")[0]?.slice(0, 300) ?? "";
3
6
  return firstLine ? `${failure.name}: ${firstLine}` : failure.name;
@@ -71,8 +74,26 @@ export function testResultToProjectDiagnostics(result, stale = false) {
71
74
  },
72
75
  ];
73
76
  }
74
- export function testRunnerFindingsToProjectDiagnostics(cache) {
77
+ export function testRunnerFindingsToProjectDiagnostics(cache, cwd, runtime) {
75
78
  if (!cache.results || cache.results.length === 0)
76
79
  return [];
77
- return cache.results.flatMap((r) => testResultToProjectDiagnostics(r, cache.stale));
80
+ const validation = cwd
81
+ ? validateAdvisoryProvenance(cache, cwd, runtime)
82
+ : { status: "unknown", reasons: ["validation-context-missing"] };
83
+ const root = cwd ?? process.cwd();
84
+ const missingKeys = new Set((cache.provenance?.files ?? [])
85
+ .filter((file) => !fs.existsSync(path.resolve(root, file.path)))
86
+ .map((file) => advisoryPathKey(file.path, root)));
87
+ const historical = cache.superseded === true || cache.stale === true || validation.status !== "current";
88
+ return cache.results
89
+ .filter((result) => !missingKeys.has(advisoryPathKey(result.file, root)))
90
+ .flatMap((result) => testResultToProjectDiagnostics(result, historical))
91
+ .map((diagnostic) => historical ? {
92
+ ...diagnostic,
93
+ severity: "info",
94
+ semantic: "none",
95
+ message: diagnostic.message.startsWith("[stale")
96
+ ? diagnostic.message
97
+ : `[historical — re-run to confirm] ${diagnostic.message}`,
98
+ } : diagnostic);
78
99
  }
@@ -1,3 +1,4 @@
1
+ import { detectFileRole } from "../file-role.js";
1
2
  import { normalizeMapKey } from "../path-utils.js";
2
3
  import { findModuleForPath, getDownstreamModules, getModuleSourceFiles, } from "./workspace-modules.js";
3
4
  function dedupe(items) {
@@ -76,6 +77,29 @@ export function computeImpactCascade(graph, changedFile, moduleGraph) {
76
77
  const normalizedFile = normalizeMapKey(changedFile);
77
78
  const fileNodeId = graph.fileNodes.get(normalizedFile);
78
79
  if (!fileNodeId) {
80
+ // #1445: a missing node has two causes that look identical here but mean
81
+ // opposite things — "the graph SHOULD know this file but doesn't" (a real
82
+ // gap) versus "this file's role is excluded from the graph by design"
83
+ // (test files, #260 — every graph-admission chokepoint in builder.ts
84
+ // guards on this exact condition, `detectFileRole(file) === "test"`;
85
+ // reused verbatim here rather than a second role list). A test edit not
86
+ // cascading is expected behavior, not a graph failure, so it must not
87
+ // produce the "review graph was unavailable" advisory that misled agents
88
+ // into distrusting a healthy graph 19% of the time in dogfooding.
89
+ if (detectFileRole(normalizedFile) === "test") {
90
+ return {
91
+ filePath: normalizedFile,
92
+ changedSymbols: [],
93
+ directImporters: [],
94
+ directCallers: [],
95
+ neighborFiles: [],
96
+ riskFlags: [],
97
+ indeterminate: {
98
+ reason: "excluded_by_role",
99
+ detail: "test-role file — excluded from the review graph by design (#260)",
100
+ },
101
+ };
102
+ }
79
103
  // #1023: the changed file has no node in the graph — we CANNOT enumerate
80
104
  // its dependents, so this empty result is "couldn't compute", NOT "nothing
81
105
  // depends on it". Mark it indeterminate so callers surface an honest
@@ -0,0 +1,55 @@
1
+ /**
2
+ * #1479/#1480: one place decides whether a test run was actually timed, and
3
+ * one place renders the answer.
4
+ *
5
+ * `TestResult.duration` is optional, and the two states are different facts.
6
+ * A present number is a MEASUREMENT — including `0`, because pytest really
7
+ * does print `in 0.00s` and a suite whose `startTime` equals its `endTime`
8
+ * really did run in under a millisecond. An absent value means no
9
+ * runner-reported figure was found at all.
10
+ *
11
+ * That contract was written down once, in the doc comment on
12
+ * `TestResult.duration`, and then re-derived at every site that reads or
13
+ * produces a duration. A reader of any one of those sites cannot see the
14
+ * contract, and a producer that reaches for `0` when it means "did not
15
+ * measure" reintroduces exactly the defect #1479 removed. These three
16
+ * functions are the contract in executable form: `isMeasuredDuration`
17
+ * classifies, `toMeasuredDurationMs` normalises a freshly parsed figure into
18
+ * it, and `formatRunDurationMs` renders it.
19
+ *
20
+ * NOTE FOR ANYONE EDITING `isMeasuredDuration`: the predicate is `>= 0`, not
21
+ * `> 0`. A measured zero is real. `> 0` looks like a harmless tightening and
22
+ * is the single change that would silently restore the bug.
23
+ *
24
+ * Kept in its own module rather than exported from `test-runner-client.ts`:
25
+ * `runtime-turn.ts` needs it on the hot turn-end path, and the client is
26
+ * deliberately loaded lazily (`bootstrap.ts` dynamic-imports it), so a value
27
+ * import from there would drag the whole runner into startup.
28
+ */
29
+ /**
30
+ * True only for a value that represents a real measurement.
31
+ *
32
+ * `0` is measured. `undefined`, `null`, `NaN`, the infinities, and a negative
33
+ * figure are not: the last three can only come from a garbled parse, and a
34
+ * garbled parse must degrade to "we did not measure this", never to a wrong
35
+ * number.
36
+ */
37
+ export function isMeasuredDuration(duration) {
38
+ return (typeof duration === "number" && Number.isFinite(duration) && duration >= 0);
39
+ }
40
+ /**
41
+ * Normalise a parsed elapsed time to the `TestResult.duration` contract.
42
+ *
43
+ * Returns whole milliseconds for a real reading — `0` included — and
44
+ * `undefined` for anything that is not one, so a producer never has to spell
45
+ * out the absent case itself.
46
+ */
47
+ export function toMeasuredDurationMs(duration) {
48
+ return isMeasuredDuration(duration) ? Math.round(duration) : undefined;
49
+ }
50
+ /** Render a duration for a log line: `123ms`, or `unmeasured` when absent. */
51
+ export function formatRunDurationMs(duration) {
52
+ return isMeasuredDuration(duration)
53
+ ? `${Math.round(duration)}ms`
54
+ : "unmeasured";
55
+ }
@@ -7,9 +7,10 @@ import { logLatency } from "./latency-logger.js";
7
7
  import { isPathIgnoredByProject } from "./file-utils.js";
8
8
  import { newLspMutationCorrelationId } from "./lsp-mutation.js";
9
9
  import { getGlobalActionableWarningMaxFixes, } from "./lens-config.js";
10
- import { resyncLspFile, runFormatPhase } from "./pipeline.js";
10
+ import { resyncLspFile, runAutofix, runFormatPhase } from "./pipeline.js";
11
11
  import { getAmbientAbortSignal } from "./safe-spawn.js";
12
12
  import { appendProjectChange, } from "./project-changes.js";
13
+ import { getAutofixPolicyForFile, hasBiomeConfig, hasDetektConfig, hasEslintConfig, hasGolangciConfig, hasKtfmtConfig, hasKtlintConfig, hasOxlintConfig, hasRubocopConfig, hasSqlfluffConfig, hasStylelintConfig, } from "./tool-policy.js";
13
14
  /**
14
15
  * A queued file is claimed by any flush once it has sat unclaimed by its own
15
16
  * owning session for this long — recovery for an orphaned record whose
@@ -40,13 +41,49 @@ function recordProjectChange(args) {
40
41
  args.dbg(`project change log append failed for ${args.filePath}: ${err}`);
41
42
  }
42
43
  }
43
- export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, dbg, runtime, cacheManager, getFormatService, currentSessionId, staleAfterMs = DEFERRED_FORMAT_STALE_AFTER_MS, }) {
44
+ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, dbg, runtime, cacheManager, getFormatService, biomeClient, ruffClient, getAutofixClients, currentSessionId, staleAfterMs = DEFERRED_FORMAT_STALE_AFTER_MS, }) {
44
45
  // #791: ownership-filtered drain — records queued by a DIFFERENT known
45
46
  // session (e.g. a concurrent in-process secondary/subagent) stay queued
46
47
  // for their owner's own agent_end, unless they've been stale long enough
47
48
  // to fall back to "claim as orphaned" (see claimDeferredFormatFiles).
48
- const { claimed, staleClaimed, deferredToOwner } = runtime.claimDeferredFormatFiles(currentSessionId, Date.now(), staleAfterMs);
49
+ const { claimed, staleClaimed, deferredToOwner } = runtime.claimDeferredMutations(currentSessionId, Date.now(), staleAfterMs);
49
50
  const records = [...claimed, ...staleClaimed];
51
+ const requeuedKinds = new Set();
52
+ // S2d (gap 4, #1432 review): the aggregate `agent_end_deferred_mutation_drain`
53
+ // row only carried a coalesced `requeuedKinds` set with no reason, per-call
54
+ // file count, or tool provenance — every requeue (abort mid-drain, missing
55
+ // autofix clients, an autofix/format failure) collapsed into one
56
+ // indistinguishable bucket. Log one bounded record PER requeue call instead,
57
+ // so a real incident can tell "aborted with N files still queued" apart
58
+ // from "M files failed to format".
59
+ const requeue = (pending, reason) => {
60
+ if (pending.length === 0)
61
+ return;
62
+ const kinds = new Set();
63
+ const toolNames = new Set();
64
+ for (const record of pending) {
65
+ for (const kind of record.kinds) {
66
+ requeuedKinds.add(kind);
67
+ kinds.add(kind);
68
+ }
69
+ for (const toolName of record.toolNames)
70
+ toolNames.add(toolName);
71
+ }
72
+ logLatency({
73
+ type: "phase",
74
+ toolName: "agent_end",
75
+ filePath: ctxCwd ?? runtime.projectRoot,
76
+ phase: "agent_end_deferred_mutation_requeue",
77
+ durationMs: 0,
78
+ metadata: {
79
+ reason,
80
+ kinds: [...kinds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
81
+ fileCount: pending.length,
82
+ toolNames: [...toolNames].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
83
+ },
84
+ });
85
+ runtime.requeueDeferredMutations(pending);
86
+ };
50
87
  const rootActionableAutofixEnabled = !!getFlag("lens-actionable-warning-autofix");
51
88
  // A path-aware source resolver signals that nested configs may re-enable
52
89
  // actionable autofix even when the root default is off. Legacy/test hosts
@@ -97,6 +134,86 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
97
134
  // publishFilesTouched call so consumers can tell "pi-lens formatted this"
98
135
  // from an agent edit.
99
136
  const deferredFormatFixes = [];
137
+ const deferredAutofixFixes = [];
138
+ const deferredAutofixChanged = new Set();
139
+ // Mutation ordering is intentional: lint --write may disturb wrapping, so
140
+ // autofix reaches the final edited state first and formatting stabilizes it.
141
+ const ambientSignal = getAmbientAbortSignal();
142
+ const executedAutofixScopes = new Set();
143
+ const autofixRecords = records.filter((candidate) => candidate.kinds.has("autofix"));
144
+ if (autofixRecords.length > 0 && (!biomeClient || !ruffClient) && getAutofixClients) {
145
+ ({ biomeClient, ruffClient } = await getAutofixClients());
146
+ }
147
+ for (let autofixIndex = 0; autofixIndex < autofixRecords.length; autofixIndex++) {
148
+ const record = autofixRecords[autofixIndex];
149
+ if (ambientSignal?.aborted) {
150
+ requeue(autofixRecords.slice(autofixIndex).map((pending) => ({
151
+ ...pending,
152
+ kinds: new Set(["autofix"]),
153
+ toolNames: new Set(pending.toolNames),
154
+ })), "abort");
155
+ break;
156
+ }
157
+ const filePath = path.resolve(record.filePath);
158
+ if (!nodeFs.existsSync(filePath)) {
159
+ summary.skipped.push({ filePath, reason: "missing" });
160
+ continue;
161
+ }
162
+ if (!biomeClient || !ruffClient) {
163
+ dbg(`agent_end deferred_autofix: clients unavailable for ${filePath}`);
164
+ requeue([{ ...record, kinds: new Set(["autofix"]), toolNames: new Set(record.toolNames) }], "clients-unavailable");
165
+ continue;
166
+ }
167
+ const policy = getAutofixPolicyForFile(filePath, {
168
+ hasEslintConfig: hasEslintConfig(record.cwd),
169
+ hasStylelintConfig: hasStylelintConfig(record.cwd),
170
+ hasSqlfluffConfig: hasSqlfluffConfig(record.cwd),
171
+ hasRubocopConfig: hasRubocopConfig(record.cwd),
172
+ hasBiomeConfig: hasBiomeConfig(record.cwd),
173
+ hasGolangciConfig: hasGolangciConfig(record.cwd),
174
+ hasDetektConfig: hasDetektConfig(record.cwd),
175
+ hasOxlintConfig: hasOxlintConfig(record.cwd),
176
+ hasKtfmtConfig: hasKtfmtConfig(record.cwd),
177
+ hasKtlintConfig: hasKtlintConfig(record.cwd),
178
+ });
179
+ const scopeKey = policy?.scope && policy.scope !== "file"
180
+ ? `${policy.defaultTool ?? "unknown"}:${path.resolve(record.cwd)}`
181
+ : `${policy?.defaultTool ?? "unknown"}:${filePath}`;
182
+ if (executedAutofixScopes.has(scopeKey))
183
+ continue;
184
+ executedAutofixScopes.add(scopeKey);
185
+ try {
186
+ const result = await runAutofix(filePath, record.cwd, getFlag, dbg, { biomeClient, ruffClient, fixedThisTurn: runtime.fixedThisTurn }, getFlagSource);
187
+ const tools = result.autofixTools.map((label) => label.split(":")[0]);
188
+ for (const changed of result.changedFiles) {
189
+ const changedPath = path.resolve(changed);
190
+ deferredAutofixChanged.add(changedPath);
191
+ for (const tool of tools)
192
+ deferredAutofixFixes.push({ path: changedPath, tool, kind: "autofix" });
193
+ if (!nodeFs.existsSync(changedPath))
194
+ continue;
195
+ recordProjectChange({ runtime, cwd: record.turnStateCwd, filePath: changedPath, source: "autofix", dbg });
196
+ if (!getFlag("no-read-guard"))
197
+ runtime.readGuard.recordWritten(changedPath);
198
+ const content = nodeFs.readFileSync(changedPath, "utf-8");
199
+ cacheManager.addModifiedRange(changedPath, { start: 1, end: content.split("\n").length }, /^import\s/m.test(content), record.turnStateCwd, currentSessionId ?? runtime.telemetrySessionId, "pi");
200
+ }
201
+ }
202
+ catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ summary.failed.push({ filePath, errors: [message] });
205
+ requeue([{ ...record, kinds: new Set(["autofix"]), toolNames: new Set(record.toolNames) }], "autofix-failed");
206
+ }
207
+ }
208
+ if (deferredAutofixFixes.length > 0) {
209
+ publishFilesTouched({
210
+ reason: "autofix",
211
+ paths: [...new Set(deferredAutofixFixes.map((fix) => fix.path))],
212
+ cwd: ctxCwd ?? runtime.projectRoot,
213
+ dbg,
214
+ fixes: deferredAutofixFixes,
215
+ });
216
+ }
100
217
  dbg(`agent_end deferred_format: ${records.length} file(s)`);
101
218
  logLatency({
102
219
  type: "phase",
@@ -127,9 +244,10 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
127
244
  cwd: ctxCwd ?? runtime.projectRoot,
128
245
  paths: records.map((r) => r.filePath),
129
246
  dbg,
247
+ kinds: [...new Set(records.flatMap((record) => [...record.kinds]))],
130
248
  });
131
249
  }
132
- const formatRecords = records.filter((record) => {
250
+ const formatRecords = records.filter((record) => record.kinds.has("format")).filter((record) => {
133
251
  const disabled = !!getFlag("no-autoformat", record.filePath);
134
252
  if (disabled) {
135
253
  const source = getFlagSource?.("no-autoformat", record.filePath);
@@ -145,7 +263,6 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
145
263
  const work = new Array(formatRecords.length);
146
264
  const started = new Set();
147
265
  let nextIndex = 0;
148
- const ambientSignal = getAmbientAbortSignal();
149
266
  const worker = async () => {
150
267
  while (nextIndex < formatRecords.length) {
151
268
  const index = nextIndex++;
@@ -179,7 +296,11 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
179
296
  };
180
297
  await Promise.all(Array.from({ length: Math.min(DEFERRED_FORMAT_CONCURRENCY, formatRecords.length) }, () => worker()));
181
298
  if (ambientSignal?.aborted) {
182
- runtime.requeueDeferredFormatFiles(formatRecords.filter((_, index) => !started.has(index)));
299
+ requeue(formatRecords.filter((_, index) => !started.has(index)).map((record) => ({
300
+ ...record,
301
+ kinds: new Set(["format"]),
302
+ toolNames: new Set(record.toolNames),
303
+ })), "abort");
183
304
  }
184
305
  for (const entry of work) {
185
306
  if (!entry)
@@ -188,12 +309,15 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
188
309
  const { record, filePath, fileStart } = entry;
189
310
  if (entry.missing) {
190
311
  dbg(`agent_end deferred_format skipped missing file: ${filePath}`);
191
- summary.skipped.push({ filePath, reason: "missing" });
312
+ if (!summary.skipped.some((item) => item.filePath === filePath && item.reason === "missing")) {
313
+ summary.skipped.push({ filePath, reason: "missing" });
314
+ }
192
315
  continue;
193
316
  }
194
317
  if (entry.error) {
195
318
  dbg(`agent_end deferred_format failed for ${filePath}: ${entry.error}`);
196
319
  summary.failed.push({ filePath, errors: [entry.error] });
320
+ requeue([{ ...record, kinds: new Set(["format"]), toolNames: new Set(record.toolNames) }], "format-failed");
197
321
  continue;
198
322
  }
199
323
  const result = entry.result;
@@ -202,6 +326,7 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
202
326
  summary.formatted++;
203
327
  if (result.formatFailures.length > 0) {
204
328
  summary.failed.push({ filePath, errors: result.formatFailures });
329
+ requeue([{ ...record, kinds: new Set(["format"]), toolNames: new Set(record.toolNames) }], "format-failed");
205
330
  }
206
331
  if (result.formatChanged) {
207
332
  summary.changed.push(filePath);
@@ -268,6 +393,14 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
268
393
  });
269
394
  }
270
395
  }
396
+ // LSP sees only authoritative content after both mutation phases. In
397
+ // particular, never publish the autofix intermediate state before format.
398
+ for (const changedPath of deferredAutofixChanged) {
399
+ if (!nodeFs.existsSync(changedPath))
400
+ continue;
401
+ const content = nodeFs.readFileSync(changedPath, "utf-8");
402
+ await resyncLspFile(changedPath, content, true, false, getFlag, dbg);
403
+ }
271
404
  if (inspectActionableReport) {
272
405
  const actionReport = cacheManager.readCache("actionable-warnings", ctxCwd ?? runtime.projectRoot, 10 * 60_000);
273
406
  if (!actionReport?.data) {
@@ -384,6 +517,19 @@ export async function handleAgentEnd({ ctxCwd, getFlag, getFlagSource, notify, d
384
517
  }
385
518
  }
386
519
  }
520
+ logLatency({
521
+ type: "phase",
522
+ toolName: "agent_end",
523
+ filePath: ctxCwd ?? runtime.projectRoot,
524
+ phase: "agent_end_deferred_mutation_drain",
525
+ durationMs: Date.now() - startedAt,
526
+ metadata: {
527
+ autofixRecords: autofixRecords.length,
528
+ formatRecords: formatRecords.length,
529
+ coalescedPaths: records.length,
530
+ requeuedKinds: [...requeuedKinds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
531
+ },
532
+ });
387
533
  logLatency({
388
534
  type: "tool_result",
389
535
  toolName: "agent_end",