@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
@@ -1,22 +1,72 @@
1
+ import { provenanceStamp, validateAdvisoryProvenance, } from "./advisory-provenance.js";
2
+ import { logLatency } from "./latency-logger.js";
1
3
  // Exported so the Stop-hook bin strips exactly what these bridges prepend.
2
4
  export const AUTOMATION_FRAMING = "[pi-lens automated check — not a user request] ";
3
- function turnEndMessage(content) {
5
+ // #1432 review (S3b): a multi-file bash write can carry hundreds of changed
6
+ // paths, one `reasons` entry apiece — logging all of them shrinks the
7
+ // smells-rollup tail window (a fixed-size ring) to a handful of these
8
+ // records. Cap what this decision row carries; the full list is never
9
+ // needed for triage, only "how many and roughly what kind".
10
+ const MAX_LOGGED_REASONS = 8;
11
+ function boundedReasons(reasons) {
12
+ if (reasons.length <= MAX_LOGGED_REASONS)
13
+ return reasons;
14
+ return [
15
+ ...reasons.slice(0, MAX_LOGGED_REASONS),
16
+ `+${reasons.length - MAX_LOGGED_REASONS} more`,
17
+ ];
18
+ }
19
+ function logProvenanceDecision(validation, provenance, advisoryKind, cwd) {
20
+ logLatency({
21
+ type: "phase",
22
+ phase: "advisory_provenance_decision",
23
+ filePath: cwd,
24
+ durationMs: 0,
25
+ metadata: {
26
+ decision: validation.status === "current" ? "current" : "historical",
27
+ reasons: boundedReasons(validation.reasons),
28
+ changedPathCount: validation.changedPathCount,
29
+ provenanceStamp: provenanceStamp(provenance),
30
+ advisoryKind,
31
+ },
32
+ });
33
+ }
34
+ function historicalPrefix(provenance) {
35
+ return `Historical finding; workspace changed since capture; re-run to confirm. (${provenanceStamp(provenance)})`;
36
+ }
37
+ function historicalTestContent(content, provenance) {
38
+ return content.startsWith("[from a prior turn")
39
+ ? content
40
+ : `${historicalPrefix(provenance)}\n\n${content}`;
41
+ }
42
+ function turnEndMessage(content, current, provenance) {
4
43
  return {
5
44
  role: "user",
6
- content: `${AUTOMATION_FRAMING}Address 🔴 blockers before continuing; ℹ️ advisories are informational only.\n\n${content}`,
45
+ content: current
46
+ ? `${AUTOMATION_FRAMING}Address 🔴 blockers before continuing; ℹ️ advisories are informational only.\n\n${content}`
47
+ : `${AUTOMATION_FRAMING}${historicalPrefix(provenance)}\n\n${content}`,
7
48
  };
8
49
  }
9
50
  /** Read a turn-end finding without changing its durable delivery state. */
10
- export function peekTurnEndFindings(cacheManager, cwd) {
51
+ export function peekTurnEndFindings(cacheManager, cwd, runtime, logDelivery = false) {
11
52
  const findings = cacheManager.readCache("turn-end-findings", cwd);
12
53
  if (!findings?.data?.content || findings.data.consumed === true)
13
54
  return;
14
- return { messages: [turnEndMessage(findings.data.content)] };
55
+ const validation = validateAdvisoryProvenance(findings.data, cwd, runtime);
56
+ if (logDelivery)
57
+ logProvenanceDecision(validation, findings.data.provenance, "turn-end", cwd);
58
+ if (validation.allFilesDeleted)
59
+ return;
60
+ return {
61
+ messages: [turnEndMessage(findings.data.content, validation.status === "current", findings.data.provenance)],
62
+ };
15
63
  }
16
- export function consumeTurnEndFindings(cacheManager, cwd) {
64
+ export function consumeTurnEndFindings(cacheManager, cwd, runtime) {
17
65
  const findings = cacheManager.readCache("turn-end-findings", cwd);
18
66
  if (!findings?.data?.content || findings.data.consumed === true)
19
67
  return;
68
+ const validation = validateAdvisoryProvenance(findings.data, cwd, runtime);
69
+ logProvenanceDecision(validation, findings.data.provenance, "turn-end", cwd);
20
70
  // A blocker record is also the opt-in commit gate's durable state. Mark the
21
71
  // context message consumed without deleting the record; clean/advisory-only
22
72
  // records retain the historical consume-and-clear behavior.
@@ -27,29 +77,72 @@ export function consumeTurnEndFindings(cacheManager, cwd) {
27
77
  else {
28
78
  cacheManager.clearCache("turn-end-findings", cwd);
29
79
  }
30
- return { messages: [turnEndMessage(findings.data.content)] };
80
+ if (validation.allFilesDeleted)
81
+ return;
82
+ return {
83
+ messages: [turnEndMessage(findings.data.content, validation.status === "current", findings.data.provenance)],
84
+ };
31
85
  }
32
86
  /** Read test findings without consuming them; used by acknowledged IPC delivery. */
33
- export function peekTestFindings(cacheManager, cwd) {
87
+ export function peekTestFindings(cacheManager, cwd, runtime, logDelivery = false) {
34
88
  const findings = cacheManager.readCache("test-runner-findings", cwd);
35
89
  if (!findings?.data?.content)
36
90
  return;
91
+ const validation = validateAdvisoryProvenance(findings.data, cwd, runtime);
92
+ if (logDelivery)
93
+ logProvenanceDecision(validation, findings.data.provenance, "test-findings", cwd);
94
+ if (validation.allFilesDeleted)
95
+ return;
96
+ const current = validation.status === "current";
37
97
  return {
38
98
  messages: [
39
99
  {
40
100
  role: "user",
41
- content: `${AUTOMATION_FRAMING}Test failures detected last turn — fix before continuing:\n\n${findings.data.content}`,
101
+ content: current
102
+ ? `${AUTOMATION_FRAMING}Test failures detected last turn — fix before continuing:\n\n${findings.data.content}`
103
+ : `${AUTOMATION_FRAMING}${historicalTestContent(findings.data.content, findings.data.provenance)}`,
42
104
  },
43
105
  ],
44
106
  };
45
107
  }
46
- export function consumeTestFindings(cacheManager, cwd) {
47
- const findings = peekTestFindings(cacheManager, cwd);
108
+ export function consumeTestFindings(cacheManager, cwd, runtime) {
109
+ const record = cacheManager.readCache("test-runner-findings", cwd);
110
+ if (!record?.data?.content)
111
+ return;
112
+ const findings = peekTestFindings(cacheManager, cwd, runtime, true);
48
113
  if (!findings)
49
114
  return;
50
- cacheManager.writeCache("test-runner-findings", null, cwd);
115
+ // Retire the content but PRESERVE the generation high-water mark: nulling
116
+ // the whole slot would let a still-in-flight OLDER batch see `undefined`,
117
+ // pass the strictly-greater suppression check, and resurrect a consumed
118
+ // one-shot advisory with stale results. An empty-content record peeks as
119
+ // undelivered while keeping late-generation ordering intact.
120
+ const priorGeneration = cacheManager.readCache("test-runner-findings", cwd)?.data?.testRunGeneration;
121
+ cacheManager.writeCache("test-runner-findings", { content: "", testRunGeneration: priorGeneration }, cwd);
51
122
  return findings;
52
123
  }
124
+ /** Complete an acknowledged MCP delivery without re-validating or re-rendering it. */
125
+ export function acknowledgeTurnEndFindings(cacheManager, cwd) {
126
+ const findings = cacheManager.readCache("turn-end-findings", cwd);
127
+ if (!findings?.data?.content || findings.data.consumed === true)
128
+ return;
129
+ if (findings.data.hasBlockers === true && typeof findings.data.sessionId === "string") {
130
+ cacheManager.writeCache("turn-end-findings", { ...findings.data, consumed: true }, cwd);
131
+ }
132
+ else {
133
+ cacheManager.clearCache("turn-end-findings", cwd);
134
+ }
135
+ }
136
+ export function acknowledgeTestFindings(cacheManager, cwd) {
137
+ const findings = cacheManager.readCache("test-runner-findings", cwd);
138
+ if (!findings?.data?.content)
139
+ return;
140
+ // Same high-water-mark preservation as consumeTestFindings.
141
+ cacheManager.writeCache("test-runner-findings", {
142
+ content: "",
143
+ testRunGeneration: findings.data.testRunGeneration,
144
+ }, cwd);
145
+ }
53
146
  export function consumeSessionStartGuidance(cacheManager, cwd) {
54
147
  const guidance = cacheManager.readCache("session-start-guidance", cwd);
55
148
  if (!guidance?.data?.content)
@@ -1,11 +1,13 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { logCascade } from "./cascade-logger.js";
4
5
  import { normalizeMapKey } from "./path-utils.js";
5
6
  import { PathKeyedMap } from "./path-keyed-map.js";
6
7
  import { ReadGuard } from "./read-guard.js";
7
8
  import { RUNTIME_CONFIG } from "./runtime-config.js";
8
9
  import { TurnSummaryCollector } from "./turn-summary.js";
10
+ import { deriveProviderFromModelId } from "./model-provider.js";
9
11
  export class RuntimeCoordinator {
10
12
  _projectRoot = normalizeMapKey(process.cwd());
11
13
  _sessionGeneration = 0;
@@ -25,7 +27,9 @@ export class RuntimeCoordinator {
25
27
  coldSnapshotTouches: 0,
26
28
  };
27
29
  _complexityBaselines = new Map();
28
- _fixedThisTurn = new Set();
30
+ _fixedThisTurn = new PathKeyedMap(normalizeMapKey);
31
+ _writtenThisTurn = new PathKeyedMap(normalizeMapKey);
32
+ _autofixDemotedThisTurn = new PathKeyedMap(normalizeMapKey);
29
33
  _reportedThisTurn = new Set();
30
34
  _projectRulesScan = {
31
35
  rules: [],
@@ -35,6 +39,19 @@ export class RuntimeCoordinator {
35
39
  _lifecycleReason;
36
40
  _hasStableSessionId = false;
37
41
  _telemetryModel = "unknown";
42
+ // Raw model/provider identity, separate from the combined `provider/model`
43
+ // display string above — worklog/disposition attribution (#1448) wants the
44
+ // two fields apart, blank when the host never supplied them. `_telemetryProvider`
45
+ // is the explicit host value when given, else derived from the model id
46
+ // (deriveProviderFromModelId, blank on ambiguity — never guessed).
47
+ _telemetryModelId = "";
48
+ _telemetryProvider = "";
49
+ // True once a host has supplied an explicit provider this session. An
50
+ // explicit provider is never downgraded by a derivation from a later
51
+ // model-only call; a DERIVED provider, by contrast, is re-derived on
52
+ // every model-only call so a mid-session model switch (e.g. gpt-5-mini →
53
+ // claude-sonnet-4-5) doesn't leave a stale provider from the old model.
54
+ _telemetryProviderIsExplicit = false;
38
55
  _turnIndex = 0;
39
56
  _writeIndex = 0;
40
57
  _projectSeq = 0;
@@ -51,7 +68,7 @@ export class RuntimeCoordinator {
51
68
  callGraph = null;
52
69
  wordIndex = null;
53
70
  _readGuard = null;
54
- _pendingDeferredFormatFiles = new Map();
71
+ _pendingDeferredMutations = new PathKeyedMap(normalizeMapKey);
55
72
  _lspReadWarmState = new Map();
56
73
  _pendingInlineBlockers = new PathKeyedMap(normalizeMapKey);
57
74
  _actionableWarningsThisTurn = new Map();
@@ -78,10 +95,15 @@ export class RuntimeCoordinator {
78
95
  coldSnapshotTouches: 0,
79
96
  };
80
97
  this._fixedThisTurn.clear();
98
+ this._writtenThisTurn.clear();
99
+ this._autofixDemotedThisTurn.clear();
81
100
  this._reportedThisTurn.clear();
82
101
  this._telemetrySessionId = `lens-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
83
102
  this._hasStableSessionId = false;
84
103
  this._telemetryModel = "unknown";
104
+ this._telemetryModelId = "";
105
+ this._telemetryProvider = "";
106
+ this._telemetryProviderIsExplicit = false;
85
107
  this._turnIndex = 0;
86
108
  this._writeIndex = 0;
87
109
  this._projectSeq = 0;
@@ -92,7 +114,7 @@ export class RuntimeCoordinator {
92
114
  this._gitGuardSummary = "";
93
115
  this._gitGuardCacheUnknownReason = undefined;
94
116
  this._readGuard = null;
95
- this._pendingDeferredFormatFiles.clear();
117
+ this._pendingDeferredMutations.clear();
96
118
  this._lspReadWarmState.clear();
97
119
  this._pendingInlineBlockers.clear();
98
120
  this._actionableWarningsThisTurn.clear();
@@ -145,7 +167,30 @@ export class RuntimeCoordinator {
145
167
  return this._gitGuardCacheUnknownReason;
146
168
  }
147
169
  beginTurn() {
148
- this._cascadeRuns = [];
170
+ // #1443: runs sitting here at turn_start were appended AFTER the last
171
+ // turn_end drained them (consumeCascadeRuns) — the quiet-window reconcile's
172
+ // late re-injection (`onResolvedFound`, clients/lsp/cascade-tier.ts) lands
173
+ // in exactly that window. Wiping them dead-ended that delivery path: the
174
+ // finding was computed, formatted, appended, and then deleted before any
175
+ // turn_end could render it. Carry them into THIS turn instead, exactly
176
+ // once: `carriedTurns` is stamped on the way through and a run that the
177
+ // next turn_end still did not consume is dropped here with a log line
178
+ // rather than queued forever (a stale finding must not outlive the state
179
+ // it describes, and an unbounded queue would replay it every turn).
180
+ this._cascadeRuns = this._cascadeRuns.flatMap((run) => {
181
+ const carriedTurns = (run.carriedTurns ?? 0) + 1;
182
+ if (carriedTurns > 1) {
183
+ logCascade({
184
+ phase: "cascade_carry_over_drop",
185
+ filePath: run.filePath,
186
+ neighborCount: run.neighborCount,
187
+ diagnosticCount: run.diagnosticCount,
188
+ metadata: { carriedTurns, turnIndex: this._turnIndex },
189
+ });
190
+ return [];
191
+ }
192
+ return [{ ...run, carriedTurns }];
193
+ });
149
194
  // _pendingCascadeRuns is deliberately NOT cleared here: a cascade compute
150
195
  // still in flight past last turn_end's settle cap (fresh graph builds have
151
196
  // measured up to ~19s) must surface on the NEXT turn_end, not be dropped —
@@ -166,6 +211,25 @@ export class RuntimeCoordinator {
166
211
  this._turnIndex += 1;
167
212
  this._writeIndex = 0;
168
213
  this._reportedThisTurn.clear();
214
+ this._writtenThisTurn.clear();
215
+ this._autofixDemotedThisTurn.clear();
216
+ }
217
+ /** Atomically records write/edit ordering before debounce can coalesce it. */
218
+ recordMutationToolReceipt(filePath, toolName) {
219
+ if (toolName === "write") {
220
+ this._writtenThisTurn.set(filePath, true);
221
+ }
222
+ else if (this._writtenThisTurn.has(filePath)) {
223
+ this._autofixDemotedThisTurn.set(filePath, true);
224
+ // A later edit establishes a new final state that must be eligible for
225
+ // the deferred pass even if the preceding write was fixed immediately.
226
+ this._fixedThisTurn.delete(filePath);
227
+ }
228
+ return {
229
+ autofixMode: toolName === "edit" || this._autofixDemotedThisTurn.has(filePath)
230
+ ? "deferred"
231
+ : "immediate",
232
+ };
169
233
  }
170
234
  get reportedThisTurn() {
171
235
  return this._reportedThisTurn;
@@ -192,6 +256,23 @@ export class RuntimeCoordinator {
192
256
  else if (provider) {
193
257
  this._telemetryModel = provider;
194
258
  }
259
+ if (model)
260
+ this._telemetryModelId = model;
261
+ if (provider) {
262
+ this._telemetryProvider = provider;
263
+ this._telemetryProviderIsExplicit = true;
264
+ }
265
+ else if (model && !this._telemetryProviderIsExplicit) {
266
+ // No explicit provider has ever been reported this session, so the
267
+ // provider is (still) a derivation — re-derive it from the CURRENT
268
+ // model id every time. Without this, a stale derived provider from
269
+ // an earlier model would survive a mid-session model switch (e.g.
270
+ // gpt-5-mini → claude-sonnet-4-5 with no explicit provider on
271
+ // either call) because the old "has any provider ever been set"
272
+ // guard treated the derived value as sticky. An explicit provider,
273
+ // once set, is never touched here regardless of later model calls.
274
+ this._telemetryProvider = deriveProviderFromModelId(model);
275
+ }
195
276
  }
196
277
  get telemetrySessionId() {
197
278
  return this._telemetrySessionId;
@@ -220,6 +301,17 @@ export class RuntimeCoordinator {
220
301
  get telemetryModel() {
221
302
  return this._telemetryModel;
222
303
  }
304
+ /** Raw model id (never the combined `provider/model` display string), blank
305
+ * when the host hasn't reported one this session. Worklog/disposition
306
+ * attribution (#1448) reads this, not {@link telemetryModel}. */
307
+ get telemetryModelId() {
308
+ return this._telemetryModelId;
309
+ }
310
+ /** Explicit host-reported provider, or a conservative derivation from the
311
+ * model id (see clients/model-provider.ts), blank when neither is known. */
312
+ get telemetryProviderId() {
313
+ return this._telemetryProvider;
314
+ }
223
315
  get turnIndex() {
224
316
  return this._turnIndex;
225
317
  }
@@ -378,6 +470,26 @@ export class RuntimeCoordinator {
378
470
  this._cascadeRuns = [];
379
471
  return runs;
380
472
  }
473
+ /**
474
+ * R1 (#1443 follow-up): non-destructive peek used by turn_end's read-only
475
+ * fast path. A carried cascade run (or one still in flight) represents a
476
+ * DELIVERY OPPORTUNITY, not turn activity — an agent that answers a question
477
+ * without editing anything must still get yesterday's late finding. Before
478
+ * this, the files-empty early return skipped `settleCascadeRuns` /
479
+ * `consumeCascadeRuns` entirely on a read-only turn, so `beginTurn`'s next
480
+ * carry pass saw the run as having survived a turn_start with no offsetting
481
+ * drain and dropped it — burning the one-turn carry allowance on a turn that
482
+ * never had a chance to deliver.
483
+ */
484
+ hasCascadeRuns() {
485
+ // Carried, ALREADY-BUILT runs only. Pending (still-settling) computes are
486
+ // deliberately excluded: a read-only turn that fell through for a pending
487
+ // run would block on the full settle cap — every turn, forever, when the
488
+ // compute never resolves (re-review finding F1). A pending run loses
489
+ // nothing by waiting: settleCascadeRuns re-parks it and the next turn
490
+ // that actually settles it delivers it.
491
+ return this._cascadeRuns.length > 0;
492
+ }
381
493
  recordInlineBlockers(filePath, summary) {
382
494
  this._pendingInlineBlockers.set(path.resolve(filePath), {
383
495
  filePath,
@@ -462,7 +574,19 @@ export class RuntimeCoordinator {
462
574
  return this._complexityBaselines;
463
575
  }
464
576
  get fixedThisTurn() {
465
- return this._fixedThisTurn;
577
+ // Self-referencing local so chained add() returns the same facade
578
+ // instead of re-entering this getter and allocating a new one per call
579
+ // (sonar S7725).
580
+ const facade = {
581
+ add: (filePath) => {
582
+ this._fixedThisTurn.set(filePath, true);
583
+ return facade;
584
+ },
585
+ has: (filePath) => this._fixedThisTurn.has(filePath),
586
+ delete: (filePath) => this._fixedThisTurn.delete(filePath),
587
+ clear: () => this._fixedThisTurn.clear(),
588
+ };
589
+ return facade;
466
590
  }
467
591
  get projectRulesScan() {
468
592
  return this._projectRulesScan;
@@ -475,39 +599,47 @@ export class RuntimeCoordinator {
475
599
  return this._readGuard;
476
600
  }
477
601
  /**
478
- * Queue `filePath` for deferred formatting at `agent_end`. Returns `true`
479
- * when this call created a NEW pending entry, `false` when it re-touched
480
- * an already-queued file. #673: the caller uses this to publish
481
- * `pilens:format:queued` only on first queue entry, so repeated edits to
482
- * the same already-queued file before `agent_end` don't spam the bus.
602
+ * Queue one mutation kind for `filePath` at `agent_end`. Returns `true`
603
+ * when this call created a pending entry or added a new kind, and `false`
604
+ * for a same-kind re-touch. Callers publish each kind's first transition
605
+ * without spamming repeated edits before `agent_end`.
483
606
  */
484
- deferFormat(filePath, cwd, toolName, turnStateCwd, ownerSessionId) {
607
+ deferMutation(filePath, cwd, toolName, turnStateCwd, kind, ownerSessionId) {
485
608
  const key = path.resolve(filePath);
486
609
  const now = Date.now();
487
- const existing = this._pendingDeferredFormatFiles.get(key);
610
+ const existing = this._pendingDeferredMutations.get(key);
488
611
  if (existing) {
612
+ const addedKind = !existing.kinds.has(kind);
489
613
  existing.lastTouchedAt = now;
490
614
  existing.cwd = cwd;
491
615
  existing.turnStateCwd = turnStateCwd;
492
616
  existing.toolNames.add(toolName);
617
+ existing.kinds.add(kind);
493
618
  existing.queuedTurnIndex = this._turnIndex;
494
619
  existing.ownerSessionId = ownerSessionId;
495
- return false;
620
+ return addedKind;
496
621
  }
497
- this._pendingDeferredFormatFiles.set(key, {
622
+ this._pendingDeferredMutations.set(key, {
498
623
  filePath: key,
499
624
  cwd,
500
625
  turnStateCwd,
501
626
  firstTouchedAt: now,
502
627
  lastTouchedAt: now,
503
628
  toolNames: new Set([toolName]),
629
+ kinds: new Set([kind]),
504
630
  queuedTurnIndex: this._turnIndex,
505
631
  ownerSessionId,
506
632
  });
507
633
  return true;
508
634
  }
635
+ deferFormat(filePath, cwd, toolName, turnStateCwd, ownerSessionId) {
636
+ return this.deferMutation(filePath, cwd, toolName, turnStateCwd, "format", ownerSessionId);
637
+ }
509
638
  get pendingDeferredFormatCount() {
510
- return this._pendingDeferredFormatFiles.size;
639
+ return this._pendingDeferredMutations.size;
640
+ }
641
+ get pendingDeferredMutationCount() {
642
+ return this._pendingDeferredMutations.size;
511
643
  }
512
644
  /**
513
645
  * Legacy unconditional drain — still exposed for any caller that
@@ -515,8 +647,8 @@ export class RuntimeCoordinator {
515
647
  * flush call sites should prefer {@link claimDeferredFormatFiles}.
516
648
  */
517
649
  consumeDeferredFormatFiles() {
518
- const records = [...this._pendingDeferredFormatFiles.values()];
519
- this._pendingDeferredFormatFiles.clear();
650
+ const records = [...this._pendingDeferredMutations.values()];
651
+ this._pendingDeferredMutations.clear();
520
652
  return records;
521
653
  }
522
654
  /**
@@ -540,19 +672,19 @@ export class RuntimeCoordinator {
540
672
  const claimed = [];
541
673
  const staleClaimed = [];
542
674
  const deferredToOwner = [];
543
- for (const [key, record] of this._pendingDeferredFormatFiles) {
675
+ for (const [key, record] of this._pendingDeferredMutations) {
544
676
  const sameSession = record.ownerSessionId === undefined ||
545
677
  currentSessionId === undefined ||
546
678
  record.ownerSessionId === currentSessionId;
547
679
  if (sameSession) {
548
680
  claimed.push(record);
549
- this._pendingDeferredFormatFiles.delete(key);
681
+ this._pendingDeferredMutations.delete(key);
550
682
  continue;
551
683
  }
552
684
  const age = now - record.lastTouchedAt;
553
685
  if (age > staleAfterMs) {
554
686
  staleClaimed.push(record);
555
- this._pendingDeferredFormatFiles.delete(key);
687
+ this._pendingDeferredMutations.delete(key);
556
688
  continue;
557
689
  }
558
690
  deferredToOwner.push(record);
@@ -563,11 +695,27 @@ export class RuntimeCoordinator {
563
695
  requeueDeferredFormatFiles(records) {
564
696
  for (const record of records) {
565
697
  const key = path.resolve(record.filePath);
566
- if (!this._pendingDeferredFormatFiles.has(key)) {
567
- this._pendingDeferredFormatFiles.set(key, record);
698
+ const existing = this._pendingDeferredMutations.get(key);
699
+ if (existing) {
700
+ for (const kind of record.kinds)
701
+ existing.kinds.add(kind);
702
+ for (const toolName of record.toolNames)
703
+ existing.toolNames.add(toolName);
704
+ continue;
568
705
  }
706
+ this._pendingDeferredMutations.set(key, {
707
+ ...record,
708
+ kinds: new Set(record.kinds),
709
+ toolNames: new Set(record.toolNames),
710
+ });
569
711
  }
570
712
  }
713
+ claimDeferredMutations(currentSessionId, now, staleAfterMs) {
714
+ return this.claimDeferredFormatFiles(currentSessionId, now, staleAfterMs);
715
+ }
716
+ requeueDeferredMutations(records) {
717
+ this.requeueDeferredFormatFiles(records);
718
+ }
571
719
  shouldWarmLspOnRead(filePath, maxAgeMs = 120_000) {
572
720
  const state = this._lspReadWarmState.get(path.resolve(filePath));
573
721
  if (!state)
@@ -1,15 +1,16 @@
1
1
  import * as nodeFs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { createDeadline, yieldIfOverBudget } from "./cooperative-budget.js";
3
4
  import { deadCodeIssueCount } from "./dead-code-client.js";
4
5
  import { logDeadCodeScan } from "./dead-code-logger.js";
5
- import { getDiagnosticTracker } from "./diagnostic-tracker.js";
6
6
  import { resetDegradationLedger } from "./degradation-ledger.js";
7
+ import { getDiagnosticTracker } from "./diagnostic-tracker.js";
7
8
  import { resetDispatchAvailabilityState } from "./dispatch/runners/utils/runner-helpers.js";
8
9
  import { clearAllSessions as clearFileTimeSessions } from "./file-time.js";
9
- import { createDeadline, yieldIfOverBudget } from "./cooperative-budget.js";
10
10
  import { getGlobalPiLensDir, getKnipIgnorePatterns, getProjectDataDir, } from "./file-utils.js";
11
11
  import { GitleaksClient } from "./gitleaks-client.js";
12
12
  import { GovulncheckClient, } from "./govulncheck-client.js";
13
+ import { sweepAtomicWriteStages } from "./instance-reaper.js";
13
14
  import { canRunStartupHeavyScans } from "./language-policy.js";
14
15
  import { detectProjectLanguageProfile, getDefaultStartupTools, } from "./language-profile.js";
15
16
  import { logLatency } from "./latency-logger.js";
@@ -27,7 +28,6 @@ import { getSlowFsVerdict, isSlowFs, slowFsDegradationNotice, } from "./slow-fs.
27
28
  import { countRecentSmells, formatSmellsSessionStartLine, resetSmellsSessionState, } from "./smells-rollup.js";
28
29
  import { findNearestProjectRoot, getStartupScanMaxEntries, isStartupScanVerdictFresh, resolveStartupScanContext, } from "./startup-scan.js";
29
30
  import { getSubagentIdentity, isSubagentSession, subagentLightModeNotice, } from "./subagent-mode.js";
30
- import { sweepAtomicWriteStages } from "./instance-reaper.js";
31
31
  import { TrivyClient } from "./trivy-client.js";
32
32
  import { isWarmAttached } from "./warm-attach.js";
33
33
  import { setSessionLanguages } from "./widget-state.js";
@@ -452,10 +452,14 @@ async function buildOrRefreshWordIndex(args) {
452
452
  let rebuildPreflightFiles;
453
453
  const latestSeq = readLatestProjectSequence(snapshotRoot, snapshotSequenceBase(snapshotRoot));
454
454
  const effectiveSeq = runtime.projectSeq ?? latestSeq.projectSeq;
455
+ const snapshotLoadStartMs = Date.now();
455
456
  const snapshot = loadProjectSnapshot(snapshotRoot);
457
+ const snapshotLoadMs = Date.now() - snapshotLoadStartMs;
456
458
  if (snapshot?.wordIndex) {
457
459
  const { deserializeWordIndex, refreshWordIndexIncrementally } = await import("./word-index.js");
460
+ const deserializeStartMs = Date.now();
458
461
  const index = deserializeWordIndex(snapshot.wordIndex);
462
+ const deserializeMs = Date.now() - deserializeStartMs;
459
463
  if (index) {
460
464
  try {
461
465
  const result = await refreshWordIndexIncrementally(index, analysisRoot, () => runtime.isCurrentSession(sessionGeneration));
@@ -469,10 +473,16 @@ async function buildOrRefreshWordIndex(args) {
469
473
  cwd: snapshotRoot,
470
474
  trigger: "session_start",
471
475
  reason: result.reason,
476
+ phaseDurationsMs: {
477
+ snapshotLoadMs,
478
+ deserializeMs,
479
+ ...result.timings,
480
+ },
472
481
  });
473
482
  }
474
483
  else {
475
484
  runtime.wordIndex = index;
485
+ let snapshotSaveSyncMs = 0;
476
486
  // A stale project seq must be advanced even when mtimes prove every
477
487
  // indexed document reusable. Fresh snapshots with no changes avoid
478
488
  // an unnecessary rewrite of the large shared snapshot.
@@ -480,9 +490,15 @@ async function buildOrRefreshWordIndex(args) {
480
490
  result.refreshed > 0 ||
481
491
  result.dropped > 0 ||
482
492
  snapshot.wordIndex.truncated !== index.truncated) {
493
+ const serializeSaveEnqueueStartMs = Date.now();
483
494
  saveRuntimeProjectSnapshot({ cwd: snapshotRoot, runtime, dbg });
495
+ snapshotSaveSyncMs = Date.now() - serializeSaveEnqueueStartMs;
484
496
  }
485
- dbg(`session_start word-index: incremental (seq=${effectiveSeq}, refreshed=${result.refreshed}, dropped=${result.dropped}, skipped=${result.skipped}, reused=${result.reused}, ${Date.now() - startMs}ms)`);
497
+ const phases = `snapshot-load=${snapshotLoadMs}ms, deserialize=${deserializeMs}ms, ` +
498
+ `source-walk=${result.timings.sourceWalkMs}ms, stat-walk=${result.timings.statWalkMs}ms, ` +
499
+ `refresh-reads=${result.timings.refreshReadsMs}ms, ` +
500
+ `snapshot-save-sync=${snapshotSaveSyncMs}ms`;
501
+ dbg(`session_start word-index: incremental (seq=${effectiveSeq}, refreshed=${result.refreshed}, dropped=${result.dropped}, skipped=${result.skipped}, reused=${result.reused}, ${Date.now() - startMs}ms, phases: ${phases})`);
486
502
  // M2, #958: the incremental-vs-full decision + honest coverage
487
503
  // (indexedFileCount/truncated/skipped), independent of host `dbg`.
488
504
  logWordIndex({
@@ -493,6 +509,12 @@ async function buildOrRefreshWordIndex(args) {
493
509
  indexedFileCount: index.docCount,
494
510
  tokens: index.postings.size,
495
511
  truncated: index.truncated,
512
+ phaseDurationsMs: {
513
+ snapshotLoadMs,
514
+ deserializeMs,
515
+ ...result.timings,
516
+ snapshotSaveSyncMs,
517
+ },
496
518
  refreshed: result.refreshed,
497
519
  dropped: result.dropped,
498
520
  skipped: result.skipped,
@@ -516,6 +538,7 @@ async function buildOrRefreshWordIndex(args) {
516
538
  cwd: snapshotRoot,
517
539
  trigger: "session_start",
518
540
  reason: err instanceof Error ? err.message : String(err),
541
+ phaseDurationsMs: { snapshotLoadMs, deserializeMs },
519
542
  });
520
543
  }
521
544
  }
@@ -1004,7 +1027,7 @@ function scheduleStartupScans(deps, runtime, sessionGeneration, analysisRoot, sn
1004
1027
  return;
1005
1028
  if (!runtime.callGraph)
1006
1029
  return;
1007
- const { buildCodebaseModel, saveCodebaseModel, DEFAULT_CODEBASE_MODEL_TOKEN_BUDGET } = await import("./codebase-model.js");
1030
+ const { buildCodebaseModel, saveCodebaseModel, DEFAULT_CODEBASE_MODEL_TOKEN_BUDGET, } = await import("./codebase-model.js");
1008
1031
  if (!callGraphIdentity)
1009
1032
  return;
1010
1033
  const model = buildCodebaseModel(runtime.callGraph, analysisRoot, DEFAULT_CODEBASE_MODEL_TOKEN_BUDGET, callGraphIdentity);