pi-pr-review 1.17.7 → 1.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.17.9](https://github.com/10ego/pi-pr-review/compare/v1.17.8...v1.17.9) (2026-09-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **review:** publish omitted lane candidates ([#140](https://github.com/10ego/pi-pr-review/issues/140)) ([846c85a](https://github.com/10ego/pi-pr-review/commit/846c85a38a0b2f54becef74c5e35b11487701963))
9
+
10
+ ## [1.17.8](https://github.com/10ego/pi-pr-review/compare/v1.17.7...v1.17.8) (2026-09-01)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **review:** publish retained incomplete findings ([#138](https://github.com/10ego/pi-pr-review/issues/138)) ([586205b](https://github.com/10ego/pi-pr-review/commit/586205bbe46b50e04f00d53d43b3c353c3bea95e))
16
+
3
17
  ## [1.17.7](https://github.com/10ego/pi-pr-review/compare/v1.17.6...v1.17.7) (2026-09-01)
4
18
 
5
19
 
package/README.md CHANGED
@@ -195,7 +195,7 @@ Every invocation has a host-owned monotonic 15-minute hard cap, including the tw
195
195
 
196
196
  A timed-out or retryable quota/rate-limit/capacity lane may start at most one configured fallback attempt. It starts only when at least `minimumFallbackMs` plus cleanup reserve remains; the host never changes the configured model, thinking level, or tool policy to save time. If a tier is unset, its existing nearest-configured-tier/Pi-default behavior is unchanged.
197
197
 
198
- On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for concise `COMMENT` publication. Complete contract-valid candidate blocks retained before a timeout are deterministically recovered as canonical findings, including when terminal synthesis is absent; a truncated final block and arbitrary lane prose remain private. A timed-out lane is never reported as `NO FINDINGS` or full coverage, and a lane ended by the host total or synthesis deadline is disclosed with that kind (`deadline_expired`) instead of being mistaken for its own attempt deadline expiring. Host lane artifacts are authoritative for completeness in both directions: a false assistant completion claim cannot upgrade incomplete lanes, and a paraphrased or omitted `Lane completeness` line cannot downgrade a host-complete batch away from the concise renderer.
198
+ On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for concise `COMMENT` publication. Complete contract-valid candidate blocks retained before a timeout are deterministically recovered as canonical findings from the terminal lane output and retained primary/fallback attempt history, including conventional YAML-list field indentation and cases where a later attempt or terminal synthesis is empty or malformed. A recovered lane candidate omitted by terminal synthesis keeps its priority and inline eligibility and carries an explicit recommendation that readers validate it independently. Host-recorded attempt ordinals—not array position—define chronology; duplicate ordinals disable history recovery, a later exact clean contract supersedes older provisional findings, and truncated blocks or arbitrary lane prose remain private. A timed-out lane is never reported as `NO FINDINGS` or full coverage, and a lane ended by the host total or synthesis deadline is disclosed with that kind (`deadline_expired`) instead of being mistaken for its own attempt deadline expiring. Host lane artifacts are authoritative for completeness in both directions: a false assistant completion claim cannot upgrade incomplete lanes, and a paraphrased or omitted `Lane completeness` line cannot downgrade a host-complete batch away from the concise renderer.
199
199
 
200
200
  Initial operating targets are ordinary-review p50 ≤ 6 minutes and p95 ≤ 12 minutes, and large-review p50 ≤ 10 minutes and p95 ≤ 14 minutes, with the 15-minute hard cap authoritative. Invocation telemetry starts before GitHub preflight and records configured deadline source/caps, termination grace, cleanup reserve, and active wall time; batch details record invocation time consumed before each attempt, batch/total time remaining at dispatch, first event/output timing, lifecycle counts, configured and effective batch-truncated attempt deadlines, fallback starts/budget rejections, external total/synthesis deadline expiries per lane, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
201
201
 
@@ -231,7 +231,7 @@ Review semantics are Markdown-first. A deterministic tolerant parser normalizes
231
231
 
232
232
  You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “manually post the review comments,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
233
233
 
234
- Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, completion warnings, and transport diagnostics stay out of the public summary. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
234
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, completion warnings, and transport diagnostics stay out of the public summary. Incomplete/degraded reviews publish whenever synthesis, retained attempts, or validated extraction produced at least one canonical finding. Complete host-validated lane candidates omitted by synthesis retain normal priority markers and inline placement with an explicit independent-validation advisory rather than disappearing. If none did, the completed record remains available privately and GitHub publication is skipped rather than creating a verdict-only review. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
235
235
 
236
236
  All publication paths apply host-enforced safety gates: captured posting authority, repository and requested-PR binding, reviewed/current-head and stale policy, bounded bodies and payloads, draft and lifecycle checks, non-open authorization, authenticated-identity same-head duplicate detection, and a final head check. Fully or partially parsed review paths additionally enforce safe inline locations, including degraded incomplete-lane reviews whose findings still parse. Raw and lane-assembled fallbacks without parsed findings are body-only; every degraded path is `COMMENT`-only, and assistant text cannot select event, commit, repository, hostname, API path, or inline anchors. Unknown or invalid host states fail closed before a write.
237
237
 
@@ -490,7 +490,7 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
490
490
  return { command: "pi", args };
491
491
  }
