@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 +22 -10
- package/package.json +3 -2
- package/src/git.ts +124 -22
- package/src/history/collect.ts +101 -158
- package/src/history/synthesize.ts +14 -18
- package/src/review.ts +4 -5
- package/src/target/packet.ts +23 -91
- package/src/tool/brief-runner.ts +1 -2
- package/src/tool/review-runner.ts +83 -16
- package/src/tool/runner-types.ts +0 -6
- package/src/tool/snapshot-tools.ts +116 -0
- package/src/types.ts +0 -9
- package/src/ui/flow.ts +57 -22
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
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.
|
|
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
|
+

|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+

|
|
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
|
|
42
|
-
7.
|
|
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
|
-
-
|
|
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
|
|
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` —
|
|
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.
|
|
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 {
|
|
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})`;
|
package/src/history/collect.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
+
* Serialize the resolved LLM-visible session context into a compaction-style
|
|
13
|
+
* readable transcript for the brief synthesizer.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
21
|
+
export function serializeSessionContext(
|
|
16
22
|
messages: ResolvedSessionMessage[],
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
(
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
):
|
|
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
|
-
|
|
45
|
-
text
|
|
107
|
+
label: message.role === "user" ? "User" : "Assistant",
|
|
108
|
+
text,
|
|
109
|
+
isSummary: false,
|
|
46
110
|
};
|
|
47
111
|
}
|
|
48
112
|
case "custom": {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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,
|
|
23
|
+
const { model, modelRegistry, cwd, snapshot, serializedContext, note, signal, onProgress } =
|
|
24
|
+
options;
|
|
24
25
|
|
|
25
26
|
return runBriefSynthesis({
|
|
26
|
-
prompt: buildBriefSynthesisPrompt(snapshot,
|
|
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
|
-
|
|
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("", "##
|
|
69
|
-
if (
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/target/packet.ts
CHANGED
|
@@ -12,17 +12,20 @@ export interface DiffSection {
|
|
|
12
12
|
deletions: number;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Build
|
|
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
|
|
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
|
-
|
|
60
|
+
parts.push("", "## Snapshot notes", truncate(preamble.trim(), 1_500));
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
|
|
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
|
-
"##
|
|
98
|
-
|
|
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
|
-
"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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([
|
package/src/tool/brief-runner.ts
CHANGED
|
@@ -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, "
|
|
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
|
-
"
|
|
57
|
-
"
|
|
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
|
-
"---
|
|
65
|
-
"-
|
|
66
|
-
"
|
|
67
|
-
"-
|
|
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
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
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: [
|
|
121
|
-
|
|
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(
|
|
381
|
+
session = await createReviewerSession(
|
|
382
|
+
invocation,
|
|
383
|
+
submitReviewTool,
|
|
384
|
+
snapshotDiffTool,
|
|
385
|
+
snapshotFileTool,
|
|
386
|
+
);
|
|
320
387
|
} catch (error) {
|
|
321
388
|
return {
|
|
322
389
|
kind: "failed",
|
package/src/tool/runner-types.ts
CHANGED
|
@@ -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>((
|
|
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
|
|
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
|
-
// ──
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
),
|