pi-pr-review 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.6.5"
2
+ ".": "1.7.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0](https://github.com/10ego/pi-pr-review/compare/v1.6.6...v1.7.0) (2026-07-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [1.6.6](https://github.com/10ego/pi-pr-review/compare/v1.6.5...v1.6.6) (2026-07-13)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **publish:** persist completed review cache ([#10](https://github.com/10ego/pi-pr-review/issues/10)) ([e5a1f33](https://github.com/10ego/pi-pr-review/commit/e5a1f333ce6a4bbf624d889a0954bdd251e7d902))
16
+
3
17
  ## [1.6.5](https://github.com/10ego/pi-pr-review/compare/v1.6.4...v1.6.5) (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.” 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.
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 lasts for the current Pi session.
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, 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.
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,13 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { ReviewLoopCoordinator } from "../lib/pr-review-loop.ts";
3
+ import { CachedPublishAuthorizationGate } from "../lib/pr-review-publish.ts";
4
+ import registerPrReviewSubagents from "./pr-review-subagent.ts";
5
+ import registerReviewTable from "./review-table.ts";
6
+
7
+ /** Register the package behind one shared, session-scoped review-loop authority. */
8
+ export default function registerPrReview(pi: ExtensionAPI) {
9
+ const loopCoordinator = new ReviewLoopCoordinator(pi);
10
+ const publishAuthorization = new CachedPublishAuthorizationGate();
11
+ registerPrReviewSubagents(pi, loopCoordinator, () => publishAuthorization.clear());
12
+ registerReviewTable(pi, loopCoordinator, publishAuthorization);
13
+ }
@@ -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,11 @@ 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
+ revokePublishAuthorization: () => void = () => {},
991
+ ) {
944
992
  // Resolve security-sensitive executables only from the PATH trusted when this
945
993
  // extension starts, never from a later mutable process environment.
946
994
  const trustedStartupPath = process.env.PATH ?? "";
@@ -963,14 +1011,26 @@ export default function (pi: ExtensionAPI) {
963
1011
  parameters: PrReviewVerifyParams,
964
1012
 
965
1013
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
1014
+ const lease = loopCoordinator.acquire(ctx);
1015
+ if (!lease) return reviewLoopDeniedResult("pr_review_verify");
1016
+ const executionSignal = combineAbortSignals(signal, lease.signal);
966
1017
  const config = loadConfig(ctx);
1018
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify");
967
1019
  if (params.action === "list") {
968
- const discovery = await discoverVerificationBaselines(ctx.cwd, config.verificationBaselines, signal, { startupPath: trustedStartupPath });
1020
+ const discovery = await discoverVerificationBaselines(ctx.cwd, config.verificationBaselines, executionSignal, { startupPath: trustedStartupPath });
969
1021
  return {
970
1022
  content: [{ type: "text", text: JSON.stringify(discovery, null, 2) }],
971
1023
  details: discovery,
972
1024
  };
973
1025
  }
1026
+ if (params.pr_number !== loopCoordinator.peek()?.prNumber) {
1027
+ return {
1028
+ content: [{ type: "text", text: "pr_review_verify PR number does not match the active /pr-review invocation." }],
1029
+ isError: true,
1030
+ details: { authorized: false, reason: "pr_mismatch" },
1031
+ };
1032
+ }
1033
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("pr_review_verify");
974
1034
  const result = await verifyPullRequestHead(
975
1035
  ctx.cwd,
976
1036
  {
@@ -979,7 +1039,7 @@ export default function (pi: ExtensionAPI) {
979
1039
  baselineName: params.baseline_name,
980
1040
  },
981
1041
  config.verificationBaselines,
982
- signal,
1042
+ executionSignal,
983
1043
  { startupPath: trustedStartupPath },
984
1044
  );
985
1045
  return {
@@ -1008,6 +1068,9 @@ export default function (pi: ExtensionAPI) {
1008
1068
  parameters: ReviewSubagentParams,
1009
1069
 
1010
1070
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1071
+ const lease = loopCoordinator.acquire(ctx);
1072
+ if (!lease) return reviewLoopDeniedResult("review_subagent");
1073
+ const executionSignal = combineAbortSignals(signal, lease.signal);
1011
1074
  const tier = params.tier as Tier;
1012
1075
  let loadedContext;
1013
1076
  try {
@@ -1019,6 +1082,7 @@ export default function (pi: ExtensionAPI) {
1019
1082
  details: { tier, contextFileBytes: 0 },
1020
1083
  };
1021
1084
  }
1085
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagent");
1022
1086
  const config = loadConfig(ctx);
1023
1087
  const result = await runSubagentPass(
1024
1088
  config,
@@ -1031,8 +1095,9 @@ export default function (pi: ExtensionAPI) {
1031
1095
  majorOnly: params.major_only === true,
1032
1096
  minorHygiene: params.minor_hygiene === true,
1033
1097
  },
1034
- signal,
1098
+ executionSignal,
1035
1099
  (text) => onUpdate?.({ content: [{ type: "text", text }] }),
1100
+ () => loopCoordinator.isLeaseActive(lease, ctx),
1036
1101
  );
1037
1102
 
1038
1103
  const warnings = thinkingWarnings(config, [tier]);
@@ -1092,6 +1157,9 @@ export default function (pi: ExtensionAPI) {
1092
1157
  parameters: ReviewSubagentsParams,
1093
1158
 
1094
1159
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1160
+ const lease = loopCoordinator.acquire(ctx);
1161
+ if (!lease) return reviewLoopDeniedResult("review_subagents");
1162
+ const executionSignal = combineAbortSignals(signal, lease.signal);
1095
1163
  const rawPasses = Array.isArray(params.passes) ? params.passes : [];
1096
1164
  if (rawPasses.length === 0) {
1097
1165
  return {
@@ -1172,6 +1240,7 @@ export default function (pi: ExtensionAPI) {
1172
1240
  : [makePass(undefined, 0)];
1173
1241
  });
1174
1242
  const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
1243
+ if (!loopCoordinator.isLeaseActive(lease, ctx)) return reviewLoopDeniedResult("review_subagents");
1175
1244
  const config = loadConfig(ctx);
1176
1245
  const batchStartedAt = monotonicNow();
1177
1246
 
@@ -1181,7 +1250,14 @@ export default function (pi: ExtensionAPI) {
1181
1250
  .sort((a, b) => tierPriority[a.pass.tier] - tierPriority[b.pass.tier] || a.originalIndex - b.originalIndex);
1182
1251
  const dispatchResults = await runWithConcurrency(dispatchPasses, maxParallel, async ({ pass, originalIndex }) => {
1183
1252
  const startOffsetMs = monotonicNow() - batchStartedAt;
1184
- const result = await runSubagentPass(config, ctx, pass, signal);
1253
+ const result = await runSubagentPass(
1254
+ config,
1255
+ ctx,
1256
+ pass,
1257
+ executionSignal,
1258
+ undefined,
1259
+ () => loopCoordinator.isLeaseActive(lease, ctx),
1260
+ );
1185
1261
  const endOffsetMs = monotonicNow() - batchStartedAt;
1186
1262
  onUpdate?.({
1187
1263
  content: [
@@ -1251,6 +1327,9 @@ export default function (pi: ExtensionAPI) {
1251
1327
  pi.registerCommand("pr-review-config", {
1252
1328
  description: "Open review-tier settings, or show/set models, thinking, and fallbacks for /pr-review",
1253
1329
  handler: async (args, ctx) => {
1330
+ // Extension commands execute before input events, so revoke explicitly.
1331
+ loopCoordinator.clear();
1332
+ revokePublishAuthorization();
1254
1333
  const raw = (args ?? "").trim();
1255
1334
  const parsed = parseConfigArgs(raw);
1256
1335
  if (parsed.errors.length) {
@@ -1333,6 +1412,7 @@ const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
1333
1412
  label: `${t}_tool_policy=<none|configured|unset> — default tool access when a pass does not override it`,
1334
1413
  })),
1335
1414
  { value: "auto_post_reviews=", label: "auto_post_reviews=<true|false> — automatically post COMMENT reviews (default false)" },
1415
+ { value: "allow_stale_publish=", label: "allow_stale_publish=<true|false> — permit disclosed body-only stale publication (default true)" },
1336
1416
  { value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" },
1337
1417
  { value: "show", label: "show — print the current review config" },
1338
1418
  ];
@@ -1411,6 +1491,7 @@ interface ConfigPatch {
1411
1491
  thinkingLevels: Partial<Record<Tier, ThinkingLevel | null>>;
1412
1492
  toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
1413
1493
  autoPostReviews?: boolean;
1494
+ allowStalePublish?: boolean;
1414
1495
  tools?: string[];
1415
1496
  }
1416
1497
 
@@ -1451,11 +1532,15 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1451
1532
  if (value === "true") patch.autoPostReviews = true;
1452
1533
  else if (value === "false") patch.autoPostReviews = false;
1453
1534
  else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1535
+ } else if (key === "allow_stale_publish" || key === "allowstalepublish" || key === "allow-stale-publish") {
1536
+ if (value === "true") patch.allowStalePublish = true;
1537
+ else if (value === "false") patch.allowStalePublish = false;
1538
+ else errors.push(`invalid ${key} "${value}" (expected true|false)`);
1454
1539
  } else if (key === "tools") {
1455
1540
  patch.tools = splitCommaList(value);
1456
1541
  } else {
1457
1542
  errors.push(
1458
- `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|tools)`,
1543
+ `unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_thinking|<tier>_tool_policy|auto_post_reviews|allow_stale_publish|tools)`,
1459
1544
  );
1460
1545
  }
1461
1546
  }
@@ -1465,6 +1550,7 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
1465
1550
  Object.keys(patch.thinkingLevels).length > 0 ||
1466
1551
  Object.keys(patch.toolPolicies).length > 0 ||
1467
1552
  patch.autoPostReviews !== undefined ||
1553
+ patch.allowStalePublish !== undefined ||
1468
1554
  patch.tools !== undefined;
1469
1555
  return { patch, hasChanges, errors };
1470
1556
  }
@@ -1476,6 +1562,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1476
1562
  thinkingLevels: normalizeThinkingLevels(base.thinkingLevels, "PR review config"),
1477
1563
  toolPolicies: normalizeToolPolicies(base.toolPolicies),
1478
1564
  autoPostReviews: base.autoPostReviews,
1565
+ allowStalePublish: base.allowStalePublish,
1479
1566
  verificationBaselines: { ...base.verificationBaselines },
1480
1567
  tools: [...base.tools],
1481
1568
  };
@@ -1502,6 +1589,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
1502
1589
  }
1503
1590
  }
1504
1591
  if (patch.autoPostReviews !== undefined) next.autoPostReviews = patch.autoPostReviews;
1592
+ if (patch.allowStalePublish !== undefined) next.allowStalePublish = patch.allowStalePublish;
1505
1593
  if (patch.tools) next.tools = patch.tools;
1506
1594
  return next;
1507
1595
  }
@@ -1526,6 +1614,7 @@ function summarizeConfig(
1526
1614
  /* ignore */
1527
1615
  }
1528
1616
  const autoPost = resolveAutoPostForContext(ctx);
1617
+ const allowStale = resolveAllowStaleForContext(ctx);
1529
1618
  const lines = [
1530
1619
  `# PR review config${changed ? " updated" : ""}`,
1531
1620
  "",
@@ -1536,6 +1625,7 @@ function summarizeConfig(
1536
1625
  `| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
1537
1626
  ),
1538
1627
  `| \`autoPostReviews\` | \`${user.autoPostReviews}\` | \`${effective.autoPostReviews}\` (${autoPost.source}) | automatically post one GitHub \`COMMENT\` review; default \`false\` |`,
1628
+ `| \`allowStalePublish\` | \`${user.allowStalePublish}\` | \`${effective.allowStalePublish}\` (${allowStale.source}) | permit body-only stale publication with reviewed/current SHAs; default \`true\` |`,
1539
1629
  `| \`verificationBaselines\` | \`${Object.keys(user.verificationBaselines).length} configured\` | user scope only | strict named argv profiles; project overlays ignored |`,
1540
1630
  `| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
1541
1631
  "",
@@ -1555,6 +1645,10 @@ function summarizeConfig(
1555
1645
  else if (autoPost.source === "project") {
1556
1646
  lines.push("Automatic posting is controlled by the trusted project overlay; this command edits user config only.");
1557
1647
  }
1648
+ if (!allowStale.valid) lines.push(`Stale publication config error: ${allowStale.error}`);
1649
+ else if (allowStale.source === "project") {
1650
+ lines.push("Stale publication is controlled by the trusted project overlay; this command edits user config only.");
1651
+ }
1558
1652
  lines.push(
1559
1653
  "",
1560
1654
  "## Usage",
@@ -1565,6 +1659,8 @@ function summarizeConfig(
1565
1659
  "- Set tier thinking: `/pr-review-config light_thinking=low medium_thinking=medium heavy_thinking=high`",
1566
1660
  "- Enable automatic GitHub review posting: `/pr-review-config auto_post_reviews=true`",
1567
1661
  "- Disable automatic GitHub review posting: `/pr-review-config auto_post_reviews=false`",
1662
+ "- Disable stale publication: `/pr-review-config allow_stale_publish=false`",
1663
+ "- Enable stale publication (default): `/pr-review-config allow_stale_publish=true`",
1568
1664
  "- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
1569
1665
  "- Clear a tier: `/pr-review-config medium=unset`",
1570
1666
  "- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
@@ -1694,6 +1790,13 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
1694
1790
  currentValue: String(cfg.autoPostReviews),
1695
1791
  values: ["false", "true"],
1696
1792
  },
1793
+ {
1794
+ id: "allow_stale_publish",
1795
+ label: "user stale publication setting",
1796
+ description: "Permit body-only stale reviews with reviewed/current commit disclosure. Enabled by default.",
1797
+ currentValue: String(cfg.allowStalePublish),
1798
+ values: ["true", "false"],
1799
+ },
1697
1800
  {
1698
1801
  id: "tools",
1699
1802
  label: "configured tool allowlist",
@@ -1727,6 +1830,7 @@ async function showConfigMenu(
1727
1830
  settingsList.updateValue(`${tier}_tool_policy`, draft.toolPolicies[tier] ?? INHERIT_TOOL_POLICY);
1728
1831
  }
1729
1832
  settingsList.updateValue("auto_post_reviews", String(draft.autoPostReviews));
1833
+ settingsList.updateValue("allow_stale_publish", String(draft.allowStalePublish));
1730
1834
  settingsList.updateValue("tools", draft.tools.join(","));
1731
1835
  };
1732
1836
 
@@ -1758,6 +1862,8 @@ async function showConfigMenu(
1758
1862
  }
1759
1863
  } else if (id === "auto_post_reviews") {
1760
1864
  draft.autoPostReviews = newValue === "true";
1865
+ } else if (id === "allow_stale_publish") {
1866
+ draft.allowStalePublish = newValue === "true";
1761
1867
  } else if (id === "tools") {
1762
1868
  draft.tools = splitCommaList(newValue);
1763
1869
  } else {
@@ -1770,7 +1876,9 @@ async function showConfigMenu(
1770
1876
  ? draft.tools.join(",")
1771
1877
  : id === "auto_post_reviews"
1772
1878
  ? String(draft.autoPostReviews)
1773
- : isFallbackKey(id)
1879
+ : id === "allow_stale_publish"
1880
+ ? String(draft.allowStalePublish)
1881
+ : isFallbackKey(id)
1774
1882
  ? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
1775
1883
  : isThinkingKey(id)
1776
1884
  ? (draft.thinkingLevels[tierFromCompoundKey(id)] ?? INHERIT_THINKING)
@@ -1787,6 +1895,16 @@ async function showConfigMenu(
1787
1895
  } else {
1788
1896
  ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
1789
1897
  }
1898
+ } else if (id === "allow_stale_publish") {
1899
+ const effective = resolveAllowStaleForContext(ctx);
1900
+ if (effective.source === "project") {
1901
+ ctx.ui.notify(
1902
+ `User allowStalePublish saved as ${shown}, but trusted project config remains effective at ${effective.value}. Edit ${projectConfigPath(ctx.cwd)}.`,
1903
+ "warning",
1904
+ );
1905
+ } else {
1906
+ ctx.ui.notify(`PR review config: ${id} = ${shown} (effective ${effective.value})`, "info");
1907
+ }
1790
1908
  } else {
1791
1909
  ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
1792
1910
  }