pi-pr-review 1.6.6 → 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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +14 -5
- package/extensions/index.ts +13 -0
- package/extensions/pr-review-subagent.ts +133 -15
- package/extensions/review-table.ts +231 -54
- package/lib/pr-review-loop.ts +206 -0
- package/lib/pr-review-policy.ts +12 -4
- package/lib/pr-review-publish.ts +156 -4
- package/package.json +3 -3
- package/prompts/pr-review.md +4 -4
- package/tests/pr-review-extension-lifecycle.test.ts +217 -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 +101 -12
- package/tests/pr-review-subprocess.test.ts +6 -0
- package/tests/pr-review-tool-gate.test.ts +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## [1.6.6](https://github.com/10ego/pi-pr-review/compare/v1.6.5...v1.6.6) (2026-07-13)
|
|
4
11
|
|
|
5
12
|
|
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.” 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
|
|
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, 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 {
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
:
|
|
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
|
}
|