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 +14 -0
- package/README.md +4 -4
- package/extensions/review-table.ts +22 -3
- package/lib/pr-review-markdown.ts +243 -84
- package/package.json +1 -1
- package/prompts/pr-review.md +1 -1
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
function
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
628
|
-
: "publication-invalid extracted content
|
|
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
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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,
|
|
666
|
-
const hasStructure = !!overview && !!verification &&
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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(
|
|
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.
|
|
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",
|
package/prompts/pr-review.md
CHANGED
|
@@ -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
|
|
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
|
|