@herbertgao/pi-extensions 2026.9.7 → 2026.9.9

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 (158) hide show
  1. package/README.md +8 -4
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/node_modules/@herbertgao/pi-bark/package.json +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  5. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  7. package/node_modules/@herbertgao/resume-from/package.json +2 -2
  8. package/node_modules/@narumitw/pi-btw/README.md +6 -1
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts +209 -23
  10. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +1 -12
  13. package/node_modules/@narumitw/pi-btw/src/menu.ts +244 -15
  14. package/node_modules/@narumitw/pi-btw/src/settings.ts +5 -0
  15. package/node_modules/grok-mermaid/CHANGELOG.md +46 -0
  16. package/node_modules/grok-mermaid/LICENSE +205 -0
  17. package/node_modules/grok-mermaid/README.md +191 -0
  18. package/node_modules/grok-mermaid/dist/ansi.d.ts +16 -0
  19. package/node_modules/grok-mermaid/dist/ansi.d.ts.map +1 -0
  20. package/node_modules/grok-mermaid/dist/ansi.js +23 -0
  21. package/node_modules/grok-mermaid/dist/ansi.js.map +1 -0
  22. package/node_modules/grok-mermaid/dist/canvas.d.ts +87 -0
  23. package/node_modules/grok-mermaid/dist/canvas.d.ts.map +1 -0
  24. package/node_modules/grok-mermaid/dist/canvas.js +366 -0
  25. package/node_modules/grok-mermaid/dist/canvas.js.map +1 -0
  26. package/node_modules/grok-mermaid/dist/graph.d.ts +74 -0
  27. package/node_modules/grok-mermaid/dist/graph.d.ts.map +1 -0
  28. package/node_modules/grok-mermaid/dist/graph.js +91 -0
  29. package/node_modules/grok-mermaid/dist/graph.js.map +1 -0
  30. package/node_modules/grok-mermaid/dist/index.d.ts +32 -0
  31. package/node_modules/grok-mermaid/dist/index.d.ts.map +1 -0
  32. package/node_modules/grok-mermaid/dist/index.js +100 -0
  33. package/node_modules/grok-mermaid/dist/index.js.map +1 -0
  34. package/node_modules/grok-mermaid/dist/labels.d.ts +62 -0
  35. package/node_modules/grok-mermaid/dist/labels.d.ts.map +1 -0
  36. package/node_modules/grok-mermaid/dist/labels.js +324 -0
  37. package/node_modules/grok-mermaid/dist/labels.js.map +1 -0
  38. package/node_modules/grok-mermaid/dist/layout-seq.d.ts +12 -0
  39. package/node_modules/grok-mermaid/dist/layout-seq.d.ts.map +1 -0
  40. package/node_modules/grok-mermaid/dist/layout-seq.js +194 -0
  41. package/node_modules/grok-mermaid/dist/layout-seq.js.map +1 -0
  42. package/node_modules/grok-mermaid/dist/layout.d.ts +87 -0
  43. package/node_modules/grok-mermaid/dist/layout.d.ts.map +1 -0
  44. package/node_modules/grok-mermaid/dist/layout.js +881 -0
  45. package/node_modules/grok-mermaid/dist/layout.js.map +1 -0
  46. package/node_modules/grok-mermaid/dist/parse.d.ts +83 -0
  47. package/node_modules/grok-mermaid/dist/parse.d.ts.map +1 -0
  48. package/node_modules/grok-mermaid/dist/parse.js +1151 -0
  49. package/node_modules/grok-mermaid/dist/parse.js.map +1 -0
  50. package/node_modules/grok-mermaid/dist/source-box.d.ts +18 -0
  51. package/node_modules/grok-mermaid/dist/source-box.d.ts.map +1 -0
  52. package/node_modules/grok-mermaid/dist/source-box.js +78 -0
  53. package/node_modules/grok-mermaid/dist/source-box.js.map +1 -0
  54. package/node_modules/grok-mermaid/dist/types.d.ts +42 -0
  55. package/node_modules/grok-mermaid/dist/types.d.ts.map +1 -0
  56. package/node_modules/grok-mermaid/dist/types.js +1 -0
  57. package/node_modules/grok-mermaid/dist/types.js.map +1 -0
  58. package/node_modules/grok-mermaid/dist/width-data.d.ts +2 -0
  59. package/node_modules/grok-mermaid/dist/width-data.d.ts.map +1 -0
  60. package/node_modules/grok-mermaid/dist/width-data.js +994 -0
  61. package/node_modules/grok-mermaid/dist/width-data.js.map +1 -0
  62. package/node_modules/grok-mermaid/dist/width.d.ts +18 -0
  63. package/node_modules/grok-mermaid/dist/width.d.ts.map +1 -0
  64. package/node_modules/grok-mermaid/dist/width.js +76 -0
  65. package/node_modules/grok-mermaid/dist/width.js.map +1 -0
  66. package/node_modules/grok-mermaid/package.json +49 -0
  67. package/node_modules/grok-mermaid/src/ansi.ts +34 -0
  68. package/node_modules/grok-mermaid/src/canvas.ts +373 -0
  69. package/node_modules/grok-mermaid/src/graph.ts +142 -0
  70. package/node_modules/grok-mermaid/src/index.ts +104 -0
  71. package/node_modules/grok-mermaid/src/labels.ts +326 -0
  72. package/node_modules/grok-mermaid/src/layout-seq.ts +203 -0
  73. package/node_modules/grok-mermaid/src/layout.ts +1015 -0
  74. package/node_modules/grok-mermaid/src/parse.ts +1189 -0
  75. package/node_modules/grok-mermaid/src/source-box.ts +89 -0
  76. package/node_modules/grok-mermaid/src/types.ts +43 -0
  77. package/node_modules/grok-mermaid/src/width-data.ts +993 -0
  78. package/node_modules/grok-mermaid/src/width.ts +74 -0
  79. package/node_modules/pi-jev-auto-mode/CHANGELOG.md +110 -0
  80. package/node_modules/pi-jev-auto-mode/LICENSE +21 -0
  81. package/node_modules/pi-jev-auto-mode/README.md +285 -0
  82. package/node_modules/pi-jev-auto-mode/SECURITY.md +32 -0
  83. package/node_modules/pi-jev-auto-mode/docs/calibration.md +154 -0
  84. package/node_modules/pi-jev-auto-mode/docs/design.md +164 -0
  85. package/node_modules/pi-jev-auto-mode/docs/security.md +124 -0
  86. package/node_modules/pi-jev-auto-mode/index.ts +1 -0
  87. package/node_modules/pi-jev-auto-mode/package.json +72 -0
  88. package/node_modules/pi-jev-auto-mode/src/call.ts +180 -0
  89. package/node_modules/pi-jev-auto-mode/src/decide.ts +86 -0
  90. package/node_modules/pi-jev-auto-mode/src/extension.ts +862 -0
  91. package/node_modules/pi-jev-auto-mode/src/intent.ts +71 -0
  92. package/node_modules/pi-jev-auto-mode/src/jev/availability.ts +53 -0
  93. package/node_modules/pi-jev-auto-mode/src/jev/criteria.ts +19 -0
  94. package/node_modules/pi-jev-auto-mode/src/jev/decide.ts +187 -0
  95. package/node_modules/pi-jev-auto-mode/src/jev/engine.ts +165 -0
  96. package/node_modules/pi-jev-auto-mode/src/jev/index.ts +20 -0
  97. package/node_modules/pi-jev-auto-mode/src/jev/questions.ts +273 -0
  98. package/node_modules/pi-jev-auto-mode/src/jev/response.ts +64 -0
  99. package/node_modules/pi-jev-auto-mode/src/jev/state.ts +20 -0
  100. package/node_modules/pi-jev-auto-mode/src/jev/transport.ts +117 -0
  101. package/node_modules/pi-jev-auto-mode/src/jev/types.ts +46 -0
  102. package/node_modules/pi-jev-auto-mode/src/policy.ts +558 -0
  103. package/node_modules/pi-jev-auto-mode/src/records.ts +118 -0
  104. package/node_modules/pi-jev-auto-mode/src/settings.ts +327 -0
  105. package/node_modules/pi-jev-auto-mode/src/ui.ts +233 -0
  106. package/node_modules/pi-lens/CHANGELOG.md +45 -0
  107. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +21 -9
  108. package/node_modules/pi-lens/dist/clients/cascade-format.js +24 -0
  109. package/node_modules/pi-lens/dist/clients/extension-log.js +48 -1
  110. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +11 -12
  111. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +3 -0
  112. package/node_modules/pi-lens/dist/clients/lsp/index.js +41 -7
  113. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +9 -2
  114. package/node_modules/pi-lens/dist/clients/path-utils.js +51 -9
  115. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +271 -0
  116. package/node_modules/pi-lens/dist/clients/pipeline.js +15 -1
  117. package/node_modules/pi-lens/dist/clients/runtime-turn.js +124 -7
  118. package/node_modules/pi-lens/dist/clients/skills-resolver.js +38 -2
  119. package/node_modules/pi-lens/dist/clients/widget-state.js +152 -6
  120. package/node_modules/pi-lens/dist/index.js +729 -344
  121. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +14 -2
  122. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +84 -17
  123. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +32 -5
  124. package/node_modules/pi-lens/package.json +1 -1
  125. package/node_modules/pi-typesafe/LICENSE +21 -0
  126. package/node_modules/pi-typesafe/README.md +153 -0
  127. package/node_modules/pi-typesafe/dist/ask.d.ts +32 -0
  128. package/node_modules/pi-typesafe/dist/ask.js +25 -0
  129. package/node_modules/pi-typesafe/dist/auth.d.ts +56 -0
  130. package/node_modules/pi-typesafe/dist/auth.js +114 -0
  131. package/node_modules/pi-typesafe/dist/batch.d.ts +74 -0
  132. package/node_modules/pi-typesafe/dist/batch.js +116 -0
  133. package/node_modules/pi-typesafe/dist/calibrate.d.ts +106 -0
  134. package/node_modules/pi-typesafe/dist/calibrate.js +157 -0
  135. package/node_modules/pi-typesafe/dist/client.d.ts +69 -0
  136. package/node_modules/pi-typesafe/dist/client.js +188 -0
  137. package/node_modules/pi-typesafe/dist/credentials.d.ts +46 -0
  138. package/node_modules/pi-typesafe/dist/credentials.js +112 -0
  139. package/node_modules/pi-typesafe/dist/errors.d.ts +9 -0
  140. package/node_modules/pi-typesafe/dist/errors.js +31 -0
  141. package/node_modules/pi-typesafe/dist/extension.d.ts +3 -0
  142. package/node_modules/pi-typesafe/dist/extension.js +222 -0
  143. package/node_modules/pi-typesafe/dist/index.d.ts +18 -0
  144. package/node_modules/pi-typesafe/dist/index.js +9 -0
  145. package/node_modules/pi-typesafe/dist/key-prompt.d.ts +3 -0
  146. package/node_modules/pi-typesafe/dist/key-prompt.js +43 -0
  147. package/node_modules/pi-typesafe/dist/login.d.ts +27 -0
  148. package/node_modules/pi-typesafe/dist/login.js +38 -0
  149. package/node_modules/pi-typesafe/dist/schema.d.ts +43 -0
  150. package/node_modules/pi-typesafe/dist/schema.js +152 -0
  151. package/node_modules/pi-typesafe/dist/ui.d.ts +3 -0
  152. package/node_modules/pi-typesafe/dist/ui.js +3 -0
  153. package/node_modules/pi-typesafe/dist/usage.d.ts +73 -0
  154. package/node_modules/pi-typesafe/dist/usage.js +182 -0
  155. package/node_modules/pi-typesafe/examples/decision-extension.ts +41 -0
  156. package/node_modules/pi-typesafe/extensions/index.js +2 -0
  157. package/node_modules/pi-typesafe/package.json +89 -0
  158. package/package.json +16 -8
