pi-pr-review 1.11.0 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.2](https://github.com/10ego/pi-pr-review/compare/v1.11.1...v1.11.2) (2026-07-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **publish:** repair malformed reviews with light subagent ([#42](https://github.com/10ego/pi-pr-review/issues/42)) ([6bb2f1b](https://github.com/10ego/pi-pr-review/commit/6bb2f1b5d7c42ab0233be27b7fa75984eb0b6357))
9
+
10
+ ## [1.11.1](https://github.com/10ego/pi-pr-review/compare/v1.11.0...v1.11.1) (2026-07-20)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **focus:** coalesce live viewer redraws ([#40](https://github.com/10ego/pi-pr-review/issues/40)) ([6553eb1](https://github.com/10ego/pi-pr-review/commit/6553eb1c0ade7881b220269b22adf22e4a5d7e59))
16
+
3
17
  ## [1.11.0](https://github.com/10ego/pi-pr-review/compare/v1.10.7...v1.11.0) (2026-07-17)
4
18
 
5
19
 
package/README.md CHANGED
@@ -188,7 +188,7 @@ Publishing is off by default.
188
188
 
189
189
  The extension—not the model—owns publishing. After a successful review, it caches one validated completed review 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.
190
190
 
191
- If the agent's final review is not valid exact-contract JSON, the extension logs the reason and automatically asks the same agent to correct its completed output once, with tools disabled and the original posting authority unchanged. An invalid correction, a tool call, or overlapping input stops publication rather than starting another correction loop.
191
+ If the agent's final review is not valid exact-contract JSON, the extension logs the reason and runs the configured `light` subagent once to reformat the completed output, with tools disabled and the original posting authority unchanged. An invalid correction or overlapping input stops publication rather than starting another correction loop.
192
192
 
193
193
  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.
194
194
 
@@ -47,6 +47,7 @@ export class ReviewFocusView implements Component {
47
47
  private followTail = true;
48
48
  private unsubscribe?: () => void;
49
49
  private closed = false;
50
+ private renderTimer?: ReturnType<typeof setTimeout>;
50
51
 
51
52
  constructor(
52
53
  private readonly tui: Pick<TUI, "requestRender" | "terminal">,
@@ -74,7 +75,7 @@ export class ReviewFocusView implements Component {
74
75
  } else {
75
76
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, snapshot.passes.length - 1));
76
77
  }
77
- this.tui.requestRender();
78
+ this.scheduleRender();
78
79
  }
79
80
 
