@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
@@ -27,7 +27,9 @@ import { flushReviewGraphLogSync, logReviewGraph, makeReviewGraphBuildMetadata,
27
27
  import { getSharedTreeSitterClient } from "../tree-sitter-shared.js";
28
28
  import { TreeSitterSymbolExtractor, } from "../tree-sitter-symbol-extractor.js";
29
29
  import { withTreeSitterRoot } from "../tree-sitter-shared.js";
30
+ import { incrementDegradationCount } from "../degradation-ledger.js";
30
31
  import { resolveGitIdentity } from "./git-identity.js";
32
+ import { formatReviewGraphRevisionDriftNote, } from "./revision-drift.js";
31
33
  import { buildSymbolId } from "./symbol-id.js";
32
34
  import { clearReviewGraphFileIr, getFreshReviewGraphFileIr, reviewGraphIrContentHash, } from "./shared-extraction-ir.js";
33
35
  // v3 (#260): test files are no longer indexed. Bumping the version makes
@@ -92,10 +94,21 @@ const MAIN_KIND_EXTENSIONS = Array.from(MAIN_KINDS).flatMap((kind) => KIND_EXTEN
92
94
  export const REVIEW_GRAPH_SOURCE_EXTENSIONS = MAIN_KIND_EXTENSIONS;
93
95
  const CHANGED_SYMBOLS_PREFIX = "session.reviewGraph.changedSymbols:";
94
96
  const extractorCache = new Map();
95
- // Per-invocation Promise cache: deduplicates concurrent buildOrUpdateGraph calls
96
- // for the same (cwd, changedFiles). Cleared at the start of each pipeline
97
- // invocation. A separate workspace cache below preserves the expensive parsed
98
- // graph across invocations when source file mtimes/sizes have not changed.
97
+ // IN-FLIGHT Promise cache: deduplicates CONCURRENT buildOrUpdateGraph calls for
98
+ // the same (cwd, changedFiles). A separate workspace cache below preserves the
99
+ // expensive parsed graph across invocations when source file mtimes/sizes have
100
+ // not changed.
101
+ //
102
+ // #1962: an entry lives only while its build is PENDING — `buildOrUpdateGraph`
103
+ // deletes it on settle, success or failure alike. It used to delete only on
104
+ // rejection, so a settled promise for a SKIPPED or COMPLETED build answered
105
+ // every later call for the same key forever. The pipeline's `clearGraphCache()`
106
+ // (pipeline.ts) was the only thing that ever removed it, and the background
107
+ // build project_report kicks off never goes through the pipeline: four
108
+ // project_report calls over 37s produced ONE build_started, while the tool told
109
+ // the agent a retry had been started each time. That is the process-lifetime
110
+ // latch shape from AGENTS.md — dedupe state whose lifetime must be the
111
+ // operation's, not the process's.
99
112
  const _buildCache = new Map();
100
113
  const _workspaceGraphCache = new Map();
101
114
  const REVIEW_GRAPH_MAX_WARM_WORKSPACES = 8;
@@ -117,13 +130,37 @@ function clearWorkspaceGraphTimer(entry) {
117
130
  clearTimeout(entry.idleTimer);
118
131
  entry.idleTimer = undefined;
119
132
  }
133
+ /**
134
+ * The canonical workspace identity for a `_buildCache` key (#1962 review F2).
135
+ *
136
+ * `path.resolve` collapses `.`/`..` segments and anchors a relative path on
137
+ * every OS; `normalizeMapKey` folds separator and casing, and it does that by
138
+ * probing the filesystem (`realpathSync.native`) rather than branching on
139
+ * `process.platform`. Both steps are idempotent, so feeding this an
140
+ * already-canonical key — which the two read sites below do — returns it
141
+ * unchanged.
142
+ */
143
+ function buildCacheWorkspaceKey(cwd) {
144
+ return normalizeMapKey(path.resolve(cwd));
145
+ }
146
+ /**
147
+ * The workspace half of an existing `_buildCache` key. `buildCacheKey`
148
+ * canonicalizes that half at construction, so this is a plain split.
149
+ */
150
+ function buildCacheKeyWorkspace(buildKey) {
151
+ const separator = buildKey.indexOf("|");
152
+ return separator >= 0 ? buildKey.slice(0, separator) : undefined;
153
+ }
120
154
  function evictWorkspaceGraph(key, entry) {
121
155
  if (_workspaceGraphCache.get(key) !== entry)
122
156
  return;
123
157
  clearWorkspaceGraphTimer(entry);
158
+ // `key` is a workspace-cache key; run it through the build-key derivation so
159
+ // both sides of this comparison are canonical the same way. Idempotent, so
160
+ // an already-canonical key passes through untouched (#1962 review F2).
161
+ const buildWorkspace = buildCacheWorkspaceKey(key);
124
162
  for (const buildKey of _buildCache.keys()) {
125
- const separator = buildKey.indexOf("|");
126
- if (separator >= 0 && normalizeMapKey(buildKey.slice(0, separator)) === key) {
163
+ if (buildCacheKeyWorkspace(buildKey) === buildWorkspace) {
127
164
  _buildCache.delete(buildKey);
128
165
  }
129
166
  }
@@ -254,8 +291,12 @@ export function clearReviewGraphWorkspaceCache(cwd) {
254
291
  }
255
292
  else {
256
293
  const normalized = normalizeMapKey(cwd);
294
+ // Compare the key's WORKSPACE half only, both sides canonicalized the same
295
+ // way. The old form normalized the whole key, changed-file list included,
296
+ // which is neither meaningful nor cheap (#1962 review F2).
297
+ const buildWorkspace = buildCacheWorkspaceKey(cwd);
257
298
  for (const key of _buildCache.keys()) {
258
- if (normalizeMapKey(key).startsWith(`${normalized}|`)) {
299
+ if (buildCacheKeyWorkspace(key) === buildWorkspace) {
259
300
  _buildCache.delete(key);
260
301
  }
261
302
  }
@@ -440,8 +481,11 @@ export function getCachedReviewGraph(cwd) {
440
481
  // skip the disk read. loadPersistedGraph already rebuilt the indexes.
441
482
  // #300: this read is BLIND — nothing downstream content-verifies it, so a
442
483
  // stamped snapshot from a different HEAD/worktree must be dropped here.
484
+ // #1961: a snapshot stamped for a DIFFERENT WORKTREE is dropped here; one
485
+ // stamped at a different HEAD is served and marked drifted. See
486
+ // loadPersistedGraph for the one-policy rationale.
443
487
  const disk = loadPersistedGraph(cwd, {
444
- verifyGitStamp: true,
488
+ verifyWorktreeIdentity: true,
445
489
  allowPartial: true,
446
490
  });
447
491
  if (!disk)
@@ -451,9 +495,39 @@ export function getCachedReviewGraph(cwd) {
451
495
  fileSignatures: disk.fileSignatures,
452
496
  fileHashes: disk.fileHashes,
453
497
  graph: disk.graph,
498
+ // Durable half of the drift fact. The current-HEAD half is never cached
499
+ // beside it — see getReviewGraphRevisionDrift (#1961 review F3).
500
+ ...(disk.stampedHead ? { snapshotStampedHead: disk.stampedHead } : {}),
454
501
  });
455
502
  return disk.graph;
456
503
  }
504
+ /**
505
+ * Revision drift for `cwd`'s currently cached graph, computed NOW (#1961).
506
+ *
507
+ * Returns the stamped/current commit pair when the warm entry came from a disk
508
+ * snapshot whose stamp names a commit other than the worktree's HEAD at this
509
+ * instant, and `undefined` otherwise — including the case where HEAD has moved
510
+ * BACK to the stamped commit, which resolves the drift and must clear the note.
511
+ *
512
+ * Derived, never stored: caching the pair is what made the first version of
513
+ * this feature report a commit that had since stopped being HEAD, and keep
514
+ * reporting drift after it was resolved. Callers ask on every render.
515
+ */
516
+ export function getReviewGraphRevisionDrift(cwd) {
517
+ const entry = _workspaceGraphCache.get(normalizeMapKey(cwd));
518
+ const stampedHead = entry?.snapshotStampedHead;
519
+ if (!stampedHead)
520
+ return undefined;
521
+ const current = resolveGitIdentity(cwd);
522
+ // Unresolvable identity is "can't tell", never "drifted" — the same
523
+ // fail-open rule loadPersistedGraph applies to the worktree check.
524
+ if (!current || current.headCommit === stampedHead)
525
+ return undefined;
526
+ return { stampedHead, currentHead: current.headCommit };
527
+ }
528
+ // Re-exported so a consumer that already imports the builder dynamically
529
+ // (module_report) gets the accessor and its renderer from one place (#1961).
530
+ export { formatReviewGraphRevisionDriftNote };
457
531
  function makeCtx(filePath, cwd, facts) {
458
532
  return {
459
533
  filePath,
@@ -888,6 +962,29 @@ const LEGACY_GRAPH_CACHE_FILENAME = "review-graph.json";
888
962
  // laundered to a consumer as a complete graph. It is read back exclusively by
889
963
  // the full-build resume path (`loadReviewGraphCheckpoint`).
890
964
  const GRAPH_CHECKPOINT_FILENAME = "review-graph.checkpoint.json.gz";
965
+ /**
966
+ * Record what the blind read decided about a stamped snapshot (#1961).
967
+ *
968
+ * Bounded the way AGENTS.md requires and `bounded-telemetry.ts` documents: the
969
+ * ledger counts EVERY occurrence exactly, and only the rising edge per
970
+ * (verdict, cwd) also writes the detailed `review-graph.log` record. The
971
+ * accessor runs on every module_report / lens-engine / project_report call, so
972
+ * an unbounded record here would flood the log during a single navigation
973
+ * session. No second latch: the rising edge comes from the ledger's own tally.
974
+ */
975
+ function logSnapshotReadVerdict(cwd, phase, reason) {
976
+ const verdict = phase === "snapshot_read_dropped" ? "dropped" : "drifted";
977
+ const isRisingEdge = incrementDegradationCount({
978
+ kind: "review-graph-snapshot-read",
979
+ // Subject keeps BOTH discriminators, so aggregation still answers which
980
+ // workspace and which verdict after the detailed records stop.
981
+ subject: `${verdict}:${normalizeMapKey(cwd)}`,
982
+ reason,
983
+ });
984
+ if (!isRisingEdge)
985
+ return;
986
+ logReviewGraph({ cwd, phase, reason });
987
+ }
891
988
  function loadPersistedGraph(cwd, opts) {
892
989
  const cacheDir = path.join(getProjectDataDir(cwd), "cache");
893
990
  const cachePath = path.join(cacheDir, GRAPH_CACHE_FILENAME);
@@ -907,24 +1004,33 @@ function loadPersistedGraph(cwd, opts) {
907
1004
  return null;
908
1005
  if (data.coverage?.partial && !opts?.allowPartial)
909
1006
  return null;
910
- if (opts?.verifyGitStamp && data.gitStamp) {
911
- // #300: a stamped snapshot must match the CURRENT repo identity. This
912
- // closes the "worktree removed + re-added at the same path for a
913
- // different branch" edge — the data-dir slug is reused, but the stamp
914
- // mismatch forces a cold rebuild instead of serving the old branch's
915
- // graph. Opt-in per call site: only the BLIND read path
916
- // (getCachedReviewGraph) verifies — the build path's tier-2 load is
917
- // already content-verified downstream (signature + #202 hash confirm),
918
- // and dropping there on every HEAD move would nuke the cold cache
919
- // after each commit. Any resolution failure (non-git, unreadable HEAD)
920
- // yields undefined from resolveGitIdentity — treated as "can't
921
- // verify," not a mismatch, so it does NOT drop the snapshot.
1007
+ // #1961: ONE verification policy across both load paths — verify tree
1008
+ // IDENTITY, never revision. A HEAD move says nothing about file contents,
1009
+ // and the build path's tier-2 load has said so since #300 (see the comment
1010
+ // above its `loadPersistedGraph(cwd)` call): dropping on every HEAD move
1011
+ // forces a full whole-repo rebuild after each plain `git commit`. The BLIND
1012
+ // read path used to do exactly that, which is why a snapshot survived a
1013
+ // median of ~12 minutes before every reader saw "graph: cold".
1014
+ //
1015
+ // What stays: `worktreeRoot`. A snapshot stamped for a DIFFERENT worktree
1016
+ // reached this data dir through slug reuse, so it describes another tree
1017
+ // and nothing downstream would catch it — this read is blind. What goes:
1018
+ // the `headCommit` equality drop. A revision difference is now REPORTED
1019
+ // (`stampedHead` below → `getReviewGraphRevisionDrift` → computeTrust and
1020
+ // module_report) instead of hiding the graph. Any resolution failure
1021
+ // (non-git, unreadable HEAD) yields undefined from resolveGitIdentity —
1022
+ // "can't verify," not a mismatch, so it does not drop the snapshot.
1023
+ let stampedHead;
1024
+ if (opts?.verifyWorktreeIdentity && data.gitStamp) {
922
1025
  const current = resolveGitIdentity(cwd);
923
- if (current &&
924
- (current.headCommit !== data.gitStamp.headCommit ||
925
- current.worktreeRoot !== data.gitStamp.worktreeRoot)) {
1026
+ if (current && current.worktreeRoot !== data.gitStamp.worktreeRoot) {
1027
+ logSnapshotReadVerdict(cwd, "snapshot_read_dropped", "worktree_mismatch");
926
1028
  return null;
927
1029
  }
1030
+ if (current && current.headCommit !== data.gitStamp.headCommit) {
1031
+ stampedHead = data.gitStamp.headCommit;
1032
+ logSnapshotReadVerdict(cwd, "snapshot_read_drifted", "head_moved");
1033
+ }
928
1034
  }
929
1035
  const graph = {
930
1036
  version: data.version,
@@ -944,6 +1050,7 @@ function loadPersistedGraph(cwd, opts) {
944
1050
  fileSignatures: new Map(data.fileSignatures ?? []),
945
1051
  fileHashes: new Map(data.fileHashes ?? []),
946
1052
  graph,
1053
+ ...(stampedHead ? { stampedHead } : {}),
947
1054
  };
948
1055
  }
949
1056
  catch {
@@ -1951,8 +2058,13 @@ function deleteReviewGraphCheckpoint(cwd) {
1951
2058
  /**
1952
2059
  * Read back a checkpoint for `cwd`, gated on the graph version (single source of
1953
2060
  * truth: {@link REVIEW_GRAPH_VERSION}) and, when both stamps resolve, the git
1954
- * identity — the same drop-on-mismatch guard `loadPersistedGraph` uses. Returns
1955
- * null (and best-effort deletes an unusable file) when absent/stale/corrupt.
2061
+ * WORKTREE identity — the same one-policy guard `loadPersistedGraph` uses, and
2062
+ * for the same reason (#1961): the resume path content-verifies every processed
2063
+ * file by hash below (`contentHashEntry` vs `processedHashes`) and evicts the
2064
+ * stale ones, so revision equality proves nothing the hashes do not already
2065
+ * prove. Dropping on a HEAD move threw away a whole resumable partial build
2066
+ * after each plain `git commit`. Returns null (and best-effort deletes an
2067
+ * unusable file) when absent/stale/corrupt.
1956
2068
  * The returned graph carries `persistCoverage.inProgress` so it can never be
1957
2069
  * mistaken for a complete graph if it escapes the resume path.
1958
2070
  */
@@ -1984,13 +2096,11 @@ function loadReviewGraphCheckpoint(cwd) {
1984
2096
  }
1985
2097
  if (data.gitStamp) {
1986
2098
  const current = resolveGitIdentity(cwd);
1987
- if (current &&
1988
- (current.headCommit !== data.gitStamp.headCommit ||
1989
- current.worktreeRoot !== data.gitStamp.worktreeRoot)) {
2099
+ if (current && current.worktreeRoot !== data.gitStamp.worktreeRoot) {
1990
2100
  logReviewGraph({
1991
2101
  cwd,
1992
2102
  phase: "checkpoint_discarded",
1993
- reason: "git_stamp_mismatch",
2103
+ reason: "worktree_mismatch",
1994
2104
  });
1995
2105
  deleteReviewGraphCheckpoint(cwd);
1996
2106
  return null;
@@ -3904,6 +4014,15 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
3904
4014
  const graph = resumed?.graph ?? createEmptyGraph();
3905
4015
  const filesToExtract = resumed?.remaining ?? filesToBuild;
3906
4016
  const treeSitterClient = getSharedTreeSitterClient();
4017
+ // #1941: grow the tree cache to span THIS pass's actual per-parse working
4018
+ // set before parsing starts — filesToExtract, not filesToBuild. On a cold
4019
+ // build they're the same array; on a resumed build filesToExtract is only
4020
+ // the checkpoint's remaining (unprocessed) files, since resumed files are
4021
+ // reused from the checkpoint graph and never re-parsed here. Sizing to
4022
+ // filesToBuild on a resume would over-grow the cache for files this pass
4023
+ // never touches. Same #1715 pattern as scanner.ts:147 — monotonic and
4024
+ // ceiling-bounded inside ensureTreeCacheCapacity itself.
4025
+ treeSitterClient?.ensureTreeCacheCapacity(filesToExtract.length);
3907
4026
  const extractionStartedAt = Date.now();
3908
4027
  // Seeded with the reused files' hashes on resume so the completed snapshot
3909
4028
  // still records a hash for every file (needed by #202 incremental next time).
@@ -4045,8 +4164,42 @@ async function _doBuildGraph(cwd, changedFiles, facts, seqHint, buildId) {
4045
4164
  facts.setSessionFact("session.reviewGraph", graph);
4046
4165
  return graph;
4047
4166
  }
4167
+ /**
4168
+ * The one place the in-flight dedupe key is derived — and the one place its
4169
+ * workspace half is normalized (#1962 review F2).
4170
+ *
4171
+ * The key used to interpolate the caller's RAW `cwd`. Callers hand in whatever
4172
+ * path they hold: `runtime-session.ts:1562`, `lens-map.ts:1243`, and
4173
+ * `mcp/cli.ts:59` pass an unnormalized root, while project_report's trigger
4174
+ * passes `normalizeMapKey(path.resolve(cwd))`. On Windows those differ by
4175
+ * separator and casing alone, so ONE workspace produced two live `_buildCache`
4176
+ * entries: two concurrent full builds of the same repo (the #256 two-build OOM
4177
+ * shape), and an `isGraphBuildInFlight` probe that answered about a key nobody
4178
+ * else used. Folding here makes every caller land on one key without any of
4179
+ * them having to know that.
4180
+ *
4181
+ * The derivation lives in {@link buildCacheWorkspaceKey}, which the two sites
4182
+ * that read build keys BACK also use, so write and read agree by construction
4183
+ * rather than by coincidence.
4184
+ */
4185
+ function buildCacheKey(cwd, changedFiles) {
4186
+ return `${buildCacheWorkspaceKey(cwd)}|${[...changedFiles].sort((a, b) => a.localeCompare(b)).join(",")}`;
4187
+ }
4188
+ /**
4189
+ * Whether a build for this exact (cwd, changedFiles) key is PENDING right now
4190
+ * (#1962). Callers that report to a user — project_report's cold-path trigger —
4191
+ * read this BEFORE calling `buildOrUpdateGraph`, so "a retry was started" is
4192
+ * only said when a build actually started rather than when an in-flight one
4193
+ * absorbed the call. Check and call must happen in the same synchronous block;
4194
+ * anything awaited between them reopens the race this exists to close.
4195
+ */
4196
+ export function isGraphBuildInFlight(cwd, changedFiles = []) {
4197
+ return _buildCache.has(buildCacheKey(cwd, changedFiles));
4198
+ }
4048
4199
  export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4049
- const cacheKey = `${cwd}|${[...changedFiles].sort((a, b) => a.localeCompare(b)).join(",")}`;
4200
+ const cacheKey = buildCacheKey(cwd, changedFiles);
4201
+ // Only a PENDING build is here (see `_buildCache`), so this dedupes genuine
4202
+ // concurrency and nothing else.
4050
4203
  const cached = _buildCache.get(cacheKey);
4051
4204
  if (cached)
4052
4205
  return cached;
@@ -4117,7 +4270,6 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4117
4270
  return graph;
4118
4271
  })
4119
4272
  .catch((err) => {
4120
- _buildCache.delete(cacheKey);
4121
4273
  const reason = err instanceof Error ? err.message : String(err);
4122
4274
  recordBuildAttempt(cwd, "failed", reason, buildId);
4123
4275
  logReviewGraph({
@@ -4138,6 +4290,17 @@ export function buildOrUpdateGraph(cwd, changedFiles, facts, seqHint) {
4138
4290
  throw err;
4139
4291
  });
4140
4292
  _buildCache.set(cacheKey, promise);
4293
+ // #1962: the entry's lifetime is the BUILD's, not the process's. Settling —
4294
+ // fulfilled, skipped, or rejected — releases the key so the next caller
4295
+ // really builds. The identity guard means a newer build that already claimed
4296
+ // the key survives this older build's cleanup. `then(fn, fn)` rather than
4297
+ // `finally` so the derived promise handles the rejection instead of raising
4298
+ // an unhandled one; the returned `promise` is unchanged either way.
4299
+ const release = () => {
4300
+ if (_buildCache.get(cacheKey) === promise)
4301
+ _buildCache.delete(cacheKey);
4302
+ };
4303
+ promise.then(release, release);
4141
4304
  return promise;
4142
4305
  }
4143
4306
  /**
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Revision-drift evidence for a served review-graph snapshot, and the one
3
+ * wording every consumer renders it with (#1961).
4
+ *
5
+ * A separate module on purpose. `project-report.ts` and `module-report.ts`
6
+ * import the builder DYNAMICALLY, to keep that large module (and its
7
+ * tree-sitter dependencies) off their eager load path. Both still need this
8
+ * caveat's wording synchronously, and two hand-written copies of a warning
9
+ * sentence drift apart. This file imports nothing, so depending on it costs
10
+ * nothing.
11
+ */
12
+ /**
13
+ * The one wording for the drift caveat, so `project_report`'s trust note and
14
+ * `module_report`'s warning cannot say different things about the same fact.
15
+ * Short commit ids only.
16
+ */
17
+ export function formatReviewGraphRevisionDriftNote(drift) {
18
+ return (`Graph was built at commit ${drift.stampedHead.slice(0, 8)}; HEAD is now ` +
19
+ `${drift.currentHead.slice(0, 8)}. Results below reflect the earlier ` +
20
+ "revision — run pilens_rebuild or re-analyze to refresh.");
21
+ }
@@ -490,11 +490,14 @@ export function getModuleSourceFiles(moduleRoot, maxFiles = 20) {
490
490
  }
491
491
  if (!entry.isFile())
492
492
  continue;
493
+ // #1974: extension gate before isIgnored — isIgnored recompiles
494
+ // minimatch patterns per ancestor dir per call, so it should only run
495
+ // for files the cheap extension check already flags as source.
496
+ if (!SOURCE_EXTS.has(path.extname(entry.name).toLowerCase()))
497
+ continue;
493
498
  if (ignoreMatcher.isIgnored(fullPath, false))
494
499
  continue;
495
- if (SOURCE_EXTS.has(path.extname(entry.name).toLowerCase())) {
496
- files.push(normalizeMapKey(fullPath));
497
- }
500
+ files.push(normalizeMapKey(fullPath));
498
501
  }
499
502
  };
500
503
  visit(root, 0);
@@ -610,6 +610,50 @@ export class RuntimeCoordinator {
610
610
  });
611
611
  return true;
612
612
  }
613
+ /**
614
+ * #1944: retire a past-EOF demotion after its ONE degraded delivery.
615
+ *
616
+ * The past-EOF gate demotes and re-arms, but nothing ever retired the
617
+ * record, so a blocker whose file shrank past the cited lines re-served on
618
+ * every turn end for the rest of the session — measured live at 80+
619
+ * minutes on session 01a0234c. The re-serve is unbounded, not the six
620
+ * turns the first evidence window suggested.
621
+ *
622
+ * It is unbounded because the record cannot resolve itself. The two
623
+ * clearing events (`clearInlineBlockers` on a later dispatch of the same
624
+ * path, `retireInlineBlockerOnConfirmedClean` on a fresh clean verdict)
625
+ * both need the file to be looked at again, and the agent has no reason to
626
+ * look: the coordinates it was handed do not exist. "Re-run to confirm" is
627
+ * an instruction this record makes impossible to follow.
628
+ *
629
+ * So the delivery surface retires it after serving it once, degraded. That
630
+ * is a DROP, which #1419's demote-not-drop rule normally forbids; the
631
+ * exception is narrow and stated here. The finding was already delivered
632
+ * this turn with its dead coordinates annotated, and what remains is a
633
+ * message pinned to a line the file does not have. `deadLines` is required
634
+ * and must be non-empty, so a record stale for any OTHER reason, or
635
+ * past-EOF with no identified dead line, still stands.
636
+ *
637
+ * @returns true when an entry was retired — the caller records it, so the
638
+ * suppression is never silent (#1432 Gap 1).
639
+ */
640
+ retireDemotedPastEofBlocker(filePath, deadLines) {
641
+ if (deadLines.length === 0)
642
+ return false;
643
+ const key = path.resolve(filePath);
644
+ const existing = this._pendingInlineBlockers.get(key);
645
+ if (!existing)
646
+ return false;
647
+ // Only this gate's own demotion. A dependency-drift demotion (#1631)
648
+ // keeps in-bounds coordinates the agent CAN re-run against, so it stays
649
+ // in the store until a fresh verdict clears it.
650
+ if (!existing.stale)
651
+ return false;
652
+ if ((existing.staleReason ?? "past-eof") !== "past-eof")
653
+ return false;
654
+ this._pendingInlineBlockers.delete(key);
655
+ return true;
656
+ }
613
657
  /**
614
658
  * Retire a file's inline blocker because a FRESH, content-bound diagnostic
615
659
  * verdict proved it gone (#1561).
@@ -19,6 +19,7 @@ import { canRunStartupHeavyScans } from "./language-policy.js";
19
19
  import { detectProjectLanguageProfile, getDefaultStartupTools, } from "./language-profile.js";
20
20
  import { logLatency } from "./latency-logger.js";
21
21
  import { runLogCleanup } from "./log-cleanup.js";
22
+ import { resetCascadeTierSessionState } from "./lsp/cascade-tier.js";
22
23
  import { initLSPConfig, loadLSPConfig } from "./lsp/config.js";
23
24
  import { resetWorkspaceDiagnosticsCacheSession } from "./lsp/workspace-diagnostics-session.js";
24
25
  import { resetDirectLspCommandAvailability } from "./lsp/server.js";
@@ -1680,6 +1681,14 @@ export async function handleSessionStart(deps) {
1680
1681
  // concrete KnipClient. Session reset is an optional lifecycle capability;
1681
1682
  // its absence must not make session_start fail.
1682
1683
  knipClient.resetSessionState?.();
1684
+ // #1910: the tier-3 cascade outstanding-touch registry and its
1685
+ // sweep-scoped expired/evicted counters (clients/lsp/cascade-tier.ts) are
1686
+ // a per-SESSION claim about touches THIS session fired. #1899 bounded the
1687
+ // registry between sweeps but, by its own review, left the session
1688
+ // boundary unwired — a session replacement inherited the prior session's
1689
+ // outstanding touches and misattributed a stray expiry/eviction to the
1690
+ // next session's first reconcile gauge.
1691
+ resetCascadeTierSessionState();
1683
1692
  runtime.resetForSession(sessionStartMs);
1684
1693
  logLatency({
1685
1694
  type: "phase",
@@ -1857,6 +1866,38 @@ export async function handleSessionStart(deps) {
1857
1866
  log(`Active tools: ${quickTools.join(", ")}`);
1858
1867
  dbg(`session_start tools: ${quickTools.join(", ") || "deferred (quick mode)"}`);
1859
1868
  dbg("session_start: quick mode active - skipping slow tool probes, language profiling, preinstall, scans, and error debt baseline");
1869
+ // #1911: the debug line above says WHICH steps were skipped, but nothing
1870
+ // bounded or structured said so — quick mode's absence of work and an
1871
+ // absent LOGGER read identically in latency.log. This record is that
1872
+ // line's structured twin: one bounded gauge per quick-mode session_start,
1873
+ // naming exactly the step set skipped, so a reader can tell "quick mode
1874
+ // correctly skipped these" from "the probes silently never ran".
1875
+ logLatency({
1876
+ type: "phase",
1877
+ phase: "session_start_skipped_steps",
1878
+ filePath: cwd,
1879
+ durationMs: 0,
1880
+ metadata: {
1881
+ mode: startupMode,
1882
+ reason: deps.sessionReason,
1883
+ // #1911 review F3: this `if (quickMode)` branch is an early return —
1884
+ // the whole quick path — so there is no enumerable list of "steps
1885
+ // run vs. skipped" this array could be derived from mechanically. It
1886
+ // is DESCRIPTIVE documentation of what the early return skips,
1887
+ // matching the `dbg()` line above word for word; keep both in sync by
1888
+ // hand if either changes. It also does NOT narrow under
1889
+ // `getFlag("no-lsp")` — quick mode skips the same five steps either
1890
+ // way; the LSP flag instead affects `quickTools` above, a separate
1891
+ // record.
1892
+ steps: [
1893
+ "slow_tool_probes",
1894
+ "language_profiling",
1895
+ "tool_preinstall",
1896
+ "startup_scans",
1897
+ "error_debt_baseline",
1898
+ ],
1899
+ },
1900
+ });
1860
1901
  const totalDurationMs = Date.now() - sessionStartMs;
1861
1902
  dbg(`session_start total: ${totalDurationMs}ms (interactive path)`);
1862
1903
  logLatency({
@@ -34,6 +34,8 @@ import { sweepInlineBlockerPastEof } from "./blocker-past-eof.js";
34
34
  // #1631 review V2: moved to its own leaf module so a low-level store
35
35
  // (widget-state.ts) can use the marker without importing this orchestrator —
36
36
  // see clients/stale-marker.ts's doc comment.
37
+ import { incrementDegradationCount } from "./degradation-ledger.js";
38
+ import { degradeDemotedFindingBody, formatRetirementNote, } from "./demoted-finding-render.js";
37
39
  import { STALE_LINE_MARKER } from "./stale-marker.js";
38
40
  import { getWidgetBlockingFilesForSweep, markWidgetFileBlockersStale, } from "./widget-state.js";
39
41
  /**
@@ -358,7 +360,9 @@ export async function handleTurnEnd(deps) {
358
360
  total: blockerPastEof.total,
359
361
  checked: blockerPastEof.checked,
360
362
  demoted: blockerPastEof.demoted,
361
- healed: blockerPastEof.healed,
363
+ // #1944 review F3: `healed` is gone. Retirement makes the falling edge
364
+ // unreachable on this store, so the field could only ever log zero —
365
+ // see `BlockerPastEofCounts`.
362
366
  },
363
367
  });
364
368
  // #1631: freshness gate. A cached blocker is a verdict about the file AND
@@ -395,14 +399,38 @@ export async function handleTurnEnd(deps) {
395
399
  // Re-surface inline blockers from this turn that the agent didn't fix.
396
400
  // These were shown inline during write/edit but the agent moved on without resolving them.
397
401
  const unresolvedBlockers = runtime.getInlineBlockersSnapshot();
402
+ /** #1944: past-EOF demotions retired after their single degraded delivery. */
403
+ let demotedFindingsRetired = 0;
398
404
  for (const { filePath: bPath, summary, stale } of unresolvedBlockers) {
399
405
  const displayPath = toRunnerDisplayPath(cwd, bPath);
400
406
  if (stale) {
401
407
  // #1631: demoted — out of the authoritative blocker channel and into the
402
408
  // advisory channel with a stale marker, so the agent is told to re-run
403
409
  // rather than pressured by a verdict that may already be resolved.
410
+ //
411
+ // #1944: the CHANNEL change is not enough. Until this call the advisory
412
+ // embedded the blocker body verbatim, so the agent read "🔴 STOP — 11
413
+ // issue(s) must be fixed" with dead line numbers under a hedge line it
414
+ // ignored. Degrade the body itself, and — when the file shrank past the
415
+ // cited lines, so no re-run can ever confirm it — retire the record
416
+ // after this ONE delivery instead of re-serving it for the rest of the
417
+ // session.
418
+ const deadLines = blockerPastEof.deadLinesByPath.get(bPath) ?? [];
419
+ const degraded = degradeDemotedFindingBody(summary, { deadLines });
420
+ const retired = runtime.retireDemotedPastEofBlocker(bPath, deadLines);
421
+ if (retired) {
422
+ demotedFindingsRetired += 1;
423
+ // Bounded by the ledger's own per-kind/subject tally, and the subject
424
+ // keeps the discriminating identity (which store, which file).
425
+ incrementDegradationCount({
426
+ kind: "demoted-finding-retired",
427
+ subject: `inline-blocker:${displayPath}`,
428
+ reason: `file shrank past cited line(s) ${deadLines.join(", ")}; retired after one degraded delivery`,
429
+ });
430
+ }
404
431
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
405
- advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${summary}`);
432
+ advisoryParts.push(`${STALE_LINE_MARKER} ${displayPath}:\n${degraded.body}` +
433
+ (retired ? `\n${formatRetirementNote(deadLines)}` : ""));
406
434
  }
407
435
  else {
408
436
  // @delivery-surface: runtime-turn:unresolved-inline-blocker
@@ -1906,6 +1934,18 @@ export async function handleTurnEnd(deps) {
1906
1934
  runtime.fixedThisTurn.clear();
1907
1935
  runtime.clearActionableWarnings();
1908
1936
  runtime.clearCodeQualityWarnings();
1937
+ if (demotedFindingsRetired > 0) {
1938
+ // #1944: the retired payload must not survive as a SUPPRESSION key.
1939
+ // `turn-end-findings-last` holds a content signature used to silence a
1940
+ // duplicate re-prompt; live evidence found the demoted payload still on
1941
+ // disk 80+ minutes after the file shrank. It is not a delivery source —
1942
+ // `runtime-context.ts` never reads it, and the read at the top of this
1943
+ // function is gated on the current `sessionId`, so it cannot resurrect
1944
+ // the finding in a later session. It CAN, however, silence a genuinely
1945
+ // new report of content the store no longer holds. Drop it with the
1946
+ // record it describes.
1947
+ cacheManager.clearCache("turn-end-findings-last", cwd);
1948
+ }
1909
1949
  logLatency({
1910
1950
  type: "tool_result",
1911
1951
  toolName: "turn_end",
@@ -1924,6 +1964,11 @@ export async function handleTurnEnd(deps) {
1924
1964
  blockerSections: blockerParts.length,
1925
1965
  staleSecretSections: staleSecretParts.length,
1926
1966
  advisorySections: advisoryParts.length,
1967
+ // #1944 AC3: an empty advisory section on its own cannot say whether
1968
+ // the turn had nothing to report or dropped something. This counter
1969
+ // answers that from latency.log even when the payload is empty, and
1970
+ // the payload itself carries the retirement note when it is not.
1971
+ demotedFindingsRetired,
1927
1972
  },
1928
1973
  });
1929
1974
  resetFormatService();