pi-pr-review 1.14.0 → 1.14.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.14.2](https://github.com/10ego/pi-pr-review/compare/v1.14.1...v1.14.2) (2026-08-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **extract:** scan lane evidence past the synthesis findings framing ([#77](https://github.com/10ego/pi-pr-review/issues/77)) ([dfebc36](https://github.com/10ego/pi-pr-review/commit/dfebc3632910fdde46581d4a48d236d72c7046df))
9
+
10
+ ## [1.14.1](https://github.com/10ego/pi-pr-review/compare/v1.14.0...v1.14.1) (2026-08-22)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **extract:** harden provenance and retained-binding handling from sample review ([#75](https://github.com/10ego/pi-pr-review/issues/75)) ([ff5206a](https://github.com/10ego/pi-pr-review/commit/ff5206ad94ec0a901c1a8398149d31445d49874b))
16
+
3
17
  ## [1.14.0](https://github.com/10ego/pi-pr-review/compare/v1.13.0...v1.14.0) (2026-08-22)
4
18
 
5
19
 
@@ -56,7 +56,7 @@ import {
56
56
  } from "../lib/pr-review-artifacts.ts";
57
57
  import { runWithConcurrency } from "../lib/pr-review-concurrency.ts";
58
58
  import { attemptDeadline, fallbackBudget, type ReviewBudget } from "../lib/pr-review-deadlines.ts";
59
- import { buildExtractionSystemPrompt, MAX_EXTRACTION_OUTPUT_BYTES } from "../lib/pr-review-extract.ts";
59
+ import { buildExtractionSystemPrompt, buildExtractionTask, MAX_EXTRACTION_OUTPUT_BYTES } from "../lib/pr-review-extract.ts";
60
60
  import { loadReviewContext, shardUnifiedDiff } from "../lib/pr-review-context.ts";
61
61
  import {
62
62
  combineAbortSignals,
@@ -2727,7 +2727,7 @@ export async function runFindingExtraction(
2727
2727
  invocation.command,
2728
2728
  invocation.args,
2729
2729
  ctx.cwd,
2730
- input,
2730
+ buildExtractionTask(input),
2731
2731
  lease.signal,
2732
2732
  undefined,
2733
2733
  undefined,
@@ -2738,7 +2738,8 @@ export async function runFindingExtraction(
2738
2738
  },
2739
2739
  );
2740
2740
  // Output blast-radius bound: reject oversized documents before parsing.
2741
- if (result.text.length > MAX_EXTRACTION_OUTPUT_BYTES) {
2741
+ // Measured in UTF-8 bytes to match the parser's own budget.
2742
+ if (Buffer.byteLength(result.text, "utf8") > MAX_EXTRACTION_OUTPUT_BYTES) {
2742
2743
  return { text: "", exitCode: result.exitCode || 1, errorMessage: "extraction output exceeded the size cap" };
2743
2744
  }
2744
2745
  return {
@@ -552,10 +552,14 @@ export default function registerReviewTable(
552
552
  // Telemetry is best-effort and must never affect the review.
553
553
  }
554
554
  };
555
- // A stale generation (replacement, cancellation, session switch) drops
556
- // the merge entirely; the deterministic artifact was never completed.
557
555
  if (!loopCoordinator.isLeaseActive(deferred.lease, ctx)) {
556
+ // Deadline expiry retains the same generation for deterministic
557
+ // degraded synthesis, so completion proceeds with the deterministic
558
+ // artifact. Only a genuine replacement (new generation) drops it.
558
559
  recordOutcome("aborted");
560
+ if (loopCoordinator.retainedGenerationIs(deferred.lease.generation, ctx)) {
561
+ return { artifact: deferred.artifact };
562
+ }
559
563
  return undefined;
560
564
  }
561
565
  let result: Awaited<PendingExtraction["promise"]>;
@@ -570,8 +574,12 @@ export default function registerReviewTable(
570
574
  // Re-check ownership after the suspension: a replacement, cancellation,
571
575
  // or session switch that landed while awaiting must not have this review
572
576
  // consume the successor's invocation, publish, or revoke its binding.
577
+ // Same-generation deadline expiry still completes deterministically.
573
578
  if (!loopCoordinator.isLeaseActive(deferred.lease, ctx)) {
574
579
  recordOutcome("aborted");
580
+ if (loopCoordinator.retainedGenerationIs(deferred.lease.generation, ctx)) {
581
+ return { artifact: deferred.artifact };
582
+ }
575
583
  return undefined;
576
584
  }
577
585
  if (result.timedOut) {
@@ -1091,7 +1099,10 @@ export default function registerReviewTable(
1091
1099
  if (active && artifact && artifact.quality !== "fully_parsed") {
1092
1100
  const setting = resolveExtractionSetting(getAgentDir());
1093
1101
  if (setting.warning) ctx.ui.notify(setting.warning, "warning");
1094
- if (setting.enabled && !pendingExtraction) {
1102
+ if (setting.enabled && !pendingExtraction && !loopCoordinator.deadlineExpired()) {
1103
+ // acquire() must never see a deadline-expired binding: it would
1104
+ // clear the retained invocation that degraded synthesis still
1105
+ // needs. An expired deadline leaves no window for extraction.
1095
1106
  const lease = loopCoordinator.acquire(ctx);
1096
1107
  if (lease) {
1097
1108
  const input = buildExtractionInput(artifact.rawText, laneArtifacts);
@@ -89,6 +89,8 @@ export function buildExtractionSystemPrompt(): string {
89
89
  "When the document names a file for the finding, also include: path (repo-relative file path exactly as written), start_line and end_line (integers from the document when stated), side (\"RIGHT\" for added lines, \"LEFT\" for removed lines), and location_quote (a verbatim substring of the document containing that path).",
90
90
  "Optionally include source (the lane or section the finding came from).",
91
91
  "Rules: report only findings actually stated in the document; never invent, merge, or upgrade severity; keep the reviewer's own wording in title and body; omit path fields when no location is stated; do not report strengths, summaries, or coverage notes as findings.",
92
+ "The synthesis's own Findings section is NOT authoritative about what the document states: a review summary saying \"No findings\" while a retained lane section states a concrete defect means that defect IS stated in the document and must be extracted.",
93
+ "Findings appear in any format: ### severity blocks, bulleted field lists (title/severity/why/location), or prose. Scan every section, including retained lane output, before deciding the document states no findings.",
92
94
  "If the document states no findings, return {\"findings\":[]}.",
93
95
  "Do not use tools, read files, or modify anything.",
94
96
  ].join("\n");
@@ -112,6 +114,23 @@ function truncateWithMarker(text: string, maxBytes: number, omittedBytes?: numbe
112
114
  }
113
115
 
114
116
  /** Assemble the bounded extraction payload: synthesis first, then lanes in order, each truncated with its own byte-count marker. */
117
+ /**
118
+ * Host-authored framing wrapped around the data document. Without it the
119
+ * child treats the synthesis's own "Findings: No findings." framing as
120
+ * authoritative and misses defects stated in retained lane output (observed
121
+ * in the first production extraction run).
122
+ */
123
+ export function buildExtractionTask(input: string): string {
124
+ return [
125
+ "Objective: Extract every concrete defect finding stated anywhere in this review document, including inside the retained lane output sections.",
126
+ "A \"No findings\" line written by the summary does not mean the document states no findings: check the lane sections too.",
127
+ "",
128
+ "--- Review document ---",
129
+ input,
130
+ "--- End of review document ---",
131
+ ].join("\n");
132
+ }
133
+
115
134
  export function buildExtractionInput(
116
135
  rawText: string,
117
136
  lanes: readonly ReviewLaneArtifact[],
@@ -189,7 +208,11 @@ function safeRelativePath(value: string): boolean {
189
208
  /** Verify a verbatim quote against the normalized input. */
190
209
  export function verifyQuote(quote: string, normalizedInput: string): boolean {
191
210
  if (!quote || quote.length < MIN_QUOTE_CHARS || byteLength(quote) > MAX_QUOTE_BYTES) return false;
192
- return normalizedInput.includes(normalizeForQuote(quote));
211
+ const normalized = normalizeForQuote(quote);
212
+ // Whitespace-only or control-heavy quotes normalize away; the normalized
213
+ // form must itself clear the minimum length before it can match.
214
+ if (normalized.length < MIN_QUOTE_CHARS) return false;
215
+ return normalizedInput.includes(normalized);
193
216
  }
194
217
 
195
218
  function deriveBlocking(severity: string): boolean {
@@ -197,8 +220,10 @@ function deriveBlocking(severity: string): boolean {
197
220
  }
198
221
 
199
222
  function normalizeFinding(wire: ExtractedFindingWire): ReviewFindingLike | undefined {
200
- const title = String(wire.title ?? "").trim();
201
- const body = String(wire.body ?? "").trim();
223
+ // Non-string fields are contract violations, not values to stringify.
224
+ if (typeof wire.title !== "string" || typeof wire.body !== "string") return undefined;
225
+ const title = wire.title.trim();
226
+ const body = wire.body.trim();
202
227
  const severity = String(wire.severity ?? "");
203
228
  if (!title || byteLength(title) > MAX_TITLE_BYTES) return undefined;
204
229
  if (!body || byteLength(body) > MAX_BODY_BYTES) return undefined;
@@ -263,6 +288,10 @@ export function parseExtractionOutput(text: string, input: string): ExtractionPa
263
288
  if (!Array.isArray(record.findings)) {
264
289
  return { ok: false, rejection: { kind: "rejected", reason: "findings was not an array" } };
265
290
  }
291
+ // Bound the scan before any per-record provenance work.
292
+ if (record.findings.length > MAX_EXTRACTED_FINDINGS) {
293
+ return { ok: false, rejection: { kind: "rejected", reason: `more than ${MAX_EXTRACTED_FINDINGS} findings` } };
294
+ }
266
295
  const normalizedInput = normalizeForQuote(input);
267
296
  const findings: ReviewFindingLike[] = [];
268
297
  let rejectedProvenance = 0;
@@ -308,9 +337,6 @@ export function parseExtractionOutput(text: string, input: string): ExtractionPa
308
337
  }
309
338
  findings.push(finding);
310
339
  }
311
- if (findings.length > MAX_EXTRACTED_FINDINGS) {
312
- return { ok: false, rejection: { kind: "rejected", reason: `more than ${MAX_EXTRACTED_FINDINGS} findings` } };
313
- }
314
340
  return {
315
341
  ok: true,
316
342
  value: {
@@ -413,6 +413,21 @@ export class ReviewLoopCoordinator {
413
413
  return this.binding?.deadlineKind !== undefined;
414
414
  }
415
415
 
416
+ /**
417
+ * Whether `generation` still names the deadline-expired binding that is
418
+ * deliberately retained for deterministic degraded synthesis. True only for
419
+ * the same session/cwd binding with an active deadline kind — never for a
420
+ * replacement generation, a cleared loop, or a live lease.
421
+ */
422
+ retainedGenerationIs(
423
+ generation: number,
424
+ ctx: Pick<ExtensionContext, "cwd" | "sessionManager">,
425
+ ): boolean {
426
+ const binding = this.binding;
427
+ return !!binding && binding.generation === generation &&
428
+ binding.deadlineKind !== undefined && sameBinding(binding, ctx);
429
+ }
430
+
416
431
  totalDeadlineExpired(): boolean {
417
432
  return this.binding?.deadlineKind === "total";
418
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and host-gated COMMENT or qualified APPROVE publishing.",
5
5
  "keywords": [
6
6
  "pi-package",