@mrclrchtr/supi-review 2.8.0 → 3.0.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.
Files changed (44) hide show
  1. package/README.md +71 -180
  2. package/node_modules/@mrclrchtr/supi-core/package.json +2 -1
  3. package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +40 -0
  5. package/package.json +3 -3
  6. package/src/config.ts +30 -21
  7. package/src/git-command.ts +78 -0
  8. package/src/git.ts +306 -505
  9. package/src/history/collect.ts +43 -120
  10. package/src/model.ts +3 -1
  11. package/src/review-path.ts +85 -0
  12. package/src/review-result.ts +43 -92
  13. package/src/review.ts +154 -290
  14. package/src/session/review-plan-store.ts +20 -51
  15. package/src/target/packet.ts +46 -369
  16. package/src/tool/agent-review-schemas.ts +101 -104
  17. package/src/tool/agent-review-tools.ts +268 -309
  18. package/src/tool/child-failure-diagnostics.ts +1 -1
  19. package/src/tool/child-resource-loader.ts +37 -0
  20. package/src/tool/child-session-runner.ts +83 -0
  21. package/src/tool/output-page.ts +47 -0
  22. package/src/tool/planner-runner.ts +69 -0
  23. package/src/tool/review-runner.ts +42 -257
  24. package/src/tool/review-system-prompt.ts +12 -110
  25. package/src/tool/review-tools.ts +119 -0
  26. package/src/tool/review-workflow.ts +309 -0
  27. package/src/tool/runner-helpers.ts +3 -8
  28. package/src/tool/schemas.ts +75 -62
  29. package/src/tui/common.ts +175 -0
  30. package/src/tui/prepare.ts +132 -0
  31. package/src/tui/run.ts +160 -0
  32. package/src/types.ts +153 -275
  33. package/src/history/synthesize.ts +0 -121
  34. package/src/tool/agent-review-workflow.ts +0 -398
  35. package/src/tool/brief-runner.ts +0 -180
  36. package/src/tool/guidance.ts +0 -21
  37. package/src/tool/review-handlers.ts +0 -314
  38. package/src/tool/snapshot-tools.ts +0 -117
  39. package/src/ui/flow.ts +0 -189
  40. package/src/ui/format-content.ts +0 -143
  41. package/src/ui/renderer.ts +0 -274
  42. package/src/ui/review-plan-inspector.ts +0 -391
  43. package/src/ui/review-tool-format.ts +0 -138
  44. package/src/ui/review-tool-renderer.ts +0 -234
@@ -1,397 +1,74 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type {
2
- ReviewerAssignment,
3
- ReviewInstructionBlockId,
3
+ ResolvedReviewTarget,
4
+ ReviewInput,
4
5
  ReviewModelSelection,
5
6
  ReviewPacket,
6
7
  ReviewSnapshot,
7
- SynthesizedReviewBrief,
8
+ ReviewTask,
8
9
  } from "../types.ts";
9
- export interface ReviewInstructionBlock {
10
- id: ReviewInstructionBlockId;
11
- title: string;
12
- instruction: string;
13
- }
14
10
 
15
- const REVIEW_INSTRUCTION_BLOCKS: readonly ReviewInstructionBlock[] = [
16
- {
17
- id: "public-surface",
18
- title: "Public-surface / rename / merge audit",
19
- instruction:
20
- "Sweep source, tests, docs, user-facing strings, and debug/status lists for stale public names after renames, removals, or merges.",
21
- },
22
- {
23
- id: "cross-layer",
24
- title: "Cross-layer propagation audit",
25
- instruction:
26
- "Verify every provider/runtime/orchestration/presentation/test handoff and look for at least one end-to-end expectation covering the threaded behavior.",
27
- },
28
- {
29
- id: "schema-widening",
30
- title: "Enum / operation / schema widening audit",
31
- instruction:
32
- "Audit validation, unavailable paths, branch coverage, aliases, and negative tests for widened enums, operations, or schemas.",
33
- },
34
- {
35
- id: "cleanup",
36
- title: "Cleanup / deletion / orphan audit",
37
- instruction:
38
- "Check for orphan files, dead imports or re-exports, stale comments, and outdated expectations after deletions or consumer removals.",
39
- },
40
- ];
11
+ /** Protocol version included in every canonical reviewer packet for future evolution. */
12
+ export const REVIEW_PACKET_PROTOCOL_VERSION = "1";
41
13
 