80
81
  handleInput(data: string): void {
@@ -199,6 +200,18 @@ export class ReviewFocusView implements Component {
199
200
  dispose(): void {
200
201
  this.unsubscribe?.();
201
202
  this.unsubscribe = undefined;
203
+ if (this.renderTimer !== undefined) {
204
+ clearTimeout(this.renderTimer);
205
+ this.renderTimer = undefined;
206
+ }
207
+ }
208
+
209
+ private scheduleRender(): void {
210
+ if (this.closed || this.renderTimer !== undefined) return;
211
+ this.renderTimer = setTimeout(() => {
212
+ this.renderTimer = undefined;
213
+ if (!this.closed) this.tui.requestRender();
214
+ }, 16);
202
215
  }
203
216
 
204
217
  private pageSize(): number {
@@ -608,6 +608,7 @@ interface SubagentPassRequest {
608
608
  toolPolicy?: ToolPolicy;
609
609
  majorOnly?: boolean;
610
610
  minorHygiene?: boolean;
611
+ systemPrompt?: string;
611
612
  focusPublisher?: ReviewFocusPublisher;
612
613
  }
613
614
 
@@ -704,7 +705,7 @@ async function runSubagentAttempt(
704
705
 
705
706
  tmp = await writeTempPrompt(
706
707
  pass.tier,
707
- buildSubagentSystemPrompt(
708
+ pass.systemPrompt ?? buildSubagentSystemPrompt(
708
709
  pass.tier,
709
710
  pass.majorOnly === true,
710
711
  pass.minorHygiene && pass.tier === "light",
@@ -812,6 +813,30 @@ async function runSelfReviewAttempt(
812
813
  }
813
814
  }
814
815
 
816
+ const OUTPUT_REPAIR_SYSTEM_PROMPT = [
817
+ "You are an isolated output-repair subagent for a completed PR review.",
818
+ "You have no tools. Do not review the PR, add findings, remove findings, or change the review's substance.",
819
+ "Convert the supplied completed-review text into exactly one JSON object that satisfies the supplied output contract.",
820
+ "Return only that JSON object: no Markdown, headings, prose, or code fences. If the source cannot be safely converted, return it unchanged so host validation rejects it.",
821
+ ].join("\\n");
822
+
823
+ /** Run the configured light-tier model as a no-tools, one-shot JSON-format repairer. */
824
+ export async function repairReviewOutput(
825
+ text: string,
826
+ outputContract: string,
827
+ ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">,
828
+ signal?: AbortSignal,
829
+ ): Promise<string | undefined> {
830
+ const result = await runSubagentPass(loadConfig(ctx), ctx, {
831
+ id: "output-repair",
832
+ tier: "light",
833
+ objective: `Reformat this completed review without changing its substance.\n\n--- required output contract ---\n${outputContract}\n\n--- completed review ---\n${text}`,
834
+ toolPolicy: "none",
835
+ systemPrompt: OUTPUT_REPAIR_SYSTEM_PROMPT,
836
+ }, signal);
837
+ return result.status === "completed" && result.text.trim() ? result.text : undefined;
838
+ }
839
+
815
840
  async function runSubagentPass(
816
841
  config: PrReviewConfig,
817
842
  ctx: Pick<ExtensionContext, "cwd">,
@@ -86,10 +86,23 @@ interface Review {
86
86
  }
87
87
 
88
88
  type MessagePart = { type: string; text?: string };
89
+ type ReviewOutputRepair = (
90
+ text: string,
91
+ outputContract: string,
92
+ ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">,
93
+ signal?: AbortSignal,
94
+ ) => Promise<string | undefined>;
95
+
96
+ const defaultReviewOutputRepair: ReviewOutputRepair = async (...args) => {
97
+ const { repairReviewOutput } = await import("./pr-review-subagent.ts");
98
+ return repairReviewOutput(...args);
99
+ };
89
100
 
90
101
  const OWN_REVIEW_PROMPT = fs.realpathSync(
91
102
  fileURLToPath(new URL("../prompts/pr-review.md", import.meta.url)),
92
103
  );
104
+ const REVIEW_PROMPT_TEXT = fs.readFileSync(OWN_REVIEW_PROMPT, "utf8");
105
+ const REVIEW_OUTPUT_CONTRACT = REVIEW_PROMPT_TEXT.slice(REVIEW_PROMPT_TEXT.indexOf("## OUTPUT FORMAT"));
93
106
 
94
107
  function isOwnReviewPrompt(pi: Pick<ExtensionAPI, "getCommands">): boolean {
95
108
  try {
@@ -436,6 +449,7 @@ export default function registerReviewTable(
436
449
  pi: ExtensionAPI,
437
450
  loopCoordinator = new ReviewLoopCoordinator(pi),
438
451
  selfReviewCoordinator = new SelfReviewPermitCoordinator(pi, () => !!loopCoordinator.peek()),
452
+ repairOutput: ReviewOutputRepair = defaultReviewOutputRepair,
439
453
  ) {
440
454
  const completedReviews = new CompletedReviewCache();
441
455
  const sessionIdentity = (ctx: ExtensionContext): CompletedReviewSessionIdentity | undefined => {
@@ -454,7 +468,11 @@ export default function registerReviewTable(
454
468
  restoreCompletedReviewBranch(completedReviews, ctx.sessionManager.getBranch(), session);
455
469
  };
456
470
  type PendingCompletion =
457
- | { readonly record: CompletedReviewRecord; readonly session?: CompletedReviewSessionIdentity }
471
+ | {
472
+ readonly record: CompletedReviewRecord;
473
+ readonly replacedRecord?: CompletedReviewRecord;
474
+ readonly session?: CompletedReviewSessionIdentity;
475
+ }
458
476
  | { readonly error: string };
459
477
  let pendingCompletion: PendingCompletion | undefined;
460
478
  const completionError = (invocation: ReviewInvocation, failure?: string): PendingCompletion | undefined => {
@@ -474,12 +492,15 @@ export default function registerReviewTable(
474
492
  try {
475
493
  const repository = await resolveRepositoryBinding(ctx.cwd);
476
494
  // Cache before publication preflight; persist after Pi stores the assistant message.
477
- const record = completedReviews.remember(parsed.review, invocation, repository);
495
+ const replacement = completedReviews.replace(parsed.review, invocation, repository);
496
+ const { record } = replacement;
478
497
  const session = sessionIdentity(ctx);
479
498
  if (!session) {
480
499
  ctx.ui.notify("Completed review cache will not survive reload: session identity is unavailable", "warning");
481
500
  }
482
- return session ? { record, session } : { record };
501
+ return session
502
+ ? { record, ...(replacement.previous ? { replacedRecord: replacement.previous } : {}), session }
503
+ : { record, ...(replacement.previous ? { replacedRecord: replacement.previous } : {}) };
483
504
  } catch (error) {
484
505
  ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
485
506
  return completionError(
@@ -490,6 +511,12 @@ export default function registerReviewTable(
490
511
  };
491
512
  let outputRepairAttempted = false;
492
513
  let outputRepairCancelled = false;
514
+ let outputRepairGeneration = 0;
515
+ const clearOutputRepair = () => {
516
+ outputRepairGeneration++;
517
+ outputRepairAttempted = false;
518
+ outputRepairCancelled = false;
519
+ };
493
520
  const telemetryTracker = new ReviewTelemetryTracker();
494
521
  const persistTelemetry = (completion: ReviewPerformanceTelemetry["completion"]) => {
495
522
  const telemetry = telemetryTracker.finish(completion);
@@ -563,8 +590,7 @@ export default function registerReviewTable(
563
590
  loopCoordinator.clear();
564
591
  selfReviewCoordinator.clear();
565
592
  pendingCompletion = undefined;
566
- outputRepairAttempted = false;
567
- outputRepairCancelled = false;
593
+ clearOutputRepair();
568
594
  telemetryTracker.clear();
569
595
  };
570
596
 
@@ -586,8 +612,7 @@ export default function registerReviewTable(
586
612
  selfReviewCoordinator.clear();
587
613
  if (!outputRepairCancelled) return;
588
614
  loopCoordinator.clear();
589
- outputRepairAttempted = false;
590
- outputRepairCancelled = false;
615
+ clearOutputRepair();
591
616
  persistTelemetry("cleared");
592
617
  });
593
618
 
@@ -595,8 +620,7 @@ export default function registerReviewTable(
595
620
  loopCoordinator.clear();
596
621
  selfReviewCoordinator.clear();
597
622
  pendingCompletion = undefined;
598
- outputRepairAttempted = false;
599
- outputRepairCancelled = false;
623
+ clearOutputRepair();
600
624
  restoreCompletedReviews(ctx);
601
625
  telemetryTracker.clear();
602
626
  const session = sessionIdentity(ctx);
@@ -615,11 +639,14 @@ export default function registerReviewTable(
615
639
  selfReviewCoordinator.clear();
616
640
  if (outputRepairAttempted) {
617
641
  outputRepairCancelled = true;
642
+ loopCoordinator.clear();
643
+ clearOutputRepair();
644
+ persistTelemetry("cleared");
618
645
  ctx.abort();
619
- ctx.ui.notify("PR review output correction was cancelled; overlapping input was not queued, so retry it when the agent settles", "warning");
646
+ ctx.ui.notify("PR review output correction was cancelled; overlapping input was not queued, so retry it", "warning");
620
647
  return { action: "handled" as const };
621
648
  }
622
- outputRepairAttempted = false;
649
+ clearOutputRepair();
623
650
  const source = event.source as ReviewLoopInputSource;
624
651
  const directPublish = parseDirectPublishRequest(event.text);
625
652
  if (
@@ -748,8 +775,7 @@ export default function registerReviewTable(
748
775
  ctx.ui.notify("PR review was not posted: automatic output correction attempted to call tools", "error");
749
776
  ctx.abort();
750
777
  loopCoordinator.clear();
751
- outputRepairAttempted = false;
752
- outputRepairCancelled = false;
778
+ clearOutputRepair();
753
779
  persistTelemetry("cleared");
754
780
  return;
755
781
  }
@@ -766,8 +792,7 @@ export default function registerReviewTable(
766
792
  }
767
793
  if (completion === "clear_invocation") {
768
794
  loopCoordinator.clear();
769
- outputRepairAttempted = false;
770
- outputRepairCancelled = false;
795
+ clearOutputRepair();
771
796
  persistTelemetry("cleared");
772
797
  return;
773
798
  }
@@ -787,22 +812,63 @@ export default function registerReviewTable(
787
812
  if (active && !publishable?.review && !outputRepairAttempted) {
788
813
  const error = publishable?.error ?? "final review JSON is invalid";
789
814
  if (loopCoordinator.suspendToolsForRepair()) {
815
+ const lease = loopCoordinator.repairLease(ctx);
816
+ if (!lease) {
817
+ ctx.ui.notify("Automatic PR review output correction was skipped because its review lease was lost", "warning");
818
+ return;
819
+ }
790
820
  outputRepairAttempted = true;
791
821
  outputRepairCancelled = false;
792
- ctx.ui.notify(`PR review output is invalid (${error}); automatically correcting it once`, "warning");
793
- pi.sendMessage(
794
- {
795
- customType: "pr-review-output-repair",
796
- content: [
797
- `The completed PR review could not be accepted because ${error}.`,
798
- "This is the only automatic correction attempt. Do not call tools, redo the review, or change its substance.",
799
- "Re-emit the same completed review as exactly one JSON object satisfying the required OUTPUT FORMAT. Emit no Markdown fences, prose, headings, or trailing text.",
800
- ].join(" "),
801
- display: false,
802
- details: { error, attempt: 1 },
803
- },
804
- { triggerTurn: true, deliverAs: "followUp" },
805
- );
822
+ const repairGeneration = ++outputRepairGeneration;
823
+ const isActiveRepair = () => !outputRepairCancelled && repairGeneration === outputRepairGeneration &&
824
+ loopCoordinator.isRepairLeaseActive(lease, ctx);
825
+ ctx.ui.notify(`PR review output is invalid (${error}); asking the light repair subagent once`, "warning");
826
+ void repairOutput(text, REVIEW_OUTPUT_CONTRACT, ctx, lease.signal).then(async (repairedText) => {
827
+ if (!isActiveRepair()) return;
828
+ const repaired = repairedText ? parsePublishableReview(repairedText) : undefined;
829
+ if (!repaired?.review) {
830
+ if (isActiveRepair()) {
831
+ loopCoordinator.consume();
832
+ clearOutputRepair();
833
+ persistTelemetry("terminal_response");
834
+ ctx.ui.notify("PR review was not posted: light output correction did not produce valid final JSON", "error");
835
+ }
836
+ return;
837
+ }
838
+ const invocation = loopCoordinator.peek();
839
+ if (!invocation || !isActiveRepair()) return;
840
+ const completion = await resolveCompletion(repaired, invocation, ctx);
841
+ if (!isActiveRepair()) {
842
+ if (completion && "record" in completion) {
843
+ completedReviews.restoreReplacement(completion.record, completion.replacedRecord);
844
+ }
845
+ return;
846
+ }
847
+ if (completion && "record" in completion) {
848
+ if (completion.session) {
849
+ try {
850
+ pi.appendEntry(COMPLETED_REVIEW_ENTRY_TYPE, completedReviews.persist(completion.record, completion.session));
851
+ } catch (persistError) {
852
+ ctx.ui.notify(`Completed review cache will not survive an extension reload: ${String(persistError)}`, "warning");
853
+ }
854
+ }
855
+ if (!isActiveRepair()) return;
856
+ await publishCompletedReview(completion.record, { kind: "frozen-invocation" }, ctx);
857
+ } else if (completion && "error" in completion) {
858
+ ctx.ui.notify(`PR review was not posted: ${completion.error}`, "error");
859
+ }
860
+ if (isActiveRepair()) {
861
+ loopCoordinator.consume();
862
+ clearOutputRepair();
863
+ persistTelemetry("terminal_response");
864
+ }
865
+ }).catch(() => {
866
+ if (!isActiveRepair()) return;
867
+ loopCoordinator.consume();
868
+ clearOutputRepair();
869
+ persistTelemetry("terminal_response");
870
+ ctx.ui.notify("PR review was not posted: light output correction failed", "error");
871
+ });
806
872
  return;
807
873
  }
808
874
  ctx.ui.notify("Automatic PR review output correction was skipped because tools could not be disabled", "warning");
@@ -812,8 +878,7 @@ export default function registerReviewTable(
812
878
  // Persist timing before publication so network/write latency is never coupled to review wall time.
813
879
  const invocation = active ? loopCoordinator.consume() : undefined;
814
880
  if (invocation) {
815
- outputRepairAttempted = false;
816
- outputRepairCancelled = false;
881
+ clearOutputRepair();
817
882
  persistTelemetry("terminal_response");
818
883
  }
819
884
  const review = publishable?.review
@@ -233,6 +233,24 @@ export class ReviewLoopCoordinator {
233
233
  this.setToolsEnabled(false);
234
234
  }
235
235
 
236
+ /** Return the current loop's abortable lease while its tools are suspended for output repair. */
237
+ repairLease(ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): ReviewLoopLease | undefined {
238
+ const phase = this.invocationGate.phase();
239
+ if (!this.binding || this.suspendedTools === undefined || (phase !== "reviewing" && phase !== "confirmed")) return undefined;
240
+ if (!sameBinding(this.binding, ctx) || this.binding.controller.signal.aborted) {
241
+ this.clear();
242
+ return undefined;
243
+ }
244
+ return Object.freeze({ generation: this.binding.generation, signal: this.binding.controller.signal });
245
+ }
246
+
247
+ /** Check a repair lease without re-enabling the review tools. */
248
+ isRepairLeaseActive(lease: ReviewLoopLease, ctx: Pick<ExtensionContext, "cwd" | "sessionManager">): boolean {
249
+ return !!this.binding && this.suspendedTools !== undefined &&
250
+ this.binding.generation === lease.generation && !lease.signal.aborted &&
251
+ (this.phase() === "reviewing" || this.phase() === "confirmed") && sameBinding(this.binding, ctx);
252
+ }
253
+
236
254
  /** Hide every tool for a format-only repair turn while retaining invocation authority. */
237
255
  suspendToolsForRepair(): boolean {
238
256
  const phase = this.invocationGate.phase();
@@ -603,17 +603,26 @@ export class CompletedReviewCache {
603
603
  private readonly reviews = new Map<string, CompletedReviewRecord>();
604
604
 
605
605
  remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): CompletedReviewRecord {
606
+ return this.replace(review, invocation, repository).record;
607
+ }
608
+
609
+ /** Replace one PR record while retaining its predecessor for cancellation rollback. */
610
+ replace(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): {
611
+ record: CompletedReviewRecord;
612
+ previous?: CompletedReviewRecord;
613
+ } {
606
614
  const record = {
607
615
  review,
608
616
  invocation: { ...invocation, autoPost: { ...invocation.autoPost } },
609
617
  repository: { ...repository },
610
618
  };
611
619
  const key = completedReviewKey(repository, invocation.prNumber);
620
+ const previous = this.reviews.get(key);
612
621
  // Refresh insertion order so unnumbered direct publish requests bind to
613
622
  // the most recently completed review in this repository.
614
623
  this.reviews.delete(key);
615
624
  this.reviews.set(key, record);
616
- return record;
625
+ return previous ? { record, previous } : { record };
617
626
  }
618
627
 
619
628
  persist(
@@ -675,6 +684,20 @@ export class CompletedReviewCache {
675
684
  return true;
676
685
  }
677
686
 
687
+ /** Remove this exact record without deleting a newer completion for the same PR. */
688
+ forget(record: CompletedReviewRecord): void {
689
+ const key = completedReviewKey(record.repository, record.invocation.prNumber);
690
+ if (this.reviews.get(key) === record) this.reviews.delete(key);
691
+ }
692
+
693
+ /** Undo a replacement only when this exact record is still current. */
694
+ restoreReplacement(record: CompletedReviewRecord, previous: CompletedReviewRecord | undefined): void {
695
+ const key = completedReviewKey(record.repository, record.invocation.prNumber);
696
+ if (this.reviews.get(key) !== record) return;
697
+ this.reviews.delete(key);
698
+ if (previous) this.reviews.set(key, previous);
699
+ }
700
+
678
701
  get(prNumber: number, repository: RepositoryBinding): CompletedReviewRecord | undefined {
679
702
  return this.reviews.get(completedReviewKey(repository, prNumber));
680
703
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.11.0",
3
+ "version": "1.11.2",
4
4
  "description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and safe COMMENT-only publishing.",
5
5
  "keywords": [
6
6
  "pi-package",