@herbertgao/pi-extensions 2026.8.7 → 2026.8.8

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 (99) hide show
  1. package/README.md +7 -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/compact-thinking.ts +63 -15
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +78 -16
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +13 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +17 -6
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +4 -4
  10. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +154 -96
  11. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +3 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  13. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +7 -5
  14. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  15. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +3 -0
  16. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  17. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +137 -96
  18. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +44 -32
  19. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  20. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  22. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  23. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  24. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  25. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +56 -34
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +50 -4
  28. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  29. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  30. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  31. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  32. package/node_modules/pi-lens/CHANGELOG.md +90 -0
  33. package/node_modules/pi-lens/README.md +106 -0
  34. package/node_modules/pi-lens/dist/clients/bash-file-access.js +97 -6
  35. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +16 -6
  36. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +14 -0
  37. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  38. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +22 -1
  39. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  40. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  41. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  42. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  43. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  44. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  45. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +8 -10
  46. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +10 -10
  47. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  48. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  49. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +10 -15
  50. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +19 -11
  51. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +35 -13
  52. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +8 -8
  53. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +110 -23
  54. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  55. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +117 -1
  56. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +16 -14
  57. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +8 -9
  58. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +22 -0
  59. package/node_modules/pi-lens/dist/clients/jscpd-client.js +5 -2
  60. package/node_modules/pi-lens/dist/clients/language-profile.js +7 -2
  61. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +27 -2
  62. package/node_modules/pi-lens/dist/clients/lsp/client.js +144 -5
  63. package/node_modules/pi-lens/dist/clients/lsp/index.js +151 -6
  64. package/node_modules/pi-lens/dist/clients/module-report.js +14 -1
  65. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  66. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +18 -9
  67. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  68. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +7 -2
  69. package/node_modules/pi-lens/dist/clients/read-guard.js +88 -19
  70. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  71. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  72. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  73. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +44 -0
  74. package/node_modules/pi-lens/dist/clients/runtime-session.js +41 -0
  75. package/node_modules/pi-lens/dist/clients/runtime-turn.js +47 -2
  76. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  77. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  78. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  79. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  80. package/node_modules/pi-lens/dist/index.js +1224 -393
  81. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +10 -3
  82. package/node_modules/pi-lens/package.json +18 -5
  83. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  84. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  85. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  86. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  87. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  88. package/node_modules/pi-web-access/README.md +6 -5
  89. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  90. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  91. package/node_modules/pi-web-access/extract.ts +12 -2
  92. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  93. package/node_modules/pi-web-access/github-extract.ts +65 -36
  94. package/node_modules/pi-web-access/index.ts +19 -4
  95. package/node_modules/pi-web-access/openai-search.ts +17 -6
  96. package/node_modules/pi-web-access/package.json +1 -1
  97. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  98. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  99. package/package.json +9 -9
