pi-pr-review 1.12.2 → 1.13.0

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.13.0](https://github.com/10ego/pi-pr-review/compare/v1.12.3...v1.13.0) (2026-08-20)
4
+
5
+
6
+ ### Features
7
+
8
+ * **review:** render degraded syntheses as readable generic reviews ([#69](https://github.com/10ego/pi-pr-review/issues/69)) ([ee80cde](https://github.com/10ego/pi-pr-review/commit/ee80cdea6cddfd580a68bd6ab71b9625a1e06127))
9
+
10
+ ## [1.12.3](https://github.com/10ego/pi-pr-review/compare/v1.12.2...v1.12.3) (2026-08-19)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **review:** make host lane truth authoritative for completeness ([#67](https://github.com/10ego/pi-pr-review/issues/67)) ([ad70fc3](https://github.com/10ego/pi-pr-review/commit/ad70fc3b6376f215bf045b980fef06bd87036bd1))
16
+
3
17
  ## [1.12.2](https://github.com/10ego/pi-pr-review/compare/v1.12.1...v1.12.2) (2026-08-19)
4
18
 
5
19
 
package/README.md CHANGED
@@ -176,7 +176,7 @@ Every invocation has a host-owned monotonic 15-minute hard cap, including the tw
176
176
 
177
177
  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.
178
178
 
179
- 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 lens/shard, and remain eligible for the safe body-only `COMMENT` publication path. 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.
179
+ 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 lens/shard, and remain eligible for the safe body-only `COMMENT` publication path. 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.
180
180
 
181
181
  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 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.
182
182
 
@@ -208,13 +208,13 @@ Publishing is off by default.
208
208
 
209
209
  The extension owns normal publishing. Before review execution it captures repository, hostname, PR number/title, reviewed head, lifecycle state, posting/stale authority, and invocation identity independently of assistant text. After synthesis, it caches one validated completed review (the host-owned canonical artifact) per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run.
210
210
 
211
- Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Ambiguous or partially parsed output preserves the complete original synthesis in one sanitized body-only `COMMENT`; completely malformed synthesis does the same, and absent synthesis becomes a deterministic body-only review assembled from retained complete/partial/failed lane artifacts. Reserved markers are sanitized, size limits are enforced, and the canonical marker is appended only by host code. Optional formatting repair is never required and can never suppress the raw fallback.
211
+ Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Every degraded synthesis — ambiguous, partially parsed, incomplete lane coverage, or absent terminal synthesis — publishes one deterministic host-rendered `COMMENT` body with generic code-review labels: a `Coverage` section with the exact lane lifecycle disclosure, a `Findings` section with host-formatted parsed findings (never a clean-review claim when nothing parsed), and the complete original synthesis plus every retained lane artifact preserved verbatim under `Retained synthesis` / `Retained lane output` with heading levels shifted so they nest. A contradictory completion claim inside retained text is reconciled to the host verdict. Reserved markers are sanitized, size limits are enforced, and the canonical marker is appended only by host code. Optional formatting repair is never required and can never suppress the degraded fallback.
212
212
 
213
213
  You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “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.
214
214
 
215
- 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 and a standalone visible `All requested lanes completed.` disclosure (matched case-insensitively, with an optional final period) may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, partial or contradictory lane disclosures, malformed or unsafe output, and lane-fallback artifacts remain body-only `COMMENT` reviews. 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, and transport diagnostics stay out of the public summary. The complete original Markdown remains retained internally; if the concise body plus its canonical marker exceeds GitHub's limit, the host uses its sanitized, size-bounded Markdown projection instead of dropping the review. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
215
+ 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, and transport diagnostics stay out of the public summary. The complete original Markdown remains retained internally; if the concise body plus its canonical marker exceeds GitHub's limit, the host uses its sanitized, size-bounded Markdown projection instead of dropping the review. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
216
216
 
217
- 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. Raw and lane-assembled fallbacks are body-only and `COMMENT`-only; assistant text cannot select event, commit, repository, hostname, API path, or inline anchors. Unknown or invalid host states fail closed before a write.
217
+ 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.
218
218
 
219
219
  Stale publication is enabled by default through `allowStalePublish: true`; disable it with `/pr-review-config allow_stale_publish=false`. Automatic posting and `/pr-review-publish` use the setting captured when the review starts unless the command supplies the explicit override:
220
220
 
@@ -47,7 +47,7 @@ import {
47
47
  type CompletedReviewSessionIdentity,
48
48
  type ReviewInvocation,
49
49
  } from "../lib/pr-review-publish.ts";
50
- import { safeReviewBody, synthesizeReviewArtifact, type ReviewSynthesisArtifact } from "../lib/pr-review-markdown.ts";
50
+ import { demoteHeadings, safeReviewBody, synthesizeReviewArtifact, type ReviewSynthesisArtifact } from "../lib/pr-review-markdown.ts";
51
51
  import { resolveReviewDeadlinesForContext } from "../lib/pr-review-deadline-config.ts";
52
52
  import { createReviewBudget } from "../lib/pr-review-deadlines.ts";
53
53
  import {
@@ -269,6 +269,18 @@ function verdictLine(r: Review): string {
269
269
  return parts.join(" ");
270
270
  }
271
271
 
272
+ export function renderDegradedReviewMarkdown(r: Review, body: string): string {
273
+ const out: string[] = [];
274
+ const num = r.pr?.number;
275
+ const title = (r.pr?.title ?? "").toString().replace(/\r?\n/g, " ").trim();
276
+ if (num != null) out.push(`## Code Review — PR #${num}${title ? `: ${title}` : ""}`, "");
277
+ else out.push("## Code Review", "");
278
+ // The host-built degraded body already carries verdict, coverage, findings,
279
+ // and every retained artifact; demote it under the render header.
280
+ out.push(demoteHeadings(body.trim()), "");
281
+ return out.join("\n").trimEnd();
282
+ }
283
+
272
284
  function renderReviewMarkdown(r: Review): string {
273
285
  const out: string[] = [];
274
286
 
@@ -440,8 +452,11 @@ async function publishCompletedReview(
440
452
  ...(record.synthesisQuality === "fully_parsed" && typeof record.rawText === "string" && record.rawText.trim()
441
453
  ? { fallbackPublicationBody: safeReviewBody(record.rawText) }
442
454
  : {}),
455
+ // A degraded synthesis stays COMMENT-only, but its safely parsed findings
456
+ // still earn inline placement; only unparsable output is forced body-only.
443
457
  forceBodyOnly: record.synthesisQuality !== undefined &&
444
- (record.synthesisQuality !== "fully_parsed" || record.completeness === "incomplete"),
458
+ record.synthesisQuality !== "fully_parsed" &&
459
+ !(Array.isArray(record.review.findings) && record.review.findings.length > 0),
445
460
  // A publication body identifies Markdown-derived or degraded synthesis.
446
461
  // Enforce COMMENT again at the final publication boundary so restored
447
462
  // canonical artifacts created by an older parser cannot inherit APPROVE.
@@ -935,10 +950,14 @@ export default function registerReviewTable(
935
950
  // Keep the raw assistant response for automation; only prettify interactive TUI output.
936
951
  if (ctx.mode !== "tui") return;
937
952
  const nonText = event.message.content.filter((part) => part.type !== "text");
953
+ const degradedBody = artifact && artifact.quality !== "fully_parsed" ? artifact.body : undefined;
938
954
  return {
939
955
  message: {
940
956
  ...event.message,
941
- content: [...nonText, { type: "text", text: renderReviewMarkdown(review) }],
957
+ content: [...nonText, {
958
+ type: "text",
959
+ text: degradedBody ? renderDegradedReviewMarkdown(review, degradedBody) : renderReviewMarkdown(review),
960
+ }],
942
961
  },
943
962
  };
944
963
  });
@@ -520,65 +520,156 @@ function incompleteLaneDisclosure(lanes: readonly ReviewLaneArtifact[]): string
520
520
  ].join("\n");
521
521
  }
522
522
 
523
- function bindIncompleteLaneDisclosure(raw: string, lanes: readonly ReviewLaneArtifact[]): string {
524
- const disclosure = incompleteLaneDisclosure(lanes);
525
- if (!disclosure) return raw;
526
- const text = raw.replace(/All requested lanes completed\.?/gi, "Host verification found incomplete requested lanes.");
523
+ /**
524
+ * Shift ATX headings down (converting setext headings to ATX first) so
525
+ * retained Markdown nests under host-owned sections. Text outside the heading
526
+ * construct — including the heading's own label — is preserved byte-for-byte;
527
+ * fenced-code and HTML-block content is never touched because markdownHeadings
528
+ * only reports structural headings.
529
+ */
530
+ export function demoteHeadings(text: string, levels = 1): string {
527
531
  const headings = markdownHeadings(text);
528
- const matches = headings.filter((heading) => heading.level === 2 && heading.name.toLowerCase() === "lane completeness");
529
- if (matches.length !== 1) {
530
- return `${text.trim()}\n\n## Host-verified lane completeness\n${disclosure}`.trim();
531
- }
532
- const match = matches[0]!;
533
- const bodyStart = match.index + match.length;
534
- const next = headings.find((heading) => heading.index > match.index && heading.level <= match.level);
535
- const bodyEnd = next?.index ?? text.length;
536
- const assistantDisclosure = text.slice(bodyStart, bodyEnd).trim();
537
- return `${text.slice(0, bodyStart)}\n${assistantDisclosure}${assistantDisclosure ? "\n\n" : ""}${disclosure}\n\n${text.slice(bodyEnd)}`.trim();
538
- }
539
-
540
- function safeReviewBodyWithLaneDisclosure(raw: string, lanes: readonly ReviewLaneArtifact[]): string {
541
- const bound = bindIncompleteLaneDisclosure(raw, lanes);
542
- const body = safeReviewBody(bound);
543
- const disclosure = incompleteLaneDisclosure(lanes);
544
- if (!disclosure || body.includes(disclosure)) return body;
545
- const suffix = sanitize(`\n\n## Host-verified lane completeness\n${disclosure}`);
546
- const prefixBudget = MAX_SYNTHESIS_BODY_BYTES - Buffer.byteLength(suffix, "utf8") - 2;
547
- if (prefixBudget <= 0) return safeReviewBody(suffix);
548
- return `${truncateUtf8(sanitize(bound), prefixBudget)}\n\n${suffix}`;
549
- }
550
-
551
- function retainedLaneEvidence(lanes: readonly ReviewLaneArtifact[]): string {
552
- if (lanes.length === 0) return "";
553
- const lines = ["## Host-retained lane evidence", ""];
554
- for (const lane of lanes.slice(0, MAX_DISCLOSED_LANES)) {
555
- lines.push(`### ${disclosedPassId(lane.passId)} — ${lane.lifecycle}`, "");
556
- if (lane.deadlineExpired) {
557
- lines.push(`Host ${lane.deadlineExpired} deadline expired while this lane was still running.`, "");
532
+ if (headings.length === 0) return text;
533
+ const shifted = "#".repeat(levels);
534
+ let out = "";
535
+ let cursor = 0;
536
+ for (const heading of headings) {
537
+ const construct = text.slice(heading.index, heading.index + heading.length);
538
+ const atx = /^( {0,3})(#{1,6})([\s\S]*)$/.exec(construct);
539
+ let replacement: string;
540
+ if (atx) {
541
+ // Indented ATX stays ATX; only the opening run grows (capped at six).
542
+ const next = "#".repeat(Math.min(6, atx[2]!.length + levels));
543
+ replacement = `${atx[1]!}${next}${atx[3]!}`;
544
+ } else {
545
+ // Setext: drop the underline and re-render the paragraph as one ATX line.
546
+ const indent = /^[ \t]*/.exec(construct)![0];
547
+ const next = "#".repeat(Math.min(6, heading.level + levels));
548
+ replacement = `${indent}${next} ${heading.name}`;
558
549
  }
559
- const text = retainedLaneText(lane);
560
- if (text) lines.push(text, "");
561
- else lines.push(`No substantive output was retained${lane.errorMessage ? `: ${lane.errorMessage}` : "."}`, "");
562
- }
563
- if (lanes.length > MAX_DISCLOSED_LANES) {
564
- lines.push(`### ${lanes.length - MAX_DISCLOSED_LANES} additional lane artifact(s) omitted`, "");
550
+ out += `${text.slice(cursor, heading.index)}${replacement}`;
551
+ cursor = heading.index + heading.length;
565
552
  }
566
- return lines.join("\n").trim();
567
- }
568
-
569
- function laneFallback(lanes: readonly ReviewLaneArtifact[]): string {
570
- const lines = ["# PR Review", "", "**Verdict:** comment", "", "## Lane completeness", ""];
571
- if (lanes.length === 0) {
572
- lines.push("- No synthesis or retained lane output was available.");
553
+ return out + text.slice(cursor);
554
+ }
555
+
556
+ function degradedFindingLocation(finding: ReviewFindingLike): string {
557
+ const location = finding.code_location;
558
+ const path = location?.absolute_file_path;
559
+ if (!path) return "summary-only";
560
+ const range = location?.line_range;
561
+ const start = range?.start;
562
+ const end = range?.end ?? start;
563
+ const side = location?.side === "LEFT" ? "LEFT" : location?.side === "RIGHT" ? "RIGHT" : undefined;
564
+ const linesPart = start !== undefined ? `:${start}${end !== undefined && end !== start ? `-${end}` : ""}` : "";
565
+ return `${String(path)}${linesPart}${side ? ` ${side}` : ""}`;
566
+ }
567
+
568
+ /** Host-rendered finding blocks that match the canonical synthesis labels. */
569
+ function degradedFindingBlocks(findings: readonly ReviewFindingLike[]): string[] {
570
+ if (findings.length === 0) return [];
571
+ return findings.map((finding) => {
572
+ const severity = String(finding.severity ?? "P2");
573
+ const rawTitle = String(finding.title ?? "Finding").trim() || "Finding";
574
+ // Parsed titles may already carry their severity tag; never double it.
575
+ const title = rawTitle.replace(/^\[?(P0|P1|P2|P3|nit)\]\s*/i, "$1 ").replace(/^(P0|P1|P2|P3|nit)\s+/, "").trim() || rawTitle;
576
+ const lines = [
577
+ `### [${severity}] ${title}`,
578
+ `**Severity:** ${severity}`,
579
+ ];
580
+ if (finding.body?.trim()) lines.push(finding.body.trim());
581
+ lines.push(`**Location:** \`${degradedFindingLocation(finding)}\``);
573
582
  return lines.join("\n");
574
- }
575
- lines.push(retainedLaneEvidence(lanes));
576
- return lines.join("\n").trim();
583
+ });
577
584
  }
578
585
 
579
- function synthesisWithRetainedLaneEvidence(raw: string, lanes: readonly ReviewLaneArtifact[]): string {
580
- const evidence = retainedLaneEvidence(lanes);
581
- return evidence ? `${raw.trim()}\n\n${evidence}`.trim() : raw;
586
+ /**
587
+ * Deterministic, readable body for degraded syntheses. The model's raw Markdown
588
+ * and every retained lane artifact are preserved verbatim (only heading levels
589
+ * shift) under explicit host-owned labels, parsed findings are rendered in the
590
+ * canonical format, and coverage is disclosed instead of implied.
591
+ */
592
+ function buildDegradedReviewBody(input: {
593
+ rawText: string;
594
+ lanes: readonly ReviewLaneArtifact[];
595
+ findings: readonly ReviewFindingLike[];
596
+ reason?: string;
597
+ /** Host-registered dispatch count for exact-coverage disclosure. */
598
+ expectedLaneCount?: number;
599
+ /** Whether retained lanes cover every expected dispatch (host-computed). */
600
+ exactCoverage?: boolean;
601
+ }): string {
602
+ const blocking = input.findings.some((finding) => finding.severity === "P0" || finding.severity === "P1");
603
+ const verdict = blocking ? "Request changes" : "Comment";
604
+ const reason = input.reason?.trim();
605
+ const lines: string[] = [
606
+ "# PR Review",
607
+ "",
608
+ `**Verdict:** ${verdict}${reason ? ` — ${reason}` : ""}`,
609
+ "",
610
+ "## Coverage",
611
+ "",
612
+ ];
613
+ // The host's own voice may only claim completion when every retained lane
614
+ // completed AND the retained artifacts cover every expected dispatch.
615
+ const disclosure = incompleteLaneDisclosure(input.lanes);
616
+ const expected = input.expectedLaneCount ?? 0;
617
+ let coverage: string;
618
+ if (input.lanes.length === 0 && expected === 0) {
619
+ coverage = "No host lane evidence was retained for this review.";
620
+ } else if (disclosure) {
621
+ coverage = disclosure;
622
+ } else if (expected === 0 || input.exactCoverage === true) {
623
+ coverage = "All requested lanes completed.";
624
+ } else {
625
+ coverage = [
626
+ "Host-verified incomplete requested lenses/shards:",
627
+ "Exact incomplete lifecycle counts: partial=0; timed_out=0; failed=0.",
628
+ `- retained lane artifacts do not cover every expected dispatch (${input.lanes.length} retained / ${expected} registered)`,
629
+ ].join("\n");
630
+ }
631
+ lines.push(coverage, "");
632
+ const hasRetainedEvidence = input.rawText.trim().length > 0 || input.lanes.length > 0;
633
+ lines.push("## Findings", "");
634
+ if (input.findings.length === 0) {
635
+ lines.push("No structurally parsed findings were extracted from this degraded synthesis.");
636
+ if (hasRetainedEvidence) {
637
+ lines.push("The retained reviewer output below is the authoritative record; it is not evidence of a clean review.");
638
+ }
639
+ lines.push("");
640
+ } else {
641
+ for (const block of degradedFindingBlocks(input.findings)) lines.push(block, "");
642
+ }
643
+ const synthesis = input.rawText.trim();
644
+ if (synthesis) {
645
+ // A retained synthesis may still carry a completion claim the host has
646
+ // disproven; replace it so the published body never states both. With no
647
+ // batch evidence the model's own claim stays authoritative.
648
+ const claimComplete = coverage === "All requested lanes completed.";
649
+ const batchEvidence = input.lanes.length > 0 || expected > 0;
650
+ const reconciled = !claimComplete && batchEvidence
651
+ ? synthesis.replace(/All requested lanes completed\.?/gi, "Host verification found incomplete requested lanes.")
652
+ : synthesis;
653
+ // Two levels keep the synthesis's own document heading below the host
654
+ // labels (its canonical "# PR Review" becomes a level-3 heading).
655
+ lines.push("## Retained synthesis", "", demoteHeadings(reconciled, 2).trim(), "");
656
+ }
657
+ if (input.lanes.length > 0) {
658
+ lines.push("## Retained lane output", "");
659
+ for (const lane of input.lanes.slice(0, MAX_DISCLOSED_LANES)) {
660
+ lines.push(`### ${disclosedPassId(lane.passId)} — ${lane.lifecycle}`, "");
661
+ if (lane.deadlineExpired) {
662
+ lines.push(`Host ${lane.deadlineExpired} deadline expired while this lane was still running.`, "");
663
+ }
664
+ const text = retainedLaneText(lane);
665
+ if (text) lines.push(demoteHeadings(text, 2).trim(), "");
666
+ else lines.push(`No substantive output was retained${lane.errorMessage ? `: ${lane.errorMessage}` : "."}`, "");
667
+ }
668
+ if (input.lanes.length > MAX_DISCLOSED_LANES) {
669
+ lines.push(`### ${input.lanes.length - MAX_DISCLOSED_LANES} additional lane artifact(s) omitted`, "");
670
+ }
671
+ }
672
+ return safeReviewBody(lines.join("\n").trim());
582
673
  }
583
674
 
584
675
  /** Build the canonical semantic artifact while taking every authority field from the host binding. */
@@ -597,12 +688,35 @@ export function synthesizeReviewArtifact(input: {
597
688
  new Set(["light", "medium", "heavy"]).has(lane.tier) && typeof lane.minorHygiene === "boolean")
598
689
  .map((lane) => Object.freeze({ ...lane })));
599
690
  const expectedLaneCount = expectedLaneDescriptors.length;
691
+ const exactLaneCoverage = expectedLaneCount > 0 && lanes.length === expectedLaneCount &&
692
+ new Set(lanes.map((lane) => lane.key)).size === expectedLaneCount &&
693
+ new Set(expectedLaneDescriptors.map((lane) => lane.key)).size === expectedLaneCount &&
694
+ lanes.every((lane) => expectedLaneDescriptors.some((expected) =>
695
+ expected.key === lane.key && expected.tier === lane.tier &&
696
+ expected.minorHygiene === !!lane.minorHygiene));
600
697
  if (input.strictJsonReview) {
601
- const completeness = synthesisCompleteness(input.rawText, lanes);
698
+ // Strict JSON carries no assistant disclosure line; host lane evidence is
699
+ // the only completeness authority whenever a batch ran.
700
+ const batchEvidence = lanes.length > 0 || expectedLaneCount > 0;
701
+ const completeness = synthesisCompleteness(
702
+ input.rawText,
703
+ lanes,
704
+ batchEvidence ? lanes.every((lane) => lane.lifecycle === "complete") && (expectedLaneCount === 0 || exactLaneCoverage) : true,
705
+ );
602
706
  const safe = publicationSafeStrictReview(input.strictJsonReview);
603
707
  const bodyFallback = !safe || completeness === "incomplete";
708
+ const strictFindings = safe ? (input.strictJsonReview.findings ?? []) : [];
604
709
  const body = bodyFallback
605
- ? safeReviewBodyWithLaneDisclosure(synthesisWithRetainedLaneEvidence(input.rawText, lanes), lanes)
710
+ ? buildDegradedReviewBody({
711
+ rawText: input.rawText,
712
+ lanes,
713
+ findings: strictFindings,
714
+ expectedLaneCount,
715
+ exactCoverage: exactLaneCoverage || expectedLaneCount === 0,
716
+ reason: safe
717
+ ? "incomplete lane evidence degraded this synthesis"
718
+ : "publication-invalid extracted content degraded this synthesis",
719
+ })
606
720
  : "";
607
721
  return Object.freeze({
608
722
  quality: bodyFallback ? "raw" as const : "fully_parsed" as const,
@@ -612,7 +726,7 @@ export function synthesizeReviewArtifact(input: {
612
726
  // Rebind every target field to the frozen host snapshot so an assistant
613
727
  // cannot substitute the final head and bypass stale-head handling.
614
728
  review: bodyFallback
615
- ? syntheticReview(input.prNumber, input.prTitle, input.headSha, body)
729
+ ? syntheticReview(input.prNumber, input.prTitle, input.headSha, body, strictFindings)
616
730
  : {
617
731
  ...input.strictJsonReview,
618
732
  pr: { number: input.prNumber, title: input.prTitle, head_sha: input.headSha },
@@ -624,18 +738,24 @@ export function synthesizeReviewArtifact(input: {
624
738
  mergeApprovalEligible: !bodyFallback,
625
739
  diagnostics: Object.freeze(bodyFallback
626
740
  ? [safe
627
- ? "incomplete lane evidence forced sanitized body-only publication"
628
- : "publication-invalid extracted content forced sanitized body-only publication"]
741
+ ? "incomplete lane evidence degraded this synthesis"
742
+ : "publication-invalid extracted content degraded this synthesis"]
629
743
  : []),
630
744
  });
631
745
  }
632
746
  const raw = input.rawText.trim().replace(/\r\n?/g, "\n");
633
747
  if (!raw) {
634
748
  const completeness = synthesisCompleteness(input.rawText, lanes);
635
- // The retained evidence itself can exceed the publication cap. Apply the
636
- // same host-owned disclosure reservation used for raw synthesis so an
637
- // early large lane cannot truncate away exact later incomplete shards.
638
- const body = safeReviewBodyWithLaneDisclosure(laneFallback(lanes), lanes);
749
+ // Retained lane output is bounded by the host-owned body cap so an early
750
+ // large lane cannot truncate away the coverage disclosure above it.
751
+ const body = buildDegradedReviewBody({
752
+ rawText: "",
753
+ lanes,
754
+ findings: [],
755
+ expectedLaneCount,
756
+ exactCoverage: exactLaneCoverage,
757
+ reason: "terminal synthesis was absent",
758
+ });
639
759
  return Object.freeze({
640
760
  quality: "lane_fallback" as const,
641
761
  rawText: input.rawText,
@@ -654,33 +774,78 @@ export function synthesizeReviewArtifact(input: {
654
774
  const verification = section(raw, "Verification");
655
775
  const laneDisclosure = section(raw, "Lane completeness");
656
776
  const laneDisclosureClaimsComplete = /^all requested lanes completed\.?$/i.test(laneDisclosure?.trim() ?? "");
777
+ // Host lane artifacts are authoritative whenever a batch ran: they already
778
+ // stop a false complete claim from upgrading incomplete lanes, and they must
779
+ // equally stop a paraphrased or omitted disclosure line from downgrading a
780
+ // host-complete batch to body-only publication. Completeness additionally
781
+ // requires the retained lanes to cover every expected dispatch, so an
782
+ // expected-but-unretained artifact cannot make an incomplete batch look
783
+ // complete through vacuous satisfaction.
784
+ const batchEvidencePresent = lanes.length > 0 || expectedLaneCount > 0;
785
+ const laneTruthClaimsComplete = batchEvidencePresent
786
+ ? lanes.every((lane) => lane.lifecycle === "complete") &&
787
+ (expectedLaneCount === 0 || exactLaneCoverage)
788
+ : laneDisclosureClaimsComplete;
657
789
  const preamble = documentPreamble(raw);
658
790
  const verdictField = field(preamble, "Verdict");
659
791
  const verdict = verdictField?.toLowerCase().replace(/[ -]+/g, "_");
660
- const extractedControlsSafe = publicationSafeText(overview) && publicationSafeText(verification) &&
661
- publicationSafeText(laneDisclosure) && publicationSafeText(verdictField) &&
792
+ // A missing section is a structural gap handled by hasStructure and
793
+ // diagnostics; only present-but-unsafe text disables inline extraction.
794
+ const safeIfPresent = (value: string | undefined) => value === undefined || publicationSafeText(value);
795
+ const extractedControlsSafe = safeIfPresent(overview) && safeIfPresent(verification) &&
796
+ safeIfPresent(laneDisclosure) && safeIfPresent(verdictField) &&
662
797
  new Set(["approve", "request_changes", "comment"]).has(verdict ?? "") &&
663
798
  fieldCount(preamble, "Verdict") === 1 && fieldCount(raw, "Verdict") === 1;
664
799
  const canonicalParsed = parsed.unsafe ? parsed : { ...parsed, unsafe: !extractedControlsSafe };
665
- const completeness = synthesisCompleteness(raw, lanes, laneDisclosureClaimsComplete);
666
- const hasStructure = !!overview && !!verification && laneDisclosureClaimsComplete && extractedControlsSafe;
800
+ const completeness = synthesisCompleteness(raw, lanes, laneTruthClaimsComplete);
801
+ const hasStructure = !!overview && !!verification && laneTruthClaimsComplete && extractedControlsSafe;
667
802
  const quality: ReviewSynthesisQuality = canonicalParsed.unsafe
668
803
  ? "raw"
669
804
  : hasStructure && canonicalParsed.complete && completeness === "complete"
670
805
  ? "fully_parsed"
671
806
  : canonicalParsed.findings.length > 0 ? "partially_parsed" : "raw";
672
- // Markdown is the durable semantic product. Keep it in the body even when
673
- // deterministic extraction also makes safe inline placement available. Host
674
- // lane artifacts override contradictory assistant completion claims.
675
- const bodySource = quality === "fully_parsed" ? raw : synthesisWithRetainedLaneEvidence(raw, lanes);
676
- const body = safeReviewBodyWithLaneDisclosure(bodySource, lanes);
677
807
  const safeFindings = canonicalParsed.unsafe ? [] : canonicalParsed.findings;
678
- const exactLaneCoverage = expectedLaneCount > 0 && lanes.length === expectedLaneCount &&
679
- new Set(lanes.map((lane) => lane.key)).size === expectedLaneCount &&
680
- new Set(expectedLaneDescriptors.map((lane) => lane.key)).size === expectedLaneCount &&
681
- lanes.every((lane) => expectedLaneDescriptors.some((expected) =>
682
- expected.key === lane.key && expected.tier === lane.tier &&
683
- expected.minorHygiene === !!lane.minorHygiene));
808
+ const degradationReasons = (() => {
809
+ if (canonicalParsed.unsafe) {
810
+ return ["unsafe Markdown fields were preserved in the sanitized body and inline extraction was disabled"];
811
+ }
812
+ if (quality === "fully_parsed") return [];
813
+ const reasons: string[] = [];
814
+ if (!overview) reasons.push("Overview section missing or empty");
815
+ if (!verification) reasons.push("Verification section missing or empty");
816
+ if (!laneTruthClaimsComplete) {
817
+ if (lanes.length > 0 && !lanes.every((lane) => lane.lifecycle === "complete")) {
818
+ reasons.push("host lane evidence contains incomplete lanes");
819
+ } else if (expectedLaneCount > 0 && !exactLaneCoverage) {
820
+ reasons.push("retained lane evidence does not cover every expected lane dispatch");
821
+ } else {
822
+ reasons.push("Lane completeness section absent or did not state the canonical completion line");
823
+ }
824
+ }
825
+ if (verdictField !== undefined && !new Set(["approve", "request_changes", "comment"]).has(verdict ?? "")) {
826
+ reasons.push("Verdict field outside the canonical set");
827
+ }
828
+ if (fieldCount(preamble, "Verdict") !== 1 || fieldCount(raw, "Verdict") !== 1) {
829
+ reasons.push("Verdict field count is not exactly one");
830
+ }
831
+ if (parsed.count > parsed.findings.length) {
832
+ reasons.push(`${parsed.count - parsed.findings.length} finding section(s) could not be parsed and remain in the body`);
833
+ }
834
+ return reasons.length > 0 ? reasons : ["terminal synthesis was not structurally parseable; preserved as body-only Markdown"];
835
+ })();
836
+ // Markdown is the durable semantic product. A fully parsed complete synthesis
837
+ // publishes verbatim; every degraded synthesis publishes the deterministic
838
+ // host-rendered body so labels stay readable while all content is retained.
839
+ const body = quality === "fully_parsed"
840
+ ? safeReviewBody(raw)
841
+ : buildDegradedReviewBody({
842
+ rawText: raw,
843
+ lanes,
844
+ findings: safeFindings,
845
+ expectedLaneCount,
846
+ exactCoverage: exactLaneCoverage,
847
+ reason: degradationReasons[0],
848
+ });
684
849
  return Object.freeze({
685
850
  quality,
686
851
  rawText: input.rawText,
@@ -701,12 +866,6 @@ export function synthesizeReviewArtifact(input: {
701
866
  // Markdown approval requires exact host evidence for every registered
702
867
  // dispatch; a nonempty subset cannot establish requested coverage.
703
868
  mergeApprovalEligible: quality === "fully_parsed" && completeness === "complete" && exactLaneCoverage,
704
- diagnostics: Object.freeze(canonicalParsed.unsafe
705
- ? ["unsafe Markdown fields were preserved in the sanitized body and inline extraction was disabled"]
706
- : quality === "partially_parsed"
707
- ? [completeness === "incomplete"
708
- ? "incomplete lane disclosure or evidence forced sanitized body-only publication"
709
- : `${parsed.count - parsed.findings.length} finding section(s) could not be parsed and remain in the body`]
710
- : quality === "raw" ? ["terminal synthesis was not structurally parseable; preserved as body-only Markdown"] : []),
869
+ diagnostics: Object.freeze(degradationReasons),
711
870
  });
712
871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.12.2",
3
+ "version": "1.13.0",
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",
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
196
196
 
197
197
  After host synthesis, the extension caches one validated completed review as a canonical host-owned artifact per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run. `/pr-review-publish` and a matching direct request publish only the cache and never start or rerun review agents. On a later turn, the extension intercepts that direct input before an agent turn and permits stale publication without asking the orchestrator to recreate the review.
198
198
 
199
- Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; the extension never submits `REQUEST_CHANGES`. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch, a standalone visible `All requested lanes completed.` disclosure (matched case-insensitively, with an optional final period), and retained strict host-bound JSON share the same gated `APPROVE` path. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, contradictory lane disclosures, and partial, malformed, unsafe, or lane-fallback Markdown remain body-only `COMMENT`. Restored cache entries must reclassify retained lane output, reproduce the exact host-recorded artifact key set, and bind every complete artifact to the frozen invocation generation, tier, and minor-hygiene contract and re-synthesize the retained raw text under that frozen binding before preserving approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The public body contains the verdict, an inline-review cue when applicable, and `Other Notes` for nits and every finding that cannot be inline; overview, verification, strengths, and transport diagnostics remain only in the retained internal artifact. If that concise body plus its canonical marker exceeds GitHub's limit, the host publishes the sanitized, size-bounded original Markdown projection instead of dropping the review. Stale reviews and authorized closed or merged reviews are body-only. A stale review may record a qualified `APPROVE` only with the separate trusted `allowStaleApprovals: true` opt-in captured before review execution. A failed write never triggers a fallback POST.
199
+ Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; the extension never submits `REQUEST_CHANGES`. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch and retained strict host-bound JSON share the same gated `APPROVE` path. 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, and partial, malformed, unsafe, or lane-fallback Markdown remain `COMMENT` reviews that keep inline placement for every safely parsed finding and publish body-only when nothing parses. Restored cache entries must reclassify retained lane output, reproduce the exact host-recorded artifact key set, and bind every complete artifact to the frozen invocation generation, tier, and minor-hygiene contract and re-synthesize the retained raw text under that frozen binding before preserving approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The public body contains the verdict, an inline-review cue when applicable, and `Other Notes` for nits and every finding that cannot be inline; overview, verification, strengths, and transport diagnostics remain only in the retained internal artifact. If that concise body plus its canonical marker exceeds GitHub's limit, the host publishes the sanitized, size-bounded original Markdown projection instead of dropping the review. Stale reviews and authorized closed or merged reviews are body-only. A stale review may record a qualified `APPROVE` only with the separate trusted `allowStaleApprovals: true` opt-in captured before review execution. A failed write never triggers a fallback POST.
200
200
 
201
201
  Every path retains the same safety gates: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown lifecycle states and unconfirmed non-open writes fail closed. The session-backed cache survives extension reloads and session resumes but remains bound to the originating session instance and repository. If the captured stale setting disabled publication, the user may explicitly run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
202
202