42
- /** Return the full fixed catalog of review instruction blocks. */
43
- export function listReviewInstructionBlocks(): readonly ReviewInstructionBlock[] {
44
- return REVIEW_INSTRUCTION_BLOCKS;
14
+ function sha256(value: string): string {
15
+ return createHash("sha256").update(value, "utf8").digest("hex");
45
16
  }
46
17
 
47
- /** Resolve a brief-selected block ID list into canonical host-owned prompt blocks. */
48
- export function resolveReviewInstructionBlocks(
49
- ids: readonly ReviewInstructionBlockId[],
50
- ): ReviewInstructionBlock[] {
51
- const resolved: ReviewInstructionBlock[] = [];
52
- const seen = new Set<ReviewInstructionBlockId>();
53
-
54
- for (const id of ids) {
55
- if (seen.has(id)) continue;
56
- seen.add(id);
57
- const block = REVIEW_INSTRUCTION_BLOCKS.find((b) => b.id === id);
58
- if (block) resolved.push(block);
18
+ function targetIdentity(target: ResolvedReviewTarget): string {
19
+ if (target.kind === "working-tree") return `working-tree head=${target.headCommit}`;
20
+ if (target.kind === "comparison") {
21
+ return [
22
+ "comparison",
23
+ `requested-base=${target.requestedBaseCommit}`,
24
+ `merge-base=${target.mergeBaseCommit}`,
25
+ `head=${target.headCommit}`,
26
+ ].join(" ");
59
27
  }
60
-
61
- return resolved;
62
- }
63
-
64
- export interface DiffSection {
65
- file: string;
66
- text: string;
67
- additions: number;
68
- deletions: number;
69
- }
70
-
71
- export interface ReviewPacketPreviewFileRow {
72
- file: string;
73
- additions: number | null;
74
- deletions: number | null;
75
- annotations: string[];
76
- }
77
-
78
- export interface ReviewPacketPreviewData {
79
- reviewInstructionBlocks: ReviewInstructionBlock[];
80
- fileOverview: ReviewPacketPreviewFileRow[];
81
- snapshotNotes?: string;
28
+ return [
29
+ "commit",
30
+ `commit=${target.commit}`,
31
+ `parent=${target.parentCommit ?? "empty-tree"}`,
32
+ ].join(" ");
82
33
  }
83
34
 
84
- /** Build a compact review packet for the reviewer child session.
85
- *
86
- * The packet contains only the session-derived brief, target metadata, and a
87
- * changed-file overview. No large inline diffs are included. The reviewer uses
88
- * read_snapshot_diff and read_snapshot_file tools to inspect diffs on demand.
89
- */
35
+ /** Build the canonical caller-policy/engine-mechanics reviewer packet. */
90
36
  export function buildReviewPacket(
91
37
  snapshot: ReviewSnapshot,
92
- brief: SynthesizedReviewBrief,
38
+ review: ReviewInput,
39
+ task: ReviewTask,
93
40
  model: ReviewModelSelection,
94
- assignment?: ReviewerAssignment,
95
41
  ): ReviewPacket {
96
- const previewData = buildReviewPacketPreviewData(snapshot, brief.reviewInstructionBlockIds);
97
-
98
- const parts: string[] = [
42
+ const parts = [
99
43
  "# Review Task",
100
44
  "",
101
- "## Session-derived intent",
102
- `Summary: ${brief.summary}`,
103
- `Intended outcome: ${brief.intendedOutcome}`,
104
- "",
105
- "## Constraints to preserve",
106
- ...toBullets(brief.constraints, "- No explicit constraints extracted."),
107
- "",
108
- "## Focus areas",
109
- ...toBullets(brief.focusAreas, "- Review overall correctness and consistency."),
110
- "",
111
- "## Risky files",
112
- ...toBullets(brief.riskyFiles, "- No risky files explicitly called out."),
113
- "",
114
- "## Open questions",
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(
128
- "",
129
- "## Snapshot under review",
45
+ `Protocol version: ${REVIEW_PACKET_PROTOCOL_VERSION}`,
46
+ `Task id: ${task.id}`,
130
47
  `Target: ${snapshot.title}`,
131
- `Files changed: ${snapshot.changedFiles.length}`,
132
- `Diff stats: +${snapshot.stats.additions} / -${snapshot.stats.deletions}`,
48
+ `Target identity: ${targetIdentity(snapshot.target)}`,
49
+ `Target diff SHA-256: ${sha256(snapshot.diffText)}`,
133
50
  `Reviewer model: ${model.canonicalId}`,
134
- "",
135
- "## Changed files manifest",
136
- ...snapshot.changedFiles.map((file) => `- ${file}`),
137
- );
138
-
139
- if (previewData.reviewInstructionBlocks.length > 0) {
140
- parts.push(
141
- "",
142
- "## Mandatory review instructions",
143
- ...formatReviewInstructionBlocks(previewData.reviewInstructionBlocks),
144
- );
145
- }
146
-
147
- parts.push("", buildFileOverviewTable(previewData.fileOverview));
148
-
149
- if (previewData.snapshotNotes) {
150
- parts.push("", "## Snapshot notes", previewData.snapshotNotes);
51
+ `Changed files: ${snapshot.changedFiles.length}`,
52
+ `Diff stats: +${snapshot.stats.additions} / -${snapshot.stats.deletions}`,
53
+ ];
54
+ if (review.sharedContext?.trim()) {
55
+ parts.push("", "## Shared context", review.sharedContext.trim());
151
56
  }
152
-
153
57
  parts.push(
154
58
  "",
155
- "## On-demand snapshot inspection",
156
- "Use read_snapshot_diff <file> to see the exact diff for any changed file.",
157
- "Use read_snapshot_file <file> before|after to inspect file contents on either side of the change.",
158
- "These tools are scoped to the snapshot's changed-files list — request a file from the manifest above.",
59
+ "## Task instructions",
60
+ task.instructions.trim(),
61
+ "",
62
+ "## Changed files",
63
+ ...snapshot.changedFiles.map((file) => `- ${JSON.stringify(file)}`),
159
64
  "",
160
- "Combine snapshot inspection with read/grep/find/ls for broader codebase context.",
65
+ "## Inspection",
66
+ "Use list_review_files, read_review_diff, read_review_file, and search_review_files.",
67
+ "All tools resolve against the selected review target.",
161
68
  "",
162
69
  "## Delivery",
163
- "Call **submit_review** to submit your review.",
70
+ "Call submit_review exactly once with the task summary and findings.",
164
71
  );
165
-
166
- const includedFiles = snapshot.changedFiles.filter((f) => !classifySkipCategory(f));
167
- const omittedFiles = snapshot.changedFiles.filter((f) => !!classifySkipCategory(f));
168
- const charBudget = getPacketCharBudget(model);
169
-
170
- return { prompt: parts.join("\n"), includedFiles, omittedFiles, charBudget };
171
- }
172
-
173
- /** Derive a conservative prompt budget from the selected model's context window. */
174
- export function getPacketCharBudget(model: ReviewModelSelection): number {
175
- const contextWindow = model.model.contextWindow;
176
- if (!contextWindow || contextWindow <= 0) {
177
- return 32_000;
178
- }
179
-
180
- const tokenBudget = Math.max(512, Math.min(32_000, Math.floor(contextWindow * 0.2)));
181
- return tokenBudget * 4;
182
- }
183
-
184
- function countDiffLines(lines: string[]): { additions: number; deletions: number } {
185
- let additions = 0;
186
- let deletions = 0;
187
- for (const line of lines) {
188
- // Skip diff header lines (e.g. "--- a/file", "+ + + b/file").
189
- // The trailing space ensures we do not skip content lines whose first
190
- // characters happen to be "+++" or "---".
191
- if (line.startsWith("--- ") || line.startsWith("+++ ")) continue;
192
- if (line.startsWith("+")) additions++;
193
- else if (line.startsWith("-")) deletions++;
194
- }
195
- return { additions, deletions };
196
- }
197
-
198
- export function splitDiffSections(text: string): { preamble: string; sections: DiffSection[] } {
199
- const lines = text.split("\n");
200
- const preamble: string[] = [];
201
- const sections: DiffSection[] = [];
202
- let current: string[] = [];
203
- let currentFile: string | undefined;
204
-
205
- const flush = () => {
206
- if (current.length === 0 || !currentFile) {
207
- current = [];
208
- currentFile = undefined;
209
- return;
210
- }
211
- const stats = countDiffLines(current);
212
- sections.push({
213
- file: currentFile,
214
- text: current.join("\n").trimEnd(),
215
- additions: stats.additions,
216
- deletions: stats.deletions,
217
- });
218
- current = [];
219
- currentFile = undefined;
220
- };
221
-
222
- for (const line of lines) {
223
- if (line.startsWith("diff --git ")) {
224
- flush();
225
- current = [line];
226
- currentFile = parseDiffFile(line);
227
- continue;
228
- }
229
-
230
- if (/^=== .* ===$/.test(line) && current.length > 0) {
231
- flush();
232
- preamble.push(line);
233
- continue;
234
- }
235
-
236
- if (current.length > 0) {
237
- current.push(line);
238
- if (!currentFile && line.startsWith("+++ b/")) {
239
- currentFile = line.slice(6).trim();
240
- }
241
- continue;
242
- }
243
-
244
- preamble.push(line);
245
- }
246
-
247
- flush();
248
- return { preamble: preamble.join("\n").trim(), sections };
249
- }
250
-
251
- function parseDiffFile(line: string): string | undefined {
252
- const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line.trim());
253
- if (!match) return undefined;
254
- const next = match[2] ?? match[1];
255
- return next === "/dev/null" ? match[1] : next;
256
- }
257
-
258
- /** Categorize a file path for skip-list annotation, or undefined if it should be reviewed. */
259
- export function classifySkipCategory(file: string): string | undefined {
260
- const lockfiles = new Set([
261
- "package-lock.json",
262
- "yarn.lock",
263
- "pnpm-lock.yaml",
264
- "Gemfile.lock",
265
- "Cargo.lock",
266
- "poetry.lock",
267
- "composer.lock",
268
- ]);
269
-
270
- const name = file.split("/").pop() ?? file;
271
- if (lockfiles.has(name)) return "lockfile";
272
- if (name.startsWith("CHANGELOG") || name.startsWith("CHANGES") || name === "CHANGE_LOG") {
273
- return "changelog";
274
- }
275
-
276
- if (file.includes("__snapshots__/") || name.endsWith(".snap")) {
277
- return "snapshot";
278
- }
279
-
280
- if (
281
- file.includes("dist/") ||
282
- file.includes("build/") ||
283
- file.includes(".next/") ||
284
- file.includes("__generated__/")
285
- ) {
286
- return "generated";
287
- }
288
-
289
- if (file.includes("vendor/") || file.includes("third_party/")) {
290
- return "vendored";
291
- }
292
-
293
- if (name.endsWith(".min.js") || name.endsWith(".min.css")) return "generated";
294
-
295
- return undefined;
296
- }
297
-
298
- /** Derive structured preview data shared by the packet builder and inspector UI. */
299
- export function buildReviewPacketPreviewData(
300
- snapshot: ReviewSnapshot,
301
- reviewInstructionBlockIds: readonly ReviewInstructionBlockId[] = [],
302
- ): ReviewPacketPreviewData {
303
- const { preamble, sections } = splitDiffSections(snapshot.diffText);
304
- const { statsMap, binaryFiles } = buildDiffSectionStats(sections);
305
-
306
- return {
307
- reviewInstructionBlocks: resolveReviewInstructionBlocks(reviewInstructionBlockIds),
308
- fileOverview: snapshot.changedFiles.map((file) =>
309
- buildPreviewFileRow(file, statsMap, binaryFiles),
310
- ),
311
- snapshotNotes: preamble.trim() ? truncate(preamble.trim(), 1_500) : undefined,
312
- };
313
- }
314
-
315
- function buildPreviewFileRow(
316
- file: string,
317
- statsMap: Map<string, { additions: number; deletions: number }>,
318
- binaryFiles: Set<string>,
319
- ): ReviewPacketPreviewFileRow {
320
- const stats = statsMap.get(file);
321
- const skipCategory = classifySkipCategory(file);
322
- const annotations: string[] = [];
323
-
324
- if (!stats) {
325
- // File in the changed-files manifest but not in the parsed diff sections.
326
- // This happens for untracked files in working-tree snapshots, which
327
- // have no diff section to parse. Do not guess 0/0 or mark as trivial.
328
- if (skipCategory) annotations.push(`skip — ${skipCategory}`);
329
- return { file, additions: null, deletions: null, annotations };
330
- }
331
-
332
- if (binaryFiles.has(file)) {
333
- // Binary diffs have no diffable +/- lines. Show unknown stats.
334
- if (skipCategory) annotations.push(`skip — ${skipCategory}`);
335
- annotations.push("binary");
336
- return { file, additions: null, deletions: null, annotations };
337
- }
338
-
339
- const total = stats.additions + stats.deletions;
340
- if (total < 5) annotations.push("trivial");
341
- if (skipCategory) annotations.push(`skip — ${skipCategory}`);
342
- return {
343
- file,
344
- additions: stats.additions,
345
- deletions: stats.deletions,
346
- annotations,
347
- };
348
- }
349
-
350
- function buildDiffSectionStats(sections: DiffSection[]): {
351
- statsMap: Map<string, { additions: number; deletions: number }>;
352
- binaryFiles: Set<string>;
353
- } {
354
- const statsMap = new Map<string, { additions: number; deletions: number }>();
355
- const binaryFiles = new Set<string>();
356
-
357
- for (const section of sections) {
358
- const existing = statsMap.get(section.file);
359
- statsMap.set(section.file, {
360
- additions: (existing?.additions ?? 0) + section.additions,
361
- deletions: (existing?.deletions ?? 0) + section.deletions,
362
- });
363
- if (section.text.includes("Binary files ")) {
364
- binaryFiles.add(section.file);
365
- }
366
- }
367
-
368
- return { statsMap, binaryFiles };
369
- }
370
-
371
- function formatOverviewRow(row: ReviewPacketPreviewFileRow): string {
372
- const annotation = row.annotations.length > 0 ? ` (${row.annotations.join(", ")})` : "";
373
- const additions = row.additions === null ? "?" : String(row.additions);
374
- const deletions = row.deletions === null ? "?" : String(row.deletions);
375
- return `| ${row.file} | ${additions} | ${deletions}${annotation} |`;
376
- }
377
-
378
- function buildFileOverviewTable(rows: ReviewPacketPreviewFileRow[]): string {
379
- const header = "| File | +Add | -Del |";
380
- const separator = "|---|---|---|";
381
- const tableRows = rows.map((row) => formatOverviewRow(row));
382
-
383
- return [`## File overview`, "", header, separator, ...tableRows].join("\n");
384
- }
385
-
386
- function formatReviewInstructionBlocks(blocks: ReviewInstructionBlock[]): string[] {
387
- return blocks.map((block) => `- ${block.title}: ${block.instruction}`);
388
- }
389
-
390
- function toBullets(items: string[], fallback: string): string[] {
391
- return items.length > 0 ? items.map((item) => `- ${item}`) : [fallback];
392
- }
393
-
394
- function truncate(text: string, maxChars: number): string {
395
- if (text.length <= maxChars) return text;
396
- return `${text.slice(0, maxChars)}\n[... truncated ...]`;
72
+ const prompt = parts.join("\n");
73
+ return { prompt, packetHash: sha256(prompt), taskId: task.id };
397
74
  }
@@ -1,126 +1,123 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { type Static, Type } from "typebox";
3
- import { reviewBriefSchema } from "./schemas.ts";
3
+ import { Value } from "typebox/value";
4
+ import type { ReviewInput, ReviewTargetSpec } from "../types.ts";
5
+ import { reviewInputSchema } from "./schemas.ts";
4
6
 
5
- const reviewTargetSchema = Type.Object(
7
+ const commitId = Type.String({
8
+ minLength: 40,
9
+ maxLength: 64,
10
+ pattern: "^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$",
11
+ description: "Full hexadecimal Git commit object id (40 or 64 characters).",
12
+ });
13
+ const targetSchema = Type.Object(
6
14
  {
7
- kind: StringEnum(["working-tree", "branch", "commit"] as const, {
8
- description: 'Review target kind. Defaults to "working-tree" when target is omitted.',
9
- }),
10
- base: Type.Optional(
11
- Type.String({ description: 'Required local base branch when kind is "branch".' }),
12
- ),
13
- sha: Type.Optional(Type.String({ description: 'Required commit SHA when kind is "commit".' })),
15
+ kind: StringEnum(["working-tree", "comparison", "commit"] as const),
16
+ baseCommit: Type.Optional(commitId),
17
+ commit: Type.Optional(commitId),
14
18
  },
15
- { additionalProperties: false },
16
- );
17
-
18
- const briefFieldSchema = StringEnum(
19
- [
20
- "summary",
21
- "intendedOutcome",
22
- "constraints",
23
- "focusAreas",
24
- "riskyFiles",
25
- "unresolvedQuestions",
26
- // biome-ignore lint/security/noSecrets: public brief field name, not a secret
27
- "reviewInstructionBlockIds",
28
- ] as const,
29
- { description: "Generated brief field being evaluated." },
19
+ { additionalProperties: false, description: "Git change to review." },
30
20
  );
31
21
 
32
- const critiqueFindingSchema = Type.Object(
22
+ export const prepareReviewSchema = Type.Object(
33
23
  {
34
- kind: StringEnum(["omission", "unsupported-inference", "misprioritized", "unclear"] as const, {
35
- description: "Class of brief defect.",
36
- }),
37
- field: briefFieldSchema,
38
- explanation: Type.String({
39
- minLength: 1,
40
- maxLength: 2_000,
41
- description: "What is wrong with this part of the generated brief.",
42
- }),
43
- evidence: Type.String({
44
- minLength: 1,
45
- maxLength: 2_000,
46
- description: "Session or snapshot evidence supporting the criticism.",
47
- }),
48
- proposedChange: Type.String({
49
- minLength: 1,
50
- maxLength: 2_000,
51
- description: "Concrete change that would improve the brief.",
52
- }),
24
+ target: Type.Optional(targetSchema),
25
+ planning: Type.Optional(
26
+ StringEnum(["none", "suggest"] as const, {
27
+ default: "none",
28
+ description: "Whether the advisory Planner should suggest review tasks.",
29
+ }),
30
+ ),
53
31
  },
54
32
  { additionalProperties: false },
55
33
  );
56
34
 
57
- const briefCritiqueSchema = Type.Object(
35
+ const preparedDecisionSchema = Type.Object(
58
36
  {
59
- verdict: StringEnum(["accept", "revise"] as const, {
60
- description: 'Use "revise" when the generated brief should be changed before review.',
61
- }),
62
- summary: Type.String({
63
- minLength: 1,
64
- maxLength: 2_000,
65
- description: "Concise assessment of the generated brief.",
66
- }),
67
- findings: Type.Array(critiqueFindingSchema, {
68
- maxItems: 20,
69
- description: "Evidence-backed defects; use an empty array when accepting without criticism.",
70
- }),
37
+ kind: StringEnum(["accept-draft", "use-review"] as const),
38
+ review: Type.Optional(reviewInputSchema),
71
39
  },
72
- { additionalProperties: false },
40
+ { additionalProperties: false, description: "Explicit one-shot Prepared Review decision." },
73
41
  );
74
42
 
75
- const reviewerAssignmentSchema = Type.Object(
43
+ /** Object-rooted provider-compatible schema; semantic mode combinations are parsed at runtime. */
44
+ export const runReviewSchema = Type.Object(
76
45
  {
77
- id: Type.String({
78
- minLength: 1,
79
- maxLength: 64,
80
- description: 'Stable short label such as "standards" or "spec".',
81
- }),
82
- focus: Type.String({
83
- minLength: 1,
84
- maxLength: 2_000,
85
- description: "Independent review focus delegated to this reviewer child session.",
86
- }),
46
+ mode: StringEnum(["direct", "prepared"] as const),
47
+ target: Type.Optional(targetSchema),
48
+ review: Type.Optional(reviewInputSchema),
49
+ planId: Type.Optional(Type.String({ minLength: 1, description: "Session-scoped plan id." })),
50
+ decision: Type.Optional(preparedDecisionSchema),
87
51
  },
88
- { additionalProperties: false },
52
+ { additionalProperties: false, description: "Direct or Prepared Review execution request." },
89
53
  );
90
54
 
91
- /** Agent-facing schema for preparing a session-aware review plan. */
92
- export const prepareAgentReviewSchema = Type.Object(
93
- {
94
- target: Type.Optional(reviewTargetSchema),
95
- note: Type.Optional(
96
- Type.String({
97
- maxLength: 4_000,
98
- description: "Optional intent or constraint to emphasize during brief synthesis.",
99
- }),
100
- ),
101
- },
102
- { additionalProperties: false },
103
- );
55
+ type RawPrepareReviewInput = Static<typeof prepareReviewSchema>;
56
+ type RawRunReviewInput = Static<typeof runReviewSchema>;
104
57
 
105
- /** Agent-facing schema for critiquing a prepared brief and running focused reviewers. */
106
- export const runAgentReviewSchema = Type.Object(
107
- {
108
- planId: Type.String({
109
- minLength: 1,
110
- description: "Session-scoped plan id returned by supi_review_prepare.",
111
- }),
112
- critique: briefCritiqueSchema,
113
- revisedBrief: Type.Optional(reviewBriefSchema),
114
- reviewers: Type.Array(reviewerAssignmentSchema, {
115
- minItems: 1,
116
- maxItems: 4,
117
- description: "One to four independent reviewer assignments run concurrently.",
118
- }),
119
- },
120
- { additionalProperties: false },
121
- );
58
+ export interface PrepareReviewToolInput {
59
+ target?: ReviewTargetSpec;
60
+ planning?: "none" | "suggest";
61
+ }
62
+
63
+ export type RunReviewToolInput =
64
+ | { mode: "direct"; target: ReviewTargetSpec; review: ReviewInput }
65
+ | {
66
+ mode: "prepared";
67
+ planId: string;
68
+ decision: { kind: "accept-draft" } | { kind: "use-review"; review: ReviewInput };
69
+ };
70
+
71
+ function parseTarget(input: {
72
+ kind: "working-tree" | "comparison" | "commit";
73
+ baseCommit?: string;
74
+ commit?: string;
75
+ }): ReviewTargetSpec {
76
+ if (input.kind === "working-tree" && !input.baseCommit && !input.commit) {
77
+ return { kind: "working-tree" };
78
+ }
79
+ if (input.kind === "comparison" && input.baseCommit && !input.commit) {
80
+ return { kind: "comparison", baseCommit: input.baseCommit };
81
+ }
82
+ if (input.kind === "commit" && input.commit && !input.baseCommit) {
83
+ return { kind: "commit", commit: input.commit };
84
+ }
85
+ throw new Error(`Target fields do not match target kind "${input.kind}".`);
86
+ }
87
+
88
+ /** Validate and narrow preparation input after provider-level JSON parsing. */
89
+ export function parsePrepareReviewToolInput(input: unknown): PrepareReviewToolInput {
90
+ if (!Value.Check(prepareReviewSchema, input))
91
+ throw new Error("Invalid review preparation input.");
92
+ const parsed = input as RawPrepareReviewInput;
93
+ return {
94
+ ...(parsed.target ? { target: parseTarget(parsed.target) } : {}),
95
+ ...(parsed.planning ? { planning: parsed.planning } : {}),
96
+ };
97
+ }
122
98
 
123
- /** Validated input accepted by `supi_review_prepare`. */
124
- export type PrepareAgentReviewInput = Static<typeof prepareAgentReviewSchema>;
125
- /** Validated input accepted by `supi_review_run`. */
126
- export type RunAgentReviewInput = Static<typeof runAgentReviewSchema>;
99
+ /** Validate and narrow object-rooted parameters into the exact Direct/Prepared contract. */
100
+ export function parseRunReviewToolInput(input: unknown): RunReviewToolInput {
101
+ if (!Value.Check(runReviewSchema, input)) throw new Error("Invalid review execution input.");
102
+ const parsed = input as RawRunReviewInput;
103
+ if (parsed.mode === "direct") {
104
+ if (!parsed.target || !parsed.review || parsed.planId || parsed.decision) {
105
+ throw new Error("Direct Review requires only target and review.");
106
+ }
107
+ return { mode: "direct", target: parseTarget(parsed.target), review: parsed.review };
108
+ }
109
+ if (!parsed.planId || !parsed.decision || parsed.target || parsed.review) {
110
+ throw new Error("Prepared Review requires only planId and decision.");
111
+ }
112
+ if (parsed.decision.kind === "accept-draft" && !parsed.decision.review) {
113
+ return { mode: "prepared", planId: parsed.planId, decision: { kind: "accept-draft" } };
114
+ }
115
+ if (parsed.decision.kind === "use-review" && parsed.decision.review) {
116
+ return {
117
+ mode: "prepared",
118
+ planId: parsed.planId,
119
+ decision: { kind: "use-review", review: parsed.decision.review },
120
+ };
121
+ }
122
+ throw new Error(`Decision fields do not match decision kind "${parsed.decision.kind}".`);
123
+ }