pi-pr-review 1.2.0 → 1.3.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/README.md +20 -7
- package/extensions/pr-review-subagent.ts +169 -24
- package/lib/pr-review-policy.ts +32 -0
- package/package.json +4 -1
- package/prompts/pr-review.md +15 -13
- package/tests/pr-review-policy.test.ts +72 -0
package/README.md
CHANGED
|
@@ -65,15 +65,17 @@ The `/pr-review-config` command maps three labels to models:
|
|
|
65
65
|
/pr-review-config show # print the current mapping
|
|
66
66
|
/pr-review-config light=<spec> medium=<spec> heavy=<spec> # set primary tier models
|
|
67
67
|
/pr-review-config heavy_fallbacks=<spec>,<spec> # retry chain for quota/rate-limit failures
|
|
68
|
+
/pr-review-config light_tool_policy=none # tier default when a pass omits tool_policy
|
|
68
69
|
/pr-review-config medium=unset # clear a tier (back to pi default)
|
|
69
70
|
/pr-review-config heavy_fallbacks=unset # clear a fallback chain
|
|
70
|
-
/pr-review-config
|
|
71
|
+
/pr-review-config light_tool_policy=unset # restore legacy configured-tool behavior
|
|
72
|
+
/pr-review-config tools=read,bash,grep,find,ls # allowlist used by configured policy
|
|
71
73
|
```
|
|
72
74
|
|
|
73
75
|
Running `/pr-review-config` with no arguments in the TUI opens an interactive settings menu that mirrors pi's `/settings` and the NERVous `/nervous:config`:
|
|
74
76
|
|
|
75
|
-
- One primary-model
|
|
76
|
-
- Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space
|
|
77
|
+
- One primary-model, fallback-model, and tool-policy row per tier (`light` / `medium` / `heavy`) plus a configured-tool allowlist row.
|
|
78
|
+
- Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space cycles tool policies and allowlist presets. The menu sets one fallback model at a time; use the `key=value` form for longer fallback chains.
|
|
77
79
|
- Selections apply and persist **immediately**; Esc closes the menu.
|
|
78
80
|
- Type to search, and tab-completion is available for the `key=value` form.
|
|
79
81
|
|
|
@@ -98,12 +100,19 @@ Example `pr-review.json`:
|
|
|
98
100
|
"medium": ["<backup-balanced-model>"],
|
|
99
101
|
"heavy": ["<backup-strong-model:high>", "<balanced-model-spec>"]
|
|
100
102
|
},
|
|
103
|
+
"toolPolicies": {
|
|
104
|
+
"light": "none",
|
|
105
|
+
"medium": "configured",
|
|
106
|
+
"heavy": "configured"
|
|
107
|
+
},
|
|
101
108
|
"tools": ["read", "bash", "grep", "find", "ls"]
|
|
102
109
|
}
|
|
103
110
|
```
|
|
104
111
|
|
|
105
112
|
Each tier runs in an **isolated `pi` subprocess** on its configured model. The `review_subagents` batch tool runs independent passes concurrently (default `max_parallel: 4`, capped at 6) and returns ordered per-pass results; the older single-pass `review_subagent` tool remains available as a compatibility fallback. If a tier model fails with a retryable quota/rate-limit/capacity error, the subprocess retries that tier's configured `fallbacks` in order. Non-quota failures do not blindly cycle through fallbacks. If a tier is unset, that subagent falls back to the nearest configured tier, then to your pi default model.
|
|
106
113
|
|
|
114
|
+
Tool policy is additive and backward compatible: `none` emits Pi's explicit `--no-tools`; `configured` uses the existing `tools` allowlist. A tool call's optional `tool_policy` overrides `toolPolicies[tier]`, which in turn falls back to legacy `configured` behavior. The shipped `/pr-review` prompt explicitly uses `none` only for overview because its complete evidence is supplied in context. Conventions/maintainability and both heavy specialist passes use `configured` repository-context tools so they can inspect surrounding files when needed. Fallback model attempts keep the original pass policy.
|
|
115
|
+
|
|
107
116
|
### 2. The orchestrator / inline-fallback model
|
|
108
117
|
|
|
109
118
|
The orchestrator (which fetches the PR, merges findings, and emits the JSON) and the inline fallback path both run on your pi session model:
|
|
@@ -181,15 +190,19 @@ Severity tags: `[P0]` blocking/drop-everything · `[P1]` blocking/urgent · `[P2
|
|
|
181
190
|
pi-pr-review/
|
|
182
191
|
├─ package.json # pi manifest: prompts + extensions
|
|
183
192
|
├─ prompts/pr-review.md # the /pr-review orchestrator prompt
|
|
193
|
+
├─ lib/pr-review-policy.ts # pure tool-policy resolution/argv helpers
|
|
184
194
|
├─ extensions/pr-review-subagent.ts # review_subagents/review_subagent tools + /pr-review-config command
|
|
185
|
-
|
|
195
|
+
├─ extensions/review-table.ts # renders the final JSON as a table (TUI only)
|
|
196
|
+
└─ tests/pr-review-policy.test.ts # focused policy compatibility tests
|
|
186
197
|
```
|
|
187
198
|
|
|
188
|
-
##
|
|
199
|
+
## Speed, security & cost notes
|
|
189
200
|
|
|
190
|
-
- The
|
|
201
|
+
- The four independent review lenses remain intact. Only overview runs context-only with `--no-tools`; medium and both heavy specialists retain configured tools for surrounding-file validation. All subprocesses use `--no-context-files` because the orchestrator supplies the base review context explicitly, and convention excerpts are sent only to the medium pass instead of every model.
|
|
202
|
+
- Tool results include effective `toolPolicy` and `elapsedMs` telemetry (plus per-attempt timing) so repeated representative runs can be compared at p50/p95 without guessing. Restore tools for a custom pass by sending `tool_policy: "configured"`; callers that omit policy retain legacy behavior unless `toolPolicies` config says otherwise.
|
|
203
|
+
- The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Reviewer prompts prohibit modifications, but a configured allowlist containing `bash` is not technically read-only; use a narrower allowlist if stronger enforcement is required.
|
|
191
204
|
- Project-local `pr-review.json` is only read when the project is trusted.
|
|
192
|
-
- Tiered review calls multiple models per PR,
|
|
205
|
+
- Tiered review calls multiple models per PR, concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
|
|
193
206
|
|
|
194
207
|
## Design notes
|
|
195
208
|
|
|
@@ -46,6 +46,13 @@ import {
|
|
|
46
46
|
Text,
|
|
47
47
|
} from "@earendil-works/pi-tui";
|
|
48
48
|
import { Type } from "typebox";
|
|
49
|
+
import {
|
|
50
|
+
appendToolPolicyArgs,
|
|
51
|
+
buildReviewBaseArgs,
|
|
52
|
+
normalizeToolPolicy,
|
|
53
|
+
resolveToolPolicy,
|
|
54
|
+
type ToolPolicy,
|
|
55
|
+
} from "../lib/pr-review-policy.ts";
|
|
49
56
|
|
|
50
57
|
// ---------------------------------------------------------------------------
|
|
51
58
|
// Config
|
|
@@ -55,6 +62,8 @@ type Tier = "light" | "medium" | "heavy";
|
|
|
55
62
|
const TIERS: Tier[] = ["light", "medium", "heavy"];
|
|
56
63
|
|
|
57
64
|
const UNSET = "(unset — pi default)";
|
|
65
|
+
const INHERIT_TOOL_POLICY = "(inherit — configured tools)";
|
|
66
|
+
const TOOL_POLICIES: ToolPolicy[] = ["configured", "none"];
|
|
58
67
|
const TOOLS_PRESETS = ["read,bash,grep,find,ls", "read,grep,find,ls", "read"];
|
|
59
68
|
const DEFAULT_BATCH_PARALLEL = 4;
|
|
60
69
|
const MAX_BATCH_PARALLEL = 6;
|
|
@@ -69,7 +78,9 @@ interface PrReviewConfig {
|
|
|
69
78
|
tiers: Partial<Record<Tier, string>>;
|
|
70
79
|
/** Tier label -> ordered fallback model specs used for quota/capacity failures. */
|
|
71
80
|
fallbacks: Partial<Record<Tier, string[]>>;
|
|
72
|
-
/**
|
|
81
|
+
/** Optional tier-level policy used when a tool call does not override it. */
|
|
82
|
+
toolPolicies: Partial<Record<Tier, ToolPolicy>>;
|
|
83
|
+
/** Tools granted to review subagents whose effective policy is configured. */
|
|
73
84
|
tools: string[];
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -113,6 +124,18 @@ function normalizeFallbacks(
|
|
|
113
124
|
return out;
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
function normalizeToolPolicies(
|
|
128
|
+
raw: Partial<Record<Tier, unknown>> | undefined,
|
|
129
|
+
): Partial<Record<Tier, ToolPolicy>> {
|
|
130
|
+
const out: Partial<Record<Tier, ToolPolicy>> = {};
|
|
131
|
+
if (!raw || typeof raw !== "object") return out;
|
|
132
|
+
for (const tier of TIERS) {
|
|
133
|
+
const policy = normalizeToolPolicy(raw[tier]);
|
|
134
|
+
if (policy) out[tier] = policy;
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
116
139
|
/** User config, overlaid by project config when the project is trusted. */
|
|
117
140
|
function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): PrReviewConfig {
|
|
118
141
|
const user = readConfigFile(userConfigPath());
|
|
@@ -125,6 +148,10 @@ function loadConfig(ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">): Pr
|
|
|
125
148
|
return {
|
|
126
149
|
tiers: { ...(user.tiers ?? {}), ...(project.tiers ?? {}) },
|
|
127
150
|
fallbacks: { ...normalizeFallbacks(user.fallbacks, true), ...normalizeFallbacks(project.fallbacks, true) },
|
|
151
|
+
toolPolicies: {
|
|
152
|
+
...normalizeToolPolicies(user.toolPolicies),
|
|
153
|
+
...normalizeToolPolicies(project.toolPolicies),
|
|
154
|
+
},
|
|
128
155
|
tools: project.tools ?? user.tools ?? DEFAULT_TOOLS,
|
|
129
156
|
};
|
|
130
157
|
}
|
|
@@ -135,6 +162,7 @@ function readUserConfig(): PrReviewConfig {
|
|
|
135
162
|
return {
|
|
136
163
|
tiers: { ...(raw.tiers ?? {}) },
|
|
137
164
|
fallbacks: normalizeFallbacks(raw.fallbacks),
|
|
165
|
+
toolPolicies: normalizeToolPolicies(raw.toolPolicies),
|
|
138
166
|
tools: raw.tools ?? [...DEFAULT_TOOLS],
|
|
139
167
|
};
|
|
140
168
|
}
|
|
@@ -353,6 +381,7 @@ interface SubagentPassRequest {
|
|
|
353
381
|
tier: Tier;
|
|
354
382
|
objective: string;
|
|
355
383
|
context?: string;
|
|
384
|
+
toolPolicy?: ToolPolicy;
|
|
356
385
|
}
|
|
357
386
|
|
|
358
387
|
interface ModelAttemptReport {
|
|
@@ -363,6 +392,7 @@ interface ModelAttemptReport {
|
|
|
363
392
|
exitCode: number;
|
|
364
393
|
status: "completed" | "failed";
|
|
365
394
|
retryable: boolean;
|
|
395
|
+
elapsedMs: number;
|
|
366
396
|
stopReason?: string;
|
|
367
397
|
errorMessage?: string;
|
|
368
398
|
}
|
|
@@ -382,6 +412,8 @@ interface SubagentPassResult {
|
|
|
382
412
|
attempts: ModelAttemptReport[];
|
|
383
413
|
fallbackUsed: boolean;
|
|
384
414
|
retryableFailure: boolean;
|
|
415
|
+
toolPolicy: ToolPolicy;
|
|
416
|
+
elapsedMs: number;
|
|
385
417
|
}
|
|
386
418
|
|
|
387
419
|
function noticeForAttempt(tier: Tier, attempt: ModelAttempt): string {
|
|
@@ -426,14 +458,16 @@ async function runSubagentAttempt(
|
|
|
426
458
|
ctx: Pick<ExtensionContext, "cwd">,
|
|
427
459
|
pass: SubagentPassRequest,
|
|
428
460
|
attempt: ModelAttempt,
|
|
461
|
+
toolPolicy: ToolPolicy,
|
|
429
462
|
signal: AbortSignal | undefined,
|
|
430
463
|
onText?: (text: string) => void,
|
|
431
|
-
): Promise<{ result: RunResult; notice: string }> {
|
|
464
|
+
): Promise<{ result: RunResult; notice: string; elapsedMs: number }> {
|
|
432
465
|
let tmp: { dir: string; filePath: string } | undefined;
|
|
466
|
+
const startedAt = Date.now();
|
|
433
467
|
try {
|
|
434
|
-
const args =
|
|
468
|
+
const args = buildReviewBaseArgs();
|
|
435
469
|
if (attempt.spec) args.push("--model", attempt.spec);
|
|
436
|
-
|
|
470
|
+
appendToolPolicyArgs(args, toolPolicy, config.tools);
|
|
437
471
|
|
|
438
472
|
tmp = await writeTempPrompt(pass.tier, buildSubagentSystemPrompt(pass.tier));
|
|
439
473
|
args.push("--append-system-prompt", tmp.filePath);
|
|
@@ -443,11 +477,12 @@ async function runSubagentAttempt(
|
|
|
443
477
|
const result = await runReviewSubprocess(invocation.command, invocation.args, ctx.cwd, signal, (text) => {
|
|
444
478
|
onText?.(text);
|
|
445
479
|
});
|
|
446
|
-
return { result, notice: noticeForAttempt(pass.tier, attempt) };
|
|
480
|
+
return { result, notice: noticeForAttempt(pass.tier, attempt), elapsedMs: Date.now() - startedAt };
|
|
447
481
|
} catch (e) {
|
|
448
482
|
return {
|
|
449
483
|
result: { text: "", exitCode: 1, stderr: "", errorMessage: errMessage(e) },
|
|
450
484
|
notice: noticeForAttempt(pass.tier, attempt),
|
|
485
|
+
elapsedMs: Date.now() - startedAt,
|
|
451
486
|
};
|
|
452
487
|
} finally {
|
|
453
488
|
if (tmp) {
|
|
@@ -467,14 +502,24 @@ async function runSubagentPass(
|
|
|
467
502
|
signal: AbortSignal | undefined,
|
|
468
503
|
onText?: (text: string) => void,
|
|
469
504
|
): Promise<SubagentPassResult> {
|
|
505
|
+
const startedAt = Date.now();
|
|
470
506
|
const tier = pass.tier;
|
|
507
|
+
const toolPolicy = resolveToolPolicy(pass.toolPolicy, config.toolPolicies[tier]);
|
|
471
508
|
const attempts = resolveModelAttempts(config, tier);
|
|
472
509
|
const reports: ModelAttemptReport[] = [];
|
|
473
510
|
let lastResult: RunResult | undefined;
|
|
474
511
|
let lastNotice = noticeForAttempt(tier, attempts[0]!);
|
|
475
512
|
|
|
476
513
|
for (const attempt of attempts) {
|
|
477
|
-
const { result, notice } = await runSubagentAttempt(
|
|
514
|
+
const { result, notice, elapsedMs } = await runSubagentAttempt(
|
|
515
|
+
config,
|
|
516
|
+
ctx,
|
|
517
|
+
pass,
|
|
518
|
+
attempt,
|
|
519
|
+
toolPolicy,
|
|
520
|
+
signal,
|
|
521
|
+
onText,
|
|
522
|
+
);
|
|
478
523
|
const failed = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
479
524
|
const retryable = failed && isRetryableModelFailure(result);
|
|
480
525
|
lastResult = result;
|
|
@@ -487,6 +532,7 @@ async function runSubagentPass(
|
|
|
487
532
|
exitCode: result.exitCode,
|
|
488
533
|
status: failed ? "failed" : "completed",
|
|
489
534
|
retryable,
|
|
535
|
+
elapsedMs,
|
|
490
536
|
stopReason: result.stopReason,
|
|
491
537
|
errorMessage: result.errorMessage,
|
|
492
538
|
});
|
|
@@ -507,6 +553,8 @@ async function runSubagentPass(
|
|
|
507
553
|
attempts: reports,
|
|
508
554
|
fallbackUsed: attempt.kind === "fallback" || reports.length > 1,
|
|
509
555
|
retryableFailure: false,
|
|
556
|
+
toolPolicy,
|
|
557
|
+
elapsedMs: Date.now() - startedAt,
|
|
510
558
|
};
|
|
511
559
|
}
|
|
512
560
|
|
|
@@ -529,6 +577,8 @@ async function runSubagentPass(
|
|
|
529
577
|
attempts: reports,
|
|
530
578
|
fallbackUsed: reports.length > 1,
|
|
531
579
|
retryableFailure: reports.at(-1)?.retryable ?? false,
|
|
580
|
+
toolPolicy,
|
|
581
|
+
elapsedMs: Date.now() - startedAt,
|
|
532
582
|
};
|
|
533
583
|
}
|
|
534
584
|
|
|
@@ -573,7 +623,14 @@ function formatBatchResults(results: SubagentPassResult[], maxParallel: number):
|
|
|
573
623
|
);
|
|
574
624
|
}
|
|
575
625
|
for (const result of results) {
|
|
576
|
-
lines.push(
|
|
626
|
+
lines.push(
|
|
627
|
+
"",
|
|
628
|
+
`## Pass: ${result.id}`,
|
|
629
|
+
`status: ${result.status}`,
|
|
630
|
+
`tool_policy: ${result.toolPolicy}`,
|
|
631
|
+
`elapsed_ms: ${result.elapsedMs}`,
|
|
632
|
+
result.notice,
|
|
633
|
+
);
|
|
577
634
|
const attemptSummary = formatAttemptSummary(result);
|
|
578
635
|
if (attemptSummary) lines.push(attemptSummary);
|
|
579
636
|
if (result.status === "failed") {
|
|
@@ -611,13 +668,19 @@ const ReviewSubagentParams = Type.Object({
|
|
|
611
668
|
"Shared context for the subagent, typically the PR title/description plus the unified diff to review.",
|
|
612
669
|
}),
|
|
613
670
|
),
|
|
671
|
+
tool_policy: Type.Optional(
|
|
672
|
+
StringEnum(["none", "configured"] as const, {
|
|
673
|
+
description:
|
|
674
|
+
"Tool access for this pass. none emits --no-tools; configured uses the configured allowlist. Request override wins over tier config; omission preserves configured legacy behavior unless tier policy is set.",
|
|
675
|
+
}),
|
|
676
|
+
),
|
|
614
677
|
});
|
|
615
678
|
|
|
616
679
|
const ReviewSubagentsParams = Type.Object({
|
|
617
680
|
context: Type.Optional(
|
|
618
681
|
Type.String({
|
|
619
682
|
description:
|
|
620
|
-
"Shared PR context for every pass, typically PR title/body/metadata,
|
|
683
|
+
"Shared PR context for every pass, typically PR title/body/metadata, cross-cutting requirements, and the unified diff. Put specialist-only convention excerpts in the medium pass context.",
|
|
621
684
|
}),
|
|
622
685
|
),
|
|
623
686
|
max_parallel: Type.Optional(
|
|
@@ -644,6 +707,12 @@ const ReviewSubagentsParams = Type.Object({
|
|
|
644
707
|
description: "Optional pass-specific context appended after the shared context.",
|
|
645
708
|
}),
|
|
646
709
|
),
|
|
710
|
+
tool_policy: Type.Optional(
|
|
711
|
+
StringEnum(["none", "configured"] as const, {
|
|
712
|
+
description:
|
|
713
|
+
"Tool access for this pass. none emits --no-tools; configured uses the configured allowlist. The resolved policy remains fixed across fallback model attempts.",
|
|
714
|
+
}),
|
|
715
|
+
),
|
|
647
716
|
}),
|
|
648
717
|
{
|
|
649
718
|
description: "Independent review passes to run concurrently. Results are returned in this same order.",
|
|
@@ -674,7 +743,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
674
743
|
const result = await runSubagentPass(
|
|
675
744
|
loadConfig(ctx),
|
|
676
745
|
ctx,
|
|
677
|
-
{
|
|
746
|
+
{
|
|
747
|
+
tier,
|
|
748
|
+
objective: params.objective,
|
|
749
|
+
context: params.context,
|
|
750
|
+
toolPolicy: normalizeToolPolicy(params.tool_policy),
|
|
751
|
+
},
|
|
678
752
|
signal,
|
|
679
753
|
(text) => onUpdate?.({ content: [{ type: "text", text }] }),
|
|
680
754
|
);
|
|
@@ -691,6 +765,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
691
765
|
exitCode: result.exitCode,
|
|
692
766
|
fallbackUsed: result.fallbackUsed,
|
|
693
767
|
retryableFailure: result.retryableFailure,
|
|
768
|
+
toolPolicy: result.toolPolicy,
|
|
769
|
+
elapsedMs: result.elapsedMs,
|
|
694
770
|
attempts: result.attempts,
|
|
695
771
|
},
|
|
696
772
|
};
|
|
@@ -704,6 +780,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
704
780
|
model: result.model,
|
|
705
781
|
exitCode: result.exitCode,
|
|
706
782
|
fallbackUsed: result.fallbackUsed,
|
|
783
|
+
toolPolicy: result.toolPolicy,
|
|
784
|
+
elapsedMs: result.elapsedMs,
|
|
707
785
|
attempts: result.attempts,
|
|
708
786
|
},
|
|
709
787
|
};
|
|
@@ -746,6 +824,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
746
824
|
tier,
|
|
747
825
|
objective: pass.objective,
|
|
748
826
|
context: combineContexts(sharedContext, typeof pass.context === "string" ? pass.context : undefined),
|
|
827
|
+
toolPolicy: normalizeToolPolicy(pass.tool_policy),
|
|
749
828
|
};
|
|
750
829
|
});
|
|
751
830
|
const maxParallel = normalizeMaxParallel(params.max_parallel, passes.length);
|
|
@@ -783,6 +862,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
783
862
|
errorMessage: r.errorMessage,
|
|
784
863
|
fallbackUsed: r.fallbackUsed,
|
|
785
864
|
retryableFailure: r.retryableFailure,
|
|
865
|
+
toolPolicy: r.toolPolicy,
|
|
866
|
+
elapsedMs: r.elapsedMs,
|
|
786
867
|
attempts: r.attempts,
|
|
787
868
|
outputChars: r.text.length,
|
|
788
869
|
})),
|
|
@@ -867,7 +948,11 @@ const CONFIG_COMPLETIONS: Array<{ value: string; label: string }> = [
|
|
|
867
948
|
value: `${t}_fallbacks=`,
|
|
868
949
|
label: `${t}_fallbacks=<model1,model2> — retry chain for quota/rate-limit failures`,
|
|
869
950
|
})),
|
|
870
|
-
|
|
951
|
+
...TIERS.map((t) => ({
|
|
952
|
+
value: `${t}_tool_policy=`,
|
|
953
|
+
label: `${t}_tool_policy=<none|configured|unset> — default tool access when a pass does not override it`,
|
|
954
|
+
})),
|
|
955
|
+
{ value: "tools=", label: "tools=read,bash,grep,find,ls — allowlist used by configured policy" },
|
|
871
956
|
{ value: "show", label: "show — print the current tier config" },
|
|
872
957
|
];
|
|
873
958
|
|
|
@@ -913,14 +998,32 @@ function isFallbackKey(key: string): boolean {
|
|
|
913
998
|
return false;
|
|
914
999
|
}
|
|
915
1000
|
|
|
1001
|
+
function isToolPolicyKey(key: string): boolean {
|
|
1002
|
+
for (const tier of TIERS) {
|
|
1003
|
+
if (
|
|
1004
|
+
key === `${tier}_tool_policy` ||
|
|
1005
|
+
key === `${tier}-tool-policy` ||
|
|
1006
|
+
key === `${tier}.toolpolicy`
|
|
1007
|
+
) {
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function tierFromCompoundKey(key: string): Tier {
|
|
1015
|
+
return key.split(/[_\-.]/)[0] as Tier;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
916
1018
|
interface ConfigPatch {
|
|
917
1019
|
tiers: Partial<Record<Tier, string | null>>;
|
|
918
1020
|
fallbacks: Partial<Record<Tier, string[] | null>>;
|
|
1021
|
+
toolPolicies: Partial<Record<Tier, ToolPolicy | null>>;
|
|
919
1022
|
tools?: string[];
|
|
920
1023
|
}
|
|
921
1024
|
|
|
922
1025
|
function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolean; errors: string[] } {
|
|
923
|
-
const patch: ConfigPatch = { tiers: {}, fallbacks: {} };
|
|
1026
|
+
const patch: ConfigPatch = { tiers: {}, fallbacks: {}, toolPolicies: {} };
|
|
924
1027
|
const errors: string[] = [];
|
|
925
1028
|
const tokens = splitConfigArgs(args).filter((t) => !["show", "get", "current"].includes(t.toLowerCase()));
|
|
926
1029
|
for (const token of tokens) {
|
|
@@ -934,16 +1037,29 @@ function parseConfigArgs(args: string): { patch: ConfigPatch; hasChanges: boolea
|
|
|
934
1037
|
if ((TIERS as string[]).includes(key)) {
|
|
935
1038
|
patch.tiers[key as Tier] = value === "" || value === "unset" ? null : value;
|
|
936
1039
|
} else if (isFallbackKey(key)) {
|
|
937
|
-
const tier = key
|
|
1040
|
+
const tier = tierFromCompoundKey(key);
|
|
938
1041
|
patch.fallbacks[tier] = value === "" || value === "unset" || value === "none" ? null : splitCommaList(value);
|
|
1042
|
+
} else if (isToolPolicyKey(key)) {
|
|
1043
|
+
const tier = tierFromCompoundKey(key);
|
|
1044
|
+
if (value === "" || value === "unset" || value === "inherit") patch.toolPolicies[tier] = null;
|
|
1045
|
+
else {
|
|
1046
|
+
const policy = normalizeToolPolicy(value);
|
|
1047
|
+
if (policy) patch.toolPolicies[tier] = policy;
|
|
1048
|
+
else errors.push(`invalid ${key} "${value}" (expected none|configured|unset)`);
|
|
1049
|
+
}
|
|
939
1050
|
} else if (key === "tools") {
|
|
940
1051
|
patch.tools = splitCommaList(value);
|
|
941
1052
|
} else {
|
|
942
|
-
errors.push(
|
|
1053
|
+
errors.push(
|
|
1054
|
+
`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|<tier>_tool_policy|tools)`,
|
|
1055
|
+
);
|
|
943
1056
|
}
|
|
944
1057
|
}
|
|
945
1058
|
const hasChanges =
|
|
946
|
-
Object.keys(patch.tiers).length > 0 ||
|
|
1059
|
+
Object.keys(patch.tiers).length > 0 ||
|
|
1060
|
+
Object.keys(patch.fallbacks).length > 0 ||
|
|
1061
|
+
Object.keys(patch.toolPolicies).length > 0 ||
|
|
1062
|
+
patch.tools !== undefined;
|
|
947
1063
|
return { patch, hasChanges, errors };
|
|
948
1064
|
}
|
|
949
1065
|
|
|
@@ -951,6 +1067,7 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
|
|
|
951
1067
|
const next: PrReviewConfig = {
|
|
952
1068
|
tiers: { ...base.tiers },
|
|
953
1069
|
fallbacks: normalizeFallbacks(base.fallbacks),
|
|
1070
|
+
toolPolicies: normalizeToolPolicies(base.toolPolicies),
|
|
954
1071
|
tools: [...base.tools],
|
|
955
1072
|
};
|
|
956
1073
|
for (const tier of TIERS) {
|
|
@@ -964,6 +1081,11 @@ function applyConfigPatch(base: PrReviewConfig, patch: ConfigPatch): PrReviewCon
|
|
|
964
1081
|
if (value === null || value === undefined || value.length === 0) delete next.fallbacks[tier];
|
|
965
1082
|
else next.fallbacks[tier] = [...new Set(value)];
|
|
966
1083
|
}
|
|
1084
|
+
if (tier in patch.toolPolicies) {
|
|
1085
|
+
const value = patch.toolPolicies[tier];
|
|
1086
|
+
if (value === null || value === undefined) delete next.toolPolicies[tier];
|
|
1087
|
+
else next.toolPolicies[tier] = value;
|
|
1088
|
+
}
|
|
967
1089
|
}
|
|
968
1090
|
if (patch.tools) next.tools = patch.tools;
|
|
969
1091
|
return next;
|
|
@@ -997,11 +1119,14 @@ function summarizeConfig(
|
|
|
997
1119
|
(t) =>
|
|
998
1120
|
`| \`${t}\` | ${user.tiers[t] ? `\`${user.tiers[t]}\`` : "_unset_"} | ${effective.tiers[t] ? `\`${effective.tiers[t]}\`` : "_pi default_"} | ${TIER_PURPOSE[t]} |`,
|
|
999
1121
|
),
|
|
1000
|
-
`| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` |
|
|
1122
|
+
`| \`tools\` | \`${user.tools.join(",")}\` | \`${effective.tools.join(",")}\` | allowlist used when policy is \`configured\` |`,
|
|
1001
1123
|
"",
|
|
1002
|
-
"| Tier | Your fallbacks | Effective fallbacks |",
|
|
1003
|
-
"
|
|
1004
|
-
...TIERS.map(
|
|
1124
|
+
"| Tier | Your fallbacks | Effective fallbacks | Tool policy |",
|
|
1125
|
+
"|---|---|---|---|",
|
|
1126
|
+
...TIERS.map(
|
|
1127
|
+
(t) =>
|
|
1128
|
+
`| \`${t}\` | ${formatModelList(user.fallbacks[t])} | ${formatModelList(effective.fallbacks[t])} | ${user.toolPolicies[t] ? `\`${user.toolPolicies[t]}\`` : "_inherit configured_"} → \`${effective.toolPolicies[t] ?? "configured"}\` |`,
|
|
1129
|
+
),
|
|
1005
1130
|
"",
|
|
1006
1131
|
`User config: \`${userConfigPath()}\``,
|
|
1007
1132
|
];
|
|
@@ -1013,8 +1138,10 @@ function summarizeConfig(
|
|
|
1013
1138
|
"- Print this summary: `/pr-review-config show`",
|
|
1014
1139
|
"- Set directly: `/pr-review-config light=provider/model heavy=provider/model:high`",
|
|
1015
1140
|
"- Set fallback chain: `/pr-review-config heavy_fallbacks=provider/backup:high,provider/backup2`",
|
|
1141
|
+
"- Set tier tool policy: `/pr-review-config light_tool_policy=none`",
|
|
1016
1142
|
"- Clear a tier: `/pr-review-config medium=unset`",
|
|
1017
1143
|
"- Clear fallback chain: `/pr-review-config heavy_fallbacks=unset`",
|
|
1144
|
+
"- Restore legacy tier tool behavior: `/pr-review-config light_tool_policy=unset`",
|
|
1018
1145
|
"- A `<model>` is any pi model pattern (`provider/model` or `provider/model:thinking`).",
|
|
1019
1146
|
);
|
|
1020
1147
|
return lines.join("\n");
|
|
@@ -1110,15 +1237,23 @@ function configMenuItems(cfg: PrReviewConfig, available: string[]): SettingItem[
|
|
|
1110
1237
|
"Clear this tier's fallback chain. Use key=value form to set multiple fallbacks.",
|
|
1111
1238
|
),
|
|
1112
1239
|
}));
|
|
1240
|
+
const policyItems: SettingItem[] = TIERS.map((tier) => ({
|
|
1241
|
+
id: `${tier}_tool_policy`,
|
|
1242
|
+
label: `${tier} tool policy`,
|
|
1243
|
+
description: "Default when a pass does not explicitly set tool_policy. Enter/Space cycles values.",
|
|
1244
|
+
currentValue: cfg.toolPolicies[tier] ?? INHERIT_TOOL_POLICY,
|
|
1245
|
+
values: [INHERIT_TOOL_POLICY, ...TOOL_POLICIES],
|
|
1246
|
+
}));
|
|
1113
1247
|
const current = cfg.tools.join(",");
|
|
1114
1248
|
const toolValues = [current, ...TOOLS_PRESETS.filter((p) => p !== current)];
|
|
1115
1249
|
return [
|
|
1116
1250
|
...tierItems,
|
|
1117
1251
|
...fallbackItems,
|
|
1252
|
+
...policyItems,
|
|
1118
1253
|
{
|
|
1119
1254
|
id: "tools",
|
|
1120
|
-
label: "
|
|
1121
|
-
description: "Tools
|
|
1255
|
+
label: "configured tool allowlist",
|
|
1256
|
+
description: "Tools available when effective policy is configured. Enter/Space cycles presets.",
|
|
1122
1257
|
currentValue: current,
|
|
1123
1258
|
values: toolValues,
|
|
1124
1259
|
},
|
|
@@ -1144,6 +1279,7 @@ async function showConfigMenu(
|
|
|
1144
1279
|
for (const tier of TIERS) {
|
|
1145
1280
|
settingsList.updateValue(tier, draft.tiers[tier] ?? UNSET);
|
|
1146
1281
|
settingsList.updateValue(`${tier}_fallbacks`, draft.fallbacks[tier]?.join(",") || "(none)");
|
|
1282
|
+
settingsList.updateValue(`${tier}_tool_policy`, draft.toolPolicies[tier] ?? INHERIT_TOOL_POLICY);
|
|
1147
1283
|
}
|
|
1148
1284
|
settingsList.updateValue("tools", draft.tools.join(","));
|
|
1149
1285
|
};
|
|
@@ -1153,9 +1289,16 @@ async function showConfigMenu(
|
|
|
1153
1289
|
if (newValue === "__unset__") delete draft.tiers[id as Tier];
|
|
1154
1290
|
else draft.tiers[id as Tier] = newValue;
|
|
1155
1291
|
} else if (isFallbackKey(id)) {
|
|
1156
|
-
const tier = id
|
|
1292
|
+
const tier = tierFromCompoundKey(id);
|
|
1157
1293
|
if (newValue === "__unset__") delete draft.fallbacks[tier];
|
|
1158
1294
|
else draft.fallbacks[tier] = [newValue];
|
|
1295
|
+
} else if (isToolPolicyKey(id)) {
|
|
1296
|
+
const tier = tierFromCompoundKey(id);
|
|
1297
|
+
if (newValue === INHERIT_TOOL_POLICY) delete draft.toolPolicies[tier];
|
|
1298
|
+
else {
|
|
1299
|
+
const policy = normalizeToolPolicy(newValue);
|
|
1300
|
+
if (policy) draft.toolPolicies[tier] = policy;
|
|
1301
|
+
}
|
|
1159
1302
|
} else if (id === "tools") {
|
|
1160
1303
|
draft.tools = splitCommaList(newValue);
|
|
1161
1304
|
} else {
|
|
@@ -1167,8 +1310,10 @@ async function showConfigMenu(
|
|
|
1167
1310
|
const shown = id === "tools"
|
|
1168
1311
|
? draft.tools.join(",")
|
|
1169
1312
|
: isFallbackKey(id)
|
|
1170
|
-
? (draft.fallbacks[id
|
|
1171
|
-
: (
|
|
1313
|
+
? (draft.fallbacks[tierFromCompoundKey(id)]?.join(",") ?? "(none)")
|
|
1314
|
+
: isToolPolicyKey(id)
|
|
1315
|
+
? (draft.toolPolicies[tierFromCompoundKey(id)] ?? INHERIT_TOOL_POLICY)
|
|
1316
|
+
: (draft.tiers[id as Tier] ?? UNSET);
|
|
1172
1317
|
ctx.ui.notify(`PR review config: ${id} = ${shown}`, "info");
|
|
1173
1318
|
} catch (e) {
|
|
1174
1319
|
ctx.ui.notify(`pr-review-config failed: ${errMessage(e)}`, "error");
|
|
@@ -1178,7 +1323,7 @@ async function showConfigMenu(
|
|
|
1178
1323
|
|
|
1179
1324
|
settingsList = new SettingsList(
|
|
1180
1325
|
configMenuItems(draft, available),
|
|
1181
|
-
|
|
1326
|
+
12,
|
|
1182
1327
|
getSettingsListTheme(),
|
|
1183
1328
|
(id, newValue) => persist(id, newValue),
|
|
1184
1329
|
() => done(undefined),
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ToolPolicy = "none" | "configured";
|
|
2
|
+
|
|
3
|
+
/** Isolated review subprocesses receive all review context explicitly. */
|
|
4
|
+
export function buildReviewBaseArgs(): string[] {
|
|
5
|
+
return ["--mode", "json", "-p", "--no-session", "--no-context-files"];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeToolPolicy(value: unknown): ToolPolicy | undefined {
|
|
9
|
+
return value === "none" || value === "configured" ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Request override wins, then explicit tier config, then legacy configured behavior. */
|
|
13
|
+
export function resolveToolPolicy(
|
|
14
|
+
requested: ToolPolicy | undefined,
|
|
15
|
+
configured: ToolPolicy | undefined,
|
|
16
|
+
): ToolPolicy {
|
|
17
|
+
return requested ?? configured ?? "configured";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preserve legacy configured behavior; only `none` explicitly disables every tool. */
|
|
21
|
+
export function appendToolPolicyArgs(
|
|
22
|
+
args: string[],
|
|
23
|
+
policy: ToolPolicy,
|
|
24
|
+
configuredTools: string[],
|
|
25
|
+
): string[] {
|
|
26
|
+
if (policy === "none") {
|
|
27
|
+
args.push("--no-tools");
|
|
28
|
+
} else if (configuredTools.length > 0) {
|
|
29
|
+
args.push("--tools", configuredTools.join(","));
|
|
30
|
+
}
|
|
31
|
+
return args;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Model-agnostic GitHub PR code review prompt for pi. Pass a PR number; it derives the base and head branches from the PR in the current repo, reviews the diff, and returns structured JSON findings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git+https://github.com/10ego/pi-pr-review.git"
|
|
16
16
|
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "bun test"
|
|
19
|
+
},
|
|
17
20
|
"peerDependencies": {
|
|
18
21
|
"@earendil-works/pi-ai": "*",
|
|
19
22
|
"@earendil-works/pi-coding-agent": "*",
|
package/prompts/pr-review.md
CHANGED
|
@@ -20,14 +20,16 @@ You are the **orchestrator**. You own all GitHub access, skip decisions, convent
|
|
|
20
20
|
|
|
21
21
|
If the `review_subagents` batch tool is available, prefer it over multiple single-pass calls. Fetch PR metadata and the unified diff once, gather any relevant convention-file excerpts, then call `review_subagents` with shared `context`, `max_parallel`, and ordered `passes`. This guarantees bounded parallel fan-out instead of depending on whether the tool interface runs separate calls concurrently. Use these pass assignments:
|
|
22
22
|
|
|
23
|
-
| Pass id | Tier label | Scope |
|
|
24
|
-
|
|
25
|
-
| `overview` | `light` | Step 3 overview, strengths, and high-level risk areas |
|
|
26
|
-
| `conventions-maintainability` | `medium` | Step 5 convention compliance, readability, maintainability, test gaps, and nits |
|
|
27
|
-
| `correctness` | `heavy` | Step 5 compile/parse, logic, error handling, lifecycle, concurrency, and edge-case defects |
|
|
28
|
-
| `security-performance` | `heavy` | Step 5 security vulnerabilities and performance regressions |
|
|
23
|
+
| Pass id | Tier label | Tool policy | Scope |
|
|
24
|
+
|---------|------------|-------------|-------|
|
|
25
|
+
| `overview` | `light` | `none` | Step 3 overview, strengths, and high-level risk areas |
|
|
26
|
+
| `conventions-maintainability` | `medium` | `configured` | Step 5 convention compliance, readability, maintainability, test gaps, and nits |
|
|
27
|
+
| `correctness` | `heavy` | `configured` | Step 5 compile/parse, logic, error handling, lifecycle, concurrency, and edge-case defects |
|
|
28
|
+
| `security-performance` | `heavy` | `configured` | Step 5 security vulnerabilities and performance regressions |
|
|
29
29
|
|
|
30
|
-
Call `review_subagents` with `{ context, max_parallel: 4, passes: [...] }
|
|
30
|
+
Call `review_subagents` with `{ context, max_parallel: 4, passes: [...] }`, setting each pass's `tool_policy` exactly as shown. Shared `context` contains the PR title/description, relevant metadata, complete unified diff, and strictly cross-cutting review requirements. Put convention-file paths and excerpts only in the `conventions-maintainability` pass's own `context`; they are appended to shared context by the tool. This avoids sending specialist-only material to every model while still allowing the medium reviewer to inspect surrounding files when maintainability or test-gap analysis requires it.
|
|
31
|
+
|
|
32
|
+
If deterministic context assembly reports that required input is missing or truncated before dispatch (for example, an incomplete diff or unreadable applicable convention file), set `tool_policy: configured` for that affected pass instead of `none`. Do not ask the model to self-diagnose and rerun. If any pass returns `status: failed`, treat the review evidence as incomplete: rerun the failed pass with `review_subagent` using the same tool policy, or perform that pass inline before finalizing.
|
|
31
33
|
|
|
32
34
|
If `review_subagents` is unavailable but `review_subagent` is available, run the same pass assignments as individual `review_subagent` calls; emit independent calls in the same turn when the interface supports parallel tool calls. If neither subagent tool is available, perform every pass yourself inline on the current session model.
|
|
33
35
|
|
|
@@ -78,25 +80,25 @@ List (do not dump contents yet) the repository convention files that could gover
|
|
|
78
80
|
- The root convention file (`CLAUDE.md`, and/or `AGENTS.md` if present).
|
|
79
81
|
- Any convention file living in a directory that contains a file modified by this PR.
|
|
80
82
|
|
|
81
|
-
When you evaluate compliance for a given changed file, only apply convention files that share that file's path or a parent path. Read a convention file's contents when a changed file falls under its scope, and include the relevant rule excerpts (with file paths) in the
|
|
83
|
+
When you evaluate compliance for a given changed file, only apply convention files that share that file's path or a parent path. Read a convention file's contents when a changed file falls under its scope, and include the relevant rule excerpts (with file paths) only in the `conventions-maintainability` pass-specific `context`, not the batch's shared context.
|
|
82
84
|
|
|
83
85
|
## Step 3 — Overview & strengths (`light` reviewer)
|
|
84
86
|
|
|
85
87
|
Write a short **overview** (1–3 short paragraphs) of what the PR does and how, grounded in the diff and PR title/description — enough to understand author intent. Also collect a list of genuine **strengths** (good tests, nice consolidation, correct reuse of helpers, etc.) and any high-level risk areas for the specialist passes. Strengths are part of the output, and understanding intent is what lets you tell an intentional change from a bug.
|
|
86
88
|
|
|
87
|
-
When `review_subagents` is available, include this as the `overview` pass in the same batch as the Step 5 review passes instead of waiting for a separate sequential call.
|
|
89
|
+
When `review_subagents` is available, include this as the `overview` pass in the same batch as the Step 5 review passes instead of waiting for a separate sequential call. Set `tool_policy: none`; the complete PR metadata and diff are already supplied.
|
|
88
90
|
|
|
89
91
|
## Step 4 — (reserved)
|
|
90
92
|
|
|
91
93
|
## Step 5 — Review passes (dispatch by tier, then merge results)
|
|
92
94
|
|
|
93
|
-
Run these independent passes over the diff, each on its tier reviewer (or inline if subagent tools are unavailable). Give every pass the shared PR
|
|
95
|
+
Run these independent passes over the diff, each on its tier reviewer (or inline if subagent tools are unavailable). Give every pass the shared PR metadata and complete diff from Step 1; give only the medium pass the relevant convention-file excerpts from Step 2. Instruct each pass to report issues **at every severity, including nits**, within its own scope. Do **not** ask every pass to audit everything; the objective and tool-policy boundaries below reduce duplicate work while preserving coverage.
|
|
94
96
|
|
|
95
97
|
When `review_subagents` is available, dispatch the Step 3 `overview` pass and these Step 5 passes in one batch with `max_parallel: 4`, then combine the ordered outputs:
|
|
96
98
|
|
|
97
|
-
1. **Convention, readability & maintainability pass — `medium` reviewer
|
|
98
|
-
2. **Bug & correctness pass — `heavy` reviewer
|
|
99
|
-
3. **Security & performance pass — `heavy` reviewer
|
|
99
|
+
1. **Convention, readability & maintainability pass — `medium` reviewer, `tool_policy: configured`.** Audit changed lines against the in-scope convention excerpts supplied in this pass's context. Flag violations (quote the rule), softer deviations from documented style as nits, naming/dead-code/comment/typo issues, minor duplication, test gaps, and "worth confirming" observations (e.g. "no current callers — confirm intended", "confirm this generated file came from codegen not a hand-edit"). Use configured tools to inspect surrounding files, tests, callers, or generated-file context when needed; do not modify files or duplicate deep correctness/security analysis unless needed to explain a convention/maintainability issue.
|
|
100
|
+
2. **Bug & correctness pass — `heavy` reviewer, `tool_policy: configured`.** Hunt for defects in the introduced code: compile/parse failures, logic errors, wrong results, off-by-one, error handling, resource/lifecycle, concurrency, and edge cases. Include lower-severity correctness smells and missing edge cases as P2/P3. Do not duplicate pure style nits covered by the medium pass. Use configured repository-context tools when surrounding files or callers are needed to confirm impact; reviewing remains non-modifying even if the allowlist includes `bash`.
|
|
101
|
+
3. **Security & performance pass — `heavy` reviewer, `tool_policy: configured`.** Look for security issues (injection, authz, secrets, unsafe deserialization) and performance regressions in the changed code. Note minor ones too. Do not duplicate ordinary correctness/readability findings unless they are security/performance relevant. Use configured tools when repository context is needed to validate a candidate; do not modify files.
|
|
100
102
|
|
|
101
103
|
## Step 6 — Verification (best-effort, orchestrator-owned, non-destructive)
|
|
102
104
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendToolPolicyArgs,
|
|
4
|
+
buildReviewBaseArgs,
|
|
5
|
+
normalizeToolPolicy,
|
|
6
|
+
resolveToolPolicy,
|
|
7
|
+
} from "../lib/pr-review-policy.ts";
|
|
8
|
+
|
|
9
|
+
describe("tool policy resolution", () => {
|
|
10
|
+
test("request override wins over tier config", () => {
|
|
11
|
+
expect(resolveToolPolicy("none", "configured")).toBe("none");
|
|
12
|
+
expect(resolveToolPolicy("configured", "none")).toBe("configured");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("tier config applies when request omits policy", () => {
|
|
16
|
+
expect(resolveToolPolicy(undefined, "none")).toBe("none");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("omission preserves legacy configured behavior", () => {
|
|
20
|
+
expect(resolveToolPolicy(undefined, undefined)).toBe("configured");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("normalization rejects unknown values", () => {
|
|
24
|
+
expect(normalizeToolPolicy("none")).toBe("none");
|
|
25
|
+
expect(normalizeToolPolicy("configured")).toBe("configured");
|
|
26
|
+
expect(normalizeToolPolicy("auto")).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("tool policy argv", () => {
|
|
31
|
+
test("base args isolate explicit review context", () => {
|
|
32
|
+
expect(buildReviewBaseArgs()).toEqual([
|
|
33
|
+
"--mode",
|
|
34
|
+
"json",
|
|
35
|
+
"-p",
|
|
36
|
+
"--no-session",
|
|
37
|
+
"--no-context-files",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("none emits explicit --no-tools", () => {
|
|
42
|
+
const args = ["--mode", "json"];
|
|
43
|
+
expect(appendToolPolicyArgs(args, "none", ["read", "bash"])).toEqual([
|
|
44
|
+
"--mode",
|
|
45
|
+
"json",
|
|
46
|
+
"--no-tools",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("configured emits the configured allowlist", () => {
|
|
51
|
+
const args = ["--mode", "json"];
|
|
52
|
+
expect(appendToolPolicyArgs(args, "configured", ["read", "grep"])).toEqual([
|
|
53
|
+
"--mode",
|
|
54
|
+
"json",
|
|
55
|
+
"--tools",
|
|
56
|
+
"read,grep",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("configured with an empty list preserves legacy omitted-flag behavior", () => {
|
|
61
|
+
const args = ["--mode", "json"];
|
|
62
|
+
expect(appendToolPolicyArgs(args, "configured", [])).toEqual(["--mode", "json"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("one resolved policy can be reused across fallback attempts", () => {
|
|
66
|
+
const policy = resolveToolPolicy("none", "configured");
|
|
67
|
+
const first = appendToolPolicyArgs([], policy, ["read"]);
|
|
68
|
+
const fallback = appendToolPolicyArgs([], policy, ["read"]);
|
|
69
|
+
expect(first).toEqual(["--no-tools"]);
|
|
70
|
+
expect(fallback).toEqual(["--no-tools"]);
|
|
71
|
+
});
|
|
72
|
+
});
|