@mrclrchtr/supi-review 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -5
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +3 -3
- package/src/git.ts +192 -15
- package/src/history/synthesize.ts +3 -0
- package/src/model.ts +19 -0
- package/src/review.ts +2 -0
- package/src/session/review-plan-store.ts +69 -0
- package/src/target/packet.ts +15 -1
- package/src/tool/agent-review-schemas.ts +126 -0
- package/src/tool/agent-review-tools.ts +335 -0
- package/src/tool/agent-review-workflow.ts +385 -0
- package/src/tool/brief-runner.ts +6 -9
- package/src/tool/guidance.ts +21 -0
- package/src/tool/review-debug.ts +2 -0
- package/src/tool/review-handlers.ts +7 -8
- package/src/tool/review-runner.ts +1 -1
- package/src/tool/schemas.ts +34 -38
- package/src/tool/session-lifecycle.ts +7 -3
- package/src/tool/snapshot-tools.ts +2 -1
- package/src/types.ts +104 -0
- package/src/ui/review-tool-format.ts +138 -0
- package/src/ui/review-tool-renderer.ts +203 -0
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
# @mrclrchtr/supi-review
|
|
10
10
|
|
|
11
|
-
Adds
|
|
11
|
+
Adds human-driven and agent-driven Session-Aware Review workflows to the [pi coding agent](https://github.com/earendil-works/pi).
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -26,9 +26,13 @@ pi install ./packages/supi-review
|
|
|
26
26
|
|
|
27
27
|
## What you get
|
|
28
28
|
|
|
29
|
-
After install, pi gets one command:
|
|
29
|
+
After install, pi gets one human-driven command and two agent-facing tools:
|
|
30
30
|
|
|
31
31
|
- `/supi-review` — launch a guided review flow over a concrete git snapshot
|
|
32
|
+
- `supi_review_prepare` — synthesize a versioned brief and return a session-scoped `planId`
|
|
33
|
+
- `supi_review_run` — accept the main agent's structured brief critique, freshness-check the plan, and run focused reviewers concurrently
|
|
34
|
+
|
|
35
|
+
The run tool is initially inactive and is dynamically enabled after `supi_review_prepare` returns a plan. This keeps the initial agent-facing prompt surface small.
|
|
32
36
|
|
|
33
37
|
The reviewer runs in managed child agent sessions:
|
|
34
38
|
|
|
@@ -57,13 +61,34 @@ The reviewer runs in managed child agent sessions:
|
|
|
57
61
|
8. normalize the submitted review items into a host-derived verdict + structured result
|
|
58
62
|
9. if review items exist, hand off to the main agent so it can ask what to do next with fixed options (`Fix all`, `Fix selected`, `Verify findings`, `Skip`)
|
|
59
63
|
|
|
64
|
+
## Agent-driven review flow
|
|
65
|
+
|
|
66
|
+
The agent-facing path deliberately separates preparation from execution:
|
|
67
|
+
|
|
68
|
+
1. `supi_review_prepare` resolves the target, serializes the active session context, and synthesizes a generated brief.
|
|
69
|
+
2. The main agent compares that brief with the user request, session evidence, and snapshot.
|
|
70
|
+
3. The main agent calls `supi_review_run` with:
|
|
71
|
+
- the prepared `planId`
|
|
72
|
+
- an `accept` or `revise` critique
|
|
73
|
+
- evidence-backed critique findings
|
|
74
|
+
- at least one evidence-backed finding and a full `revisedBrief` when revision is required
|
|
75
|
+
- one to four independent reviewer assignments
|
|
76
|
+
4. The host atomically consumes the plan, checks snapshot freshness, and runs reviewer child sessions concurrently.
|
|
77
|
+
5. The tool returns each normalized review result plus a retained brief evaluation artifact.
|
|
78
|
+
|
|
79
|
+
The evaluation artifact keeps the generated brief, main-agent critique, effective brief, synthesis prompt version, model id, and snapshot fingerprint separate. This makes brief-synthesis prompt regressions inspectable instead of hiding main-agent repairs. The full artifact is stored in tool-result `details`; a summary is also recorded as a `supi-review/brief-critique` SuPi Debug event when debug capture is enabled.
|
|
80
|
+
|
|
81
|
+
Prepared plans are session-scoped. File or git changes before or during review invalidate the run, so `supi_review_run` should not share a tool batch with mutating tools.
|
|
82
|
+
|
|
60
83
|
## Review targets
|
|
61
84
|
|
|
62
85
|
Current targets:
|
|
63
86
|
|
|
64
87
|
- working tree
|
|
65
|
-
- branch diff vs a selected local base branch
|
|
66
|
-
- one recent
|
|
88
|
+
- branch diff vs a selected local base branch (`merge-base..HEAD`, excluding dirty worktree changes)
|
|
89
|
+
- one commit (recent-commit picker in the command; hexadecimal object id in the agent tool)
|
|
90
|
+
|
|
91
|
+
Agent-provided branch targets must name an existing local branch. Commit targets must be unique 7–64 character hexadecimal commit-object ids: object-only disambiguation rejects ambiguous prefixes, non-commit objects, and hexadecimal branch/tag fallback. Git revision arguments are option-hardened before execution.
|
|
67
92
|
|
|
68
93
|
## Session-aware brief synthesis
|
|
69
94
|
|
|
@@ -104,13 +129,15 @@ The Overview mode uses the same structured packet data that feeds the reviewer p
|
|
|
104
129
|
|
|
105
130
|
## Model selection
|
|
106
131
|
|
|
107
|
-
Every `/supi-review` run asks you to choose the reviewer model.
|
|
132
|
+
Every `/supi-review` command run asks you to choose the reviewer model.
|
|
108
133
|
|
|
109
134
|
- the picker only shows **scoped models** from Pi's `enabledModels` configuration
|
|
110
135
|
- the current session model is preselected only when it is inside that scoped set
|
|
111
136
|
- the selected model is used for both brief synthesis and the final review
|
|
112
137
|
- no review model is persisted in settings
|
|
113
138
|
|
|
139
|
+
Agent-driven tool runs use the current session model for preparation and all focused reviewers. The selected model is retained in the prepared plan so a model change between tool calls cannot silently alter the run.
|
|
140
|
+
|
|
114
141
|
## Result shape
|
|
115
142
|
|
|
116
143
|
A successful review includes:
|
|
@@ -155,6 +182,9 @@ When a successful review contains review items, `supi-review` also injects an ag
|
|
|
155
182
|
- `src/review-result.ts` — review-item normalization, verdict derivation, and summary counts
|
|
156
183
|
- `src/target/review-instruction-blocks.ts` — fixed catalog of host-owned review instruction blocks
|
|
157
184
|
- `src/target/packet.ts` — final reviewer packet builder + shared preview-data derivation for the inspector
|
|
185
|
+
- `src/session/review-plan-store.ts` — session-scoped, one-shot prepared review plans
|
|
186
|
+
- `src/tool/agent-review-tools.ts` — prepare/run tool registration, dynamic activation, progress, and debug artifact recording
|
|
187
|
+
- `src/tool/agent-review-workflow.ts` — preparation, critique validation, freshness checks, and concurrent reviewer fan-out
|
|
158
188
|
- `src/tool/brief-runner.ts` — brief synthesis child session
|
|
159
189
|
- `src/tool/review-runner.ts` — read-only reviewer child session with snapshot-aware tools
|
|
160
190
|
- `src/tool/snapshot-tools.ts` — per-file diff and before/after content tools scoped to the selected snapshot
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrclrchtr/supi-review",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "SuPi Review extension —
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"description": "SuPi Review extension — session-aware review command and agent tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"README.md"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@mrclrchtr/supi-core": "2.
|
|
34
|
+
"@mrclrchtr/supi-core": "2.6.0"
|
|
35
35
|
},
|
|
36
36
|
"bundledDependencies": [
|
|
37
37
|
"@mrclrchtr/supi-core"
|
package/src/git.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// biome-ignore lint/style/noExcessiveLinesPerFile: many tightly-coupled git helpers; splitting would create cross-ref overhead
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { lstat, readFile, readlink } from "node:fs/promises";
|
|
4
6
|
import { basename, join } from "node:path";
|
|
5
7
|
import { promisify } from "node:util";
|
|
6
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
DiffStats,
|
|
10
|
+
ReviewSnapshot,
|
|
11
|
+
ReviewSnapshotSummary,
|
|
12
|
+
ReviewTargetSpec,
|
|
13
|
+
} from "./types.ts";
|
|
7
14
|
|
|
8
15
|
const execFileAsync = promisify(execFile);
|
|
9
16
|
const GIT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const COMMIT_OBJECT_ID_RE = /^[0-9a-f]{7,64}$/i;
|
|
10
18
|
|
|
11
19
|
function scrubGitEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
12
20
|
const next = { ...env };
|
|
@@ -52,11 +60,22 @@ export function parseDiffStats(text: string): DiffStats {
|
|
|
52
60
|
return { files, additions, deletions };
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/** Return whether a value is a hexadecimal abbreviated or full Git object id. */
|
|
64
|
+
export function isCommitObjectId(value: string): boolean {
|
|
65
|
+
return COMMIT_OBJECT_ID_RE.test(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
export async function getMergeBase(repoPath: string, branch: string): Promise<string | undefined> {
|
|
69
|
+
const branchRef = `refs/heads/${branch}`;
|
|
56
70
|
try {
|
|
71
|
+
await execFileAsync(
|
|
72
|
+
"git",
|
|
73
|
+
["show-ref", "--verify", "--quiet", branchRef],
|
|
74
|
+
gitExecOptions(repoPath),
|
|
75
|
+
);
|
|
57
76
|
const { stdout } = await execFileAsync(
|
|
58
77
|
"git",
|
|
59
|
-
["merge-base", "HEAD",
|
|
78
|
+
["merge-base", "HEAD", branchRef],
|
|
60
79
|
gitExecOptions(repoPath),
|
|
61
80
|
);
|
|
62
81
|
return stdout.trim() || undefined;
|
|
@@ -66,7 +85,11 @@ export async function getMergeBase(repoPath: string, branch: string): Promise<st
|
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
export async function getDiff(repoPath: string, baseSha: string): Promise<string> {
|
|
69
|
-
const { stdout } = await execFileAsync(
|
|
88
|
+
const { stdout } = await execFileAsync(
|
|
89
|
+
"git",
|
|
90
|
+
["diff", "--end-of-options", baseSha, "HEAD"],
|
|
91
|
+
gitExecOptions(repoPath),
|
|
92
|
+
);
|
|
70
93
|
return stdout;
|
|
71
94
|
}
|
|
72
95
|
|
|
@@ -132,14 +155,18 @@ export async function getRecentCommits(repoPath: string, limit = 20): Promise<Co
|
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
export async function getCommitShow(repoPath: string, sha: string): Promise<string> {
|
|
135
|
-
const { stdout } = await execFileAsync(
|
|
158
|
+
const { stdout } = await execFileAsync(
|
|
159
|
+
"git",
|
|
160
|
+
["show", "--end-of-options", sha],
|
|
161
|
+
gitExecOptions(repoPath),
|
|
162
|
+
);
|
|
136
163
|
return stdout;
|
|
137
164
|
}
|
|
138
165
|
|
|
139
166
|
export async function getDiffFileNames(repoPath: string, baseSha: string): Promise<string[]> {
|
|
140
167
|
const { stdout } = await execFileAsync(
|
|
141
168
|
"git",
|
|
142
|
-
["diff", "--name-only", baseSha],
|
|
169
|
+
["diff", "--name-only", "--end-of-options", baseSha, "HEAD"],
|
|
143
170
|
gitExecOptions(repoPath),
|
|
144
171
|
);
|
|
145
172
|
return stdout
|
|
@@ -192,7 +219,7 @@ export async function getUncommittedFileNames(repoPath: string): Promise<string[
|
|
|
192
219
|
export async function getCommitFileNames(repoPath: string, sha: string): Promise<string[]> {
|
|
193
220
|
const { stdout } = await execFileAsync(
|
|
194
221
|
"git",
|
|
195
|
-
["diff-tree", "--no-commit-id", "--name-only", "-r", sha],
|
|
222
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", "--end-of-options", sha],
|
|
196
223
|
gitExecOptions(repoPath),
|
|
197
224
|
);
|
|
198
225
|
return stdout
|
|
@@ -230,6 +257,117 @@ export async function getLocalBranches(repoPath: string): Promise<string[]> {
|
|
|
230
257
|
return sorted;
|
|
231
258
|
}
|
|
232
259
|
|
|
260
|
+
/** Resolve any supported review target into a concrete snapshot. */
|
|
261
|
+
export function resolveReviewSnapshot(
|
|
262
|
+
repoPath: string,
|
|
263
|
+
target: ReviewTargetSpec,
|
|
264
|
+
): Promise<ReviewSnapshot | undefined> {
|
|
265
|
+
switch (target.kind) {
|
|
266
|
+
case "working-tree":
|
|
267
|
+
return resolveWorkingTreeSnapshot(repoPath);
|
|
268
|
+
case "branch":
|
|
269
|
+
return resolveBranchSnapshot(repoPath, target.base);
|
|
270
|
+
case "commit":
|
|
271
|
+
return resolveCommitSnapshot(repoPath, target.sha);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Remove bulk diff text before retaining snapshot metadata in tool results. */
|
|
276
|
+
export function summarizeReviewSnapshot(snapshot: ReviewSnapshot): ReviewSnapshotSummary {
|
|
277
|
+
return {
|
|
278
|
+
target: snapshot.target,
|
|
279
|
+
title: snapshot.title,
|
|
280
|
+
changedFiles: [...snapshot.changedFiles],
|
|
281
|
+
stats: { ...snapshot.stats },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Compute an abort-aware deterministic snapshot fingerprint.
|
|
287
|
+
* Untracked regular files are streamed, symlinks are hashed by link identity,
|
|
288
|
+
* and special file types are encoded without opening potentially blocking paths.
|
|
289
|
+
*/
|
|
290
|
+
export async function fingerprintReviewSnapshot(
|
|
291
|
+
repoPath: string,
|
|
292
|
+
snapshot: ReviewSnapshot,
|
|
293
|
+
signal?: AbortSignal,
|
|
294
|
+
): Promise<string> {
|
|
295
|
+
const changedFiles = [...snapshot.changedFiles].sort((left, right) => left.localeCompare(right));
|
|
296
|
+
const hash = createHash("sha256").update(
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
target: snapshot.target,
|
|
299
|
+
changedFiles,
|
|
300
|
+
diffText: snapshot.diffText,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
for (const file of extractUntrackedFiles(snapshot)) {
|
|
305
|
+
signal?.throwIfAborted();
|
|
306
|
+
const filePath = join(repoPath, file);
|
|
307
|
+
hash.update(`\nuntracked:${file}\n`);
|
|
308
|
+
try {
|
|
309
|
+
const stat = await lstat(filePath);
|
|
310
|
+
signal?.throwIfAborted();
|
|
311
|
+
hash.update(`mode:${stat.mode.toString(8)}\n`);
|
|
312
|
+
if (stat.isSymbolicLink()) {
|
|
313
|
+
hash.update(`symlink:${await readlink(filePath)}\n`);
|
|
314
|
+
} else if (stat.isFile()) {
|
|
315
|
+
for await (const chunk of createReadStream(filePath, { signal })) {
|
|
316
|
+
hash.update(chunk);
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
hash.update("[unsupported-file-type]");
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
hash.update("[unavailable]");
|
|
324
|
+
}
|
|
325
|
+
signal?.throwIfAborted();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return hash.digest("hex");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function extractUntrackedFiles(snapshot: ReviewSnapshot): string[] {
|
|
332
|
+
if (snapshot.target.kind !== "working-tree") return [];
|
|
333
|
+
const marker = "=== Untracked files ===\n";
|
|
334
|
+
const markerIndex = snapshot.diffText.indexOf(marker);
|
|
335
|
+
if (markerIndex < 0) return [];
|
|
336
|
+
const changedFiles = new Set(snapshot.changedFiles);
|
|
337
|
+
return snapshot.diffText
|
|
338
|
+
.slice(markerIndex + marker.length)
|
|
339
|
+
.split("\n")
|
|
340
|
+
.map((file) => file.trim())
|
|
341
|
+
.filter((file) => file.length > 0 && changedFiles.has(file))
|
|
342
|
+
.sort((left, right) => left.localeCompare(right));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Re-resolve a snapshot target and report whether its review evidence has drifted. */
|
|
346
|
+
export async function checkReviewSnapshotFreshness(
|
|
347
|
+
repoPath: string,
|
|
348
|
+
snapshot: ReviewSnapshot,
|
|
349
|
+
expectedFingerprint: string,
|
|
350
|
+
signal?: AbortSignal,
|
|
351
|
+
): Promise<{ fresh: true } | { fresh: false; reason: string }> {
|
|
352
|
+
const current = await resolveReviewSnapshot(repoPath, snapshot.target);
|
|
353
|
+
if (!current) {
|
|
354
|
+
return {
|
|
355
|
+
fresh: false,
|
|
356
|
+
reason: "The prepared review target no longer has reviewable changes.",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if ((await fingerprintReviewSnapshot(repoPath, current, signal)) !== expectedFingerprint) {
|
|
361
|
+
return {
|
|
362
|
+
fresh: false,
|
|
363
|
+
reason:
|
|
364
|
+
"The review target changed after the brief was prepared. Run supi_review_prepare again.",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { fresh: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
233
371
|
/** Resolve the current working tree into a concrete review snapshot. */
|
|
234
372
|
export async function resolveWorkingTreeSnapshot(
|
|
235
373
|
repoPath: string,
|
|
@@ -269,19 +407,54 @@ export async function resolveBranchSnapshot(
|
|
|
269
407
|
};
|
|
270
408
|
}
|
|
271
409
|
|
|
410
|
+
async function resolveCommitObjectId(
|
|
411
|
+
repoPath: string,
|
|
412
|
+
revision: string,
|
|
413
|
+
): Promise<string | undefined> {
|
|
414
|
+
if (!isCommitObjectId(revision)) return undefined;
|
|
415
|
+
try {
|
|
416
|
+
const { stdout } = await execFileAsync(
|
|
417
|
+
"git",
|
|
418
|
+
["rev-parse", `--disambiguate=${revision}`],
|
|
419
|
+
gitExecOptions(repoPath),
|
|
420
|
+
);
|
|
421
|
+
const candidates = Array.from(
|
|
422
|
+
new Set(
|
|
423
|
+
stdout
|
|
424
|
+
.split("\n")
|
|
425
|
+
.map((candidate) => candidate.trim())
|
|
426
|
+
.filter((candidate) => isCommitObjectId(candidate)),
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
if (candidates.length !== 1) return undefined;
|
|
430
|
+
|
|
431
|
+
const candidate = candidates[0];
|
|
432
|
+
const { stdout: objectType } = await execFileAsync(
|
|
433
|
+
"git",
|
|
434
|
+
["cat-file", "-t", candidate],
|
|
435
|
+
gitExecOptions(repoPath),
|
|
436
|
+
);
|
|
437
|
+
return objectType.trim() === "commit" ? candidate : undefined;
|
|
438
|
+
} catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
272
443
|
/** Resolve one commit into a concrete review snapshot. */
|
|
273
444
|
export async function resolveCommitSnapshot(
|
|
274
445
|
repoPath: string,
|
|
275
446
|
sha: string,
|
|
276
447
|
): Promise<ReviewSnapshot | undefined> {
|
|
448
|
+
const resolvedSha = await resolveCommitObjectId(repoPath, sha);
|
|
449
|
+
if (!resolvedSha) return undefined;
|
|
277
450
|
const [diffText, changedFiles] = await Promise.all([
|
|
278
|
-
getCommitShow(repoPath,
|
|
279
|
-
getCommitFileNames(repoPath,
|
|
451
|
+
getCommitShow(repoPath, resolvedSha),
|
|
452
|
+
getCommitFileNames(repoPath, resolvedSha),
|
|
280
453
|
]);
|
|
281
454
|
if (!diffText.trim() && changedFiles.length === 0) return undefined;
|
|
282
455
|
return {
|
|
283
|
-
target: { kind: "commit", sha },
|
|
284
|
-
title: `Commit ${
|
|
456
|
+
target: { kind: "commit", sha: resolvedSha },
|
|
457
|
+
title: `Commit ${resolvedSha.slice(0, 7)}`,
|
|
285
458
|
changedFiles,
|
|
286
459
|
diffText,
|
|
287
460
|
stats: parseDiffStats(diffText),
|
|
@@ -309,7 +482,7 @@ export async function getSnapshotFileDiff(
|
|
|
309
482
|
if (!baseSha) return "";
|
|
310
483
|
const { stdout } = await execFileAsync(
|
|
311
484
|
"git",
|
|
312
|
-
["diff", baseSha, "HEAD", "--", file],
|
|
485
|
+
["diff", "--end-of-options", baseSha, "HEAD", "--", file],
|
|
313
486
|
gitExecOptions(repoPath),
|
|
314
487
|
);
|
|
315
488
|
return stdout;
|
|
@@ -317,7 +490,7 @@ export async function getSnapshotFileDiff(
|
|
|
317
490
|
case "commit": {
|
|
318
491
|
const { stdout } = await execFileAsync(
|
|
319
492
|
"git",
|
|
320
|
-
["show", target.sha, "--", file],
|
|
493
|
+
["show", "--end-of-options", target.sha, "--", file],
|
|
321
494
|
gitExecOptions(repoPath),
|
|
322
495
|
);
|
|
323
496
|
return stdout;
|
|
@@ -329,7 +502,7 @@ export async function getSnapshotFileDiff(
|
|
|
329
502
|
async function showGitBlob(repoPath: string, ref: string, file: string): Promise<string> {
|
|
330
503
|
const { stdout } = await execFileAsync(
|
|
331
504
|
"git",
|
|
332
|
-
["show", `${ref}:${file}`],
|
|
505
|
+
["show", "--end-of-options", `${ref}:${file}`],
|
|
333
506
|
gitExecOptions(repoPath),
|
|
334
507
|
);
|
|
335
508
|
return stdout;
|
|
@@ -348,7 +521,11 @@ async function resolveWorkingTreeContent(
|
|
|
348
521
|
}
|
|
349
522
|
}
|
|
350
523
|
try {
|
|
351
|
-
|
|
524
|
+
const filePath = join(repoPath, file);
|
|
525
|
+
const stat = await lstat(filePath);
|
|
526
|
+
if (stat.isSymbolicLink()) return await readlink(filePath);
|
|
527
|
+
if (!stat.isFile()) return undefined;
|
|
528
|
+
return await readFile(filePath, "utf-8");
|
|
352
529
|
} catch {
|
|
353
530
|
return undefined;
|
|
354
531
|
}
|
|
@@ -10,6 +10,9 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
|
|
12
12
|
|
|
13
|
+
/** Version of the brief-synthesis prompt contract retained in evaluation artifacts. */
|
|
14
|
+
export const BRIEF_SYNTHESIS_PROMPT_VERSION = "1";
|
|
15
|
+
|
|
13
16
|
export interface SynthesizeReviewBriefOptions {
|
|
14
17
|
model: ReviewModelSelection;
|
|
15
18
|
modelRegistry: ModelRegistry;
|
package/src/model.ts
CHANGED
|
@@ -16,3 +16,22 @@ export function getSelectableReviewModels(
|
|
|
16
16
|
): ReviewModelSelection[] {
|
|
17
17
|
return getSelectableModels(ctx, enabledModelPatterns) as ReviewModelSelection[];
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the current session model for a non-interactive agent-driven review. */
|
|
21
|
+
export function getCurrentReviewModel(
|
|
22
|
+
ctx: Pick<ExtensionContext, "model">,
|
|
23
|
+
): ReviewModelSelection | undefined {
|
|
24
|
+
const model = ctx.model;
|
|
25
|
+
if (!model) return undefined;
|
|
26
|
+
|
|
27
|
+
const canonicalId = `${model.provider}/${model.id}`;
|
|
28
|
+
return {
|
|
29
|
+
canonicalId,
|
|
30
|
+
provider: model.provider,
|
|
31
|
+
id: model.id,
|
|
32
|
+
model,
|
|
33
|
+
label: model.name ?? canonicalId,
|
|
34
|
+
description: canonicalId,
|
|
35
|
+
isCurrent: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/review.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { serializeSessionContext } from "./history/collect.ts";
|
|
|
6
6
|
import { synthesizeReviewBrief } from "./history/synthesize.ts";
|
|
7
7
|
import { normalizeReviewResult } from "./review-result.ts";
|
|
8
8
|
import { buildReviewPacket } from "./target/packet.ts";
|
|
9
|
+
import { registerAgentReviewTools } from "./tool/agent-review-tools.ts";
|
|
9
10
|
import { runReviewer } from "./tool/review-runner.ts";
|
|
10
11
|
import type {
|
|
11
12
|
BriefSynthesisRunResult,
|
|
@@ -23,6 +24,7 @@ type CommandContext = Parameters<Parameters<ExtensionAPI["registerCommand"]>[1][
|
|
|
23
24
|
|
|
24
25
|
export default function reviewExtension(pi: ExtensionAPI) {
|
|
25
26
|
registerReviewRenderer(pi);
|
|
27
|
+
registerAgentReviewTools(pi);
|
|
26
28
|
|
|
27
29
|
pi.registerCommand("supi-review", {
|
|
28
30
|
description: "Run a structured code review informed by the current session history",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ReviewModelSelection, ReviewSnapshot, SynthesizedReviewBrief } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
/** Session-scoped state retained between review preparation and execution. */
|
|
5
|
+
export interface StoredAgentReviewPlan {
|
|
6
|
+
id: string;
|
|
7
|
+
snapshot: ReviewSnapshot;
|
|
8
|
+
snapshotFingerprint: string;
|
|
9
|
+
generatedBrief: SynthesizedReviewBrief;
|
|
10
|
+
model: ReviewModelSelection;
|
|
11
|
+
briefPromptVersion: string;
|
|
12
|
+
createdAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Inputs retained when creating a session-scoped review plan. */
|
|
16
|
+
export interface CreateAgentReviewPlanInput {
|
|
17
|
+
snapshot: ReviewSnapshot;
|
|
18
|
+
snapshotFingerprint: string;
|
|
19
|
+
generatedBrief: SynthesizedReviewBrief;
|
|
20
|
+
model: ReviewModelSelection;
|
|
21
|
+
briefPromptVersion: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Owns prepared review plans for one PI extension session.
|
|
26
|
+
*
|
|
27
|
+
* Plans are claimed atomically before reviewer child sessions start, preventing
|
|
28
|
+
* duplicate runs from concurrent sibling tool calls.
|
|
29
|
+
*/
|
|
30
|
+
export class ReviewPlanStore {
|
|
31
|
+
readonly #plans = new Map<string, StoredAgentReviewPlan>();
|
|
32
|
+
#sequence = 0;
|
|
33
|
+
|
|
34
|
+
/** Create and retain one prepared review plan. */
|
|
35
|
+
create(input: CreateAgentReviewPlanInput): StoredAgentReviewPlan {
|
|
36
|
+
const createdAt = Date.now();
|
|
37
|
+
const id = this.createPlanId(input.snapshotFingerprint, createdAt);
|
|
38
|
+
const plan: StoredAgentReviewPlan = { id, createdAt, ...input };
|
|
39
|
+
this.#plans.set(id, plan);
|
|
40
|
+
return plan;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a plan without consuming it. */
|
|
44
|
+
get(id: string): StoredAgentReviewPlan | undefined {
|
|
45
|
+
return this.#plans.get(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Atomically consume a plan so it can run at most once. */
|
|
49
|
+
take(id: string): StoredAgentReviewPlan | undefined {
|
|
50
|
+
const plan = this.#plans.get(id);
|
|
51
|
+
if (!plan) return undefined;
|
|
52
|
+
this.#plans.delete(id);
|
|
53
|
+
return plan;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove all plans when PI replaces or reloads the session. */
|
|
57
|
+
clear(): void {
|
|
58
|
+
this.#plans.clear();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private createPlanId(snapshotFingerprint: string, createdAt: number): string {
|
|
62
|
+
this.#sequence++;
|
|
63
|
+
const hash = createHash("sha256")
|
|
64
|
+
.update(`${snapshotFingerprint}:${createdAt}:${this.#sequence}`)
|
|
65
|
+
.digest("hex")
|
|
66
|
+
.slice(0, 12);
|
|
67
|
+
return `review-plan-${hash}`;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/target/packet.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ReviewerAssignment,
|
|
2
3
|
ReviewInstructionBlockId,
|
|
3
4
|
ReviewModelSelection,
|
|
4
5
|
ReviewPacket,
|
|
@@ -90,6 +91,7 @@ export function buildReviewPacket(
|
|
|
90
91
|
snapshot: ReviewSnapshot,
|
|
91
92
|
brief: SynthesizedReviewBrief,
|
|
92
93
|
model: ReviewModelSelection,
|
|
94
|
+
assignment?: ReviewerAssignment,
|
|
93
95
|
): ReviewPacket {
|
|
94
96
|
const previewData = buildReviewPacketPreviewData(snapshot, brief.reviewInstructionBlockIds);
|
|
95
97
|
|
|
@@ -111,6 +113,18 @@ export function buildReviewPacket(
|
|
|
111
113
|
"",
|
|
112
114
|
"## Open questions",
|
|
113
115
|
...toBullets(brief.unresolvedQuestions, "- No unresolved questions identified."),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (assignment) {
|
|
119
|
+
parts.push(
|
|
120
|
+
"",
|
|
121
|
+
"## Delegated reviewer focus",
|
|
122
|
+
`Reviewer assignment: ${assignment.id}`,
|
|
123
|
+
assignment.focus,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
parts.push(
|
|
114
128
|
"",
|
|
115
129
|
"## Snapshot under review",
|
|
116
130
|
`Target: ${snapshot.title}`,
|
|
@@ -120,7 +134,7 @@ export function buildReviewPacket(
|
|
|
120
134
|
"",
|
|
121
135
|
"## Changed files manifest",
|
|
122
136
|
...snapshot.changedFiles.map((file) => `- ${file}`),
|
|
123
|
-
|
|
137
|
+
);
|
|
124
138
|
|
|
125
139
|
if (previewData.reviewInstructionBlocks.length > 0) {
|
|
126
140
|
parts.push(
|