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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +14 -5
- package/extensions/index.ts +11 -0
- package/extensions/pr-review-subagent.ts +131 -15
- package/extensions/review-table.ts +175 -55
- package/lib/pr-review-loop.ts +206 -0
- package/lib/pr-review-policy.ts +12 -4
- package/lib/pr-review-publish.ts +86 -4
- package/package.json +3 -3
- package/prompts/pr-review.md +4 -4
- package/tests/pr-review-extension-lifecycle.test.ts +244 -4
- package/tests/pr-review-loop.test.ts +114 -0
- package/tests/pr-review-policy.test.ts +21 -2
- package/tests/pr-review-prompt.test.ts +13 -0
- package/tests/pr-review-publish.test.ts +76 -12
- package/tests/pr-review-subprocess.test.ts +6 -0
- package/tests/pr-review-tool-gate.test.ts +106 -0
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
}
|