492
492
 
493
- const NONEMPTY_CONTRACT_DESCRIPTION = "After a successful terminal stop, nonempty output is exact Markdown with no wrapper: the first nonblank line must be exactly `Review status: COMPLETE` with no leading or trailing whitespace. If you could not inspect required evidence for any reason, return the top-level exact line `Review status: INCOMPLETE` followed by a concise reason; INCOMPLETE and every other status are partial. COMPLETE must be followed in exact order by meaningful one-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` sections, then either the exact standalone `NO FINDINGS.` line or one or more complete candidate blocks. Nonblank contract lines have no trailing spaces or tabs. Framing may use plain labels, bold labels, or an ATX label followed immediately by exactly one top-level value line; do not use list-indented or quoted contract lines. Candidate fields are in exact order and each appears once: title prefixed [P0], [P1], [P2], [P3], or [nit] (the tag must match severity); severity P0/P1/P2/P3/nit; meaningful why; location as repo-relative/path:positive-line or repo-relative/path:positive-start-positive-end (ordered), or repo-wide; side RIGHT/LEFT; in_diff yes/no; pr_related yes/no; confidence 0.0-1.0. Candidate labels may be plain/bold or use exactly one top-level `- ` list marker; no other indentation or list marker is accepted. A why value may continue only on lines beginning with exactly two spaces plus a non-list character until the next reserved field. Scalar and continuation values must not contain status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order fields, arbitrary prose, or placeholder values (none, n/a, unavailable, unknown, skipped, error, review complete). Status/refusal/access/tool/model/error reasons belong in an INCOMPLETE status; inability discussed inside a valid candidate why is allowed. COMPLETE is the primary completion attestation; only obvious exact failure placeholders are rejected as defense-in-depth."
493
+ const NONEMPTY_CONTRACT_DESCRIPTION = "After a successful terminal stop, nonempty output is exact Markdown with no wrapper: the first nonblank line must be exactly `Review status: COMPLETE` with no leading or trailing whitespace. If you could not inspect required evidence for any reason, return the top-level exact line `Review status: INCOMPLETE` followed by a concise reason; INCOMPLETE and every other status are partial. COMPLETE must be followed in exact order by meaningful one-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` sections, then either the exact standalone `NO FINDINGS.` line or one or more complete candidate blocks. Nonblank contract lines have no trailing spaces or tabs. Framing may use plain labels, bold labels, or an ATX label followed immediately by exactly one top-level value line; do not use list-indented or quoted contract lines. Candidate fields are in exact order and each appears once: title prefixed [P0], [P1], [P2], [P3], or [nit] (the tag must match severity); severity P0/P1/P2/P3/nit; meaningful why; location as repo-relative/path:positive-line or repo-relative/path:positive-start-positive-end (ordered), or repo-wide; side RIGHT/LEFT; in_diff yes/no; pr_related yes/no; confidence 0.0-1.0. Candidate labels may be plain/bold, repeat one top-level `- ` marker per field, or use conventional YAML-list form with `- title:` followed by fields indented exactly two spaces; no other indentation or list marker is accepted. A why value may continue only on lines beginning with exactly two spaces in top-level form or exactly four spaces in YAML-list form plus a non-list character until the next reserved field. Scalar and continuation values must not contain status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order fields, arbitrary prose, or placeholder values (none, n/a, unavailable, unknown, skipped, error, review complete). Status/refusal/access/tool/model/error reasons belong in an INCOMPLETE status; inability discussed inside a valid candidate why is allowed. COMPLETE is the primary completion attestation; only obvious exact failure placeholders are rejected as defense-in-depth."
494
494
 
495
495
  type InternalExpectedOutput = "review_lane" | "nonempty" | "json_object";
496
496
 
@@ -554,7 +554,7 @@ function buildSubagentSystemPrompt(tier: Tier, majorOnly = false, minorHygiene =
554
554
  "- confidence: a float 0.0-1.0",
555
555
  ]),
556
556
  expectedOutput === "nonempty"
