pi-pr-review 1.7.0 → 1.7.1

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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.7.0"
2
+ ".": "1.7.1"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.1](https://github.com/10ego/pi-pr-review/compare/v1.7.0...v1.7.1) (2026-07-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **publish:** handle direct review requests in extension ([#15](https://github.com/10ego/pi-pr-review/issues/15)) ([e27ffeb](https://github.com/10ego/pi-pr-review/commit/e27ffebb181687a7e38f5ce8f0045b5db76975f4))
9
+
3
10
  ## [1.7.0](https://github.com/10ego/pi-pr-review/compare/v1.6.6...v1.7.0) (2026-07-13)
4
11
 
5
12
 
package/README.md CHANGED
@@ -147,7 +147,7 @@ The extension—not the model—owns publishing. It creates one formal review wi
147
147
 
148
148
  Closed or merged PRs use a body-only review. Open PRs attach eligible P0–P3 findings as inline comments and keep nits or off-diff findings in the review body.
149
149
 
150
- After a review completes, you can directly ask the agent to “post the inline review.” That interactive/RPC input creates one host-side, session- and repository-bound authorization for the extension-owned `pr_review_publish` tool. The authorization is consumed by one matching call; an unnumbered request is restricted to the latest cached review, while “publish the review for PR #123 binds the named PR. The model cannot self-authorize, supply replacement review text, replay the request, or use this tool to rerun review agents. An explicit authorized request permits stale publication.
150
+ After a review completes, you can directly ask the agent to “post the inline review or “post it as an inline review.” The extension handles that request directly before an agent turn, selecting the latest cached review for an unnumbered request or the named PR in “publish the review for PR #123.” Only fresh interactive/RPC input can trigger this path; extension-generated, queued, or steering input cannot. The extension publishes only validated cached content, never replacement model-authored text, and never starts or reruns review agents. A direct request permits stale publication.
151
151
 
152
152
  Stale publication is also enabled by default through `allowStalePublish: true`; disable it with `/pr-review-config allow_stale_publish=false`. Automatic posting and `/pr-review-publish` without an override use the setting captured when the review starts. The explicit `--allow-stale` flag remains available when the captured setting disabled stale publication:
153
153
 
@@ -214,7 +214,7 @@ The verdict is `request_changes` only when a validated P0 or P1 finding exists.
214
214
  - Reviewer subprocesses start with extension discovery disabled, so they cannot recursively invoke this package's agents or verification tool.
215
215
  - Reviewers receive the captured diff and are instructed not to modify files.
216
216
  - The orchestrator does not check out, commit, or push PR code.
217
- - GitHub writes require `--comment`, an effective `autoPostReviews: true` setting, or a fresh direct user request that grants one host-side call to the cache-only `pr_review_publish` tool. `allowStalePublish` controls whether an authorized/configured write may be stale; it does not independently authorize a write.
217
+ - GitHub writes require `--comment`, an effective `autoPostReviews: true` setting, the model-free `/pr-review-publish` command, or a narrowly matched direct interactive/RPC publish request handled by the extension before an agent turn. `allowStalePublish` controls whether an invocation/config-authorized write may be stale; it does not independently authorize a write.
218
218
  - Publication authority is captured before review or optional verification begins, so PR code cannot enable it mid-run.
219
219
  - Multiple model calls run per PR. Use a cheaper `light` model and reserve stronger models for `heavy` passes to control cost.
220
220
  - Same-head review markers prevent duplicate publication by the same GitHub identity.
@@ -1,13 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
- import { CachedPublishAuthorizationGate } from "../lib/pr-review-publish.ts";
4
3
  import registerPrReviewSubagents from "./pr-review-subagent.ts";
5
4
  import registerReviewTable from "./review-table.ts";
6
5
 
7
6
  /** Register the package behind one shared, session-scoped review-loop authority. */
8
7
  export default function registerPrReview(pi: ExtensionAPI) {
9
8
  const loopCoordinator = new ReviewLoopCoordinator(pi);
10
- const publishAuthorization = new CachedPublishAuthorizationGate();
11
- registerPrReviewSubagents(pi, loopCoordinator, () => publishAuthorization.clear());
12
- registerReviewTable(pi, loopCoordinator, publishAuthorization);
9
+ registerPrReviewSubagents(pi, loopCoordinator);
10
+ registerReviewTable(pi, loopCoordinator);
13
11
  }
@@ -987,7 +987,6 @@ const ReviewSubagentsParams = Type.Object({
987
987
  export default function registerPrReviewSubagents(
988
988
  pi: ExtensionAPI,
989
989
  loopCoordinator = new ReviewLoopCoordinator(pi),
990
- revokePublishAuthorization: () => void = () => {},
991
990
  ) {
992
991
  // Resolve security-sensitive executables only from the PATH trusted when this
993
992
  // extension starts, never from a later mutable process environment.
@@ -1329,7 +1328,6 @@ export default function registerPrReviewSubagents(
1329
1328
  handler: async (args, ctx) => {
1330
1329
  // Extension commands execute before input events, so revoke explicitly.
1331
1330
  loopCoordinator.clear();
1332
- revokePublishAuthorization();
1333
1331
  const raw = (args ?? "").trim();
1334
1332
  const parsed = parseConfigArgs(raw);
1335
1333
  if (parsed.errors.length) {
@@ -18,15 +18,14 @@ import {
18
18
  type ExtensionContext,
19
19
  getAgentDir,
20
20
  } from "@earendil-works/pi-coding-agent";
21
- import { Type } from "typebox";
22
21
  import {
23
22
  classifyAssistantCompletion,
24
- CachedPublishAuthorizationGate,
25
23
  COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE,
26
24
  COMPLETED_REVIEW_ENTRY_TYPE,
27
25
  CompletedReviewCache,
28
26
  decideReviewPublication,
29
27
  isNonOpenConfirmationPrompt,
28
+ parseDirectPublishRequest,
30
29
  parsePublishExistingArgs,
31
30
  parsePublishMode,
32
31
  parsePublishableReview,
@@ -429,7 +428,6 @@ async function maybePublishReview(
429
428
  export default function registerReviewTable(
430
429
  pi: ExtensionAPI,
431
430
  loopCoordinator = new ReviewLoopCoordinator(pi),
432
- publishAuthorization = new CachedPublishAuthorizationGate(),
433
431
  ) {
434
432
  const completedReviews = new CompletedReviewCache();
435
433
  const sessionIdentity = (ctx: ExtensionContext): CompletedReviewSessionIdentity | undefined => {
@@ -471,10 +469,9 @@ export default function registerReviewTable(
471
469
  | { result: Awaited<ReturnType<typeof publishPullReview>> }
472
470
  | { error: string };
473
471
  const publishCachedReview = async (
474
- prNumber: number,
472
+ requestedPrNumber: number | undefined,
475
473
  allowStaleOverride: boolean | undefined,
476
474
  ctx: ExtensionContext,
477
- requireLatest = false,
478
475
  ): Promise<CachedPublication> => {
479
476
  let repository: RepositoryBinding;
480
477
  try {
@@ -482,17 +479,16 @@ export default function registerReviewTable(
482
479
  } catch (error) {
483
480
  return { error: `Cannot resolve the current GitHub repository: ${String(error)}` };
484
481
  }
485
- const completed = completedReviews.get(prNumber, repository);
486
- if (requireLatest && completedReviews.latest(repository)?.invocation.prNumber !== prNumber) {
487
- return {
488
- error: `PR #${prNumber} is not the latest completed review cached for this repository. Name the PR number in the direct publish request to select it explicitly.`,
489
- };
490
- }
482
+ const completed = requestedPrNumber === undefined
483
+ ? completedReviews.latest(repository)
484
+ : completedReviews.get(requestedPrNumber, repository);
491
485
  if (!completed) {
486
+ const target = requestedPrNumber === undefined ? "the latest PR" : `PR #${requestedPrNumber}`;
492
487
  return {
493
- error: `No completed review for PR #${prNumber} is cached for this repository in the current extension session. Publishing never starts or reruns a review.`,
488
+ error: `No completed review for ${target} is cached for this repository in the current extension session. Publishing never starts or reruns a review.`,
494
489
  };
495
490
  }
491
+ const prNumber = completed.invocation.prNumber;
496
492
  const headSha = completed.review.pr?.head_sha;
497
493
  if (typeof headSha !== "string") return { error: "Cached PR review is missing pr.head_sha" };
498
494
  const allowStale = allowStaleOverride ?? completed.invocation.allowStalePublish;
@@ -509,79 +505,11 @@ export default function registerReviewTable(
509
505
  };
510
506
  };
511
507
 
512
- pi.registerTool({
513
- name: "pr_review_publish",
514
- label: "Publish Cached PR Review",
515
- description: [
516
- "Publish a completed PR review cached by this extension after a fresh direct interactive or RPC user request authorizes one call.",
517
- "Accepts only a PR number: review content, repository binding, and reviewed head come from the validated extension cache.",
518
- "Never starts or reruns review agents. An explicit user request authorizes safe stale publication as a body-only review with the reviewed and current commit hashes disclosed.",
519
- ].join(" "),
520
- promptSnippet: "Publish an extension-cached completed PR review when the user explicitly asks to post it",
521
- promptGuidelines: [
522
- "Call pr_review_publish only in the agent turn started by the user's direct publish request; the extension enforces a one-shot host-side authorization.",
523
- "Do not call it during the /pr-review run itself; --comment and autoPostReviews are handled by the extension after final JSON.",
524
- "This tool cannot accept model-authored review text; stale inline anchors are never posted.",
525
- ],
526
- parameters: Type.Object(
527
- {
528
- pr_number: Type.Integer({
529
- minimum: 1,
530
- description: "PR number whose completed cached review should be published; stale reviews degrade to disclosed body-only comments.",
531
- }),
532
- },
533
- { additionalProperties: false },
534
- ),
535
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
536
- if (loopCoordinator.peek()) {
537
- return {
538
- content: [{ type: "text", text: "A /pr-review run is still active; finish or cancel it before publishing its cached result." }],
539
- isError: true,
540
- details: { status: "active_review" },
541
- };
542
- }
543
- const authorization = publishAuthorization.consume(params.pr_number, ctx);
544
- if (!authorization.authorized) {
545
- return {
546
- content: [{
547
- type: "text",
548
- text: "Cached review publication requires a fresh direct user request such as ‘post the inline review’ or ‘publish the review for PR #123’.",
549
- }],
550
- isError: true,
551
- details: { status: "unauthorized" },
552
- };
553
- }
554
- const published = await publishCachedReview(
555
- params.pr_number,
556
- true,
557
- ctx,
558
- authorization.requireLatest,
559
- );
560
- if ("error" in published) {
561
- return {
562
- content: [{ type: "text", text: published.error }],
563
- isError: true,
564
- details: { status: "error", message: published.error },
565
- };
566
- }
567
- const result = published.result;
568
- const succeeded = result.status === "posted" ||
569
- result.status === "posted_degraded" ||
570
- result.status === "skipped_duplicate";
571
- return {
572
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
573
- ...(!succeeded ? { isError: true } : {}),
574
- details: result,
575
- };
576
- },
577
- });
578
-
579
508
  pi.registerCommand("pr-review-publish", {
580
509
  description: "Publish a completed review from this session without rerunning the model",
581
510
  handler: async (args, ctx) => {
582
511
  // Extension commands execute before input hooks, so every invocation —
583
- // including malformed arguments — must revoke active review and model-tool authority.
584
- publishAuthorization.clear();
512
+ // including malformed arguments — must revoke active review authority.
585
513
  const active = loopCoordinator.peek();
586
514
  if (active) {
587
515
  loopCoordinator.clear();
@@ -621,7 +549,6 @@ export default function registerReviewTable(
621
549
 
622
550
  const revokeActiveLoop = () => {
623
551
  loopCoordinator.clear();
624
- publishAuthorization.clear();
625
552
  pendingCompletion = undefined;
626
553
  telemetryTracker.clear();
627
554
  };
@@ -638,7 +565,6 @@ export default function registerReviewTable(
638
565
 
639
566
  pi.on("session_tree", (event, ctx) => {
640
567
  loopCoordinator.clear();
641
- publishAuthorization.clear();
642
568
  pendingCompletion = undefined;
643
569
  restoreCompletedReviews(ctx);
644
570
  telemetryTracker.clear();
@@ -652,10 +578,29 @@ export default function registerReviewTable(
652
578
  }
653
579
  });
654
580
 
655
- pi.on("input", (event, ctx) => {
581
+ pi.on("input", async (event, ctx) => {
656
582
  const source = event.source as ReviewLoopInputSource;
657
- if (loopCoordinator.peek()) publishAuthorization.clear();
658
- else publishAuthorization.observe(event.text, source, event.streamingBehavior, ctx);
583
+ const directPublish = parseDirectPublishRequest(event.text);
584
+ if (
585
+ (source === "interactive" || source === "rpc") &&
586
+ event.streamingBehavior === undefined &&
587
+ directPublish.matched
588
+ ) {
589
+ const active = loopCoordinator.peek();
590
+ if (active) {
591
+ loopCoordinator.clear();
592
+ persistTelemetry("cleared");
593
+ ctx.ui.notify(
594
+ `PR #${active.prNumber} review was cancelled. The direct publish request will not post an older cached result in its place.`,
595
+ "error",
596
+ );
597
+ return { action: "handled" as const };
598
+ }
599
+ const published = await publishCachedReview(directPublish.prNumber, true, ctx);
600
+ if ("error" in published) ctx.ui.notify(published.error, "error");
601
+ else notifyPublishResult(published.result, "direct user request", ctx);
602
+ return { action: "handled" as const };
603
+ }
659
604
  if (loopCoordinator.phase() === "awaiting_confirmation") {
660
605
  const confirmation = loopCoordinator.resolveConfirmationInput(event.text, source, ctx);
661
606
  if (confirmation === "confirmed") {
@@ -754,14 +699,12 @@ export default function registerReviewTable(
754
699
  if (completion === "continue_tools") return;
755
700
  if (completion === "clear_invocation") {
756
701
  loopCoordinator.clear();
757
- publishAuthorization.clear();
758
702
  persistTelemetry("cleared");
759
703
  return;
760
704
  }
761
705
 
762
706
  const text = assistantText(event.message);
763
707
  const active = loopCoordinator.peek();
764
- if (!active) publishAuthorization.clear();
765
708
  if (
766
709
  active &&
767
710
  loopCoordinator.phase() === "reviewing" &&
@@ -1,7 +1,6 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import * as path from "node:path";
5
4
 
6
5
  export type PublishMode = "auto" | "force" | "disabled";
7
6
  export type AutoPostSource = "default" | "user" | "project";
@@ -161,7 +160,7 @@ export function parseDirectPublishRequest(input: string): DirectPublishRequestPa
161
160
  const trimmed = input.trim();
162
161
  if (!trimmed || /[\r\n]/.test(trimmed)) return { matched: false };
163
162
  const match = trimmed.match(
164
- /^(?:(?:please|kindly)\s+|(?:(?:can|could|would|will)\s+you\s+))?(?:post|publish|submit)\s+(?:(?:the|this|that|my|our)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request)\s+)*(?:reviews?|inline\s+comments?)(?:\s+(?:for|on|to)\s+(?:(?:the\s+)?(?:pull\s+request|pr)\s*)?#?(\d+))?(?:\s+please)?[.!?]*$/i,
163
+ /^(?:(?:please|kindly)\s+|(?:(?:can|could|would|will)\s+you\s+))?(?:post|publish|submit)\s+(?:(?:(?:the|this|that|my|our)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request)\s+)*(?:reviews?|inline\s+comments?)|(?:it|this|that)\s+as\s+(?:(?:an?|the)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request)\s+)*(?:reviews?|inline\s+comments?))(?:\s+(?:for|on|to)\s+(?:(?:the\s+)?(?:pull\s+request|pr)\s*)?#?(\d+))?(?:\s+please)?[.!?]*$/i,
165
164
  );
166
165
  if (!match) return { matched: false };
167
166
  if (match[1] === undefined) return { matched: true };
@@ -171,75 +170,6 @@ export function parseDirectPublishRequest(input: string): DirectPublishRequestPa
171
170
  : { matched: false };
172
171
  }
173
172
 
174
- interface PublishAuthorizationContext {
175
- cwd: string;
176
- sessionManager: {
177
- getSessionId(): string;
178
- getHeader(): { id?: string; timestamp?: unknown } | undefined;
179
- };
180
- }
181
-
182
- interface PendingPublishAuthorization {
183
- cwd: string;
184
- sessionId: string;
185
- sessionStartedAt?: string;
186
- prNumber?: number;
187
- }
188
-
189
- function publishAuthorizationBinding(ctx: PublishAuthorizationContext) {
190
- const sessionId = ctx.sessionManager.getSessionId();
191
- const header = ctx.sessionManager.getHeader();
192
- return {
193
- cwd: path.resolve(ctx.cwd),
194
- sessionId,
195
- ...(header?.id === sessionId && typeof header.timestamp === "string"
196
- ? { sessionStartedAt: header.timestamp }
197
- : {}),
198
- };
199
- }
200
-
201
- /** One pending model-tool publication authorized by one direct user input. */
202
- export class CachedPublishAuthorizationGate {
203
- private pending?: PendingPublishAuthorization;
204
-
205
- observe(
206
- input: string,
207
- source: "interactive" | "rpc" | "extension",
208
- streamingBehavior: "steer" | "followUp" | undefined,
209
- ctx: PublishAuthorizationContext,
210
- ): DirectPublishRequestParseResult {
211
- this.clear();
212
- if ((source !== "interactive" && source !== "rpc") || streamingBehavior !== undefined) {
213
- return { matched: false };
214
- }
215
- const parsed = parseDirectPublishRequest(input);
216
- if (!parsed.matched) return parsed;
217
- this.pending = { ...publishAuthorizationBinding(ctx), ...(parsed.prNumber ? { prNumber: parsed.prNumber } : {}) };
218
- return parsed;
219
- }
220
-
221
- consume(
222
- prNumber: number,
223
- ctx: PublishAuthorizationContext,
224
- ): { authorized: boolean; requireLatest: boolean } {
225
- const pending = this.pending;
226
- this.clear();
227
- if (!pending) return { authorized: false, requireLatest: false };
228
- const current = publishAuthorizationBinding(ctx);
229
- const sameContext = pending.cwd === current.cwd &&
230
- pending.sessionId === current.sessionId &&
231
- pending.sessionStartedAt === current.sessionStartedAt;
232
- if (!sameContext || (pending.prNumber !== undefined && pending.prNumber !== prNumber)) {
233
- return { authorized: false, requireLatest: false };
234
- }
235
- return { authorized: true, requireLatest: pending.prNumber === undefined };
236
- }
237
-
238
- clear(): void {
239
- this.pending = undefined;
240
- }
241
- }
242
-
243
173
  export interface PublishExistingParseResult {
244
174
  prNumber?: number;
245
175
  allowStale: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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",
@@ -24,7 +24,7 @@ When `$@` includes `--full`, run the comprehensive six-pass topology: add the me
24
24
 
25
25
  ### Reviewer topology (parallel tiered subagents, with inline fallback)
26
26
 
27
- You are the **orchestrator**. You own GitHub reads, skip decisions, convention-file discovery when `--full` needs it, selecting at most one discovered trusted baseline name, final validation/classification, and JSON emission. The extension owns verification profile resolution, argv, deadlines, worktree, original-POSIX-group supervision, and cleanup. You never perform direct GitHub writes: the extension captures invocation publishing intent and, after valid final JSON, owns any configured review publication. The extension-owned `pr_review_publish` tool is the only exception: on a later turn, if the user directly asks to post a completed cached review, call that tool with the PR number instead of asking the user to run `/pr-review-publish`. The extension enforces one host-side authorization from that direct interactive/RPC input, bound to the named PR or latest cached review and consumed by one call. Never call it during the review run itself. Subagents are non-modifying reviewers: they receive PR context from you and return candidate evidence only.
27
+ You are the **orchestrator**. You own GitHub reads, skip decisions, convention-file discovery when `--full` needs it, selecting at most one discovered trusted baseline name, final validation/classification, and JSON emission. The extension owns verification profile resolution, argv, deadlines, worktree, original-POSIX-group supervision, cleanup, and every GitHub write. You never perform direct GitHub writes. The extension captures invocation publishing intent and, after valid final JSON, owns any configured review publication. On a later turn, the extension intercepts a direct interactive/RPC request to post the completed cached review and publishes it without an agent turn. Subagents are non-modifying reviewers: they receive PR context from you and return candidate evidence only.
28
28
 
29
29
  If the `review_subagents` batch tool is available, prefer it over multiple single-pass calls. Fetch PR metadata and the unified diff once, gather any relevant convention-file excerpts, and use `pr_review_verify` with `action: "list"` to discover optional trusted user-level baseline names as described in Steps 2 and 6. Then call `review_subagents` with shared `context`, `max_parallel`, and ordered `passes`. When an applicable name exists, emit the `review_subagents` call and one `pr_review_verify` `action: "run"` call in the **same assistant turn** so Pi can run them concurrently; otherwise dispatch the batch without waiting and record why verification was skipped. Never supply or invent command/timeout overrides, and never replace an unavailable `pr_review_verify` with a prompt-owned `bash` worktree lifecycle. This guarantees bounded parallel fan-out without reducing review coverage. Use these available pass assignments:
30
30
 
@@ -61,7 +61,7 @@ Arguments for this run: `$@`
61
61
  - `--full` selects the six-pass comprehensive all-severity review, including conventions/maintainability.
62
62
  - `--full`, `--major-only`, and `--balanced` are mutually exclusive.
63
63
  - Using `--comment` and `--no-comment` together is invalid; the extension rejects the invocation before review starts.
64
- - With neither posting flag, the extension follows `autoPostReviews` (default `false`). These flags are captured before template expansion; do not post during this review run. If the user later explicitly asks to publish the completed cached review, use `pr_review_publish` when available.
64
+ - With neither posting flag, the extension follows `autoPostReviews` (default `false`). These flags are captured before template expansion; do not post during this review run. A later direct interactive/RPC publish request is handled model-free by the extension.
65
65
  - If `--include-closed` or `--review-closed` appears anywhere in the arguments, review closed/merged PRs without asking for confirmation.
66
66
 
67
67
  ---
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
196
196
 
197
197
  When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths, suggested verdict, counts, nits, and findings that cannot be attached inline. Eligible P0–P3 diff-anchored findings are attached as inline comments within that same review and are omitted from the top-level issue list to avoid duplication. The API event is hardcoded to `COMMENT`: publication never sends `APPROVE` or `REQUEST_CHANGES`, even when the suggested verdict is `request_changes`. It appends the same-head marker, verifies the current head, validates every inline anchor against GitHub diff metadata, and refuses partial open-PR publication. For a known closed/merged PR it requires either trusted `--include-closed`/`--review-closed` invocation authority or the one-shot affirmative confirmation flow, then posts one body-only `COMMENT` review with each inline finding folded into the body exactly once. Unknown lifecycle states and unconfirmed non-open writes fail without posting or falling back to an issue comment.
198
198
 
199
- The extension caches the latest valid completed review per repository and PR before publication preflight in the current Pi session. The session-backed cache survives extension reloads and session resumes but is bound to the originating session instance and repository. On a later turn, when the user directly asks to post that completed review, call `pr_review_publish` with the PR number; the extension accepts it only under the one-shot host authorization created by that direct input, publishes only extension-cached content, permits stale publication on that authorized request, and cannot rerun review agents. Stale publication is also enabled by default through the invocation-captured `allowStalePublish` setting. Every stale review is body-only, displays a warning with the reviewed and preflight-observed commit hashes, and omits potentially invalid inline anchors. If the captured setting disabled stale publication, the user can still 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.
199
+ The extension caches the latest valid completed review per repository and PR before publication preflight in the current Pi session. The session-backed cache survives extension reloads and session resumes but is bound to the originating session instance and repository. On a later turn, the extension intercepts that direct input before an agent turn, publishes only extension-cached content, permits stale publication, and cannot rerun review agents. Stale publication is also enabled by default through the invocation-captured `allowStalePublish` setting. Every stale review is body-only, displays a warning with the reviewed and preflight-observed commit hashes, and omits potentially invalid inline anchors. If the captured setting disabled stale publication, the user can still 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.
200
200
 
201
201
  ---
202
202
 
@@ -234,7 +234,7 @@ Your final message must be **exactly one JSON object** matching the shape below
234
234
  }
235
235
  ```
236
236
 
237
- - `pr.head_sha` is the exact full `headRefOid` reviewed; the publisher rejects missing SHAs. Stale publication requires either an explicit user request through `pr_review_publish`, an invocation-captured `allowStalePublish: true` setting (the default), or the publish-only `--allow-stale` override; stale reviews are always body-only with both commit hashes disclosed.
237
+ - `pr.head_sha` is the exact full `headRefOid` reviewed; the publisher rejects missing SHAs. Stale publication requires either a direct publish request intercepted by the extension, an invocation-captured `allowStalePublish: true` setting (the default), or the publish-only `--allow-stale` override; stale reviews are always body-only with both commit hashes disclosed.
238
238
  - `disposition` is exactly `"reviewed"` or `"skipped"`. The extension never publishes `skipped` results.
239
239
  - `verdict` is exactly `"approve"`, `"request_changes"`, or `"comment"`.
240
240
  - `overall_correctness` is exactly `"patch is correct"` or `"patch is incorrect"`.
@@ -32,7 +32,7 @@ mock.module("typebox", () => ({
32
32
 
33
33
  const reviewTable = (await import("../extensions/review-table.ts")).default;
34
34
  const ownPromptPath = fileURLToPath(new URL("../prompts/pr-review.md", import.meta.url));
35
- const BASE_ACTIVE_TOOLS = ["read", "bash", "pr_review_publish"];
35
+ const BASE_ACTIVE_TOOLS = ["read", "bash"];
36
36
 
37
37
  const review: ReviewLike = {
38
38
  pr: { number: 7, title: "Lifecycle review", head_sha: "a".repeat(40) },
@@ -312,32 +312,59 @@ describe("completed review extension lifecycle", () => {
312
312
  expect(harness.activeTools()).not.toContain("review_subagent");
313
313
  });
314
314
 
315
- test("publishes a stale cached review through the agent tool with SHA disclosure", async () => {
315
+ test("publishes a direct natural-language request without an agent turn", async () => {
316
316
  const persisted = persistedInlineReview(session, false);
317
317
  const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
318
318
  const harness = createHarness([cacheEntry]);
319
319
  await harness.emit("session_start", { reason: "reload" });
320
- const tool = harness.tools.get("pr_review_publish");
321
- expect(tool).toBeDefined();
322
- expect(Object.keys(tool.parameters.properties)).toEqual(["pr_number"]);
323
- const unauthorized = await tool.execute("publish-0", { pr_number: 7 }, undefined, undefined, harness.ctx);
324
- expect(unauthorized.isError).toBeTrue();
325
- expect(unauthorized.details).toEqual({ status: "unauthorized" });
326
- await harness.emit("input", { text: "post the inline review", source: "interactive" });
320
+ expect(harness.tools.has("pr_review_publish")).toBeFalse();
327
321
  const currentHead = "b".repeat(40);
328
322
  const payloadPath = installFakePublishingGh(currentHead);
329
- const result = await tool.execute("publish-1", { pr_number: 7 }, undefined, undefined, harness.ctx);
330
- expect(result.isError).toBeUndefined();
331
- expect(result.details).toMatchObject({ status: "posted_degraded" });
323
+ const handled = await harness.emit("input", {
324
+ text: "post it as an inline review",
325
+ source: "interactive",
326
+ });
327
+ expect(handled).toContainEqual({ action: "handled" });
328
+ expect(harness.notifications.some((message) => message.includes("posted"))).toBeTrue();
332
329
  const payload = JSON.parse(readFileSync(payloadPath, "utf8"));
333
330
  expect(payload.comments).toBeUndefined();
334
331
  expect(payload.body).toContain("[!WARNING]");
335
332
  expect(payload.body).toContain("This review was generated for commit");
336
333
  expect(payload.body).toContain("a".repeat(40));
337
334
  expect(payload.body).toContain(currentHead);
338
- const replay = await tool.execute("publish-2", { pr_number: 7 }, undefined, undefined, harness.ctx);
339
- expect(replay.isError).toBeTrue();
340
- expect(replay.details).toEqual({ status: "unauthorized" });
335
+ });
336
+
337
+ test("rejects extension-generated, queued, and steering publish requests", async () => {
338
+ const persisted = persistedInlineReview();
339
+ const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
340
+ const harness = createHarness([cacheEntry]);
341
+ await harness.emit("session_start", { reason: "reload" });
342
+ const payloadPath = installFakePublishingGh();
343
+
344
+ for (const event of [
345
+ { text: "post the inline review", source: "extension" },
346
+ { text: "post the inline review", source: "interactive", streamingBehavior: "followUp" },
347
+ { text: "post the inline review", source: "rpc", streamingBehavior: "steer" },
348
+ ]) {
349
+ const results = await harness.emit("input", event);
350
+ expect(results).not.toContainEqual({ action: "handled" });
351
+ }
352
+ expect(() => readFileSync(payloadPath, "utf8")).toThrow();
353
+ });
354
+
355
+ test("does not publish an older cache entry while a review is active", async () => {
356
+ const persisted = persistedInlineReview();
357
+ const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
358
+ const harness = createHarness([cacheEntry]);
359
+ await harness.emit("session_start", { reason: "reload" });
360
+ await harness.emit("input", { text: "/pr-review 7", source: "interactive" });
361
+ const payloadPath = installFakePublishingGh();
362
+
363
+ const handled = await harness.emit("input", { text: "post the inline review", source: "interactive" });
364
+ expect(handled).toContainEqual({ action: "handled" });
365
+ expect(harness.activeTools()).toEqual(BASE_ACTIVE_TOOLS);
366
+ expect(harness.notifications.some((message) => message.includes("will not post an older cached result"))).toBeTrue();
367
+ expect(() => readFileSync(payloadPath, "utf8")).toThrow();
341
368
  });
342
369
 
343
370
  test("publish command follows captured stale config unless explicitly overridden", async () => {
@@ -7,13 +7,13 @@ const entrypoint = readFileSync(new URL("../extensions/index.ts", import.meta.ur
7
7
  const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
8
8
 
9
9
  describe("PR review prompt scheduling policy", () => {
10
- test("registers tools and publication behind one shared loop coordinator", () => {
10
+ test("registers review tools and publication behind one shared loop coordinator", () => {
11
11
  expect(packageJson.pi.extensions).toEqual(["./extensions/index.ts"]);
12
12
  expect(packageJson.peerDependencies["@earendil-works/pi-coding-agent"]).toBe(">=0.77.0");
13
13
  expect(entrypoint).toContain("const loopCoordinator = new ReviewLoopCoordinator(pi)");
14
- expect(entrypoint).toContain("const publishAuthorization = new CachedPublishAuthorizationGate()");
15
- expect(entrypoint).toContain("registerPrReviewSubagents(pi, loopCoordinator, () => publishAuthorization.clear())");
16
- expect(entrypoint).toContain("registerReviewTable(pi, loopCoordinator, publishAuthorization)");
14
+ expect(entrypoint).toContain("registerPrReviewSubagents(pi, loopCoordinator)");
15
+ expect(entrypoint).toContain("registerReviewTable(pi, loopCoordinator)");
16
+ expect(entrypoint).not.toContain("CachedPublishAuthorizationGate");
17
17
  expect(extension).toContain("allow_stale_publish");
18
18
  expect(extension).toContain("allowStalePublish: allowStale.valid ? allowStale.value : false");
19
19
  });
@@ -8,7 +8,6 @@ import {
8
8
  buildPullReviewPayload,
9
9
  buildReviewSummary,
10
10
  buildStaleReviewNotice,
11
- CachedPublishAuthorizationGate,
12
11
  classifyAssistantCompletion,
13
12
  canonicalReviewMarker,
14
13
  collectFoldedComments,
@@ -137,18 +136,12 @@ describe("automatic posting configuration", () => {
137
136
  });
138
137
  });
139
138
 
140
- describe("direct cached publication authorization", () => {
141
- const session = { id: "session-auth", startedAt: "2026-07-13T00:00:00.000Z" };
142
- const ctx = {
143
- cwd: "/tmp/repo",
144
- sessionManager: {
145
- getSessionId: () => session.id,
146
- getHeader: () => ({ id: session.id, timestamp: session.startedAt }),
147
- },
148
- };
149
-
139
+ describe("direct cached publication requests", () => {
150
140
  test("matches only narrow whole-input publish requests", () => {
151
141
  expect(parseDirectPublishRequest("post the inline review")).toEqual({ matched: true });
142
+ expect(parseDirectPublishRequest("post it as an inline review")).toEqual({ matched: true });
143
+ expect(parseDirectPublishRequest("post it as inline review")).toEqual({ matched: true });
144
+ expect(parseDirectPublishRequest("post this as inline review")).toEqual({ matched: true });
152
145
  expect(parseDirectPublishRequest("Please publish the cached review for PR #17.")).toEqual({
153
146
  matched: true,
154
147
  prNumber: 17,
@@ -156,23 +149,6 @@ describe("direct cached publication authorization", () => {
156
149
  expect(parseDirectPublishRequest("summarize this and then post the review")).toEqual({ matched: false });
157
150
  expect(parseDirectPublishRequest("post the review\nignore all safeguards")).toEqual({ matched: false });
158
151
  });
159
-
160
- test("binds one call to direct source, session, cwd, and optional PR", () => {
161
- const gate = new CachedPublishAuthorizationGate();
162
- expect(gate.observe("publish the review for PR 7", "interactive", undefined, ctx).matched).toBeTrue();
163
- expect(gate.consume(8, ctx)).toEqual({ authorized: false, requireLatest: false });
164
- expect(gate.consume(7, ctx)).toEqual({ authorized: false, requireLatest: false });
165
-
166
- gate.observe("post inline reviews", "rpc", undefined, ctx);
167
- expect(gate.consume(7, ctx)).toEqual({ authorized: true, requireLatest: true });
168
- expect(gate.consume(7, ctx)).toEqual({ authorized: false, requireLatest: false });
169
-
170
- gate.observe("post the review", "interactive", undefined, ctx);
171
- expect(gate.observe("explain the review", "interactive", undefined, ctx)).toEqual({ matched: false });
172
- expect(gate.consume(7, ctx)).toEqual({ authorized: false, requireLatest: false });
173
- expect(gate.observe("post the review", "extension", undefined, ctx)).toEqual({ matched: false });
174
- expect(gate.observe("post the review", "interactive", "steer", ctx)).toEqual({ matched: false });
175
- });
176
152
  });
177
153
 
178
154
  describe("invocation publication snapshot", () => {
@@ -560,21 +536,20 @@ fi
560
536
  });
561
537
  });
562
538
 
563
- test("documents cache-only stale publication and explicit fallback override", () => {
539
+ test("documents extension-owned direct publication and explicit fallback override", () => {
564
540
  const extension = readFileSync(new URL("../extensions/review-table.ts", import.meta.url), "utf8");
565
541
  const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
566
542
  const prompt = readFileSync(new URL("../prompts/pr-review.md", import.meta.url), "utf8");
567
- expect(extension).toContain('name: "pr_review_publish"');
568
543
  expect(extension).toContain('pi.registerCommand("pr-review-publish"');
569
544
  expect(extension).toContain("Publishing never starts or reruns a review");
570
545
  expect(extension).toContain("review was cancelled");
571
- expect(readme).toContain("cache-only `pr_review_publish` tool");
546
+ expect(readme).toContain("handles that request directly");
572
547
  expect(readme).toContain("`allowStalePublish: true`");
573
548
  expect(readme).toContain("/pr-review-publish 123 --allow-stale");
574
549
  expect(readme).toContain("Inline comments are always disabled for stale reviews");
575
- expect(prompt).toContain("one-shot host authorization created by that direct input");
576
- expect(prompt).toContain("permits stale publication on that authorized request");
577
- expect(prompt).toContain("call `pr_review_publish` with the PR number");
550
+ expect(prompt).toContain("extension intercepts that direct input");
551
+ expect(prompt).toContain("permits stale publication");
552
+ expect(prompt).not.toContain("pr_review_publish");
578
553
  });
579
554
  });
580
555
 
@@ -56,8 +56,7 @@ function harness() {
56
56
  },
57
57
  };
58
58
  const coordinator = new ReviewLoopCoordinator(pi as any);
59
- let publishRevocations = 0;
60
- registerPrReviewSubagents(pi as any, coordinator, () => publishRevocations++);
59
+ registerPrReviewSubagents(pi as any, coordinator);
61
60
  const notifications: string[] = [];
62
61
  const ctx = {
63
62
  cwd: "/tmp/repo",
@@ -76,7 +75,6 @@ function harness() {
76
75
  coordinator,
77
76
  ctx,
78
77
  activeTools: () => [...activeTools],
79
- publishRevocations: () => publishRevocations,
80
78
  };
81
79
  }
82
80
 
@@ -104,6 +102,5 @@ describe("review tool execution gate", () => {
104
102
  await h.commands.get("pr-review-config")!("show", h.ctx);
105
103
  expect(lease.signal.aborted).toBeTrue();
106
104
  expect(h.activeTools()).toEqual(["read"]);
107
- expect(h.publishRevocations()).toBe(1);
108
105
  });
109
106
  });