pi-pr-review 1.6.6 → 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.6.6"
2
+ ".": "1.7.1"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [1.7.0](https://github.com/10ego/pi-pr-review/compare/v1.6.6...v1.7.0) (2026-07-13)
11
+
12
+
13
+ ### Features
14
+
15
+ * **review:** restrict tools to command loops ([#13](https://github.com/10ego/pi-pr-review/issues/13)) ([fbb579d](https://github.com/10ego/pi-pr-review/commit/fbb579d0ef9d550a9b21c125040cb8ba036564e1))
16
+
3
17
  ## [1.6.6](https://github.com/10ego/pi-pr-review/compare/v1.6.5...v1.6.6) (2026-07-13)
4
18
 
5
19
 
package/README.md CHANGED
@@ -14,6 +14,7 @@ The default review prioritizes P0–P2 defects and allows up to three minor find
14
14
 
15
15
  ## Requirements
16
16
 
17
+ - Pi `0.77.0` or newer.
17
18
  - [`gh`](https://cli.github.com/) installed and authenticated with `gh auth login`.
18
19
  - Pi running inside the repository that owns the PR.
19
20
 
@@ -87,12 +88,13 @@ Common settings:
87
88
  /pr-review-config heavy_tool_policy=configured
88
89
  /pr-review-config tools=read,bash,grep,find,ls
89
90
  /pr-review-config auto_post_reviews=true
91
+ /pr-review-config allow_stale_publish=false
90
92
  /pr-review-config medium=unset
91
93
  ```
92
94
 
93
95
  Supported thinking levels are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. A thinking suffix in a model spec, such as `provider/model:xhigh`, takes precedence over the tier's thinking setting. `unset` restores inherited behavior.
94
96
 
95
- Tool policy can be `none` or `configured`. `configured` uses the `tools` allowlist; because an allowlist containing `bash` is not technically read-only, remove it if you need stricter reviewer isolation.
97
+ Tool policy can be `none` or `configured`. `configured` uses the `tools` allowlist; because an allowlist containing `bash` is not technically read-only, remove it if you need stricter reviewer isolation. Reviewer subprocesses disable extension discovery, strip the package's review-tool names from this allowlist, and use `--no-tools` when no allowed tools remain.
96
98
 
97
99
  Configuration is stored in:
98
100
 
@@ -124,7 +126,8 @@ Example:
124
126
  "heavy": "configured"
125
127
  },
126
128
  "tools": ["read", "bash", "grep", "find", "ls"],
127
- "autoPostReviews": false
129
+ "autoPostReviews": false,
130
+ "allowStalePublish": true
128
131
  }
129
132
  ```
130
133
 
@@ -144,13 +147,15 @@ The extension—not the model—owns publishing. It creates one formal review wi
144
147
 
145
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.
146
149
 
147
- If a new commit makes a completed review stale, publish the cached result without rerunning the model:
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
+
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:
148
153
 
149
154
  ```text
150
155
  /pr-review-publish 123 --allow-stale
151
156
  ```
152
157
 
153
- Inline comments are intentionally disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and identifies both the reviewed and current SHAs. The cache is stored in the current Pi session, survives extension reloads and session resumes, and is bound to that session instance's ID and creation metadata as well as the repository.
158
+ Inline comments are always disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and displays a warning containing both the reviewed and current commit hashes. The cache is stored in the current Pi session, survives extension reloads and session resumes, and is bound to that session instance's ID and creation metadata as well as the repository.
154
159
 
155
160
  ## Optional verification
156
161
 
@@ -203,9 +208,13 @@ The verdict is `request_changes` only when a validated P0 or P1 finding exists.
203
208
 
204
209
  ## Safety and cost
205
210
 
211
+ - `review_subagent`, `review_subagents`, and `pr_review_verify` are exposed only during a direct interactive or RPC `/pr-review` loop. Extension-generated input cannot authorize them.
212
+ - Every review tool also checks an in-memory, session- and cwd-bound loop lease before reading review context, running verification, or spawning a reviewer. Hiding the tools is not the only enforcement boundary.
213
+ - Unrelated input, terminal completion, cancellation, session navigation, or tree navigation revokes the lease and aborts in-flight review work. Tools are suspended while a non-open PR waits for confirmation.
214
+ - Reviewer subprocesses start with extension discovery disabled, so they cannot recursively invoke this package's agents or verification tool.
206
215
  - Reviewers receive the captured diff and are instructed not to modify files.
207
216
  - The orchestrator does not check out, commit, or push PR code.
208
- - GitHub writes require `--comment` or an effective `autoPostReviews: true` setting.
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.
209
218
  - Publication authority is captured before review or optional verification begins, so PR code cannot enable it mid-run.
210
219
  - Multiple model calls run per PR. Use a cheaper `light` model and reserve stronger models for `heavy` passes to control cost.
211
220
  - Same-head review markers prevent duplicate publication by the same GitHub identity.
@@ -0,0 +1,11 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
+ import registerPrReviewSubagents from "./pr-review-subagent.ts";
4
+ import registerReviewTable from "./review-table.ts";
5
+
6
+ /** Register the package behind one shared, session-scoped review-loop authority. */
7
+ export default function registerPrReview(pi: ExtensionAPI) {
8
+ const loopCoordinator = new ReviewLoopCoordinator(pi);
9
+ registerPrReviewSubagents(pi, loopCoordinator);
10
+ registerReviewTable(pi, loopCoordinator);
11
+ }
@@ -50,6 +50,11 @@ import {
50
50
  import { Type } from "typebox";
51
51
  import { runWithConcurrency } from "../lib/pr-review-concurrency.ts";
52
52
  import { loadReviewContext, shardUnifiedDiff } from "../lib/pr-review-context.ts";
53
+ import {
54
+ combineAbortSignals,
55
+ ReviewLoopCoordinator,
56
+ reviewLoopDeniedResult,
57
+ } from "../lib/pr-review-loop.ts";
53
58
  import {
54
59
  appendToolPolicyArgs,
55
60
  buildReviewBaseArgs,
@@ -57,7 +62,10 @@ import {
57
62
  resolveToolPolicy,
58
63
  type ToolPolicy,
59
64
  } from "../lib/pr-review-policy.ts";
60
- import { resolveAutoPostSetting } from "../lib/pr-review-publish.ts";
65
+ import {
66
+ resolveAllowStalePublishSetting,
67
+ resolveAutoPostSetting,
68
+ } from "../lib/pr-review-publish.ts";
61
69
  import { monotonicNow } from "../lib/pr-review-telemetry.ts";
62
70
  import {
63
71
  discoverVerificationBaselines,
@@ -109,6 +117,8 @@ interface PrReviewConfig {
109
117
  toolPolicies: Partial<Record<Tier, ToolPolicy>>;
110
118
  /** Automatically publish final review JSON as a GitHub COMMENT review. Disabled by default. */
111
119
  autoPostReviews: boolean;
120
+ /** Permit stale publication as body-only with reviewed/current SHAs disclosed. Enabled by default. */
121
+ allowStalePublish: boolean;
112
122
  /** Trusted user-level named verification profiles. Project config never overlays these. */
113
123
  verificationBaselines: VerificationBaselines;
114
124
  /** Tools granted to review subagents whose effective policy is configured. */
@@ -185,6 +195,17 @@ function resolveAutoPostForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjec
185
195
  return resolveAutoPostSetting(user, project);
186
196
  }
187
197
 
198
+ function resolveAllowStaleForContext(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">) {
199
+ const user = readConfigFile(userConfigPath());
200
+ let project: Partial<PrReviewConfig> | undefined;
201
+ try {
202
+ if (ctx.isProjectTrusted()) project = readConfigFile(projectConfigPath(ctx.cwd));
203
+ } catch {
204
+ /* user config only */
205
+ }
206
+ return resolveAllowStalePublishSetting(user, project);
207
+ }
208
+
188
209
  /** User config overlaid by a trusted project, except user-only verification profiles. */
189
210
  function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
190
211
  const user = readConfigFile(userConfigPath());
@@ -195,6 +216,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
195
216
  /* trust check unavailable -> user config only */
196
217
  }
197
218
  const autoPost = resolveAutoPostSetting(user, project);
219
+ const allowStale = resolveAllowStalePublishSetting(user, project);
198
220
  return {
199
221
  tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
200
222
  fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
@@ -207,6 +229,7 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
207
229
  ...normalizeToolPolicies(project.toolPolicies),
208
230
  },
209
231
  autoPostReviews: autoPost.valid ? autoPost.value : false,
232
+ allowStalePublish: allowStale.valid ? allowStale.value : false,
210
233
  // Verification is intentionally user-owned. Never accept a profile from
211
234
  // the repository-controlled project overlay, even for trusted projects.
212
235
  verificationBaselines: resolveUserVerificationBaselines(user, project),
@@ -218,12 +241,14 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
218
241
  function readUserConfig(): PrReviewConfig {
219
242
  const raw = readConfigFile(userConfigPath());
220
243
  const autoPost = resolveAutoPostSetting(raw);
244
+ const allowStale = resolveAllowStalePublishSetting(raw);
221
245
  return {
222
246
  tiers: { ...(raw.tiers ?? {}) },
223
247
  fallbacks: normalizeFallbacks(raw.fallbacks),
224
248
  thinkingLevels: normalizeThinkingLevels(raw.thinkingLevels, "User pr-review config"),
225
249
  toolPolicies: normalizeToolPolicies(raw.toolPolicies),
226
250
  autoPostReviews: autoPost.valid ? autoPost.value : false,
251
+ allowStalePublish: allowStale.valid ? allowStale.value : false,
227
252
  verificationBaselines: resolveUserVerificationBaselines(raw),
228
253
  tools: raw.tools ?? [...DEFAULT_TOOLS],
229
254
  };
@@ -432,6 +457,8 @@ function runReviewSubprocess(
432
457
  const startedAt = monotonicNow();
433
458
  let buffer = "";
434
459
  let aborted = false;
460
+ let closed = false;
461
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
435
462
  let activeTools = 0;
436
463
  let activeToolsStartedAt = 0;
437
464
 
@@ -439,6 +466,20 @@ function runReviewSubprocess(
439
466
  // Keep the complete review task off argv: macOS rejects a single argument
440
467
  // near 1 MiB, while context_file intentionally supports up to 16 MiB.
441
468
  const proc = spawn(command, args, { cwd, shell: false, stdio: ["pipe", "pipe", "pipe"] });
469
+ const kill = () => {
470
+ if (closed || aborted) return;
471
+ aborted = true;
472
+ proc.kill("SIGTERM");
473
+ killTimer = setTimeout(() => {
474
+ // ChildProcess.killed only means a signal was sent; it does not mean
475
+ // the process exited. Escalate based on the observed close event.
476
+ if (!closed) proc.kill("SIGKILL");
477
+ }, 5000);
478
+ };
479
+ const cleanupAbort = () => {
480
+ if (killTimer) clearTimeout(killTimer);
481
+ signal?.removeEventListener("abort", kill);
482
+ };
442
483
 
443
484
  const processLine = (line: string) => {
444
485
  if (!line.trim()) return;
@@ -486,6 +527,8 @@ function runReviewSubprocess(
486
527
  proc.stdin.on("error", () => {});
487
528
  proc.stdin.end(input, "utf8");
488
529
  proc.on("close", (code) => {
530
+ closed = true;
531
+ cleanupAbort();
489
532
  if (buffer.trim()) processLine(buffer);
490
533
  if (activeTools > 0) {
491
534
  result.toolElapsedMs += monotonicNow() - activeToolsStartedAt;
@@ -497,6 +540,8 @@ function runReviewSubprocess(
497
540
  resolve(result);
498
541
  });
499
542
  proc.on("error", (err) => {
543
+ closed = true;
544
+ cleanupAbort();
500
545
  if (activeTools > 0) result.toolElapsedMs += monotonicNow() - activeToolsStartedAt;
501
546
  result.exitCode = 1;
502
547
  result.errorMessage = err.message;
@@ -504,13 +549,6 @@ function runReviewSubprocess(
504
549
  });
505
550
 
506
551
  if (signal) {
507
- const kill = () => {
508
- aborted = true;
509
- proc.kill("SIGTERM");
510
- setTimeout(() => {
511
- if (!proc.killed) proc.kill("SIGKILL");
512
- }, 5000);
513
- };
514
552
  if (signal.aborted) kill();
515
553
  else signal.addEventListener("abort", kill, { once: true });
516
554
  }
@@ -607,6 +645,7 @@ async function runSubagentAttempt(
607
645
  toolPolicy: ToolPolicy,
608
646
  signal: AbortSignal | undefined,
609
647
  onText?: (text: string) => void,
648
+ beforeSpawn?: () => boolean,
610
649
  ): Promise<{ result: RunResult; notice: string; elapsedMs: number }> {
611
650
  let tmp: { dir: string; filePath: string } | undefined;
612
651
  const startedAt = monotonicNow();
@@ -627,6 +666,9 @@ async function runSubagentAttempt(
627
666
  args.push("--append-system-prompt", tmp.filePath);
628
667
  const task = buildPassTask(pass.objective, pass.context);
629
668
 
669
+ if (beforeSpawn && !beforeSpawn()) {
670
+ throw new Error("The active /pr-review loop ended before the reviewer could start.");
671
+ }
630
672
  const invocation = getPiInvocation(args);
631
673
  const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, task, signal, (text) => {
632
674
  onText?.(text);
@@ -655,6 +697,7 @@ async function runSubagentPass(
655
697
  pass: SubagentPassRequest,
656
698
  signal: AbortSignal | undefined,
657
699
  onText?: (text: string) => void,
700
+ beforeSpawn?: () => boolean,
658
701
  ): Promise<SubagentPassResult> {
659
702
  const startedAt = monotonicNow();
660
703
  const tier = pass.tier;
@@ -673,6 +716,7 @@ async function runSubagentPass(
673
716
  toolPolicy,
674
717
  signal,
675
718
  onText,
719
+ beforeSpawn,
676
720
  );
677
721
  const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
678
722
  const retryable = failed && isRetryableModelFailure(result);
@@ -940,7 +984,10 @@ const ReviewSubagentsParams = Type.Object({
940
984
  ),
941
985
  });
942
986
 
943
- export default function (pi: ExtensionAPI) {
987
+ export default function registerPrReviewSubagents(
988
+ pi: ExtensionAPI,
989
+ loopCoordinator = new ReviewLoopCoordinator(pi),
990
+ ) {
944
991
  // Resolve security-sensitive executables only from the PATH trusted when this
945
992
  // extension starts, never from a later mutable process environment.
946
993
  const trustedStartupPath = process.env.PATH ?? "";
@@ -963,14 +1010,26 @@ export default function (pi: ExtensionAPI) {
963
1010
  parameters: PrReviewVerifyParams,
964
1011
 
965
1012
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1013
+ const lease = loopCoordinator.acquire(ctx);
1014
+ if (!lease) return reviewLoopDeniedResult("pr_review_verify");
1015
+ const executionSignal = combineAbortSignals(signal, lease.signal);
966
1016
  const config = loadConfig(ctx);
1017
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify");
967
1018
  if (params.action === "list") {
968
- const discovery = await discoverVerificationBaselines(ctx.cwd, config.verificationBaselines, signal, { startupPath: trustedStartupPath });
1019
+ const discovery = await discoverVerificationBaselines(ctx.cwd, config.verificationBaselines, executionSignal, { startupPath: trustedStartupPath });
969
1020
  return {
970
1021
  content: [{ type: "text", text: JSON.stringify(discovery, null, 2) }],
971
1022
  details: discovery,
972
1023
  };
973
1024
  }
1025
+ if (params.pr_number !== loopCoordinator.peek()?.prNumber) {
1026
+ return {
1027
+ content: [{ type: "text", text: "pr_review_verify PR number does not match the active /pr-review invocation." }],
1028
+ isError: true,
1029
+ details: { authorized: false, reason: "pr_mismatch" },
1030
+ };
1031
+ }
1032
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify");
974
1033
  const result = await verifyPullRequestHead(
975
1034
  ctx.cwd,
976
1035
  {
@@ -979,7 +1038,7 @@ export default function (pi: ExtensionAPI) {
979
1038
  baselineName: params.baseline_name,
980
1039
  },
981
1040
  config.verificationBaselines,
982
- signal,
1041
+ executionSignal,
983
1042
  { startupPath: trustedStartupPath },
984
1043
  );
985
1044
  return {
@@ -1008,6 +1067,9 @@ export default function (pi: ExtensionAPI) {
1008
1067
  parameters: ReviewSubagentParams,
1009
1068
 
1010
1069
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1070
+ const lease = loopCoordinator.acquire(ctx);
1071
+ if (!lease) return reviewLoopDeniedResult("review_subagent");
1072
+ const executionSignal = combineAbortSignals(signal, lease.signal);
1011
1073
  const tier = params.tier as Tier;
1012
1074
  let loadedContext;
1013
1075
  try {
@@ -1019,6 +1081,7 @@ export default function (pi: ExtensionAPI) {
1019
1081
  details: { tier, contextFileBytes: 0 },
1020
1082
  };
1021
1083
  }
1084
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagent");
1022
1085
  const config = loadConfig(ctx);
1023
1086
  const result = await runSubagentPass(
1024
1087
  config,
@@ -1031,8 +1094,9 @@ export default function (pi: ExtensionAPI) {
1031
1094
  majorOnly: params.major_only === true,
1032
1095
  minorHygiene: params.minor_hygiene === true,
1033
1096
  },
1034
- signal,
1097
+ executionSignal,
1035
1098
  (text) => onUpdate?.({ content: [{ type: "text", text }] }),
1099
+ () => loopCoordinator.isLeaseActive(lease, ctx),
1036
1100
  );
1037
1101
 
1038
1102
  const warnings = thinkingWarnings(config, [tier]);
@@ -1092,6 +1156,9 @@ export default function (pi: ExtensionAPI) {
1092
1156
  parameters: ReviewSubagentsParams,
1093
1157
 
1094
1158
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1159
+ const lease = loopCoordinator.acquire(ctx);
1160
+ if (!lease) return reviewLoopDeniedResult("review_subagents");
1161
+ const executionSignal = combineAbortSignals(signal, lease.signal);
1095
1162
  const rawPasses = Array.isArray(params.passes) ? params.passes : [];
1096
1163
  if (rawPasses.length === 0) {
1097
1164
  return {
@@ -1172,6 +1239,7 @@ export default function (pi: ExtensionAPI) {
1172
1239
  : [makePass(undefined, 0)];
1173
1240
  });
1174
1241
  const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
1242
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagents");
1175
1243
  const config = loadConfig(ctx);
1176
1244
  const batchStartedAt = monotonicNow();
1177
1245
 
@@ -1181,7 +1249,14 @@ export default function (pi: ExtensionAPI) {
1181
1249
  .sort((a, b) => tierPriority[a.pass.tier] - tierPriority[b.pass.tier] || a.originalIndex - b.originalIndex);
1182
1250
  const dispatchResults = await runWithConcurrency(dispatchPasses, maxParallel, async ({ pass, originalIndex }) => {
1183
1251
  const startOffsetMs = monotonicNow() - batchStartedAt;
1184
- const result = await runSubagentPass(config, ctx, pass, signal);
1252
+ const result = await runSubagentPass(
1253
+ config,
1254
+ ctx,
1255
+ pass,
1256
+ executionSignal,
1257
+ undefined,
1258
+ () => loopCoordinator.isLeaseActive(lease, ctx),
1259
+ );
1185
1260
  const endOffsetMs = monotonicNow() - batchStartedAt;
1186
1261
  onUpdate?.({
1187
1262
  content: [
@@ -1251,6 +1326,8 @@ export default function (pi: ExtensionAPI) {
1251
1326
  pi.registerCommand("pr-review-config", {
1252
1327
  description: "Open review-tier settings, or show/set models, thinking, and fallbacks for /pr-review",
1253
1328
  handler: async (args, ctx) => {
1329
+ // Extension commands execute before input events, so revoke explicitly.
1330
+ loopCoordinator.clear();
1254
1331
  const raw = (args ?? "").trim();
1255
1332
  const parsed = parseConfigArgs(raw);
1256
1333
  if (parsed.errors.length) {
@@ -1333,6 +1410,7 @@ const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
1333
1410
  label: `${t}_tool_policy=<none|configured|unset> — default tool access when a pass does not override it`,
1334
1411
  })),
1335
1412
  { value: "auto_post_reviews=", label: "auto_post_reviews=<true|false> — automatically post COMMENT reviews (default false)" },
1413
+ { value: "allow_stale_publish=", label: "allow_stale_publish=<true|false> — permit disclosed body-only stale publication (default true)" },
1336
1414
  { value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" },
1337
1415
  { value: "show", label: "show — print the current review config" },
1338
1416
  ];
@@ -1411,6 +1489,7 @@ interface ConfigPatch {
1411
1489
  thinkingLevels: Partial<Record<Tier, ThinkingLevel | null>>;
1412
1490
  toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
1413
1491
  autoPostReviews?: boolean;
1492
+ allowStalePublish?: boolean;
1414
1493
  tools?: string[];
1415
1494
  }
1416
1495
 
@@ -1451,11 +1530,15 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1451
1530
  if (value === "true") patch.autoPostReviews = true;
1452
1531
  else if (value === "false") patch.autoPostReviews = false;
1453
1532
  else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1533
+ } else if (key === "allow_stale_publish" || key === "allowstalepublish" || key === "allow-stale-publish") {
1534
+ if (value === "true") patch.allowStalePublish = true;
1535
+ else if (value === "false") patch.allowStalePublish = false;
1536
+ else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1454
1537
  } else if (key === "tools") {
1455
1538
  patch.tools = splitCommaList(value);
1456
1539
  } else {
1457
1540
  errors.push(
1458
- `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|tools)`,
1541
+ `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|tools)`,
1459
1542
  );
1460
1543
  }
1461
1544
  }
@@ -1465,6 +1548,7 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1465
1548
  Object.keys(patch.thinkingLevels).length > 0 ||
1466
1549
  Object.keys(patch.toolPolicies).length > 0 ||
1467
1550
  patch.autoPostReviews !== undefined ||
1551
+ patch.allowStalePublish !== undefined ||
1468
1552
  patch.tools !== undefined;
1469
1553
  return { patch, hasChanges, errors };
1470
1554
  }
@@ -1476,6 +1560,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1476
1560
  thinkingLevels: normalizeThinkingLevels(base.thinkingLevels, "PR review config"),
1477
1561
  toolPolicies: normalizeToolPolicies(base.toolPolicies),
1478
1562
  autoPostReviews: base.autoPostReviews,
1563
+ allowStalePublish: base.allowStalePublish,
1479
1564
  verificationBaselines: { ...base.verificationBaselines },
1480
1565
  tools: [...base.tools],
1481
1566
  };
@@ -1502,6 +1587,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1502
1587
  }
1503
1588
  }
1504
1589
  if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews;
1590
+ if (patch.allowStalePublish !== undefined) next.allowStalePublish = patch.allowStalePublish;
1505
1591
  if (patch.tools) next.tools = patch.tools;
1506
1592
  return next;
1507
1593
  }
@@ -1526,6 +1612,7 @@ function summarizeConfig(
1526
1612
  /* ignore */
1527
1613
  }
1528
1614
  const autoPost = resolveAutoPostForContext(ctx);
1615
+ const allowStale = resolveAllowStaleForContext(ctx);
1529
1616
  const lines = [
1530
1617
  `# PR review config${changed ? " updated" : ""}`,
1531
1618
  "",
@@ -1536,6 +1623,7 @@ function summarizeConfig(
1536
1623
  `| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
1537
1624
  ),
1538
1625
  `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub \`COMMENT\` review; default \`false\` |`,
1626
+ `| \`allowStalePublish\` | \`${user.allowStalePublish}\` | \`${effective.allowStalePublish}\` (${allowStale.source}) | permit body-only stale publication with reviewed/current SHAs; default \`true\` |`,
1539
1627
  `| \`verificationBaselines\` | \`${Object.keys(user.verificationBaselines).length} configured\` | user scope only | strict named argv profiles; project overlays ignored |`,
1540
1628
  `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
1541
1629
  "",
@@ -1555,6 +1643,10 @@ function summarizeConfig(
1555
1643
  else if (autoPost.source === "project") {
1556
1644
  lines.push("Automatic posting is controlled by the trusted project overlay; this command edits user config only.");
1557
1645
  }
1646
+ if (!allowStale.valid) lines.push(`Stale publication config error: ${allowStale.error}`);
1647
+ else if (allowStale.source === "project") {
1648
+ lines.push("Stale publication is controlled by the trusted project overlay; this command edits user config only.");
1649
+ }
1558
1650
  lines.push(
1559
1651
  "",
1560
1652
  "## Usage",
@@ -1565,6 +1657,8 @@ function summarizeConfig(
1565
1657
  "- Set tier thinking: `/pr-review-config light_thinking=low medium_thinking=medium heavy_thinking=high`",
1566
1658
  "- Enable automatic GitHub review posting: `/pr-review-config auto_post_reviews=true`",
1567
1659
  "- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`",
1660
+ "- Disable stale publication: `/pr-review-config allow_stale_publish=false`",
1661
+ "- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`",
1568
1662
  "- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
1569
1663
  "- Clear a tier: `/pr-review-config medium=unset`",
1570
1664
  "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
@@ -1694,6 +1788,13 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
1694
1788
  currentValue: String(cfg.autoPostReviews),
1695
1789
  values: ["false", "true"],
1696
1790
  },
1791
+ {
1792
+ id: "allow_stale_publish",
1793
+ label: "user stale publication setting",
1794
+ description: "Permit body-only stale reviews with reviewed/current commit disclosure. Enabled by default.",
1795
+ currentValue: String(cfg.allowStalePublish),
1796
+ values: ["true", "false"],
1797
+ },
1697
1798
  {
1698
1799
  id: "tools",
1699
1800
  label: "configured tool allowlist",
@@ -1727,6 +1828,7 @@ async function showConfigMenu(
1727
1828
  settingsList.updateValue(`${tier}_tool_policy`, draft.toolPolicies[tier] ?? INHERIT_TOOL_POLICY);
1728
1829
  }
1729
1830
  settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews));
1831
+ settingsList.updateValue("allow_stale_publish", String(draft.allowStalePublish));
1730
1832
  settingsList.updateValue("tools", draft.tools.join(","));
1731
1833
  };
1732
1834
 
@@ -1758,6 +1860,8 @@ async function showConfigMenu(
1758
1860
  }
1759
1861
  } else if (id === "auto_post_reviews") {
1760
1862
  draft.autoPostReviews = newValue === "true";
1863
+ } else if (id === "allow_stale_publish") {
1864
+ draft.allowStalePublish = newValue === "true";
1761
1865
  } else if (id === "tools") {
1762
1866
  draft.tools = splitCommaList(newValue);
1763
1867
  } else {
@@ -1770,7 +1874,9 @@ async function showConfigMenu(
1770
1874
  ? draft.tools.join(",")
1771
1875
  : id === "auto_post_reviews"
1772
1876
  ? String(draft.autoPostReviews)
1773
- : isFallbackKey(id)
1877
+ : id === "allow_stale_publish"
1878
+ ? String(draft.allowStalePublish)
1879
+ : isFallbackKey(id)
1774
1880
  ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
1775
1881
  : isThinkingKey(id)
1776
1882
  ? (draft.thinkingLevels[tierFromCompoundKey(id)] ?? INHERIT_THINKING)
@@ -1787,6 +1893,16 @@ async function showConfigMenu(
1787
1893
  } else {
1788
1894
  ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
1789
1895
  }
1896
+ } else if (id === "allow_stale_publish") {
1897
+ const effective = resolveAllowStaleForContext(ctx);
1898
+ if (effective.source === "project") {
1899
+ ctx.ui.notify(
1900
+ `User allowStalePublish saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
1901
+ "warning",
1902
+ );
1903
+ } else {
1904
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
1905
+ }
1790
1906
  } else {
1791
1907
  ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
1792
1908
  }