@@ -131,6 +131,35 @@ export function getRegisteredLogFiles() {
131
131
  export function _resetRegisteredLogFilesForTest() {
132
132
  registeredLogFiles.clear();
133
133
  }
134
+ /**
135
+ * Snapshot of unrecovered write losses, one entry per sink that has any
136
+ * (#1970). Pure in-memory read — no I/O, so a caller (`degradation-ledger.ts`)
137
+ * can fold this into a durable ledger entry without this module ever writing
138
+ * a record about its own failure through the sink that is failing. See
139
+ * `NdjsonWriterState.writeFailures` for the recursion-hazard rationale.
140
+ */
141
+ export function getSinkWriteFailures() {
142
+ if (!ndjsonGlobalState)
143
+ return [];
144
+ const result = [];
145
+ for (const state of ndjsonGlobalState.writers.values()) {
146
+ if (state.writeFailures > 0) {
147
+ result.push({ file: state.file, droppedCount: state.writeFailures });
148
+ }
149
+ }
150
+ return result;
151
+ }
152
+ /**
153
+ * Session-boundary reset (catalog shape 17: a process-lifetime latch must
154
+ * re-arm at session_start). Wired into `resetDegradationLedger()` so both
155
+ * reset together; also used directly by tests.
156
+ */
157
+ export function resetSinkWriteFailures() {
158
+ if (!ndjsonGlobalState)
159
+ return;
160
+ for (const state of ndjsonGlobalState.writers.values())
161
+ state.writeFailures = 0;
162
+ }
134
163
  function requireCurrentGlobalState() {
135
164
  if (ndjsonGlobalState)
136
165
  return ndjsonGlobalState;
@@ -159,17 +188,46 @@ function assertCompatibleWriterOptions(existing, maxBytes, backupPath) {
159
188
  throw new Error(`createNdjsonLogger: incompatible options for shared path ${existing.file}; ` +
160
189
  `the first writer's maxBytes/backupPath must be reused`);
161
190
  }
191
+ function writeQueueItemSync(state, item) {
192
+ if (item.kind === "truncate") {
193
+ fs.writeFileSync(state.file, "");
194
+ }
195
+ else {
196
+ rotateIfNeeded(state);
197
+ fs.appendFileSync(state.file, item.line);
198
+ }
199
+ }
200
+ /**
201
+ * The pi-analyze #15 shape (#1970): a write that throws — including the
202
+ * `ERR_STREAM_DESTROYED` a torn-down sink produces — gets one reopen-and-
203
+ * retry before it counts as a loss. There is no persistent handle to close
204
+ * here (every write already opens, writes, and closes in one call), so
205
+ * "reopen" means dropping the cached `ensuredDir` assumption and
206
+ * re-verifying the parent directory before the retry — the one piece of
207
+ * cross-write state this module holds that a destroyed sink could have
208
+ * invalidated. An unrecovered write is counted, never thrown or retried a
209
+ * second time (`applyQueueItemSync`, `applyQueueItemAsync`, and the batched
210
+ * write in `drainLoop` skip DIFFERENT amounts of work per queue item, so
211
+ * each keeps its own copy of this two-step shape rather than sharing one
212
+ * generic retry wrapper across sync/async).
213
+ */
162
214
  function applyQueueItemSync(state, item) {
163
215
  ensureDirSync(state);
164
- runBestEffort(() => {
165
- if (item.kind === "truncate") {
166
- fs.writeFileSync(state.file, "");
167
- }
168
- else {
169
- rotateIfNeeded(state);
170
- fs.appendFileSync(state.file, item.line);
171
- }
172
- });
216
+ try {
217
+ writeQueueItemSync(state, item);
218
+ return;
219
+ }
220
+ catch {
221
+ // fall through to the one reopen-and-retry
222
+ }
223
+ state.ensuredDir = false;
224
+ ensureDirSync(state);
225
+ try {
226
+ writeQueueItemSync(state, item);
227
+ }
228
+ catch {
229
+ state.writeFailures += 1;
230
+ }
173
231
  }
174
232
  function flushStateSync(state) {
175
233
  // Drain the in-memory queue synchronously — safe at process exit.
@@ -208,6 +266,9 @@ function createWriterState(file, maxBytes, backupPath) {
208
266
  assertCompatibleWriterOptions(existing, maxBytes, backupPath);
209
267
  if (!exitFlushers.has(existing.exitFlusher))
210
268
  registerWriter(existing);
269
+ // A state adopted from a pre-#1970 module graph predates this field.
270
+ if (typeof existing.writeFailures !== "number")
271
+ existing.writeFailures = 0;
211
272
  return existing;
212
273
  }
213
274
  const state = {};
@@ -220,6 +281,7 @@ function createWriterState(file, maxBytes, backupPath) {
220
281
  state.syncRepairItems = null;
221
282
  state.ensuredDir = false;
222
283
  state.exitFlusher = () => flushStateSync(state);
284
+ state.writeFailures = 0;
223
285
  globalState.writers.set(file, state);
224
286
  registerWriter(state);
225
287
  return state;
@@ -258,23 +320,36 @@ function rotateIfNeeded(state) {
258
320
  // no file yet, or rename raced — nothing to rotate
259
321
  }
260
322
  }
323
+ async function writeQueueItemAsync(state, item) {
324
+ if (item.kind === "truncate") {
325
+ await fs.promises.writeFile(state.file, "");
326
+ }
327
+ else {
328
+ // Rotation is deliberately synchronous here. This function is only
329
+ // reached from the already-deferred drain, and keeping stat/rm/rename
330
+ // in one synchronous section prevents flushSync from racing a late
331
+ // async rename after it has written new data.
332
+ rotateIfNeeded(state);
333
+ await fs.promises.appendFile(state.file, item.line);
334
+ }
335
+ }
336
+ /** See `applyQueueItemSync`'s doc comment for the reopen-and-retry-once shape. */
261
337
  async function applyQueueItemAsync(state, item) {
262
338
  await ensureDirAsync(state);
263
339
  try {
264
- if (item.kind === "truncate") {
265
- await fs.promises.writeFile(state.file, "");
266
- }
267
- else {
268
- // Rotation is deliberately synchronous here. This function is only
269
- // reached from the already-deferred drain, and keeping stat/rm/rename
270
- // in one synchronous section prevents flushSync from racing a late
271
- // async rename after it has written new data.
272
- rotateIfNeeded(state);
273
- await fs.promises.appendFile(state.file, item.line);
274
- }
340
+ await writeQueueItemAsync(state, item);
341
+ return;
275
342
  }
276
343
  catch {
277
- // telemetry is best-effort
344
+ // fall through to the one reopen-and-retry
345
+ }
346
+ state.ensuredDir = false;
347
+ await ensureDirAsync(state);
348
+ try {
349
+ await writeQueueItemAsync(state, item);
350
+ }
351
+ catch {
352
+ state.writeFailures += 1;
278
353
  }
279
354
  }
280
355
  async function drainLoop(state) {
@@ -292,7 +367,7 @@ async function drainLoop(state) {
292
367
  ? [item]
293
368
  : state.queue.slice(0, pendingEnd);
294
369
  state.inFlightBatch = pending;
295
- try {
370
+ const writeBatch = async () => {
296
371
  if (item.kind === "truncate") {
297
372
  await fs.promises.writeFile(state.file, "");
298
373
  }
@@ -305,9 +380,25 @@ async function drainLoop(state) {
305
380
  .map((queued) => queued.line)
306
381
  .join(""));
307
382
  }
383
+ };
384
+ try {
385
+ await writeBatch();
308
386
  }
309
387
  catch {
310
- // telemetry is best-effort
388
+ // Reopen-and-retry once (#1970, pi-analyze #15 shape): a destroyed
389
+ // sink (ERR_STREAM_DESTROYED) or a directory that vanished mid-session
390
+ // gets exactly one recovery attempt before the batch counts as a loss.
391
+ state.ensuredDir = false;
392
+ await ensureDirAsync(state);
393
+ try {
394
+ await writeBatch();
395
+ }
396
+ catch {
397
+ // Unrecovered: count every line in this batch as dropped. Purely
398
+ // in-memory — see `writeFailures`'s doc comment for why this must
399
+ // never itself attempt a durable write through this same sink.
400
+ state.writeFailures += pending.length;
401
+ }
311
402
  }
312
403
  for (const written of pending) {
313
404
  // flushSync may have drained this prefix while the append is in
@@ -288,17 +288,26 @@ async function scanFileMajorRules(cwd, files, signal) {
288
288
  // phase-major scans.
289
289
  durationMs: Date.now() - startedAt - astGrepDurationMs,
290
290
  stats,
291
+ // #1935 review: ast-grep's own cost is often a scan's most expensive
292
+ // phase (production evidence: 13168ms over 86 files, the single
293
+ // biggest contributor). Its duration is subtracted above so it stays
294
+ // comparable to the historical phase-major scans; carry it here too
295
+ // so it stays visible SOMEWHERE instead of disappearing when the
296
+ // vacuous `project_diagnostics_ast_grep_scan` cache_stats record
297
+ // (below) was removed.
298
+ astGrep: { durationMs: astGrepDurationMs, fileCount: astGrepFilesScanned },
291
299
  });
292
300
  });
293
- await client.withParseCacheMeasurement(async () => { }, (stats) => {
294
- logTreeSitterCacheStats({
295
- scope: "project_diagnostics_ast_grep_scan",
296
- filePath: cwd,
297
- fileCount: astGrepFilesScanned,
298
- durationMs: astGrepDurationMs,
299
- stats,
300
- });
301
- });
301
+ // #1935: no `project_diagnostics_ast_grep_scan` cache_stats record here.
302
+ // `scanAstGrepFile` parses through ast-grep-napi's own `lang.parse()` — a
303
+ // separate native engine, not `TreeSitterClient`'s WASM `TreeCache` — so a
304
+ // cache_stats record for this scope would always read all-zero (0
305
+ // lookups, 0 hits, every counter 0) no matter how the scan behaves. That
306
+ // was a vacuous observability record, not a real one: it looked like a
307
+ // signal but could never carry information. `astGrepDurationMs` and
308
+ // `astGrepFilesScanned` still feed the `project_diagnostics_scan` record
309
+ // above (`astGrep` sub-field), so this cost stays observable without a
310
+ // second, always-zero record to carry it.
302
311
  return { treeSitter, factRules, astGrep, filesScanned, wasmAborted };
303
312
  }
304
313
  export async function scanProjectDiagnostics(options) {
@@ -36,6 +36,7 @@
36
36
  import * as path from "node:path";
37
37
  import { normalizeMapKey, toProjectRelativePath } from "./path-utils.js";
38
38
  import { loadProjectSnapshotWithoutWordIndex } from "./project-snapshot.js";
39
+ import { formatReviewGraphRevisionDriftNote, } from "./review-graph/revision-drift.js";
39
40
  const DEFAULT_LIMIT = 10;
40
41
  const STALE_THRESHOLD_MS = 15 * 60_000; // 15 minutes
41
42
  const LOW_COVERAGE_THRESHOLD = 0.8;
@@ -113,7 +114,11 @@ function buildFileDegrees(graph) {
113
114
  return { fanIn, fanOut };
114
115
  }
115
116
  // --- section 1: trust header --------------------------------------------------
116
- function computeTrust(graph, cwd) {
117
+ function computeTrust(graph, cwd,
118
+ // Passed in rather than looked up here: the builder is imported dynamically
119
+ // by `projectReport` to keep the module graph acyclic, and the value must be
120
+ // computed per render, never cached (#1961 review F3).
121
+ drift) {
117
122
  const filesCovered = graph.fileNodes.size;
118
123
  const snapshot = loadProjectSnapshotWithoutWordIndex(cwd);
119
124
  const snapshotFileCount = snapshot ? Object.keys(snapshot.files).length : 0;
@@ -151,6 +156,11 @@ function computeTrust(graph, cwd) {
151
156
  const lowCoverage = coverage < LOW_COVERAGE_THRESHOLD ||
152
157
  graph.persistCoverage?.partial === true;
153
158
  const notes = [];
159
+ // #1961: the blind read now SERVES a snapshot stamped at a different HEAD
160
+ // rather than dropping it. Age alone would not catch a branch switch made
161
+ // minutes ago, so the revision difference gets its own note.
162
+ if (drift)
163
+ notes.push(formatReviewGraphRevisionDriftNote(drift));
154
164
  if (stale) {
155
165
  const ageMin = Math.round(ageMs / 60_000);
156
166
  notes.push(`Graph is stale: built ${ageMin}m ago. Sections below may miss recent edits — run pilens_rebuild or re-analyze to refresh.`);
@@ -184,6 +194,7 @@ function computeTrust(graph, cwd) {
184
194
  sampleSize,
185
195
  },
186
196
  stale,
197
+ ...(drift ? { revisionDrift: drift } : {}),
187
198
  lowCoverage,
188
199
  persistCoverage: graph.persistCoverage?.partial
189
200
  ? graph.persistCoverage
@@ -493,25 +504,23 @@ const inFlightGraphBuilds = new Set();
493
504
  export function _resetProjectReportBuildGuardForTests() {
494
505
  inFlightGraphBuilds.clear();
495
506
  }
496
- function triggerBackgroundGraphBuild(cwd) {
507
+ function triggerBackgroundGraphBuild(cwd, deps) {
497
508
  const key = normalizeMapKey(path.resolve(cwd));
498
509
  if (inFlightGraphBuilds.has(key))
499
- return false;
510
+ return "already_running";
511
+ // Ask the builder, not just this module's own guard: a build kicked off by
512
+ // the edit pipeline or another reader never touches `inFlightGraphBuilds`.
513
+ // The probe and the call are in ONE synchronous block on purpose — awaiting
514
+ // between them (the pre-#1962 dynamic imports did) reopens the race.
515
+ const deduped = deps.isGraphBuildInFlight(key, []);
500
516
  inFlightGraphBuilds.add(key);
501
- void (async () => {
502
- try {
503
- const { buildOrUpdateGraph } = await import("./review-graph/builder.js");
504
- const { FactStore } = await import("./dispatch/fact-store.js");
505
- await buildOrUpdateGraph(key, [], new FactStore());
506
- }
507
- catch {
508
- // buildOrUpdateGraph records the durable failure and surfaced status.
509
- }
510
- finally {
511
- inFlightGraphBuilds.delete(key);
512
- }
513
- })();
514
- return true;
517
+ const build = deps
518
+ .buildOrUpdateGraph(key, [], new deps.FactStore())
519
+ .catch(() => {
520
+ // buildOrUpdateGraph records the durable failure and surfaced status.
521
+ });
522
+ void build.finally(() => inFlightGraphBuilds.delete(key));
523
+ return deduped ? "already_running" : "started";
515
524
  }
516
525
  // --- entry point ---------------------------------------------------------------
517
526
  /**
@@ -523,7 +532,7 @@ export async function projectReport(cwd, options) {
523
532
  const limit = clampLimit(options?.limit);
524
533
  const focusTerms = normalizeFocus(options?.focus);
525
534
  const view = options?.view;
526
- const { getCachedReviewGraph, getReviewGraphSizeSkipVerdict, getLastReviewGraphBuildAttempt, } = await import("./review-graph/builder.js");
535
+ const { getCachedReviewGraph, getReviewGraphSizeSkipVerdict, getLastReviewGraphBuildAttempt, buildOrUpdateGraph, isGraphBuildInFlight, getReviewGraphRevisionDrift, } = await import("./review-graph/builder.js");
527
536
  let graph;
528
537
  try {
529
538
  graph = getCachedReviewGraph(cwd);
@@ -556,22 +565,48 @@ export async function projectReport(cwd, options) {
556
565
  };
557
566
  }
558
567
  const previousAttempt = getLastReviewGraphBuildAttempt(cwd);
559
- const kickedOff = triggerBackgroundGraphBuild(cwd);
560
- const lastBuildAttempt = previousAttempt ?? getLastReviewGraphBuildAttempt(cwd);
568
+ const { FactStore } = await import("./dispatch/fact-store.js");
569
+ const trigger = triggerBackgroundGraphBuild(cwd, {
570
+ buildOrUpdateGraph,
571
+ isGraphBuildInFlight,
572
+ FactStore,
573
+ });
574
+ const started = trigger === "started";
575
+ // #1962: report the attempt that is CURRENT. When this call started a
576
+ // build, `buildOrUpdateGraph` has already recorded a fresh `running`
577
+ // attempt synchronously, and the previous outcome belongs in the hint
578
+ // text, not in a field labelled "last attempt". When no build started,
579
+ // the earlier attempt genuinely IS the current one.
580
+ const lastBuildAttempt = started
581
+ ? (getLastReviewGraphBuildAttempt(cwd) ?? previousAttempt)
582
+ : previousAttempt;
583
+ // A terminal previous outcome is the useful half of the message: it says
584
+ // WHY the graph is missing. It is prefixed to whichever verdict follows,
585
+ // rather than selecting between two parallel sentence pairs — the first
586
+ // version of this branch did that, and its terminal-plus-deduped arm was
587
+ // unreachable (#1962 review F1). A pending build always records a
588
+ // `running` attempt synchronously, so "terminal" and "not started" cannot
589
+ // both hold for the same key.
590
+ const terminal = previousAttempt?.outcome === "failed" ||
591
+ previousAttempt?.outcome === "skipped";
592
+ const cause = terminal
593
+ ? `Review graph unavailable: ${previousAttempt?.reason ?? previousAttempt?.outcome}. `
594
+ : "";
561
595
  return {
562
596
  available: false,
563
- hint: lastBuildAttempt?.outcome === "failed" ||
564
- lastBuildAttempt?.outcome === "skipped"
565
- ? `Review graph unavailable: ${lastBuildAttempt.reason ?? lastBuildAttempt.outcome}. A retry was ${kickedOff ? "started" : "not started"}.`
566
- : kickedOff
567
- ? "No review graph cached for this workspace yet — a build was kicked off in the background; retry this call shortly."
568
- : "No review graph cached for this workspace yet — the background build is still running; retry this call shortly.",
597
+ hint: started
598
+ ? terminal
599
+ ? `${cause}A retry was started.`
600
+ : "No review graph cached for this workspace yet — a build was kicked off in the background; retry this call shortly."
601
+ : `${cause}A build is already running for this workspace; retry this call shortly.`,
569
602
  ...(lastBuildAttempt ? { lastBuildAttempt } : {}),
570
603
  ...(view ? { view } : {}),
571
604
  };
572
605
  }
573
606
  const degrees = buildFileDegrees(graph);
574
- const trust = computeTrust(graph, cwd);
607
+ // Computed on THIS render, from the workspace entry's stamped commit and the
608
+ // worktree's HEAD right now — never from a value cached beside the graph.
609
+ const trust = computeTrust(graph, cwd, getReviewGraphRevisionDrift(cwd));
575
610
  const hubs = await computeHubs(graph, degrees, cwd, limit, focusTerms);
576
611
  const { entryPoints, entryPointFiles } = computeEntryPoints(graph, degrees, cwd, limit, focusTerms);
577
612
  const subsystems = computeSubsystems(graph, cwd, limit);
@@ -112,7 +112,7 @@ export function createReadGuardEditBatchSummary(args) {
112
112
  durationMs: Math.max(0, Math.min(args.durationMs ?? 0, 86_400_000)),
113
113
  };
114
114
  }
115
- function shouldLogEvent(event) {
115
+ export function shouldLogEvent(event) {
116
116
  if (VERBOSE_READ_GUARD_LOG)
117
117
  return true;
118
118
  if (event === "edit_allowed")
@@ -134,7 +134,12 @@ function shouldLogEvent(event) {
134
134
  event === "edit_post_edit_pipeline_failed" ||
135
135
  event === "edit_batch_summary" ||
136
136
  event === "edit_batch_summary_overflow" ||
137
- event === "touched_lines_missing");
137
+ event === "touched_lines_missing" ||
138
+ // #1913: read-guard record-cap evictions are rare by construction (only
139
+ // READ_GUARD_MAX_RECORDS_PER_FILE overflow triggers one), so this trim
140
+ // record bypasses the per-read verbosity gate — a live eviction
141
+ // regression must be visible without PI_LENS_READ_GUARD_VERBOSE=1.
142
+ event === "read_cap_trimmed");
138
143
  }
139
144
  const MAX_TELEMETRY_STRING = 512;
140
145
  const MAX_TELEMETRY_KEYS = 64;
@@ -10,6 +10,7 @@
10
10
  * Falls back safely when LSP is unavailable.
11
11
  */
12
12
  import * as fs from "node:fs";
13
+ import { incrementDegradationCount } from "./degradation-ledger.js";
13
14
  import { createFileTime } from "./file-time.js";
14
15
  import { hashDiagnosticContent } from "./lsp/diagnostic-binding.js";
15
16
  import { normalizeEphemeralMapKey, normalizeFilePath } from "./path-utils.js";
@@ -48,29 +49,22 @@ const READ_GUARD_MAX_FILES = 256;
48
49
  * age alone is the wrong order.
49
50
  */
50
51
  const READ_GUARD_MAX_RECORDS_PER_FILE = 128;
51
- /**
52
- * Trim a single file's read list to the per-file cap. Returns how many records
53
- * were dropped so the ledger can show the trim.
54
- *
55
- * Eviction is NOT purely by age. The shape that overflows this cap is
56
- * read-once-then-grep-often: one whole-file read followed by hundreds of cheap
57
- * search credits. Pure age order evicts that whole-file read first, and it is
58
- * the record `canIgnoreStalenessByHashes` needs to rescue an edit after an
59
- * unrelated mtime touch. So spend the search credits first, oldest among them
60
- * first, and only fall through to genuine reads when the credits run out.
61
- * Genuine reads are then evicted oldest-first as before.
62
- *
63
- * Records are trimmed IN PLACE: `EditRecord.precedingReads` holds a reference
64
- * to this same array, so a replacement array would silently detach it.
65
- */
52
+ const NO_TRIM = {
53
+ evictedCount: 0,
54
+ evictedCreditCount: 0,
55
+ evictedGenuineCount: 0,
56
+ };
66
57
  function enforceRecordCapForFile(records) {
67
58
  if (records.length <= READ_GUARD_MAX_RECORDS_PER_FILE)
68
- return 0;
59
+ return NO_TRIM;
69
60
  const excess = records.length - READ_GUARD_MAX_RECORDS_PER_FILE;
70
61
  const evicted = new Set();
62
+ let evictedCreditCount = 0;
71
63
  for (let i = 0; i < records.length && evicted.size < excess; i++) {
72
- if (records[i].searchCredit !== undefined)
64
+ if (records[i].searchCredit !== undefined) {
73
65
  evicted.add(i);
66
+ evictedCreditCount++;
67
+ }
74
68
  }
75
69
  for (let i = 0; i < records.length && evicted.size < excess; i++) {
76
70
  evicted.add(i);
@@ -79,7 +73,11 @@ function enforceRecordCapForFile(records) {
79
73
  records.length = 0;
80
74
  for (const record of kept)
81
75
  records.push(record);
82
- return excess;
76
+ return {
77
+ evictedCount: excess,
78
+ evictedCreditCount,
79
+ evictedGenuineCount: excess - evictedCreditCount,
80
+ };
83
81
  }
84
82
  /**
85
83
  * #1904 class sweep: `this.edits` is the same shape as `this.reads` — a
@@ -285,6 +283,8 @@ export class ReadGuard {
285
283
  // happened to the file since. Pruned inside `evictFile` so it never
286
284
  // outlives the record it points at.
287
285
  knownPathIndex = new Map();
286
+ /** Running per-file record-cap trim totals for this session (#1913 F1). */
287
+ trimAccumulators = new Map();
288
288
  sessionId;
289
289
  sessionStartMs;
290
290
  constructor(sessionId, config = {}) {
@@ -399,7 +399,7 @@ export class ReadGuard {
399
399
  // eviction starts, so it stops showing growth. `rawReadCountForFile` keeps
400
400
  // the real arrival count observable alongside `evictedRecordCount`.
401
401
  const rawReadCountForFile = arr.length;
402
- const evictedRecordCount = enforceRecordCapForFile(arr);
402
+ const { evictedCount: evictedRecordCount, evictedCreditCount, evictedGenuineCount } = enforceRecordCapForFile(arr);
403
403
  this.touchFile(storedRecord.filePath);
404
404
  this.enforceFileCap();
405
405
  logReadGuardEvent({
@@ -434,6 +434,63 @@ export class ReadGuard {
434
434
  }),
435
435
  },
436
436
  });
437
+ // #1913: the eviction counters above ride `read_recorded`, which is
438
+ // gated behind PI_LENS_READ_GUARD_VERBOSE — off by default. That left a
439
+ // live eviction regression with no trace at default verbosity.
440
+ //
441
+ // review F1: a naive "emit every trim" fix floods read-guard.log once a
442
+ // hot file sits past the cap — every later push trims exactly 1 record
443
+ // (the array is always AT the cap before the push, so `excess` is
444
+ // always exactly 1), so 300 recordRead calls on one file is ~172
445
+ // identical always-on lines, and can rotate `edit_blocked` records out
446
+ // of the 1MB cap. `rawReadCountForFile` also freezes at cap+1 forever,
447
+ // so a raw per-trim record can't even discriminate thrash severity.
448
+ //
449
+ // Fix: emit the read-guard.log line ONCE per file per session, on the
450
+ // FIRST trim only. `trimAccumulators` still updates on every trim,
451
+ // queryable via `getTrimStats` — the running totals a health surface or
452
+ // a future emission point would need are never lost, even though
453
+ // read-guard.log stops re-announcing them. "Have we already logged
454
+ // this file's first trim" routes through the degradation ledger's own
455
+ // rising-edge tally (`incrementDegradationCount` — the pattern
456
+ // CLAUDE.md names for repeated degradations) instead of a hand-rolled
457
+ // per-file Set; subsequent trims still call it, so the ledger's own
458
+ // entry for this (kind, subject) keeps its reason text and count
459
+ // current for the health/degradation summary even after read-guard.log
460
+ // goes quiet.
461
+ if (evictedRecordCount > 0) {
462
+ const key = storedRecord.filePath;
463
+ const acc = this.trimAccumulators.get(key) ?? {
464
+ totalEvicted: 0,
465
+ evictedCreditCount: 0,
466
+ evictedGenuineCount: 0,
467
+ trimEventCount: 0,
468
+ };
469
+ acc.totalEvicted += evictedRecordCount;
470
+ acc.evictedCreditCount += evictedCreditCount;
471
+ acc.evictedGenuineCount += evictedGenuineCount;
472
+ acc.trimEventCount += 1;
473
+ this.trimAccumulators.set(key, acc);
474
+ const isRisingEdge = incrementDegradationCount({
475
+ kind: "read-guard-record-cap-trim",
476
+ subject: key,
477
+ reason: `trim #${acc.trimEventCount}: evicted ${evictedRecordCount} (credit ${evictedCreditCount}, genuine ${evictedGenuineCount})`,
478
+ });
479
+ if (isRisingEdge) {
480
+ logReadGuardEvent({
481
+ event: "read_cap_trimmed",
482
+ sessionId: this.sessionId,
483
+ filePath: storedRecord.filePath,
484
+ metadata: {
485
+ trimEventCount: acc.trimEventCount,
486
+ evictedRecordCount: acc.totalEvicted,
487
+ evictedCreditCount: acc.evictedCreditCount,
488
+ evictedGenuineCount: acc.evictedGenuineCount,
489
+ rawReadCountForFile,
490
+ },
491
+ });
492
+ }
493
+ }
437
494
  // Also update FileTime stamp for this file
438
495
  this.fileTime.read(storedRecord.filePath);
439
496
  }
@@ -828,6 +885,18 @@ export class ReadGuard {
828
885
  this.touchFile(key);
829
886
  return this.reads.get(key) ?? [];
830
887
  }
888
+ /**
889
+ * Session-lifetime record-cap trim totals for a file (#1913 review F1).
890
+ * `recordRead` only writes ONE `read_cap_trimmed` read-guard.log line per
891
+ * file per session (on the first trim), so this is the running-totals
892
+ * surface for everything after that — the exact split this class already
893
+ * computes on every trim, not re-derivable from `getReadHistory` alone
894
+ * (which only shows the SURVIVING records, not what was evicted).
895
+ */
896
+ getTrimStats(filePath) {
897
+ const stats = this.trimAccumulators.get(this.key(filePath));
898
+ return stats ? { ...stats } : undefined;
899
+ }
831
900
  /**
832
901
  * Snapshot the read-set for persistence across a session resume (#1041).
833
902
  * Mirrors widget-state's `exportWidgetState`: the Map is emitted as