557
- ? "After the exact COMPLETE status and three framing sections, the clean sentinel is one top-level standalone uppercase line `NO FINDINGS.`; never use `No findings.` or append a sentence. Candidate fields are top-level or use exactly one `- ` marker, with no arbitrary indentation; candidate `why` may use exactly-two-space non-list continuation lines, but never absorb a reserved field, status, sentinel, or wrapper."
557
+ ? "After the exact COMPLETE status and three framing sections, the clean sentinel is one top-level standalone uppercase line `NO FINDINGS.`; never use `No findings.` or append a sentence. Candidate fields may be top-level, repeat one `- ` marker per field, or use conventional YAML-list form with `- title:` followed by exactly-two-space-indented fields; candidate `why` continuations use two spaces in top-level form or four in YAML-list form and may never absorb a reserved field, status, sentinel, or wrapper."
558
558
  : majorOnly
559
559
  ? "If there are no substantiated P0-P2 findings, reply exactly with: NO FINDINGS."
560
560
  : "If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.",
@@ -461,6 +461,14 @@ async function publishCompletedReview(
461
461
  }
462
462
  const degradedPublication = record.completeness === "incomplete" ||
463
463
  (record.synthesisQuality !== undefined && record.synthesisQuality !== "fully_parsed");
464
+ const findings = Array.isArray(record.review.findings) ? record.review.findings : [];
465
+ if (degradedPublication && findings.length === 0) {
466
+ // A result-less degraded record remains cached for private diagnostics and a
467
+ // later rerun, but must not create a verdict-only GitHub review. Incomplete
468
+ // reviews with any validated synthesis/lane/extraction finding still publish.
469
+ ctx.ui.notify("PR review was not posted: the incomplete review retained no validated findings", "warning");
470
+ return undefined;
471
+ }
464
472
  const result = await publishPullReview({
465
473
  cwd: ctx.cwd,
466
474
  prNumber: record.invocation.prNumber,
@@ -475,9 +483,6 @@ async function publishCompletedReview(
475
483
  // inline findings. Retained raw synthesis and lane evidence remain private
476
484
  // completion diagnostics, including for degraded/incomplete COMMENT runs.
477
485
  // If the concise payload cannot fit, publication fails closed.
478
- forceBodyOnly: record.synthesisQuality !== undefined &&
479
- record.synthesisQuality !== "fully_parsed" &&
480
- !(Array.isArray(record.review.findings) && record.review.findings.length > 0),
481
486
  // A publication body identifies Markdown-derived or degraded synthesis.
482
487
  // Enforce COMMENT again at the final publication boundary so restored
483
488
  // canonical artifacts created by an older parser cannot inherit APPROVE.
@@ -1076,7 +1081,7 @@ export default function registerReviewTable(
1076
1081
  }
1077
1082
  }
1078
1083
  const publishResult = await publishCompletedReview(pending.record, { kind: "frozen-invocation" }, ctx);
1079
- if (pending.extraction) {
1084
+ if (pending.extraction && publishResult) {
1080
1085
  try {
1081
1086
  // `published` decorates exactly the attempt whose merged event it
1082
1087
  // follows: the spread carries that attempt's identity, so tooling can
@@ -1,3 +1,4 @@
1
+ import { Buffer } from "node:buffer";
1
2
  import type { ReviewDeadlineKind } from "./pr-review-deadlines.ts";
2
3
 
3
4
  export type ReviewLaneLifecycle = "complete" | "partial" | "timed_out" | "failed";
@@ -137,6 +138,9 @@ const CANDIDATE_SEVERITIES = new Set(["P0", "P1", "P2", "P3", "nit"]);
137
138
  const FRAMING_LABELS = ["Overview", "Strengths", "Risk areas"] as const;
138
139
  const PLACEHOLDER_ONLY = /^(?:none|n\/?a|na|unavailable|unknown|skipped|error|review complete|no findings|nothing to review)(?:\s+(?:identified|found|available|present))?[.!]?$/i;
139
140
  const NO_FINDINGS_SENTINEL = "NO FINDINGS.";
141
+ const MAX_CANDIDATE_TITLE_BYTES = 1_024;
142
+ const MAX_CANDIDATE_WHY_BYTES = 16 * 1_024;
143
+ const MAX_CANDIDATE_PATH_BYTES = 4_096;
140
144
  const CODE_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
141
145
  const COMMONMARK_HTML_BLOCK_TAGS = [
142
146
  "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", "col",
@@ -236,6 +240,21 @@ function candidateLabel(line: string): MarkdownLabel | undefined {
236
240
  return { field: (match[1] ?? match[2] ?? match[3])!, value: match[4] ?? "", kind: "inline" };
237
241
  }
238
242
 
243
+ type CandidateBlockStyle = "top-level" | "list-undecided" | "repeated-list" | "yaml-list";
244
+
245
+ /** Parse top-level, repeated-list-marker, or conventional YAML-list fields. */
246
+ function candidateBlockLabel(line: string, style: CandidateBlockStyle): MarkdownLabel | undefined {
247
+ if (style === "top-level") return line.startsWith("- ") ? undefined : candidateLabel(line);
248
+ if (style === "repeated-list") return line.startsWith("- ") ? candidateLabel(line) : undefined;
249
+ if (style === "list-undecided") return undefined;
250
+ // In conventional YAML form the title owns the list marker and subsequent
251
+ // fields use exactly two spaces with no nested marker. Broader indentation
252
+ // remains unavailable to arbitrary Markdown containers.
253
+ if (!/^ {2}\S/.test(line) || /^ {3}/.test(line)) return undefined;
254
+ const unindented = line.slice(2);
255
+ return unindented.startsWith("- ") ? undefined : candidateLabel(unindented);
256
+ }
257
+
239
258
  function canonicalField(field: string): string {
240
259
  return field.toLowerCase();
241
260
  }
@@ -285,7 +304,8 @@ function safeLocation(value: string): boolean {
285
304
  const start = Number(match[2]);
286
305
  const end = match[3] === undefined ? start : Number(match[3]);
287
306
  if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) return false;
288
- if (!locationPath || locationPath.startsWith("/") || locationPath.startsWith("~") || /^[A-Za-z]:/.test(locationPath)) return false;
307
+ if (!locationPath || Buffer.byteLength(locationPath, "utf8") > MAX_CANDIDATE_PATH_BYTES ||
308
+ locationPath.startsWith("/") || locationPath.startsWith("~") || /^[A-Za-z]:/.test(locationPath)) return false;
289
309
  if (/[\\\u0000-\u001f\u007f]/.test(locationPath)) return false;
290
310
  const segments = locationPath.split("/");
291
311
  return segments.length > 0 && segments.every((segment) => segment.length > 0 && segment === segment.trim() && segment !== "." && segment !== ".." && !segment.includes(":"));
@@ -341,8 +361,9 @@ function validCandidateFields(fields: ReadonlyMap<string, string>): boolean {
341
361
  const inDiff = fields.get("in_diff")!.trim();
342
362
  const prRelated = fields.get("pr_related")!.trim();
343
363
  return !!titleMatch && meaningfulValue(titleMatch[2]!) &&
364
+ Buffer.byteLength(title, "utf8") <= MAX_CANDIDATE_TITLE_BYTES &&
344
365
  CANDIDATE_SEVERITIES.has(severity) && titleMatch[1] === severity &&
345
- meaningfulValue(why) && safeLocation(location) &&
366
+ meaningfulValue(why) && Buffer.byteLength(why, "utf8") <= MAX_CANDIDATE_WHY_BYTES && safeLocation(location) &&
346
367
  /^(?:RIGHT|LEFT)$/.test(side) &&
347
368
  /^(?:yes|no)$/.test(inDiff) &&
348
369
  /^(?:yes|no)$/.test(prRelated) &&
@@ -402,11 +423,18 @@ function parseCandidatePrefix(
402
423
  if (cursor >= lines.length) return { candidates, consumedAll: true };
403
424
  const start = cursor;
404
425
  const fields = new Map<string, string>();
426
+ let style: CandidateBlockStyle = "top-level";
405
427
  for (const expected of CANDIDATE_FIELDS) {
406
428
  while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
407
429
  const fieldLine = lines[cursor] ?? "";
408
430
  if (fieldLine.trim() && /[ \t]+$/.test(fieldLine)) return { candidates, consumedAll: false };
409
- const field = candidateLabel(fieldLine);
431
+ if (expected === "title" && fieldLine.startsWith("- ")) style = "list-undecided";
432
+ if (expected === "severity" && style === "list-undecided") {
433
+ style = fieldLine.startsWith("- ") ? "repeated-list" : "yaml-list";
434
+ }
435
+ const field = expected === "title"
436
+ ? candidateLabel(fieldLine)
437
+ : candidateBlockLabel(fieldLine, style);
410
438
  if (!field || canonicalField(field.field) !== expected || fields.has(expected)) {
411
439
  return { candidates, consumedAll: false };
412
440
  }
@@ -419,12 +447,20 @@ function parseCandidatePrefix(
419
447
  const continuation = lines[cursor]!;
420
448
  if (!continuation.trim()) break;
421
449
  if (/[ \t]+$/.test(continuation)) return { candidates, consumedAll: false };
450
+ const nextField = candidateBlockLabel(continuation, style);
451
+ if (nextField && canonicalField(nextField.field) === "location") break;
422
452
  if (isReservedContractLine(continuation)) break;
423
- if (!/^ {2}\S/.test(continuation) || /^ {2}(?:[-*+>]|#{1,6})[ \t]+/.test(continuation)) {
453
+ const continuationIndent = style === "yaml-list" ? 4 : 2;
454
+ const prefix = " ".repeat(continuationIndent);
455
+ if (!continuation.startsWith(prefix) || continuation.startsWith(`${prefix} `) ||
456
+ new RegExp(`^ {${continuationIndent}}(?:[-*+>]|#{1,6})[ \\t]+`).test(continuation)) {
457
+ return { candidates, consumedAll: false };
458
+ }
459
+ const continuationValue = continuation.slice(continuationIndent);
460
+ if (continuationValue.startsWith("\t") || hasReservedCandidateProduction("why", continuationValue)) {
424
461
  return { candidates, consumedAll: false };
425
462
  }
426
- if (hasReservedCandidateProduction("why", continuation.slice(2))) return { candidates, consumedAll: false };
427
- whyLines.push(continuation.slice(2));
463
+ whyLines.push(continuationValue);
428
464
  cursor++;
429
465
  }
430
466
  fields.set("why", whyLines.join("\n"));
@@ -480,6 +516,15 @@ function parseIntegratedCompletion(text: string): boolean {
480
516
  * output. A timed-out final block may be truncated; earlier complete blocks
481
517
  * remain usable, while arbitrary prose and unsafe containers recover nothing.
482
518
  */
519
+ export function isValidatedReviewLaneNoFindings(
520
+ rawText: string,
521
+ expectedOutput: "review_lane" | "nonempty" = "review_lane",
522
+ ): boolean {
523
+ const text = normalizeReviewText(rawText);
524
+ if (expectedOutput === "review_lane") return /^NO FINDINGS\.?$/.test(text);
525
+ return parseIntegratedCompletion(text) && text.split("\n").some((line) => line === NO_FINDINGS_SENTINEL);
526
+ }
527
+
483
528
  export function extractValidatedReviewLaneCandidates(
484
529
  rawText: string,
485
530
  expectedOutput: "review_lane" | "nonempty" = "review_lane",
@@ -559,8 +604,25 @@ function expectedLaneSections(input: ReviewLaneCompletionInput): boolean {
559
604
  // missing final period carries no semantic uncertainty and must not amplify
560
605
  // into expensive replacement passes. Integrated/deep `nonempty` output keeps
561
606
  // its exact byte contract in parseIntegratedCompletion().
562
- if (/^NO FINDINGS\.?$/.test(text)) return true;
563
- return CANDIDATE_FIELDS.every((field) => hasMeaningfulField(text, field));
607
+ if (/^NO FINDINGS\.?$/.test(normalized)) return true;
608
+ return parseOrdinaryCandidateCompletion(normalized);
609
+ }
610
+
611
+ function parseOrdinaryCandidateCompletion(rawText: string): boolean {
612
+ const text = normalizeReviewText(rawText);
613
+ if (
614
+ !text.trim() || hasTrailingHorizontalWhitespace(text) || CODE_FENCE.test(text) ||
615
+ hasHtmlContainer(text) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text) ||
616
+ /<!--\s*pi-pr-review:/i.test(text)
617
+ ) return false;
618
+ const lines = text.split("\n");
619
+ if (lines.some((line) => hasReservedStatusProduction(line) || line.trim() === NO_FINDINGS_SENTINEL || line.trim() === "NO FINDINGS")) {
620
+ return false;
621
+ }
622
+ let cursor = 0;
623
+ while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
624
+ const parsed = parseCandidatePrefix(lines, cursor);
625
+ return parsed.consumedAll && parsed.candidates.length > 0;
564
626
  }
565
627
 
566
628
  /** Process exit is necessary but insufficient: only a terminal stop with valid lane output is complete. */
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import { createHash } from "node:crypto";
3
3
  import {
4
4
  extractValidatedReviewLaneCandidates,
5
+ isValidatedReviewLaneNoFindings,
5
6
  type ExpectedReviewLane,
6
7
  type ReviewLaneArtifact,
7
8
  } from "./pr-review-artifacts.ts";
@@ -492,51 +493,98 @@ function retainedLaneText(lane: ReviewLaneArtifact): string {
492
493
  return "";
493
494
  }
494
495
 
496
+ function retainedLaneCandidateTexts(
497
+ lane: ReviewLaneArtifact,
498
+ contract: "review_lane" | "nonempty",
499
+ ): string[] {
500
+ const texts: string[] = [];
501
+ const seen = new Set<string>();
502
+ const retain = (value: string): "continue" | "stop" => {
503
+ if (!value.trim() || seen.has(value)) return "continue";
504
+ seen.add(value);
505
+ const text = value;
506
+ // A later exact clean contract supersedes provisional findings from older
507
+ // attempts. Malformed or empty later output does not.
508
+ if (isValidatedReviewLaneNoFindings(text, contract)) return "stop";
509
+ texts.push(text);
510
+ return "continue";
511
+ };
512
+ // The lane-level text is the latest authoritative output. Earlier attempts
513
+ // remain eligible only for independently contract-valid findings; malformed
514
+ // prose can neither become a finding nor poison a valid earlier attempt.
515
+ if (retain(lane.rawText) === "stop") return texts;
516
+ const ordinals = new Set<number>();
517
+ for (const attempt of lane.attempts) {
518
+ if (ordinals.has(attempt.ordinal)) return texts;
519
+ ordinals.add(attempt.ordinal);
520
+ }
521
+ const newestFirst = [...lane.attempts].sort((left, right) =>
522
+ left.ordinal === right.ordinal ? 0 : left.ordinal > right.ordinal ? -1 : 1);
523
+ for (const attempt of newestFirst) {
524
+ if (retain(attempt.rawText) === "stop") break;
525
+ }
526
+ return texts;
527
+ }
528
+
495
529
  function retainedLaneFindings(
496
530
  lanes: readonly ReviewLaneArtifact[],
497
531
  expected: readonly ExpectedReviewLane[],
498
532
  ): ReviewFindingLike[] {
499
533
  const findings: ReviewFindingLike[] = [];
500
- const seen = new Set<string>();
534
+ const seen = new Map<string, number>();
501
535
  for (const lane of lanes) {
502
536
  const contract = expected.find((descriptor) => descriptor.key === lane.key)?.expectedOutput ?? "review_lane";
503
- for (const candidate of extractValidatedReviewLaneCandidates(retainedLaneText(lane), contract)) {
504
- if (!candidate.prRelated) continue;
505
- const parsedLocation = candidate.location === "repo-wide"
506
- ? { status: "absent" as const, location: null }
507
- : parseLocation(`${candidate.location} ${candidate.side}`);
508
- if (parsedLocation.status === "unsafe") continue;
509
- const codeLocation = parsedLocation.location
510
- ? { ...parsedLocation.location, commentable: candidate.inDiff }
511
- : null;
512
- const key = JSON.stringify([
513
- candidate.severity,
514
- candidate.title,
515
- candidate.why,
516
- candidate.location,
517
- candidate.side,
518
- ]);
519
- if (seen.has(key)) continue;
520
- seen.add(key);
521
- findings.push({
522
- title: candidate.title,
523
- severity: candidate.severity,
524
- blocking: candidate.severity === "P0" || candidate.severity === "P1",
525
- body: candidate.why,
526
- confidence_score: candidate.confidence,
527
- code_location: codeLocation,
528
- });
537
+ for (const text of retainedLaneCandidateTexts(lane, contract)) {
538
+ for (const candidate of extractValidatedReviewLaneCandidates(text, contract)) {
539
+ if (!candidate.prRelated) continue;
540
+ const parsedLocation = candidate.location === "repo-wide"
541
+ ? { status: "absent" as const, location: null }
542
+ : parseLocation(`${candidate.location} ${candidate.side}`);
543
+ if (parsedLocation.status === "unsafe") continue;
544
+ const codeLocation = parsedLocation.location
545
+ ? { ...parsedLocation.location, commentable: candidate.inDiff }
546
+ : null;
547
+ const key = JSON.stringify([
548
+ candidate.severity,
549
+ candidate.title,
550
+ candidate.why,
551
+ candidate.location,
552
+ candidate.side,
553
+ ]);
554
+ const duplicateIndex = seen.get(key);
555
+ if (duplicateIndex !== undefined) {
556
+ const existing = findings[duplicateIndex];
557
+ if (candidate.inDiff && existing?.code_location && !existing.code_location.commentable) {
558
+ findings[duplicateIndex] = {
559
+ ...existing,
560
+ code_location: { ...existing.code_location, commentable: true },
561
+ };
562
+ }
563
+ continue;
564
+ }
565
+ seen.set(key, findings.length);
566
+ findings.push({
567
+ title: candidate.title,
568
+ severity: candidate.severity,
569
+ blocking: candidate.severity === "P0" || candidate.severity === "P1",
570
+ body: candidate.why,
571
+ confidence_score: candidate.confidence,
572
+ code_location: codeLocation,
573
+ });
574
+ }
529
575
  }
530
576
  }
531
577
  return findings;
532
578
  }
533
579
 
580
+ const INDEPENDENT_VALIDATION_ADVISORY = "_Recommend validating this comment independently._";
581
+
534
582
  function mergeUniqueFindings(
535
583
  primary: readonly ReviewFindingLike[],
536
584
  additional: readonly ReviewFindingLike[],
537
585
  ): ReviewFindingLike[] {
538
586
  const merged = [...primary];
539
- const keys = new Set(merged.map((finding) => JSON.stringify([
587
+ const findingKey = (finding: ReviewFindingLike) => JSON.stringify([
540
588
  finding.severity,
541
589
  finding.title,
542
590
  finding.body,
@@ -544,20 +592,30 @@ function mergeUniqueFindings(
544
592
  finding.code_location?.line_range?.start,
545
593
  finding.code_location?.line_range?.end,
546
594
  finding.code_location?.side,
547
- ])));
595
+ ]);
596
+ const keys = new Map(merged.map((finding, index) => [findingKey(finding), index]));
548
597
  for (const finding of additional) {
549
- const key = JSON.stringify([
550
- finding.severity,
551
- finding.title,
552
- finding.body,
553
- finding.code_location?.absolute_file_path,
554
- finding.code_location?.line_range?.start,
555
- finding.code_location?.line_range?.end,
556
- finding.code_location?.side,
557
- ]);
558
- if (keys.has(key)) continue;
559
- keys.add(key);
560
- merged.push(finding);
598
+ const key = findingKey(finding);
599
+ const duplicateIndex = keys.get(key);
600
+ if (duplicateIndex !== undefined) {
601
+ const existing = merged[duplicateIndex];
602
+ if (finding.code_location?.commentable && existing?.code_location && !existing.code_location.commentable) {
603
+ merged[duplicateIndex] = {
604
+ ...existing,
605
+ code_location: { ...existing.code_location, commentable: true },
606
+ };
607
+ }
608
+ continue;
609
+ }
610
+ keys.set(key, merged.length);
611
+ // These are complete, host-validated lane candidate blocks, but the
612
+ // terminal synthesizer did not independently confirm them. Keep their
613
+ // normal severity and inline eligibility while making that provenance
614
+ // explicit to the reader.
615
+ merged.push({
616
+ ...finding,
617
+ body: `${finding.body?.trim() ?? ""}\n\n${INDEPENDENT_VALIDATION_ADVISORY}`.trim(),
618
+ });
561
619
  }
562
620
  return merged;
563
621
  }
@@ -789,10 +847,12 @@ export function synthesizeReviewArtifact(input: {
789
847
  batchEvidence ? lanes.every((lane) => lane.lifecycle === "complete") && (expectedLaneCount === 0 || exactLaneCoverage) : true,
790
848
  );
791
849
  const safe = publicationSafeStrictReview(input.strictJsonReview);
792
- const bodyFallback = !safe || completeness === "incomplete";
850
+ const recoveredOverridesSkip = input.strictJsonReview.disposition === "skipped" &&
851
+ validatedLaneFindings.length > 0;
852
+ const bodyFallback = !safe || completeness === "incomplete" || recoveredOverridesSkip;
793
853
  const strictFindings = mergeUniqueFindings(
794
- safe ? (input.strictJsonReview.findings ?? []) : [],
795
- bodyFallback ? validatedLaneFindings : [],
854
+ safe && !recoveredOverridesSkip ? (input.strictJsonReview.findings ?? []) : [],
855
+ validatedLaneFindings,
796
856
  );
797
857
  const body = bodyFallback
798
858
  ? buildDegradedReviewBody({
@@ -801,9 +861,11 @@ export function synthesizeReviewArtifact(input: {
801
861
  findings: strictFindings,
802
862
  expectedLaneCount,
803
863
  exactCoverage: exactLaneCoverage || expectedLaneCount === 0,
804
- reason: safe
805
- ? "incomplete lane evidence degraded this synthesis"
806
- : "publication-invalid extracted content degraded this synthesis",
864
+ reason: recoveredOverridesSkip
865
+ ? "retained lane findings overrode a skipped model synthesis"
866
+ : safe
867
+ ? "incomplete lane evidence degraded this synthesis"
868
+ : "publication-invalid extracted content degraded this synthesis",
807
869
  })
808
870
  : "";
809
871
  return Object.freeze({
@@ -814,10 +876,21 @@ export function synthesizeReviewArtifact(input: {
814
876
  // Rebind every target field to the frozen host snapshot so an assistant
815
877
  // cannot substitute the final head and bypass stale-head handling.
816
878
  review: bodyFallback
817
- ? syntheticReview(input.prNumber, input.prTitle, input.headSha, body, strictFindings)
879
+ ? input.strictJsonReview.disposition === "skipped" && !recoveredOverridesSkip
880
+ ? {
881
+ ...input.strictJsonReview,
882
+ pr: { number: input.prNumber, title: input.prTitle, head_sha: input.headSha },
883
+ }
884
+ : syntheticReview(input.prNumber, input.prTitle, input.headSha, body, strictFindings)
818
885
  : {
819
886
  ...input.strictJsonReview,
820
887
  pr: { number: input.prNumber, title: input.prTitle, head_sha: input.headSha },
888
+ // A model-level skip cannot suppress complete host-retained findings.
889
+ disposition: validatedLaneFindings.length > 0 ? "reviewed" : input.strictJsonReview.disposition,
890
+ findings: strictFindings,
891
+ ...(strictFindings.some((finding) => finding.severity === "P0" || finding.severity === "P1")
892
+ ? { verdict: "request_changes", overall_correctness: "patch is incorrect" }
893
+ : {}),
821
894
  },
822
895
  laneArtifacts: lanes,
823
896
  expectedLaneDescriptors,
@@ -825,21 +898,24 @@ export function synthesizeReviewArtifact(input: {
825
898
  completeness,
826
899
  mergeApprovalEligible: !bodyFallback,
827
900
  diagnostics: Object.freeze(bodyFallback
828
- ? [safe
829
- ? "incomplete lane evidence degraded this synthesis"
830
- : "publication-invalid extracted content degraded this synthesis"]
901
+ ? [recoveredOverridesSkip
902
+ ? "retained lane findings overrode a skipped model synthesis"
903
+ : safe
904
+ ? "incomplete lane evidence degraded this synthesis"
905
+ : "publication-invalid extracted content degraded this synthesis"]
831
906
  : []),
832
907
  });
833
908
  }
834
909
  const raw = input.rawText.trim().replace(/\r\n?/g, "\n");
835
910
  if (!raw) {
836
911
  const completeness = synthesisCompleteness(input.rawText, lanes);
912
+ const recoveredLaneFindings = mergeUniqueFindings([], validatedLaneFindings);
837
913
  // Retained lane output is bounded by the host-owned body cap so an early
838
914
  // large lane cannot truncate away the coverage disclosure above it.
839
915
  const body = buildDegradedReviewBody({
840
916
  rawText: "",
841
917
  lanes,
842
- findings: validatedLaneFindings,
918
+ findings: recoveredLaneFindings,
843
919
  expectedLaneCount,
844
920
  exactCoverage: exactLaneCoverage,
845
921
  reason: "terminal synthesis was absent",
@@ -848,7 +924,7 @@ export function synthesizeReviewArtifact(input: {
848
924
  quality: "lane_fallback" as const,
849
925
  rawText: input.rawText,
850
926
  body,
851
- review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body, validatedLaneFindings),
927
+ review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body, recoveredLaneFindings),
852
928
  laneArtifacts: lanes,
853
929
  expectedLaneDescriptors,
854
930
  expectedLaneCount,
@@ -898,7 +974,10 @@ export function synthesizeReviewArtifact(input: {
898
974
  ? "fully_parsed"
899
975
  : canonicalParsed.findings.length > 0 ? "partially_parsed" : "raw";
900
976
  const parsedSynthesisFindings = canonicalParsed.unsafe ? [] : canonicalParsed.findings;
901
- const recoveredLaneFindings = quality === "fully_parsed" ? [] : validatedLaneFindings;
977
+ // Terminal semantic validation may confirm a candidate, but it may not erase
978
+ // a complete host-validated lane block. Omitted candidates retain ordinary
979
+ // finding behavior with an explicit independent-validation advisory.
980
+ const recoveredLaneFindings = validatedLaneFindings;
902
981
  const safeFindings = mergeUniqueFindings(parsedSynthesisFindings, recoveredLaneFindings);
903
982
  const degradationReasons = (() => {
904
983
  if (canonicalParsed.unsafe) {
@@ -596,7 +596,7 @@ export interface CompletedReviewSessionIdentity {
596
596
  }
597
597
 
598
598
  export interface PersistedCompletedReview {
599
- schemaVersion: 3;
599
+ schemaVersion: 4;
600
600
  session: CompletedReviewSessionIdentity;
601
601
  invocation: ReviewInvocation;
602
602
  repository: RepositoryBinding;
@@ -819,7 +819,7 @@ export class CompletedReviewCache {
819
819
  const digest = reviewHash(record.review);
820
820
  const useReference = !!reviewEntryId && !!referencedReview && reviewHash(referencedReview) === digest;
821
821
  return {
822
- schemaVersion: 3,
822
+ schemaVersion: 4,
823
823
  session: { ...session },
824
824
  invocation: { ...record.invocation, autoPost: { ...record.invocation.autoPost } },
825
825
  repository: { ...record.repository },
@@ -849,7 +849,7 @@ export class CompletedReviewCache {
849
849
  ): boolean {
850
850
  if (
851
851
  !isObject(value) ||
852
- value.schemaVersion !== 3 ||
852
+ value.schemaVersion !== 4 ||
853
853
  !validSessionIdentity(value.session) ||
854
854
  !sameSessionIdentity(value.session, session) ||
855
855
  !validRepositoryBinding(value.repository)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.17.7",
3
+ "version": "1.17.9",
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",