@@ -0,0 +1,271 @@
1
+ /**
2
+ * #3170 — the bounded persistent-reverify pass.
3
+ *
4
+ * A finding whose own file is unchanged is re-delivered turn after turn from
5
+ * the persisted actionable-warnings report without ever being re-observed
6
+ * against the live server: the in-band publish carries forward DEFERRED-origin
7
+ * file entries while their file has not moved
8
+ * (`mergeActionableWarningsReports`'s scope guard), and the delta freshness
9
+ * gate passes them because the file's mtime has not moved either. When the
10
+ * root cause was fixed in a DIFFERENT file, the carried finding is stale in
11
+ * fact but fresh by every on-disk axis.
12
+ *
13
+ * This module re-observes those files at turn_end, BEFORE the advisory
14
+ * assembles, and the replacement entries fold into the turn's SINGLE in-band
15
+ * publish (#3176 F1: a separate replacement publish spends the carry marker
16
+ * and the main publish then drops the entries at the scope guard — the
17
+ * blocker the review caught).
18
+ *
19
+ * Review-round contracts (#3176):
20
+ * - **F2**: the fresh observation converts through the PRODUCER's own
21
+ * pipeline (`enrichFileFromLsp` — severity-2, code-action enrichment,
22
+ * fixable-only, the per-file caps), fed the touch's collected diagnostics
23
+ * as the cached arm so no second pull happens. No re-implementation.
24
+ * - **F3**: an empty result is clean ONLY when the touch itself answers
25
+ * `confirmation: "confirmed"` — the silent-on-clean rule (#240/#533/#1253).
26
+ * A bare `{diags: []}` (the house double's silent empty, a tier-3 server
27
+ * still analyzing, a `skipReason`) is UNCONFIRMED: the carried warnings are
28
+ * kept verbatim and the render labels the gap. `touched.inconclusive` is
29
+ * `resolveTouchVerdict`'s output computed service-side and is honored
30
+ * directly.
31
+ * - **F4**: every await is bound-wrapped (`bounded()`); `maxClientWaitMs` is
32
+ * only the spawn/ready budget, not a request ceiling.
33
+ * - **F6**: no skip guard — every deferred-origin entry re-verifies each
34
+ * turn (re-arm), per the issue's failure table.
35
+ *
36
+ * Bounds: at most {@link MAX_REVERIFY_FILES} files per turn_end, a wall
37
+ * budget of {@link REVERIFY_BUDGET_MS}, the turn-end abort signal, and no
38
+ * new durable state — candidates come from the persisted report itself,
39
+ * which is what was delivered last turn.
40
+ */
41
+ import * as fs from "node:fs";
42
+ import { enrichFileFromLsp } from "./actionable-warnings.js";
43
+ import { bounded } from "./deadline-utils.js";
44
+ import { logLatency } from "./latency-logger.js";
45
+ import { touchCompletedConfirmationPolicy, } from "./lsp/diagnostic-binding.js";
46
+ import { findAuxiliaryProfileForSource } from "./dispatch/auxiliary-lsp.js";
47
+ /** At most this many files are re-observed per turn_end (the drift backstop's
48
+ * own 4-resyncs-per-pass precedent). */
49
+ export const MAX_REVERIFY_FILES = 4;
50
+ /** Wall-clock budget for the whole pass; a touch that cannot answer inside
51
+ * its remaining slice is recorded unconfirmed, never waited out. */
52
+ export const REVERIFY_BUDGET_MS = 3000;
53
+ /** The touch options the `source=lsp` probe uses, with this pass's source. */
54
+ const TOUCH_SOURCE = "persistent_reverify";
55
+ /**
56
+ * Select the report's re-verify candidates: DEFERRED-origin file entries (the
57
+ * only population the in-band publish carries forward, so the only population
58
+ * that re-serves across turns — F6: with no skip guard, so every carried
59
+ * entry re-verifies each turn) whose file stat is UNCHANGED since the entry's
60
+ * own observation stamp. A file that changed is skipped and counted — the
61
+ * edit path already re-observed it. Missing files are skipped too: the delta
62
+ * freshness gate drops them outright.
63
+ */
64
+ export function selectPersistentReverifyFiles(report, nowMs = Date.now()) {
65
+ const candidates = [];
66
+ let skippedChanged = 0;
67
+ for (const entry of report.files) {
68
+ if (entry.origin !== "deferred")
69
+ continue;
70
+ // F3 (population half): the pass re-observes through a PRIMARY-scope
71
+ // touch, so an auxiliary-lane finding can never be re-confirmed — an
72
+ // entry carrying aux-sourced warnings is skipped (kept verbatim),
73
+ // never scored dropped. The aux lookup is the one shared seam.
74
+ if (entry.warnings.some((w) => findAuxiliaryProfileForSource(w.source ?? "") !== undefined)) {
75
+ continue;
76
+ }
77
+ const stamp = entry.generatedAt ? Date.parse(entry.generatedAt) : NaN;
78
+ if (!Number.isFinite(stamp) || stamp > nowMs)
79
+ continue;
80
+ let mtimeMs;
81
+ try {
82
+ mtimeMs = fs.statSync(entry.filePath).mtimeMs;
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (mtimeMs > stamp) {
88
+ skippedChanged += 1;
89
+ continue;
90
+ }
91
+ if (candidates.length < MAX_REVERIFY_FILES)
92
+ candidates.push(entry);
93
+ }
94
+ return { candidates, skippedChanged };
95
+ }
96
+ /**
97
+ * Run the bounded re-verify pass over the report's candidates and build the
98
+ * replacement entries for the caller's single in-band publish. Never throws:
99
+ * an unexpected per-file failure degrades that file to `unconfirmed` (kept
100
+ * verbatim, labeled), which is the shape-48 direction — the harm reaching the
101
+ * user is a hidden real finding, worse than re-delivering a stale one.
102
+ */
103
+ export async function runPersistentReverify(args) {
104
+ const started = Date.now();
105
+ const deadlineAt = started + (args.budgetMs ?? REVERIFY_BUDGET_MS);
106
+ const nowMs = args.nowMs ?? started;
107
+ const { candidates, skippedChanged } = selectPersistentReverifyFiles(args.report, nowMs);
108
+ const outcomes = [];
109
+ const replacementFiles = [];
110
+ let touched = 0;
111
+ // F2: the producer's own pipeline (enrichFileFromLsp) — constructed once,
112
+ // reused per candidate. deltaOnly: false (a re-observation is not gated on
113
+ // this turn's modified ranges — the file was not edited).
114
+ const buildArgs = {
115
+ cwd: args.cwd,
116
+ sessionId: args.report.sessionId,
117
+ turnIndex: 0,
118
+ files: [],
119
+ modifiedRangesByFile: new Map(),
120
+ dispatchWarnings: [],
121
+ includeLspCodeActions: true,
122
+ deltaOnly: false,
123
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
124
+ lspBudgetMs: REVERIFY_BUDGET_MS,
125
+ dbg: () => { },
126
+ };
127
+ // R3-4: every candidate the budget or the abort cut, so the phase row
128
+ // distinguishes "the pass ran out of time" from "the server answered
129
+ // nothing". Counted from what the loop actually reached, never predicted.
130
+ let processed = 0;
131
+ for (const entry of candidates) {
132
+ if (args.signal?.aborted || Date.now() >= deadlineAt)
133
+ break;
134
+ processed += 1;
135
+ let content;
136
+ try {
137
+ content = fs.readFileSync(entry.filePath, "utf-8");
138
+ }
139
+ catch {
140
+ outcomes.push({
141
+ filePath: entry.filePath,
142
+ displayPath: entry.displayPath,
143
+ outcome: "unconfirmed",
144
+ dropped: 0,
145
+ kept: entry.warnings.length,
146
+ });
147
+ replacementFiles.push({ ...entry, reVerifyIncomplete: true });
148
+ continue;
149
+ }
150
+ // F4: the touch is bound-wrapped — `maxClientWaitMs` bounds the
151
+ // spawn/ready window, `bounded()` is the request ceiling.
152
+ let touchedResult;
153
+ try {
154
+ touchedResult = await bounded(args.lspService.touchFile(entry.filePath, content, {
155
+ diagnostics: "document",
156
+ collectDiagnostics: true,
157
+ source: TOUCH_SOURCE,
158
+ clientScope: "primary",
159
+ }), {
160
+ ms: Math.max(250, deadlineAt - Date.now()),
161
+ signal: args.signal,
162
+ hook: "turn_end",
163
+ label: "persistent_reverify_touch",
164
+ });
165
+ }
166
+ catch {
167
+ // The never-throws contract: a wedged touch degrades to unconfirmed
168
+ // (kept verbatim, labeled), never an escaped error.
169
+ touchedResult = undefined;
170
+ }
171
+ // F3 (#3176): an empty result is clean ONLY when the touch COMPLETED
172
+ // its confirmation policy — the silent-on-clean rule (#240/#533/#1253).
173
+ // A bare `{diags: []}` (the house double's silent empty, a tier-3
174
+ // server still analyzing) or a `skipReason` is UNCONFIRMED: the carried
175
+ // warnings are kept verbatim and the render labels the gap.
176
+ // `touched.inconclusive` is resolveTouchVerdict's output, computed
177
+ // service-side, and is honored directly.
178
+ // R3-3: read the policy through `touchCompletedConfirmationPolicy`, the
179
+ // #1470 seam, NOT the literal `confirmation !== "confirmed"` that
180
+ // `diagnostic-binding.ts:274-288` exists to forbid: `partial` means
181
+ // every server except the named cut-off auxiliaries answered, so the
182
+ // primary's silent-clean gates ran to completion and its observation is
183
+ // as trustworthy as a full confirmation. The literal labels a file the
184
+ // primary fully answered and keeps a finding the touch just re-derived.
185
+ if (touchedResult === undefined ||
186
+ touchedResult.inconclusive === true ||
187
+ touchedResult.skipReason !== undefined ||
188
+ (touchedResult.diagnosticsUnsupportedServerIds ?? []).length > 0 ||
189
+ !touchCompletedConfirmationPolicy(touchedResult)) {
190
+ outcomes.push({
191
+ filePath: entry.filePath,
192
+ displayPath: entry.displayPath,
193
+ outcome: "unconfirmed",
194
+ dropped: 0,
195
+ kept: entry.warnings.length,
196
+ });
197
+ replacementFiles.push({ ...entry, reVerifyIncomplete: true });
198
+ continue;
199
+ }
200
+ touched += 1;
201
+ // F2: the touch's collected diagnostics feed the producer's pipeline
202
+ // as the cached arm — no second pull, the producer's filters and
203
+ // caps are reused verbatim.
204
+ // F4: the outer await is bound-wrapped too — the module carries ZERO
205
+ // syntactic unbounded awaits (#2523 slice-3 worklist), and the hard
206
+ // ceiling composes with enrichFileFromLsp's own internal deadline.
207
+ const freshRecords = (await bounded(enrichFileFromLsp(args.cwd, buildArgs, { filePath: entry.filePath, cached: touchedResult.diags }, {
208
+ lspService: args.lspService,
209
+ pullTimeoutMs: 2000,
210
+ deadlineAt,
211
+ ...(args.signal !== undefined ? { signal: args.signal } : {}),
212
+ site: { hook: "turn_end", label: "persistent_reverify" },
213
+ }), {
214
+ ms: Math.max(250, deadlineAt - Date.now()),
215
+ signal: args.signal,
216
+ hook: "turn_end",
217
+ label: "persistent_reverify_enrich",
218
+ })) ?? [];
219
+ let dropped = 0;
220
+ let kept = 0;
221
+ for (const warning of entry.warnings) {
222
+ const stillThere = freshRecords.some((record) => record.rule === warning.rule && record.message === warning.message);
223
+ if (stillThere)
224
+ kept += 1;
225
+ else
226
+ dropped += 1;
227
+ }
228
+ const outcome = freshRecords.length === 0
229
+ ? "clean"
230
+ : dropped === 0
231
+ ? "reconfirmed"
232
+ : "mixed";
233
+ outcomes.push({
234
+ filePath: entry.filePath,
235
+ displayPath: entry.displayPath,
236
+ outcome,
237
+ dropped,
238
+ kept,
239
+ });
240
+ replacementFiles.push({
241
+ ...entry,
242
+ warnings: freshRecords,
243
+ reVerified: true,
244
+ generatedAt: new Date(nowMs).toISOString(),
245
+ });
246
+ }
247
+ logLatency({
248
+ type: "phase",
249
+ toolName: "turn_end",
250
+ filePath: args.cwd,
251
+ phase: "persistent_reverify",
252
+ durationMs: Date.now() - started,
253
+ metadata: {
254
+ candidates: candidates.length,
255
+ touched,
256
+ clean: outcomes.filter((o) => o.outcome === "clean").length,
257
+ reconfirmed: outcomes.filter((o) => o.outcome === "reconfirmed").length,
258
+ mixed: outcomes.filter((o) => o.outcome === "mixed").length,
259
+ unconfirmed: outcomes.filter((o) => o.outcome === "unconfirmed").length,
260
+ skippedChanged,
261
+ skippedBudget: candidates.length - processed,
262
+ },
263
+ });
264
+ return {
265
+ outcomes,
266
+ replacementFiles,
267
+ candidates: candidates.length,
268
+ touched,
269
+ skippedChanged,
270
+ };
271
+ }
@@ -923,6 +923,15 @@ export async function runFormatPhase(filePath, getFormatService, dbg, signal, bu
923
923
  * → const randomInt = Math.floor(result);
924
924
  */
925
925
  function buildEnrichedBlockerOutput(blockers, fileContent = "") {
926
+ // #3188: no blockers, no banner. The delivery gate above can retract every
927
+ // blocker (all cited files deleted), and the header interpolates the count,
928
+ // so without this the agent reads "🔴 STOP — 0 issue(s) must be fixed:" with
929
+ // nothing under it. Same guard, same reason, as `formatDiagnostics` in
930
+ // dispatch/utils/format-utils.ts — it belongs to the renderer, not to each
931
+ // call site, because the #2028 branch below cannot gate on the count without
932
+ // falling through to the UNGATED raw output.
933
+ if (blockers.length === 0)
934
+ return "";
926
935
  // Empty fileContent (readback failed, e.g. deleted-file race) still
927
936
  // renders the gated blocker list - just without per-line snippets.
928
937
  const fileLines = fileContent ? fileContent.split("\n") : [];
@@ -1228,7 +1237,12 @@ export async function runPipeline(ctx, deps) {
1228
1237
  const rest = dispatchResult.output.slice(dispatchResult.blockerOutput.length);
1229
1238
  if (rest)
1230
1239
  gatedOut += rest;
1231
- output += `\n\n${gatedOut}`;
1240
+ // #3188: every blocker retracted and nothing after the blocker
1241
+ // section leaves nothing to say — appending the separator alone would
1242
+ // still deliver a (whitespace-only) tool-result block, because
1243
+ // `handleToolResult` decides delivery on `output` truthiness.
1244
+ if (gatedOut)
1245
+ output += `\n\n${gatedOut}`;
1232
1246
  }
1233
1247
  else {
1234
1248
  output += `\n\n${dispatchResult.output}`;
@@ -40,6 +40,8 @@ import { getLSPService } from "./lsp/index.js";
40
40
  import { drainPendingAuxCapEvictedCount, drainPendingAuxiliaryCoverage, isPendingAuxiliaryPastRearmTtl, rearmPendingAuxiliaryCoverage, MAX_LATE_AUX_REARMS, pendingAuxiliaryCoverageSize, } from "./lsp/pending-aux-coverage.js";
41
41
  import { convertLspDiagnostics } from "./dispatch/utils/lsp-diagnostics.js";
42
42
  import { retagAuxiliaryDiagnostics } from "./dispatch/auxiliary-lsp.js";
43
+ import { runPersistentReverify, } from "./persistent-reverify.js";
44
+ import { cascadeCarrySuffix } from "./cascade-format.js";
43
45
  import { applyFindingPolicy, loadProjectRulePolicyMap, renderedRuleIdentities, } from "./dispatch/finding-policy.js";
44
46
  import { detectFileRole } from "./file-role.js";
45
47
  import { drainPendingRunnerFindings, dropStaleRunnerFindings, pendingRunnerFindingsSize, } from "./dispatch/pending-runner-findings.js";
@@ -894,6 +896,24 @@ export async function handleTurnEnd(deps) {
894
896
  return true;
895
897
  });
896
898
  const cascadeResults = cascadeRuns.flatMap((r) => r.result ? [r.result] : []);
899
+ // Fix B (#3167): which results belong to a CARRIED run — the label is
900
+ // per-result at render time, so the run→result pairing must survive the
901
+ // flatMap above (which discards the wrapper).
902
+ // Fix B (#3168 F6): the success-path record — latency.log could not show
903
+ // the feature ever rendered without it (the only phases were the drop/
904
+ // settle records). One bounded record per turn, emitted after both render
905
+ // sections below.
906
+ let carriedRunsRendered = 0;
907
+ let labeledAdvisories = 0;
908
+ const carriedMetaByResult = new Map();
909
+ for (const r of cascadeRuns) {
910
+ if (r.result && (r.carriedTurns ?? 0) > 0 && r.carriedTurns !== undefined) {
911
+ carriedMetaByResult.set(r.result, {
912
+ carriedTurns: r.carriedTurns,
913
+ observedAt: r.observedAt,
914
+ });
915
+ }
916
+ }
897
917
  // #1550 class sweep: every cascade record below summarises `cascadeResults`
898
918
  // — runs, which carry their own paths and can be carried across turns
899
919
  // (#1443) — so labelling them with the turn's first EDITED file is the same
@@ -931,7 +951,16 @@ export async function handleTurnEnd(deps) {
931
951
  const pk = normalizeMapKey(result.filePath);
932
952
  const ownsAny = result.neighbors.some((n) => neighborOwner.get(normalizeMapKey(n.filePath)) === pk);
933
953
  if (ownsAny && result.formatted) {
934
- parts.push(result.formatted);
954
+ // Fix B (#3167/#3168): a carried run's re-rendered blocker is labeled
955
+ // so the agent can tell it from a fresh observation — with the
956
+ // run's own observation age (#3168 F3).
957
+ const carryMeta = carriedMetaByResult.get(result);
958
+ const carrySuffix = cascadeCarrySuffix(carryMeta?.carriedTurns, carryMeta?.observedAt);
959
+ parts.push(carrySuffix
960
+ ? `${result.formatted}\n${carrySuffix}`
961
+ : result.formatted);
962
+ if (carrySuffix)
963
+ carriedRunsRendered += 1;
935
964
  injectedNeighborCount += result.neighbors.length;
936
965
  injectedDiagnosticCount += result.neighbors.reduce((s, n) => s + n.diagnostics.length, 0);
937
966
  }
@@ -1081,27 +1110,59 @@ export async function handleTurnEnd(deps) {
1081
1110
  // "ℹ️ Advisory — no action required this turn:" label, so an imperative
1082
1111
  // ("review dependents manually") would contradict it. The #533 substance
1083
1112
  // stays: a clean cascade result does NOT cover these files' dependents.
1084
- const graphAdvisory = buildAdvisory(graphRuns, {
1113
+ // Fix B (#3167/#3168 F4): a coverage advisory computed from a CARRIED
1114
+ // indeterminate run describes the previous turn's evidence — label it so
1115
+ // the absence-of-coverage statement is not read as current. A MIXED
1116
+ // bucket (some carried, some fresh) is left UNLABELED: a Math.max
1117
+ // suffix on the finished multi-line advisory would attach the carry to
1118
+ // a file that was not carried.
1119
+ const withCarryLabel = (advisory, runs) => {
1120
+ if (advisory === undefined)
1121
+ return undefined;
1122
+ const carried = runs.filter((r) => (r.carriedTurns ?? 0) > 0);
1123
+ if (carried.length === 0 || carried.length !== runs.length) {
1124
+ return advisory;
1125
+ }
1126
+ // #3168 F13: the bucket's age is the OLDEST carried observation, and
1127
+ // only when EVERY carried run carries one. A `Math.min` sentinel
1128
+ // (`?? Number.MAX_SAFE_INTEGER`) silently ignored the unstamped runs
1129
+ // and stated a confident age for a bucket that contains an unaged
1130
+ // one; one missing stamp collapses the age half to the helper's
1131
+ // neutral "scan age unknown" wording instead.
1132
+ const stamps = carried.map((r) => r.observedAt);
1133
+ const observedAt = stamps.every((s) => s !== undefined)
1134
+ ? Math.min(...stamps)
1135
+ : undefined;
1136
+ const suffix = cascadeCarrySuffix(carried[0]?.carriedTurns, observedAt);
1137
+ if (!suffix)
1138
+ return advisory;
1139
+ labeledAdvisories += 1;
1140
+ // #3168 F11: newline, as the blocker path does above. A space join
1141
+ // welded the suffix onto the LAST bullet of a multi-bullet advisory,
1142
+ // so the carry label read as a property of that one file.
1143
+ return `${advisory}\n${suffix}`;
1144
+ };
1145
+ const graphAdvisory = withCarryLabel(buildAdvisory(graphRuns, {
1085
1146
  lead: (fileCount, reasons) => `Cascade could not compute downstream impact for ${fileCount} edited file(s) this turn — ` +
1086
1147
  `the review graph was unavailable (${reasons}), so their dependents were not ` +
1087
1148
  `cascade-checked and a clean cascade result does not cover them.`,
1088
1149
  fallbackDetail: (r) => r.indeterminate?.reason === "missing_node"
1089
1150
  ? "changed file not in the review graph"
1090
1151
  : "review graph unavailable",
1091
- });
1152
+ }), graphRuns);
1092
1153
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1093
1154
  if (graphAdvisory)
1094
1155
  advisoryParts.push(graphAdvisory);
1095
- const bindingAdvisory = buildAdvisory(bindingRuns, {
1156
+ const bindingAdvisory = withCarryLabel(buildAdvisory(bindingRuns, {
1096
1157
  lead: (fileCount, reasons) => `Cascade identified dependents for ${fileCount} edited file(s) this turn, but their ` +
1097
1158
  `diagnostics could not be freshly confirmed (${reasons}) and were withheld — a clean ` +
1098
1159
  `cascade result does not cover them.`,
1099
1160
  fallbackDetail: () => "cascade diagnostics withheld (binding rejected)",
1100
- });
1161
+ }), bindingRuns);
1101
1162
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1102
1163
  if (bindingAdvisory)
1103
1164
  advisoryParts.push(bindingAdvisory);
1104
- const budgetAdvisory = buildAdvisory(budgetRuns, {
1165
+ const budgetAdvisory = withCarryLabel(buildAdvisory(budgetRuns, {
1105
1166
  lead: (fileCount, reasons) => `Cascade checked the selected neighbors for ${fileCount} edited file(s) this turn, ` +
1106
1167
  `but some eligible dependents were not checked because the cascade budget ` +
1107
1168
  `was exhausted (${reasons}); a clean cascade result does not cover them.`,
@@ -1114,7 +1175,7 @@ export async function handleTurnEnd(deps) {
1114
1175
  ? `${detail}; transitive expansion was capped before all eligible dependents were enumerated`
1115
1176
  : detail;
1116
1177
  },
1117
- });
1178
+ }), budgetRuns);
1118
1179
  // @delivery-surface: runtime-turn:cascade-coverage-advisory
1119
1180
  if (budgetAdvisory)
1120
1181
  advisoryParts.push(budgetAdvisory);
@@ -1152,6 +1213,14 @@ export async function handleTurnEnd(deps) {
1152
1213
  },
1153
1214
  });
1154
1215
  }
1216
+ // Fix B (#3168 F6): the success-path record — see the counters above.
1217
+ if (carriedRunsRendered > 0 || labeledAdvisories > 0) {
1218
+ logCascade({
1219
+ phase: "cascade_carry_rendered",
1220
+ filePath: cascadeLogFilePath,
1221
+ metadata: { carriedRunsRendered, labeledAdvisories },
1222
+ });
1223
+ }
1155
1224
  const cascadeSkipped = {
1156
1225
  blockers: 0,
1157
1226
  non_code: 0,
@@ -2672,7 +2741,37 @@ export async function handleTurnEnd(deps) {
2672
2741
  fileSeqByPath.set(filePath, getFileSeq.call(runtime, filePath));
2673
2742
  }
2674
2743
  }
2744
+ let reverify;
2675
2745
  if (getFlag("lens-actionable-warnings")) {
2746
+ // #3170: re-verify carried deferred findings before the advisory
2747
+ // assembles — a finding whose file is unchanged re-serves from the
2748
+ // persisted report without ever being re-observed; root-cause-fixed-
2749
+ // elsewhere findings converge here instead of repeating. Bounded in the
2750
+ // module (≤4 files, wall budget, abort signal). #3176 F1: the
2751
+ // replacement entries fold into THIS turn's single in-band publish
2752
+ // below — a separate replacement publish spends the carry marker and
2753
+ // this publish then drops the entries at the scope guard (the blocker
2754
+ // the review caught).
2755
+ const persistedReport = cacheManager.readCache("actionable-warnings", cwd, Number.MAX_SAFE_INTEGER)?.data;
2756
+ if (persistedReport?.files?.some((entry) => entry.origin === "deferred")) {
2757
+ const reverifyLspService = getLSPService();
2758
+ if (reverifyLspService) {
2759
+ // #2523: the hook-path await is bound-wrapped with the hook's own
2760
+ // budget and signal; the pass's internal deadline (3s) is the
2761
+ // tighter of the two.
2762
+ reverify = await bounded(runPersistentReverify({
2763
+ report: persistedReport,
2764
+ cwd,
2765
+ lspService: reverifyLspService,
2766
+ signal: getAmbientAbortSignal(),
2767
+ }), {
2768
+ ms: HOOK_WALL_BUDGET_MS.turn_end,
2769
+ signal: getAmbientAbortSignal(),
2770
+ hook: "turn_end",
2771
+ label: "persistent_reverify",
2772
+ });
2773
+ }
2774
+ }
2676
2775
  try {
2677
2776
  const report = await buildActionableWarningsReport({
2678
2777
  cwd,
@@ -2751,6 +2850,24 @@ export async function handleTurnEnd(deps) {
2751
2850
  // forward only entries a DEFERRAL produced, and only while their
2752
2851
  // file has not moved, so a `turn_delta` report does not accumulate
2753
2852
  // every prior turn's findings.
2853
+ // #3176 F1: the re-verify replacements fold into THIS report — one
2854
+ // in-band publish total, so the carry marker is spent exactly once and
2855
+ // the merged report cannot drop the entries behind the publish (the
2856
+ // blocker the review caught).
2857
+ if (reverify?.replacementFiles.length) {
2858
+ const replacementByPath = new Map(reverify.replacementFiles.map((file) => [
2859
+ normalizeMapKey(file.filePath),
2860
+ file,
2861
+ ]));
2862
+ const files = (report.files ?? []).map((file) => replacementByPath.get(normalizeMapKey(file.filePath)) ?? file);
2863
+ for (const replacement of reverify.replacementFiles) {
2864
+ if (!files.some((file) => normalizeMapKey(file.filePath) ===
2865
+ normalizeMapKey(replacement.filePath))) {
2866
+ files.push(replacement);
2867
+ }
2868
+ }
2869
+ report.files = files;
2870
+ }
2754
2871
  const publishResult = publishActionableWarningsReport(cacheManager, cwd, report, {
2755
2872
  origin: "in-band",
2756
2873
  getFileSeq: getFileSeq
@@ -82,7 +82,43 @@ export function resolveSkillPaths(importMetaUrl) {
82
82
  });
83
83
  if (health.status !== "healthy") {
84
84
  const entryDir = path.dirname(fileURLToPath(importMetaUrl));
85
- const reason = `${describeBundledResourceHealth(health, skillsDir, `no SKILL.md (or loadable .md) found under ${skillsDir}`)} (entry loaded from ${entryDir})`;
85
+ const healthDescription = describeBundledResourceHealth(health, skillsDir, `no SKILL.md (or loadable .md) found under ${skillsDir}`);
86
+ // #3175: two DIFFERENT strings, not one `reason` reused for both sinks
87
+ // as this used to be.
88
+ //
89
+ // `notifyReason` (uncapped — `notifyUserDegradation` never truncates)
90
+ // keeps the full, natural description: classification first, then
91
+ // where the entry file that triggered this lookup was loaded from.
92
+ const notifyReason = `${healthDescription} (entry loaded from ${entryDir})`;
93
+ // `ledgerReason` is built from a SHORT, path-free classification tag
94
+ // instead of `healthDescription`, because the LEDGER's `reason` field
95
+ // IS capped (`truncateForLedger`, `LEDGER_FIELD_MAX` = 200 chars,
96
+ // head-preserving — see `ledger-bounds.ts`) and `healthDescription`
97
+ // repeats `skillsDir` in full — already recorded, untruncated, in
98
+ // `subject` below whenever it's under the cap on its own. A
99
+ // managed-cache `skillsDir` plus this entry's `entryDir` sharing one
100
+ // long root routinely sums past 200 chars (#3175: TMPDIR ~60+ chars is
101
+ // a real dry-roll and CI-lane shape, not a pathological one); a
102
+ // head-preserving cap over the OLD concatenation (dir-bearing text
103
+ // first, `entryDir` last) then kept the redundant path and dropped the
104
+ // one fact nothing else records — which entry file's directory
105
+ // triggered this lookup (#2587's "entry file copied out of the
106
+ // package tree" case is exactly this). Simply swapping that order is
107
+ // NOT enough: `healthDescription`'s own `unreadable` branch reads
108
+ // `cannot read ${dir} (${fsErrorCode})`, so `dir` sitting in the
109
+ // MIDDLE of the classification text can still push `fsErrorCode`
110
+ // itself off the cap once `entryDir` leads. Dropping the redundant
111
+ // path from the ledger's copy entirely — keeping only the
112
+ // classification word/code — removes that squeeze instead of moving
113
+ // it, and leaves comfortable room (entry directories under roughly
114
+ // 150 chars fit whole) for every managed-cache/npm-install layout
115
+ // observed so far.
116
+ const classification = health.status === "unreadable"
117
+ ? `cannot read (${health.fsErrorCode})`
118
+ : health.status === "empty"
119
+ ? "no SKILL.md found"
120
+ : "no such directory";
121
+ const ledgerReason = `${classification} (entry loaded from ${entryDir})`;
86
122
  // #2626 review F3: the notify-once gate previously re-derived "have we
87
123
  // already recorded this" by scanning `getDegradationSummary()` and
88
124
  // comparing the RAW `skillsDir` against subjects the ledger stores
@@ -99,7 +135,7 @@ export function resolveSkillPaths(importMetaUrl) {
99
135
  // never silently replace it. Passed explicitly rather than inferred
100
136
  // from `label` so this stays a per-caller decision, not a convention
101
137
  // the next caller has to remember.
102
- reportBundledResourceDirHealth(SKILLS_DIR_MISSING_KIND, skillsDir, health, "skills", reason, `pi-lens: registers zero skills — ${reason}.`);
138
+ reportBundledResourceDirHealth(SKILLS_DIR_MISSING_KIND, skillsDir, health, "skills", ledgerReason, `pi-lens: registers zero skills — ${notifyReason}.`);
103
139
  }
104
140
  return [skillsDir];
105
141
  }