@mrclrchtr/supi-review 2.5.0 → 2.6.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
@@ -8,7 +8,7 @@
8
8
 
9
9
  # @mrclrchtr/supi-review
10
10
 
11
- Adds an interactive `/supi-review` command to the [pi coding agent](https://github.com/earendil-works/pi) for session-aware code review.
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 commit
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-review",
3
- "version": "2.5.0",
4
- "description": "SuPi Review extension — structured code review via /supi-review command",
3
+ "version": "2.6.1",
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.5.0"
34
+ "@mrclrchtr/supi-core": "2.6.1"
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 { readFile } from "node:fs/promises";
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 { DiffStats, ReviewSnapshot, ReviewTargetSpec } from "./types.ts";
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", branch],
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("git", ["diff", baseSha], gitExecOptions(repoPath));
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("git", ["show", sha], gitExecOptions(repoPath));
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, sha),
279
- getCommitFileNames(repoPath, sha),
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 ${sha.slice(0, 7)}`,
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
- return await readFile(join(repoPath, file), "utf-8");
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
  }
@@ -1,4 +1,3 @@
1
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
1
  import { listReviewInstructionBlocks } from "../target/packet.ts";
3
2
  import { runBriefSynthesis } from "../tool/brief-runner.ts";
4
3
  import type {
@@ -10,9 +9,11 @@ import type {
10
9
 
11
10
  const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
12
11
 
12
+ /** Version of the brief-synthesis prompt contract retained in evaluation artifacts. */
13
+ export const BRIEF_SYNTHESIS_PROMPT_VERSION = "1";
14
+
13
15
  export interface SynthesizeReviewBriefOptions {
14
16
  model: ReviewModelSelection;
15
- modelRegistry: ModelRegistry;
16
17
  cwd: string;
17
18
  snapshot: ReviewSnapshot;
18
19
  serializedContext: string;
@@ -25,13 +26,11 @@ export interface SynthesizeReviewBriefOptions {
25
26
  export function synthesizeReviewBrief(
26
27
  options: SynthesizeReviewBriefOptions,
27
28
  ): Promise<BriefSynthesisRunResult> {
28
- const { model, modelRegistry, cwd, snapshot, serializedContext, note, signal, onProgress } =
29
- options;
29
+ const { model, cwd, snapshot, serializedContext, note, signal, onProgress } = options;
30
30
 
31
31
  return runBriefSynthesis({
32
32
  prompt: buildBriefSynthesisPrompt(snapshot, serializedContext, note),
33
33
  model: model.model,
34
- modelRegistry,
35
34
  cwd,
36
35
  signal,
37
36
  onProgress,
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",
@@ -62,7 +64,6 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
62
64
  (signal: AbortSignal, onProgress: (p: WidgetProgress) => void) =>
63
65
  synthesizeReviewBrief({
64
66
  model,
65
- modelRegistry: ctx.modelRegistry,
66
67
  cwd: ctx.cwd,
67
68
  snapshot,
68
69
  serializedContext,
@@ -112,7 +113,6 @@ async function handleInteractive(ctx: CommandContext, pi: ExtensionAPI): Promise
112
113
  runReviewer({
113
114
  prompt: plan.packet.prompt,
114
115
  model: plan.model,
115
- modelRegistry: ctx.modelRegistry,
116
116
  cwd: ctx.cwd,
117
117
  signal,
118
118
  snapshot: plan.snapshot,
@@ -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
+ }
@@ -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(