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 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 tools=read,bash,grep,find,ls # tools granted to each subagent
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 row and one fallback-model row per tier (`light` / `medium` / `heavy`) plus a `subagent tools` row.
76
- - Press Enter on a primary or fallback row to pick a model from a searchable list (or unset it); Enter/Space on `subagent tools` cycles presets. The menu sets one fallback model at a time; use the `key=value` form for longer fallback chains.
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
- └─ extensions/review-table.ts # renders the final JSON as a table (TUI only)
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
- ## Security & cost notes
199
+ ## Speed, security & cost notes
189
200
 
190
- - The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Subagents are read-only reviewers (`read,bash,grep,find,ls` by default) and never post comments or edit files.
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, now concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for the deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
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
- /** Tools granted to each review subagent process. */
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 = ["--mode", "json", "-p", "--no-session"];
468
+ const args = buildReviewBaseArgs();
435
469
  if (attempt.spec) args.push("--model", attempt.spec);
436
- if (config.tools.length > 0) args.push("--tools", config.tools.join(","));
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(config, ctx, pass, attempt, signal, onText);
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("", `## Pass: ${result.id}`, `status: ${result.status}`, result.notice);
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, in-scope convention-file summaries, and the unified diff.",
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
- { tier, objective: params.objective, context: params.context },
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
- { value: "tools=", label: "tools=read,bash,grep,find,ls — tools granted to review subagents" },
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.split(/[_\-.]/)[0] as Tier;
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(`unknown key "${key}" (expected light|medium|heavy|<tier>_fallbacks|tools)`);
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 || Object.keys(patch.fallbacks).length > 0 || patch.tools !== undefined;
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(",")}\` | tools granted to each review subagent |`,
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((t) => `| \`${t}\` | ${formatModelList(user.fallbacks[t])} | ${formatModelList(effective.fallbacks[t])} |`),
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: "subagent tools",
1121
- description: "Tools granted to each review subagent. Enter/Space cycles presets.",
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.split(/[_\-.]/)[0] as Tier;
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.split(/[_\-.]/)[0] as Tier]?.join(",") ?? "(none)")
1171
- : (draft.tiers[id as Tier] ?? UNSET);
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
- 10,
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.2.0",
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": "*",
@@ -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: [...] }`. If any pass returns `status: failed`, treat the review evidence as incomplete: rerun the failed pass with `review_subagent` or perform that pass inline before finalizing. Always put the PR title/description, relevant metadata, convention-file excerpts, and unified diff in shared `context` so subagents do not refetch anything.
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 shared context for the medium pass.
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 context from Step 1 plus the relevant convention-file excerpts from Step 2, and 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 boundaries below reduce duplicate work while preserving coverage.
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.** Audit changed lines against the in-scope convention files from Step 2. 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"). Do not duplicate deep correctness or security analysis unless needed to explain a convention/maintainability issue.
98
- 2. **Bug & correctness pass — `heavy` reviewer.** 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.
99
- 3. **Security & performance pass — `heavy` reviewer.** 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.
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
+ });