@mrclrchtr/supi-review 1.7.0 → 1.8.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
@@ -1,3 +1,5 @@
1
+ ![SuPi](assets/logo.png)
2
+
1
3
  # @mrclrchtr/supi-review
2
4
 
3
5
  Adds an interactive `/supi-review` command to the [pi coding agent](https://github.com/earendil-works/pi) for session-aware code review.
@@ -8,7 +10,7 @@ Adds an interactive `/supi-review` command to the [pi coding agent](https://gith
8
10
  pi install npm:@mrclrchtr/supi-review
9
11
  ```
10
12
 
11
- This is a **beta** package. It is not bundled in `@mrclrchtr/supi`.
13
+ This is a **beta** package. Install individually.
12
14
 
13
15
  For local development:
14
16
 
@@ -16,8 +18,6 @@ For local development:
16
18
  pi install ./packages/supi-review
17
19
  ```
18
20
 
19
- After editing the source, run `/reload`.
20
-
21
21
  ## What you get
22
22
 
23
23
  After install, pi gets one command:
@@ -27,7 +27,15 @@ After install, pi gets one command:
27
27
  The reviewer runs in managed child agent sessions:
28
28
 
29
29
  - a **brief synthesizer** creates a structured review brief from the active session branch
30
- - a **read-only reviewer** inspects the selected snapshot and submits structured findings
30
+ - a **read-only reviewer** inspects the selected snapshot (without receiving bulk inline diffs) and submits structured findings
31
+
32
+ ![Review target selection](https://raw.githubusercontent.com/mrclrchtr/supi/main/screenshots/supi-review-1.png)
33
+
34
+ ![Review brief preview](https://raw.githubusercontent.com/mrclrchtr/supi/main/screenshots/supi-review-2.png)
35
+
36
+ ![Review result](https://raw.githubusercontent.com/mrclrchtr/supi/main/screenshots/supi-review-3.png)
37
+
38
+ ![Review progress](https://raw.githubusercontent.com/mrclrchtr/supi/main/screenshots/supi-review-4.png)
31
39
 
32
40
  ## Review flow
33
41
 
@@ -38,8 +46,8 @@ The reviewer runs in managed child agent sessions:
38
46
  3. optionally add a short note
39
47
  4. resolve the snapshot
40
48
  5. synthesize a review brief from the current session history
41
- 6. preview the synthesized brief + prompt coverage
42
- 7. run the review with a live progress widget
49
+ 6. preview the synthesized brief + compact prompt preview
50
+ 7. the reviewer fetches per-file diffs on demand via snapshot-aware tools; live progress widget shows activity
43
51
  8. show the structured result as a custom message
44
52
  9. if findings exist, hand off to the main agent so it can ask what to do next
45
53
 
@@ -58,7 +66,8 @@ The generated review prompt is **not** just a static diff wrapper.
58
66
  Before the actual review starts, the package:
59
67
 
60
68
  - resolves the **active session branch into the current LLM-visible context**
61
- - extracts high-signal user/assistant/custom/compaction context from that resolved view
69
+ - **serializes** that resolved context into a compaction-style transcript
70
+ - feeds the serialized transcript (plus snapshot + optional note) to a dedicated brief synthesizer
62
71
  - synthesizes a structured brief with:
63
72
  - summary
64
73
  - intended outcome
@@ -69,7 +78,9 @@ Before the actual review starts, the package:
69
78
 
70
79
  The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
71
80
 
72
- That synthesized brief is then combined with the git snapshot into the final reviewer prompt.
81
+ That synthesized brief is then combined with the git snapshot into a compact reviewer prompt. The prompt contains the brief, file manifest, and per-file overview, but no large inline diffs. Instead, the reviewer session gets snapshot-aware tools (`read_snapshot_diff`, `read_snapshot_file`) to fetch exact per-file diffs and before/after file contents on demand.
82
+
83
+ The session-transcript approach mirrors how Pi summarizes context for compaction: the entire resolved conversation is rendered in a readable label format and sent to the model as a whole, rather than relying on heuristic excerpt ranking.
73
84
 
74
85
  ## Model selection
75
86
 
@@ -104,9 +115,10 @@ When a successful review contains findings, `supi-review` also injects an agent-
104
115
  - `src/review.ts` — command orchestration and interactive flow
105
116
  - `src/model.ts` — explicit model selection helpers
106
117
  - `src/git.ts` — git snapshot resolution
107
- - `src/history/collect.ts` — active-branch evidence extraction
118
+ - `src/history/collect.ts` — compaction-style session-context serialization
108
119
  - `src/history/synthesize.ts` — brief synthesis orchestration
109
120
  - `src/target/packet.ts` — final reviewer packet builder
110
121
  - `src/tool/brief-runner.ts` — brief synthesis child session
111
- - `src/tool/review-runner.ts` — read-only reviewer child session
122
+ - `src/tool/review-runner.ts` — read-only reviewer child session with snapshot-aware tools
123
+ - `src/tool/snapshot-tools.ts` — per-file diff and before/after content tools scoped to the selected snapshot
112
124
  - `src/ui/renderer.ts` — structured result rendering
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "SuPi Review extension — structured code review via /supi-review command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,8 @@
42
42
  "pi": {
43
43
  "extensions": [
44
44
  "./src/extension.ts"
45
- ]
45
+ ],
46
+ "image": "https://raw.githubusercontent.com/mrclrchtr/supi/main/packages/supi-review/assets/logo.png"
46
47
  },
47
48
  "main": "src/api.ts",
48
49
  "exports": {
package/src/git.ts CHANGED
@@ -1,10 +1,11 @@
1
+ // biome-ignore lint/nursery/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
1
2
  import { execFile } from "node:child_process";
2
- import { basename } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
3
5
  import { promisify } from "node:util";
4
- import type { DiffStats, ReviewSnapshot } from "./types.ts";
6
+ import type { DiffStats, ReviewSnapshot, ReviewTargetSpec } from "./types.ts";
5
7
 
6
8
  const execFileAsync = promisify(execFile);
7
-
8
9
  const GIT_TIMEOUT_MS = 30_000;
9
10
 
10
11
  function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
@@ -237,11 +238,7 @@ export async function resolveWorkingTreeSnapshot(
237
238
  getUncommittedDiff(repoPath),
238
239
  getUncommittedFileNames(repoPath),
239
240
  ]);
240
-
241
- if (!diffText.trim() && changedFiles.length === 0) {
242
- return undefined;
243
- }
244
-
241
+ if (!diffText.trim() && changedFiles.length === 0) return undefined;
245
242
  return {
246
243
  target: { kind: "working-tree" },
247
244
  title: "Working tree changes",
@@ -257,19 +254,12 @@ export async function resolveBranchSnapshot(
257
254
  base: string,
258
255
  ): Promise<ReviewSnapshot | undefined> {
259
256
  const baseSha = await getMergeBase(repoPath, base);
260
- if (!baseSha) {
261
- return undefined;
262
- }
263
-
257
+ if (!baseSha) return undefined;
264
258
  const [diffText, changedFiles] = await Promise.all([
265
259
  getDiff(repoPath, baseSha),
266
260
  getDiffFileNames(repoPath, baseSha),
267
261
  ]);
268
-
269
- if (!diffText.trim() && changedFiles.length === 0) {
270
- return undefined;
271
- }
272
-
262
+ if (!diffText.trim() && changedFiles.length === 0) return undefined;
273
263
  return {
274
264
  target: { kind: "branch", base },
275
265
  title: `Changes vs ${base}`,
@@ -288,11 +278,7 @@ export async function resolveCommitSnapshot(
288
278
  getCommitShow(repoPath, sha),
289
279
  getCommitFileNames(repoPath, sha),
290
280
  ]);
291
-
292
- if (!diffText.trim() && changedFiles.length === 0) {
293
- return undefined;
294
- }
295
-
281
+ if (!diffText.trim() && changedFiles.length === 0) return undefined;
296
282
  return {
297
283
  target: { kind: "commit", sha },
298
284
  title: `Commit ${sha.slice(0, 7)}`,
@@ -302,6 +288,122 @@ export async function resolveCommitSnapshot(
302
288
  };
303
289
  }
304
290
 
291
+ /** Get the per-file diff for a single changed file in the snapshot. */
292
+ export async function getSnapshotFileDiff(
293
+ repoPath: string,
294
+ snapshot: ReviewSnapshot,
295
+ file: string,
296
+ ): Promise<string> {
297
+ const { target } = snapshot;
298
+ switch (target.kind) {
299
+ case "working-tree": {
300
+ const { stdout } = await execFileAsync(
301
+ "git",
302
+ ["diff", "HEAD", "--", file],
303
+ gitExecOptions(repoPath),
304
+ );
305
+ return stdout;
306
+ }
307
+ case "branch": {
308
+ const baseSha = await getMergeBase(repoPath, target.base);
309
+ if (!baseSha) return "";
310
+ const { stdout } = await execFileAsync(
311
+ "git",
312
+ ["diff", baseSha, "HEAD", "--", file],
313
+ gitExecOptions(repoPath),
314
+ );
315
+ return stdout;
316
+ }
317
+ case "commit": {
318
+ const { stdout } = await execFileAsync(
319
+ "git",
320
+ ["show", target.sha, "--", file],
321
+ gitExecOptions(repoPath),
322
+ );
323
+ return stdout;
324
+ }
325
+ }
326
+ }
327
+
328
+ /** Run `git show <ref>:<file>` and return the blob content. */
329
+ async function showGitBlob(repoPath: string, ref: string, file: string): Promise<string> {
330
+ const { stdout } = await execFileAsync(
331
+ "git",
332
+ ["show", `${ref}:${file}`],
333
+ gitExecOptions(repoPath),
334
+ );
335
+ return stdout;
336
+ }
337
+
338
+ async function resolveWorkingTreeContent(
339
+ repoPath: string,
340
+ file: string,
341
+ side: "before" | "after",
342
+ ): Promise<string | undefined> {
343
+ if (side === "before") {
344
+ try {
345
+ return await showGitBlob(repoPath, "HEAD", file);
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ }
350
+ try {
351
+ return await readFile(join(repoPath, file), "utf-8");
352
+ } catch {
353
+ return undefined;
354
+ }
355
+ }
356
+
357
+ async function resolveBranchContent(
358
+ repoPath: string,
359
+ target: ReviewTargetSpec & { kind: "branch" },
360
+ file: string,
361
+ side: "before" | "after",
362
+ ): Promise<string | undefined> {
363
+ const baseSha = await getMergeBase(repoPath, target.base);
364
+ if (!baseSha) return undefined;
365
+ const ref = side === "before" ? baseSha : "HEAD";
366
+ try {
367
+ return await showGitBlob(repoPath, ref, file);
368
+ } catch (err) {
369
+ if (side === "before") return undefined;
370
+ throw err;
371
+ }
372
+ }
373
+
374
+ async function resolveCommitContent(
375
+ repoPath: string,
376
+ target: ReviewTargetSpec & { kind: "commit" },
377
+ file: string,
378
+ side: "before" | "after",
379
+ ): Promise<string | undefined> {
380
+ const ref = side === "before" ? `${target.sha}^` : target.sha;
381
+ try {
382
+ return await showGitBlob(repoPath, ref, file);
383
+ } catch (err) {
384
+ if (side === "before") return undefined;
385
+ throw err;
386
+ }
387
+ }
388
+
389
+ /** Get before or after content for a single changed file in the snapshot. Returns undefined when legitimately unavailable; propagates unexpected errors. */
390
+ export async function getSnapshotFileContent(
391
+ repoPath: string,
392
+ snapshot: ReviewSnapshot,
393
+ file: string,
394
+ side: "before" | "after",
395
+ ): Promise<string | undefined> {
396
+ const { target } = snapshot;
397
+ switch (target.kind) {
398
+ case "working-tree":
399
+ return resolveWorkingTreeContent(repoPath, file, side);
400
+ case "branch":
401
+ return resolveBranchContent(repoPath, target, file, side);
402
+ case "commit":
403
+ return resolveCommitContent(repoPath, target, file, side);
404
+ }
405
+ }
406
+
305
407
  /** Convenience label for one changed file, used in synthesized prompts/UI. */
306
408
  export function formatChangedFileLabel(file: string): string {
307
409
  return basename(file) === file ? file : `${basename(file)} (${file})`;
@@ -1,114 +1,134 @@
1
1
  import type { SessionContext } from "@earendil-works/pi-coding-agent";
2
- import type { HistoryEvidence, ReviewSnapshot } from "../types.ts";
3
-
4
- const INTENT_PATTERN =
5
- /\b(fix|refactor|rename|preserve|avoid|should|must|ensure|intended|goal|risk|concern|regression|security|correctness|performance|api|breaking|review)\b/i;
6
2
 
7
3
  type ResolvedSessionMessage = SessionContext["messages"][number];
8
4
 
5
+ const DEFAULT_MAX_CHARS = 8_000;
6
+
7
+ export interface SerializeSessionContextOptions {
8
+ maxChars?: number;
9
+ }
10
+
9
11
  /**
10
- * Extract the highest-signal evidence from the resolved LLM-visible session context.
12
+ * Serialize the resolved LLM-visible session context into a compaction-style
13
+ * readable transcript for the brief synthesizer.
11
14
  *
12
- * The collector intentionally favors user intent, assistant plans, compaction
13
- * summaries, and custom extension messages over raw tool chatter.
15
+ * This mirrors the overall approach Pi uses for compaction:
16
+ * - messages are labeled by role and rendered in chronological order
17
+ * - compaction and branch summaries appear with their own labels
18
+ * - the output is bounded so the synthesizer stays predictable on long sessions
19
+ * - no heuristic ranking or scoring is applied
14
20
  */
15
- export function collectHistoryEvidence(
21
+ export function serializeSessionContext(
16
22
  messages: ResolvedSessionMessage[],
17
- snapshot: ReviewSnapshot,
18
- note?: string,
19
- ): HistoryEvidence[] {
20
- const scoringContext = {
21
- changedPathTokens: collectPathTokens(snapshot.changedFiles),
22
- noteTokens: collectFreeformTokens(note),
23
- total: messages.length,
24
- };
25
-
26
- const scored = messages
27
- .map((message) => toEvidence(message))
23
+ options?: SerializeSessionContextOptions,
24
+ ): string {
25
+ const maxChars = options?.maxChars ?? DEFAULT_MAX_CHARS;
26
+ if (messages.length === 0) return "";
27
+
28
+ const entries = messages
29
+ .map((message) => serializeEntry(message))
28
30
  .filter(
29
- (evidence): evidence is Omit<HistoryEvidence, "score" | "reason"> => evidence !== undefined,
30
- )
31
- .map((evidence, index) => scoreEvidence(evidence, index, scoringContext))
32
- .sort((a, b) => b.score - a.score || b.text.length - a.text.length);
31
+ (entry): entry is { label: string; text: string; isSummary: boolean } => entry !== undefined,
32
+ );
33
+
34
+ if (entries.length === 0) return "";
35
+
36
+ // Separate summary entries (compaction/branch summaries) from regular entries
37
+ const summaryEntries = entries.filter((e) => e.isSummary);
38
+ const regularEntries = entries.filter((e) => !e.isSummary);
39
+
40
+ // Estimate the total size if we keep everything
41
+ const allLines = entries.map(formatEntry);
42
+ const totalSize = measureLines(allLines);
43
+
44
+ if (totalSize <= maxChars) {
45
+ return allLines.join("\n");
46
+ }
47
+
48
+ // Build the output: always include summaries, then as many recent regular entries as fit
49
+ const summaryLines = summaryEntries.map(formatEntry);
50
+
51
+ // Walk regular entries from newest to oldest, keeping as many as fit
52
+ const keptRegular: typeof regularEntries = [];
53
+ let size = measureLines(summaryLines);
54
+
55
+ for (let i = regularEntries.length - 1; i >= 0; i--) {
56
+ const line = formatEntry(regularEntries[i]);
57
+ const lineLen = line.length + 1; // +1 for newline
58
+ if (keptRegular.length > 0 && size + lineLen > maxChars) {
59
+ break;
60
+ }
61
+ keptRegular.unshift(regularEntries[i]);
62
+ size += lineLen;
63
+ }
64
+
65
+ // Build final output: summaries first, then kept recent entries
66
+ const keptLines = [...summaryLines, ...keptRegular.map(formatEntry)];
33
67
 
34
- return capEvidence(scored, 10, 4_500);
68
+ // If still nothing fits with summaries alone, truncate the summaries
69
+ if (keptLines.length > 0) {
70
+ const output = keptLines.join("\n");
71
+ if (output.length <= maxChars) return output;
72
+ }
73
+
74
+ // Last resort: truncate the full output
75
+ return keepFirstLines(keptLines, maxChars);
76
+ }
77
+
78
+ function formatEntry(entry: { label: string; text: string }): string {
79
+ return `[${entry.label}]\n${entry.text}`;
35
80
  }
36
81
 
37
- function toEvidence(
82
+ function measureLines(lines: string[]): number {
83
+ return lines.reduce((acc, line) => acc + line.length + 1, 0); // +1 for newline
84
+ }
85
+
86
+ function keepFirstLines(lines: string[], maxChars: number): string {
87
+ const kept: string[] = [];
88
+ let size = 0;
89
+ for (const line of lines) {
90
+ const lineLen = line.length + 1;
91
+ if (size + lineLen > maxChars) break;
92
+ kept.push(line);
93
+ size += lineLen;
94
+ }
95
+ return kept.join("\n");
96
+ }
97
+
98
+ function serializeEntry(
38
99
  message: ResolvedSessionMessage,
39
- ): Omit<HistoryEvidence, "score" | "reason"> | undefined {
100
+ ): { label: string; text: string; isSummary: boolean } | undefined {
40
101
  switch (message.role) {
41
102
  case "user":
42
103
  case "assistant": {
104
+ const text = normalizeText(extractMessageText(message.content));
105
+ if (!text) return undefined;
43
106
  return {
44
- kind: message.role,
45
- text: normalizeText(extractMessageText(message.content)),
107
+ label: message.role === "user" ? "User" : "Assistant",
108
+ text,
109
+ isSummary: false,
46
110
  };
47
111
  }
48
112
  case "custom": {
49
- return {
50
- kind: "custom",
51
- text: normalizeText(extractMessageText(message.content)),
52
- };
113
+ const text = normalizeText(extractMessageText(message.content));
114
+ if (!text) return undefined;
115
+ return { label: "Custom", text, isSummary: false };
53
116
  }
54
117
  case "compactionSummary": {
55
- return {
56
- kind: "compaction",
57
- text: normalizeText(message.summary),
58
- };
118
+ const text = normalizeText(message.summary);
119
+ if (!text) return undefined;
120
+ return { label: "Compaction summary", text, isSummary: true };
59
121
  }
60
122
  case "branchSummary": {
61
- return {
62
- kind: "branch-summary",
63
- text: normalizeText(message.summary),
64
- };
123
+ const text = normalizeText(message.summary);
124
+ if (!text) return undefined;
125
+ return { label: "Branch summary", text, isSummary: true };
65
126
  }
66
127
  default:
67
128
  return undefined;
68
129
  }
69
130
  }
70
131
 
71
- function scoreEvidence(
72
- evidence: Omit<HistoryEvidence, "score" | "reason">,
73
- index: number,
74
- context: { total: number; changedPathTokens: string[]; noteTokens: string[] },
75
- ): HistoryEvidence {
76
- const recencyBoost = context.total > 0 ? (index / context.total) * 3 : 0;
77
- const pathMatches = countTokenMatches(evidence.text, context.changedPathTokens);
78
- const noteMatches = countTokenMatches(evidence.text, context.noteTokens);
79
- const intentBoost = INTENT_PATTERN.test(evidence.text) ? 4 : 0;
80
- const shortTextPenalty = evidence.text.length < 24 ? -2 : 0;
81
- const longTextPenalty = evidence.text.length > 900 ? -1 : 0;
82
- const score =
83
- baseKindScore(evidence.kind) +
84
- recencyBoost +
85
- pathMatches * 3 +
86
- noteMatches * 2 +
87
- intentBoost +
88
- shortTextPenalty +
89
- longTextPenalty;
90
-
91
- return {
92
- ...evidence,
93
- score,
94
- reason: buildReason(pathMatches, noteMatches, intentBoost),
95
- };
96
- }
97
-
98
- function buildReason(pathMatches: number, noteMatches: number, intentBoost: number): string {
99
- const reasons: string[] = [];
100
- if (pathMatches > 0) {
101
- reasons.push(`mentions ${pathMatches} changed-path token${pathMatches === 1 ? "" : "s"}`);
102
- }
103
- if (noteMatches > 0) {
104
- reasons.push(`matches ${noteMatches} note token${noteMatches === 1 ? "" : "s"}`);
105
- }
106
- if (intentBoost > 0) {
107
- reasons.push("contains intent/constraint language");
108
- }
109
- return reasons.join(", ") || "high-signal session context";
110
- }
111
-
112
132
  function extractMessageText(content: unknown): string {
113
133
  if (typeof content === "string") {
114
134
  return content;
@@ -131,80 +151,3 @@ function extractMessageText(content: unknown): string {
131
151
  function normalizeText(text: string): string {
132
152
  return text.replace(/\s+/g, " ").trim();
133
153
  }
134
-
135
- function collectPathTokens(paths: string[]): string[] {
136
- const tokens = new Set<string>();
137
-
138
- for (const path of paths) {
139
- for (const part of path.split(/[\\/]/)) {
140
- addToken(tokens, part);
141
- for (const inner of part.split(/[-_.]/)) {
142
- addToken(tokens, inner);
143
- }
144
- }
145
- addToken(tokens, path);
146
- }
147
-
148
- return Array.from(tokens);
149
- }
150
-
151
- function collectFreeformTokens(text: string | undefined): string[] {
152
- if (!text) return [];
153
-
154
- const tokens = new Set<string>();
155
- for (const part of text.split(/\W+/)) {
156
- addToken(tokens, part);
157
- }
158
- return Array.from(tokens);
159
- }
160
-
161
- function addToken(tokens: Set<string>, token: string): void {
162
- const normalized = token.trim().toLowerCase();
163
- if (normalized.length < 3) return;
164
- tokens.add(normalized);
165
- }
166
-
167
- function countTokenMatches(text: string, tokens: string[]): number {
168
- if (tokens.length === 0 || text.length === 0) return 0;
169
- const lower = text.toLowerCase();
170
- return tokens.reduce((count, token) => count + (lower.includes(token) ? 1 : 0), 0);
171
- }
172
-
173
- function baseKindScore(kind: HistoryEvidence["kind"]): number {
174
- switch (kind) {
175
- case "user":
176
- return 8;
177
- case "assistant":
178
- return 5;
179
- case "compaction":
180
- case "branch-summary":
181
- return 6;
182
- case "custom":
183
- return 4;
184
- }
185
- }
186
-
187
- function capEvidence(
188
- evidence: HistoryEvidence[],
189
- maxItems: number,
190
- maxChars: number,
191
- ): HistoryEvidence[] {
192
- const kept: HistoryEvidence[] = [];
193
- let used = 0;
194
-
195
- for (const item of evidence) {
196
- if (kept.length >= maxItems) break;
197
- const text = truncate(item.text, 600);
198
- const nextUsed = used + text.length;
199
- if (kept.length > 0 && nextUsed > maxChars) break;
200
- kept.push({ ...item, text });
201
- used = nextUsed;
202
- }
203
-
204
- return kept;
205
- }
206
-
207
- function truncate(text: string, maxChars: number): string {
208
- if (text.length <= maxChars) return text;
209
- return `${text.slice(0, maxChars)}…`;
210
- }
@@ -1,7 +1,7 @@
1
1
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
2
  import { runBriefSynthesis } from "../tool/brief-runner.ts";
3
3
  import type { BriefSynthesisRunResult, ReviewProgress } from "../tool/runner-types.ts";
4
- import type { HistoryEvidence, ReviewModelSelection, ReviewSnapshot } from "../types.ts";
4
+ import type { ReviewModelSelection, ReviewSnapshot } from "../types.ts";
5
5
 
6
6
  const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
7
7
 
@@ -10,20 +10,21 @@ export interface SynthesizeReviewBriefOptions {
10
10
  modelRegistry: ModelRegistry;
11
11
  cwd: string;
12
12
  snapshot: ReviewSnapshot;
13
- evidence: HistoryEvidence[];
13
+ serializedContext: string;
14
14
  note?: string;
15
15
  signal?: AbortSignal;
16
16
  onProgress?: (progress: ReviewProgress) => void;
17
17
  }
18
18
 
19
- /** Synthesize a structured review brief from the current snapshot and session evidence. */
19
+ /** Synthesize a structured review brief from the current snapshot and session context. */
20
20
  export function synthesizeReviewBrief(
21
21
  options: SynthesizeReviewBriefOptions,
22
22
  ): Promise<BriefSynthesisRunResult> {
23
- const { model, modelRegistry, cwd, snapshot, evidence, note, signal, onProgress } = options;
23
+ const { model, modelRegistry, cwd, snapshot, serializedContext, note, signal, onProgress } =
24
+ options;
24
25
 
25
26
  return runBriefSynthesis({
26
- prompt: buildBriefSynthesisPrompt(snapshot, evidence, note),
27
+ prompt: buildBriefSynthesisPrompt(snapshot, serializedContext, note),
27
28
  model: model.model,
28
29
  modelRegistry,
29
30
  cwd,
@@ -34,7 +35,7 @@ export function synthesizeReviewBrief(
34
35
 
35
36
  export function buildBriefSynthesisPrompt(
36
37
  snapshot: ReviewSnapshot,
37
- evidence: HistoryEvidence[],
38
+ serializedContext: string,
38
39
  note?: string,
39
40
  ): string {
40
41
  const diffExcerpt = buildDiffExcerpt(snapshot.diffText);
@@ -65,18 +66,11 @@ export function buildBriefSynthesisPrompt(
65
66
  parts.push("", "## User note", note.trim());
66
67
  }
67
68
 
68
- parts.push("", "## Session evidence");
69
- if (evidence.length === 0) {
70
- parts.push(
71
- "- No strong session evidence was extracted. Derive a minimal brief from the snapshot only.",
72
- );
69
+ parts.push("", "## Serialized session context");
70
+ if (serializedContext.trim()) {
71
+ parts.push(serializedContext.trim());
73
72
  } else {
74
- parts.push(
75
- ...evidence.flatMap((item, index) => [
76
- `${index + 1}. [${item.kind}] ${item.reason}`,
77
- ` ${item.text}`,
78
- ]),
79
- );
73
+ parts.push("No session context was available. Derive the brief from the snapshot only.");
80
74
  }
81
75
 
82
76
  parts.push(
@@ -87,7 +81,9 @@ export function buildBriefSynthesisPrompt(
87
81
  "- intendedOutcome: what the session seems to be trying to achieve",
88
82
  "- constraints: invariants or requirements to preserve",
89
83
  "- focusAreas: what the reviewer should inspect carefully",
90
- "- riskyFiles: changed files that seem especially important or risky",
84
+ "- riskyFiles: changed files that seem especially important or risky.",
85
+ " Prioritize files touching auth, data handling, core logic, error handling, or public APIs.",
86
+ " Include only files from the changed-files list above.",
91
87
  "- unresolvedQuestions: ambiguities or concerns that remain unclear",
92
88
  );
93
89
 
package/src/review.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { buildSessionContext, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { resolveBranchSnapshot, resolveCommitSnapshot, resolveWorkingTreeSnapshot } from "./git.ts";
3
- import { collectHistoryEvidence } from "./history/collect.ts";
3
+ import { serializeSessionContext } from "./history/collect.ts";
4
4
  import { synthesizeReviewBrief } from "./history/synthesize.ts";
5
5
  import { buildReviewPacket } from "./target/packet.ts";
6
6
  import { runReviewer } from "./tool/review-runner.ts";
@@ -46,7 +46,7 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
46
46
  ctx.sessionManager.getEntries(),
47
47
  ctx.sessionManager.getLeafId(),
48
48
  );
49
- const evidence = collectHistoryEvidence(sessionContext.messages, snapshot, normalizedNote);
49
+ const serializedContext = serializeSessionContext(sessionContext.messages);
50
50
  const synthesis = await runBriefWithLoader({
51
51
  snapshot,
52
52
  modelId: model.canonicalId,
@@ -58,7 +58,7 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
58
58
  modelRegistry: ctx.modelRegistry,
59
59
  cwd: ctx.cwd,
60
60
  snapshot,
61
- evidence,
61
+ serializedContext,
62
62
  note: normalizedNote,
63
63
  signal,
64
64
  onProgress,
@@ -73,7 +73,6 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
73
73
  const brief = {
74
74
  ...synthesis.brief,
75
75
  note: normalizedNote,
76
- evidenceCount: evidence.length,
77
76
  };
78
77
 
79
78
  const packet = buildReviewPacket(snapshot, brief, model);
@@ -289,7 +288,7 @@ function buildReviewFollowUpInstruction(
289
288
  }
290
289
 
291
290
  const contradictionNote =
292
- overall_correctness === "patch is correct" && findings.length > 0
291
+ overall_correctness.toLowerCase().includes("is correct") && findings.length > 0
293
292
  ? "The reviewer marked the patch as correct but found issues — verify the verdict before acting."
294
293
  : undefined;
295
294
 
@@ -12,17 +12,20 @@ export interface DiffSection {
12
12
  deletions: number;
13
13
  }
14
14
 
15
- /** Build the final prompt packet for the reviewer child session. */
15
+ /** Build a compact review packet for the reviewer child session.
16
+ *
17
+ * The packet contains only the session-derived brief, target metadata, and a
18
+ * changed-file overview. No large inline diffs are included. The reviewer uses
19
+ * read_snapshot_diff and read_snapshot_file tools to inspect diffs on demand.
20
+ */
16
21
  export function buildReviewPacket(
17
22
  snapshot: ReviewSnapshot,
18
23
  brief: SynthesizedReviewBrief,
19
24
  model: ReviewModelSelection,
20
25
  ): ReviewPacket {
21
- const charBudget = getPacketCharBudget(model);
22
26
  const { preamble, sections } = splitDiffSections(snapshot.diffText);
23
- const orderedSections = prioritizeSections(sections, brief, snapshot.changedFiles);
24
27
 
25
- const baseParts: string[] = [
28
+ const parts: string[] = [
26
29
  "# Review Task",
27
30
  "",
28
31
  "## Session-derived intent",
@@ -49,63 +52,29 @@ export function buildReviewPacket(
49
52
  "",
50
53
  "## Changed files manifest",
51
54
  ...snapshot.changedFiles.map((file) => `- ${file}`),
55
+ "",
56
+ buildFileOverviewTable(snapshot.changedFiles, sections),
52
57
  ];
53
58
 
54
- baseParts.push("", buildFileOverviewTable(snapshot.changedFiles, sections));
55
-
56
59
  if (preamble.trim()) {
57
- baseParts.push("", "## Snapshot notes", truncate(preamble.trim(), 1_500));
60
+ parts.push("", "## Snapshot notes", truncate(preamble.trim(), 1_500));
58
61
  }
59
62
 
60
- let prompt = baseParts.join("\n");
61
- let remaining = charBudget - prompt.length;
62
- const includedFiles: string[] = [];
63
- const diffBlocks: string[] = [];
64
-
65
- for (const section of orderedSections) {
66
- if (remaining <= 2_000) break;
67
- const fenced = [`### ${section.file}`, "", "```diff", section.text, "```"].join("\n");
68
-
69
- if (fenced.length <= remaining) {
70
- diffBlocks.push(fenced);
71
- includedFiles.push(section.file);
72
- remaining -= fenced.length + 2;
73
- continue;
74
- }
75
-
76
- if (includedFiles.length === 0) {
77
- const excerptBudget = Math.max(1_000, remaining - 200);
78
- const excerpt = truncate(section.text, excerptBudget);
79
- const partialBlock = [`### ${section.file}`, "", "```diff", excerpt, "```"].join("\n");
80
- diffBlocks.push(partialBlock);
81
- includedFiles.push(section.file);
82
- remaining -= partialBlock.length + 2;
83
- }
84
- break;
85
- }
86
-
87
- const omittedFiles = snapshot.changedFiles.filter((file) => !includedFiles.includes(file));
88
-
89
- prompt = [
90
- prompt,
91
- "",
92
- "## Included diffs",
93
- diffBlocks.length > 0
94
- ? diffBlocks.join("\n\n")
95
- : "No inline diff sections fit in the prompt budget. Use the changed-file manifest to inspect the files directly.",
63
+ parts.push(
96
64
  "",
97
- "## Omitted files",
98
- ...toBullets(omittedFiles, "- None"),
65
+ "## On-demand snapshot inspection",
66
+ "Use read_snapshot_diff <file> to see the exact diff for any changed file.",
67
+ "Use read_snapshot_file <file> before|after to inspect file contents on either side of the change.",
68
+ "These tools are scoped to the snapshot's changed-files list — request a file from the manifest above.",
99
69
  "",
100
- "Review the included diff carefully. Use read/grep/find/ls to inspect surrounding code before submitting findings.",
101
- ].join("\n");
102
-
103
- return {
104
- prompt,
105
- includedFiles,
106
- omittedFiles,
107
- charBudget,
108
- };
70
+ "Combine snapshot inspection with read/grep/find/ls for broader codebase context.",
71
+ );
72
+
73
+ const includedFiles = snapshot.changedFiles.filter((f) => !classifySkipCategory(f));
74
+ const omittedFiles = snapshot.changedFiles.filter((f) => !!classifySkipCategory(f));
75
+ const charBudget = getPacketCharBudget(model);
76
+
77
+ return { prompt: parts.join("\n"), includedFiles, omittedFiles, charBudget };
109
78
  }
110
79
 
111
80
  /** Derive a conservative prompt budget from the selected model's context window. */
@@ -193,43 +162,6 @@ function parseDiffFile(line: string): string | undefined {
193
162
  return next === "/dev/null" ? match[1] : next;
194
163
  }
195
164
 
196
- function prioritizeSections(
197
- sections: DiffSection[],
198
- brief: SynthesizedReviewBrief,
199
- changedFiles: string[],
200
- ): DiffSection[] {
201
- const riskyTokens = brief.riskyFiles.flatMap(toPathTokens);
202
- const order = new Map(changedFiles.map((file, index) => [file, index]));
203
-
204
- return [...sections].sort((a, b) => {
205
- const delta = sectionScore(b, riskyTokens, order) - sectionScore(a, riskyTokens, order);
206
- if (delta !== 0) return delta;
207
- return a.file.localeCompare(b.file);
208
- });
209
- }
210
-
211
- function sectionScore(
212
- section: DiffSection,
213
- riskyTokens: string[],
214
- order: Map<string, number>,
215
- ): number {
216
- const lowerFile = section.file.toLowerCase();
217
- const riskyScore = riskyTokens.reduce(
218
- (score, token) => score + (lowerFile.includes(token) ? 20 : 0),
219
- 0,
220
- );
221
- const orderScore = order.has(section.file) ? Math.max(0, 10 - (order.get(section.file) ?? 0)) : 0;
222
- return riskyScore + orderScore;
223
- }
224
-
225
- function toPathTokens(path: string): string[] {
226
- return path
227
- .toLowerCase()
228
- .split(/[\\/._-]/)
229
- .map((part) => part.trim())
230
- .filter((part) => part.length >= 3);
231
- }
232
-
233
165
  /** Categorize a file path for skip-list annotation, or undefined if it should be reviewed. */
234
166
  export function classifySkipCategory(file: string): string | undefined {
235
167
  const lockfiles = new Set([
@@ -27,8 +27,7 @@ function createSubmitBriefTool(resultHolder: {
27
27
  parameters: reviewBriefSchema,
28
28
  execute: async (_toolCallId, args) => {
29
29
  resultHolder.value = {
30
- ...(args as Omit<SynthesizedReviewBrief, "evidenceCount" | "note">),
31
- evidenceCount: 0,
30
+ ...(args as Omit<SynthesizedReviewBrief, "note">),
32
31
  };
33
32
  return {
34
33
  content: [{ type: "text" as const, text: "Review brief submitted successfully." }],
@@ -11,6 +11,7 @@ import {
11
11
  import type { ReviewOutputEvent, ReviewResult } from "../types.ts";
12
12
  import type { ReviewInvocation, ReviewProgress } from "./runner-types.ts";
13
13
  import { reviewOutputSchema } from "./schemas.ts";
14
+ import { createSnapshotDiffTool, createSnapshotFileTool } from "./snapshot-tools.ts";
14
15
 
15
16
  const DEFAULT_TIMEOUT_MS = 20 * 60 * 1_000;
16
17
  const GRACE_TURNS = 3;
@@ -25,6 +26,8 @@ function toolNameToActivity(name: string, phase: "start" | "end"): string {
25
26
  find: "finding files",
26
27
  ls: "listing files",
27
28
  submit_review: "submitting review",
29
+ read_snapshot_diff: "reading diff",
30
+ read_snapshot_file: "reading file",
28
31
  };
29
32
  return map[name] ?? name;
30
33
  }
@@ -52,19 +55,52 @@ function createSubmitReviewTool(resultHolder: {
52
55
  /** Build the reviewer system prompt used by the read-only child session. */
53
56
  export function buildReviewerSystemPrompt(): string {
54
57
  return [
55
- "You are a rigorous code reviewer.",
56
- "The review task already includes session-derived intent and a concrete code snapshot.",
57
- "Use the prompt packet as the primary brief, then inspect files with the available read-only tools before drawing conclusions.",
58
+ "You are a rigorous code reviewer. Your task already includes session-derived intent",
59
+ "and a concrete list of changed files. Use the prompt packet as the primary brief,",
60
+ "then inspect code with the available read-only tools before drawing conclusions.",
58
61
  "",
59
62
  "--- Guardrails ---",
60
63
  "- You have read-only tools only. Do NOT modify files or propose running write/edit/bash commands.",
61
- "- Verify any suspected issue against the diff and surrounding code before reporting it.",
62
- "- If the patch is fully correct, set overall_correctness to 'patch is correct' with high confidence.",
63
64
  "",
64
- "--- Finding quality ---",
65
- "- Title: concise and specific.",
66
- "- Body: explain the issue, why it matters, and a concrete fix direction.",
67
- "- code_location: 1-based inclusive line range.",
65
+ "--- Depth ---",
66
+ "- Read the full changed file, not just the diff. The diff shows what changed;",
67
+ " surrounding code shows whether it still makes sense.",
68
+ "- For high-risk files, also read immediate callers and callees (use grep / find / read",
69
+ " to trace references). Without surrounding context you miss broken call sites,",
70
+ " stale comments, and silent convention violations.",
71
+ "",
72
+ "--- Convention awareness ---",
73
+ "- Before flagging a style or convention issue, read CLAUDE.md, AGENTS.md, and",
74
+ " sibling files in the same directory.",
75
+ '- "This doesn\'t match the codebase style" only counts when you can point to',
76
+ " the real convention in the codebase.",
77
+ "",
78
+ "--- What counts as a finding ---",
79
+ "Report only issues that meet ALL of these criteria:",
80
+ "1. It meaningfully impacts correctness, security, performance, or maintainability.",
81
+ "2. It was introduced by this change — pre-existing issues are out of scope",
82
+ " unless the change makes them worse.",
83
+ "3. It is discrete and actionable — the author can fix it in one focused pass.",
84
+ "4. It does not require assuming unstated intent or speculative downstream effects.",
85
+ "5. It does not demand a level of rigor not present in the rest of the codebase.",
86
+ "6. The author would likely fix it if they were made aware of it.",
87
+ "7. It is not clearly an intentional change by the original author.",
88
+ "",
89
+ "--- Do not flag ---",
90
+ "- Trivial style issues unless they obscure meaning or violate documented standards.",
91
+ "- Pre-existing bugs unrelated to this change.",
92
+ '- Things that "might" break without an identified concrete code path.',
93
+ "- Hypothetical issues without a concrete scenario.",
94
+ "- Speculative downstream effects — identify the specific affected code.",
95
+ "",
96
+ "--- Review checklist ---",
97
+ "Check for:",
98
+ "- Logic bugs — wrong condition, off-by-one, missing null/undefined check, race condition.",
99
+ "- Security — injection, authz bypass, secret exposure.",
100
+ "- Convention violations — only when you can cite the convention.",
101
+ "- Missing or weak tests — new behavior without test coverage.",
102
+ "- Dead or unreachable code introduced by this change.",
103
+ "- Breaking changes — removed exports, changed signatures, config format changes.",
68
104
  "",
69
105
  "--- Finding calibration ---",
70
106
  "Priority:",
@@ -76,22 +112,36 @@ export function buildReviewerSystemPrompt(): string {
76
112
  " 0.8-1.0: you verified the issue by reading surrounding code or grepping the codebase",
77
113
  " 0.5-0.8: suspected from the diff, plausible but not fully verified",
78
114
  " <0.5: do not report — too uncertain; either verify further or drop it",
79
- "overall_correctness:",
80
- " 'patch is correct': no issues found, or only info-level suggestions",
81
- " 'mostly correct': minor issues present, safe to merge with fixes",
82
- " 'patch is incorrect': major/critical issues that should block merge",
115
+ "",
116
+ "--- Verdict ---",
117
+ "PATCH IS CORRECT: no P2 (major) or P3 (critical) findings. Info/minor suggestions",
118
+ " (P0/P1) that are non-blocking do not prevent this verdict.",
119
+ "PATCH HAS ISSUES: one or more P2 or P3 findings exist. These should block merge.",
120
+ "Explain the verdict in overall_explanation.",
121
+ "",
122
+ "--- Finding format ---",
123
+ '- Title: concise and specific imperative (e.g. "Guard null token path").',
124
+ "- Body: what's wrong, why it matters, and a concrete fix direction. One paragraph.",
125
+ "- code_location: 1-based inclusive line range.",
83
126
  "",
84
127
  "--- Tool strategy ---",
128
+ "- Start by fetching the diff for each changed file using read_snapshot_diff.",
129
+ "- Use read_snapshot_file <file> before|after to inspect file contents on either side of the change.",
85
130
  "- Use read to inspect full files when the inline diff lacks context.",
86
131
  "- Use grep to verify patterns across the codebase.",
87
132
  "- Use find to locate related files quickly.",
88
133
  "- Use ls when you need a quick directory overview.",
89
134
  "",
135
+ "--- Large diffs ---",
136
+ "- If the diff spans many files, prioritize high-risk files (core logic, auth, data handling).",
137
+ "- Note in overall_explanation which files you reviewed deeply vs. skimmed.",
138
+ "",
90
139
  "--- Skipped files ---",
91
140
  "- Skip reviewing: lockfiles, generated/bundled code (dist/, .next/, __generated__/),",
92
141
  " vendored dependencies, changelogs, snapshot files, minified bundles, and binary files.",
93
142
  "- Focus on application source and test code.",
94
143
  "",
144
+ "--- Output ---",
95
145
  "Do NOT output JSON directly — call submit_review with the structured result.",
96
146
  ].join("\n");
97
147
  }
@@ -99,6 +149,8 @@ export function buildReviewerSystemPrompt(): string {
99
149
  async function createReviewerSession(
100
150
  invocation: ReviewInvocation,
101
151
  submitReviewTool: ReturnType<typeof defineTool>,
152
+ snapshotDiffTool: ReturnType<typeof defineTool>,
153
+ snapshotFileTool: ReturnType<typeof defineTool>,
102
154
  ): Promise<AgentSession> {
103
155
  const resourceLoader = new DefaultResourceLoader({
104
156
  cwd: invocation.cwd,
@@ -117,8 +169,16 @@ async function createReviewerSession(
117
169
  model: invocation.model.model,
118
170
  modelRegistry: invocation.modelRegistry,
119
171
  thinkingLevel: clampThinkingLevel(invocation.model.model, "xhigh"),
120
- tools: ["read", "grep", "find", "ls", "submit_review"],
121
- customTools: [submitReviewTool],
172
+ tools: [
173
+ "read",
174
+ "grep",
175
+ "find",
176
+ "ls",
177
+ "submit_review",
178
+ "read_snapshot_diff",
179
+ "read_snapshot_file",
180
+ ],
181
+ customTools: [submitReviewTool, snapshotDiffTool, snapshotFileTool],
122
182
  resourceLoader,
123
183
  sessionManager: SessionManager.inMemory(invocation.cwd),
124
184
  });
@@ -313,10 +373,17 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<ReviewR
313
373
 
314
374
  const resultHolder: { value: ReviewOutputEvent | undefined } = { value: undefined };
315
375
  const submitReviewTool = createSubmitReviewTool(resultHolder);
376
+ const snapshotDiffTool = createSnapshotDiffTool(invocation.cwd, invocation.snapshot);
377
+ const snapshotFileTool = createSnapshotFileTool(invocation.cwd, invocation.snapshot);
316
378
 
317
379
  let session: AgentSession;
318
380
  try {
319
- session = await createReviewerSession(invocation, submitReviewTool);
381
+ session = await createReviewerSession(
382
+ invocation,
383
+ submitReviewTool,
384
+ snapshotDiffTool,
385
+ snapshotFileTool,
386
+ );
320
387
  } catch (error) {
321
388
  return {
322
389
  kind: "failed",
@@ -20,12 +20,6 @@ export type BriefSynthesisRunResult =
20
20
  | { kind: "canceled" }
21
21
  | { kind: "timeout"; timeoutMs: number };
22
22
 
23
- export interface BriefSynthesisInput {
24
- snapshot: ReviewSnapshot;
25
- evidence: Array<{ kind: string; reason: string; text: string }>;
26
- note?: string;
27
- }
28
-
29
23
  export interface BriefSynthesisInvocation {
30
24
  prompt: string;
31
25
  // biome-ignore lint/suspicious/noExplicitAny: Model<any> is pi's canonical type
@@ -0,0 +1,116 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { getSnapshotFileContent, getSnapshotFileDiff } from "../git.ts";
4
+ import type { ReviewSnapshot } from "../types.ts";
5
+
6
+ /** Create the read_snapshot_diff custom tool for the reviewer child session. */
7
+ export function createSnapshotDiffTool(
8
+ cwd: string,
9
+ snapshot: ReviewSnapshot,
10
+ ): ReturnType<typeof defineTool> {
11
+ return defineTool({
12
+ name: "read_snapshot_diff",
13
+ label: "Read Snapshot Diff",
14
+ description:
15
+ "Read the exact diff for a single changed file in the selected review snapshot. " +
16
+ "The file must be in the snapshot's changed-files list.",
17
+ parameters: Type.Object({
18
+ file: Type.String(),
19
+ }),
20
+ execute: async (_toolCallId, args) => {
21
+ const file = (args as { file: string }).file;
22
+ if (!snapshot.changedFiles.includes(file)) {
23
+ return {
24
+ content: [
25
+ {
26
+ type: "text" as const,
27
+ text:
28
+ `Error: "${file}" is not in the snapshot's changed-files list. ` +
29
+ "Use the changed-file manifest from the review task to pick a valid file.",
30
+ },
31
+ ],
32
+ details: null,
33
+ };
34
+ }
35
+
36
+ const diff = await getSnapshotFileDiff(cwd, snapshot, file);
37
+ if (!diff.trim()) {
38
+ return {
39
+ content: [
40
+ {
41
+ type: "text" as const,
42
+ text:
43
+ `No diff available for "${file}" in this snapshot. ` +
44
+ "The file may be untracked; use read_snapshot_file to inspect its current content.",
45
+ },
46
+ ],
47
+ details: null,
48
+ };
49
+ }
50
+
51
+ return {
52
+ content: [{ type: "text" as const, text: diff }],
53
+ details: null,
54
+ };
55
+ },
56
+ });
57
+ }
58
+
59
+ /** Create the read_snapshot_file custom tool for the reviewer child session. */
60
+ export function createSnapshotFileTool(
61
+ cwd: string,
62
+ snapshot: ReviewSnapshot,
63
+ ): ReturnType<typeof defineTool> {
64
+ return defineTool({
65
+ name: "read_snapshot_file",
66
+ label: "Read Snapshot File",
67
+ description:
68
+ "Read the before or after content of a single changed file in the selected review snapshot. " +
69
+ '"before" shows the file before the change (HEAD for working tree, base for branch, parent for commit). ' +
70
+ '"after" shows the file after the change. The file must be in the snapshot\'s changed-files list.',
71
+ parameters: Type.Object({
72
+ file: Type.String(),
73
+ side: Type.Union([Type.Literal("before"), Type.Literal("after")]),
74
+ }),
75
+ execute: async (_toolCallId, args) => {
76
+ const { file, side } = args as { file: string; side: "before" | "after" };
77
+
78
+ if (!snapshot.changedFiles.includes(file)) {
79
+ return {
80
+ content: [
81
+ {
82
+ type: "text" as const,
83
+ text:
84
+ `Error: "${file}" is not in the snapshot's changed-files list. ` +
85
+ "Use the changed-file manifest from the review task to pick a valid file.",
86
+ },
87
+ ],
88
+ details: null,
89
+ };
90
+ }
91
+
92
+ const content = await getSnapshotFileContent(cwd, snapshot, file, side);
93
+
94
+ if (content === undefined) {
95
+ const hint =
96
+ side === "before"
97
+ ? "The file may be newly added or renamed (use read_snapshot_diff to check for renames)."
98
+ : "The file may have been deleted.";
99
+ return {
100
+ content: [
101
+ {
102
+ type: "text" as const,
103
+ text: `Content for "${file}" (${side}) is not available. ${hint}`,
104
+ },
105
+ ],
106
+ details: null,
107
+ };
108
+ }
109
+
110
+ return {
111
+ content: [{ type: "text" as const, text: content }],
112
+ details: null,
113
+ };
114
+ },
115
+ });
116
+ }
package/src/types.ts CHANGED
@@ -51,14 +51,6 @@ export interface ReviewSnapshot {
51
51
  stats: DiffStats;
52
52
  }
53
53
 
54
- /** One evidence item extracted from the active session branch. */
55
- export interface HistoryEvidence {
56
- kind: "user" | "assistant" | "custom" | "compaction" | "branch-summary";
57
- text: string;
58
- score: number;
59
- reason: string;
60
- }
61
-
62
54
  /** Model picked explicitly for the current review run. */
63
55
  export interface ReviewModelSelection {
64
56
  canonicalId: string;
@@ -80,7 +72,6 @@ export interface SynthesizedReviewBrief {
80
72
  riskyFiles: string[];
81
73
  unresolvedQuestions: string[];
82
74
  note?: string;
83
- evidenceCount: number;
84
75
  }
85
76
 
86
77
  /** Final prompt packet passed to the reviewer child session. */
package/src/ui/flow.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import { spawn } from "node:child_process";
2
+ import { unlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
1
6
  import { DynamicBorder, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
7
  import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
8
  import { getLocalBranches, getRecentCommits } from "../git.ts";
@@ -135,23 +140,62 @@ export async function collectReviewNote(ctx: ExtensionContext): Promise<string |
135
140
 
136
141
  /** Show the synthesized brief, the actual reviewer prompt preview, and ask for approval. */
137
142
  export function previewReviewPlan(ctx: ExtensionContext, plan: ReviewPlan): Promise<boolean> {
138
- return ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
143
+ return ctx.ui.custom<boolean>((tui, theme, _kb, done) => {
139
144
  const container = buildReviewPlanContainer(theme, plan);
145
+ const pagerState = { running: false };
146
+
147
+ function handleInput(data: string) {
148
+ if (pagerState.running) return;
149
+ if (data === "\r" || data === "\n" || data === "y" || data === "Y") {
150
+ done(true);
151
+ } else if (data === "\x1b" || data === "n" || data === "N") {
152
+ done(false);
153
+ } else if (data === "v" || data === "V") {
154
+ openPromptInPager(plan.packet.prompt, tui, pagerState);
155
+ }
156
+ }
140
157
 
141
158
  return {
142
159
  render: (width) => container.render(width),
143
160
  invalidate: () => container.invalidate(),
144
- handleInput: (data) => {
145
- if (data === "\r" || data === "\n" || data === "y" || data === "Y") {
146
- done(true);
147
- } else if (data === "\x1b" || data === "n" || data === "N") {
148
- done(false);
149
- }
150
- },
161
+ handleInput,
151
162
  };
152
163
  });
153
164
  }
154
165
 
166
+ /** Open the full reviewer prompt in the user's pager for inspection. */
167
+ function openPromptInPager(
168
+ prompt: string,
169
+ tui: { requestRender: () => void },
170
+ pagerState: { running: boolean },
171
+ ): void {
172
+ const tmpFile = join(tmpdir(), `supi-review-prompt-${Date.now()}.txt`);
173
+ try {
174
+ writeFileSync(tmpFile, prompt, "utf-8");
175
+ } catch {
176
+ return;
177
+ }
178
+
179
+ pagerState.running = true;
180
+
181
+ let cleanedUp = false;
182
+ const cleanup = () => {
183
+ if (cleanedUp) return;
184
+ cleanedUp = true;
185
+ pagerState.running = false;
186
+ try {
187
+ unlinkSync(tmpFile);
188
+ } catch {
189
+ // best-effort cleanup
190
+ }
191
+ tui.requestRender();
192
+ };
193
+
194
+ const pager = spawn("less", ["-R", tmpFile], { stdio: "inherit" });
195
+ pager.on("exit", cleanup);
196
+ pager.on("error", cleanup);
197
+ }
198
+
155
199
  /** Build the review plan preview container with all styled sections. */
156
200
  function buildReviewPlanContainer(theme: ReviewTheme, plan: ReviewPlan): Container {
157
201
  const { model, snapshot, brief, packet } = plan;
@@ -236,25 +280,16 @@ function buildReviewPlanContainer(theme: ReviewTheme, plan: ReviewPlan): Contain
236
280
  container.addChild(new Text(previewText, 1, 0));
237
281
  container.addChild(new Spacer(1));
238
282
 
239
- // ── File coverage line ──
240
- container.addChild(
241
- new Text(
242
- theme.fg(
243
- "dim",
244
- ` Included diffs: ${packet.includedFiles.length} file${packet.includedFiles.length === 1 ? "" : "s"}` +
245
- ` \u2022 Omitted: ${packet.omittedFiles.length} file${packet.omittedFiles.length === 1 ? "" : "s"}` +
246
- ` \u2022 Budget: ${(packet.charBudget / 1000).toFixed(0)}K chars`,
247
- ),
248
- 1,
249
- 0,
250
- ),
251
- );
283
+ // ── Snapshot access line ──
284
+ const accessLine = ` Diffs: on-demand via read_snapshot_diff \u2022 Files: ${snapshot.changedFiles.length} changed`;
285
+
286
+ container.addChild(new Text(theme.fg("dim", accessLine), 1, 0));
252
287
  container.addChild(new Spacer(1));
253
288
 
254
289
  // ── Confirm / Cancel hints ──
255
290
  container.addChild(
256
291
  new Text(
257
- ` ${dim("Enter")} ${theme.fg("success", "Run review")} ${dim("\u2022")} ${dim("Esc")} ${theme.fg("muted", "Cancel")} ${dim("\u2022 y/n")}`,
292
+ ` ${dim("Enter")} ${theme.fg("success", "Run review")} ${dim("\u2022")} ${dim("Esc")} ${theme.fg("muted", "Cancel")} ${dim("\u2022 y/n")} ${dim("\u2022")} ${dim("v")} ${theme.fg("accent", "view full prompt")}`,
258
293
  1,
259
294
  0,
260
295
  ),