@mrclrchtr/supi-review 1.4.0 → 1.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.
Files changed (43) hide show
  1. package/README.md +53 -59
  2. package/package.json +2 -9
  3. package/src/git.ts +107 -1
  4. package/src/history/collect.ts +210 -0
  5. package/src/history/synthesize.ts +109 -0
  6. package/src/model.ts +100 -0
  7. package/src/review.ts +204 -342
  8. package/src/target/packet.ts +216 -0
  9. package/src/tool/brief-runner.ts +269 -0
  10. package/src/tool/review-runner.ts +434 -0
  11. package/src/tool/runner-types.ts +29 -12
  12. package/src/tool/schemas.ts +31 -0
  13. package/src/types.ts +87 -48
  14. package/src/ui/flow.ts +302 -0
  15. package/src/ui/format-content.ts +22 -16
  16. package/src/ui/progress-widget.ts +40 -40
  17. package/src/ui/renderer.ts +38 -21
  18. package/src/ui/theme-type.ts +16 -0
  19. package/node_modules/@mrclrchtr/supi-core/README.md +0 -107
  20. package/node_modules/@mrclrchtr/supi-core/package.json +0 -44
  21. package/node_modules/@mrclrchtr/supi-core/src/api.ts +0 -83
  22. package/node_modules/@mrclrchtr/supi-core/src/config/config-settings.ts +0 -76
  23. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +0 -186
  24. package/node_modules/@mrclrchtr/supi-core/src/context/context-messages.ts +0 -119
  25. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +0 -36
  26. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +0 -31
  27. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -255
  28. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +0 -1
  29. package/node_modules/@mrclrchtr/supi-core/src/index.ts +0 -83
  30. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +0 -170
  31. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +0 -54
  32. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +0 -29
  33. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-command.ts +0 -15
  34. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +0 -41
  35. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-ui.ts +0 -226
  36. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +0 -60
  37. package/src/briefs.ts +0 -101
  38. package/src/profiles.ts +0 -52
  39. package/src/prompts.ts +0 -116
  40. package/src/settings.ts +0 -246
  41. package/src/tool/runner.ts +0 -432
  42. package/src/tool/target-resolution.ts +0 -102
  43. package/src/ui/ui.ts +0 -208
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mrclrchtr/supi-review
2
2
 
3
- Adds a guided `/supi-review` command to the [pi coding agent](https://github.com/earendil-works/pi) for structured code review.
3
+ Adds an interactive `/supi-review` command to the [pi coding agent](https://github.com/earendil-works/pi) for session-aware code review.
4
4
 
5
5
  ## Install
6
6
 
@@ -22,58 +22,63 @@ After editing the source, run `/reload`.
22
22
 
23
23
  After install, pi gets one command:
24
24
 
25
- - `/supi-review` — launch an interactive review flow and render a structured review result
25
+ - `/supi-review` — launch a guided review flow over a concrete git snapshot
26
26
 
27
- The reviewer runs in a managed child agent session with read-only review tools:
27
+ The reviewer runs in managed child agent sessions:
28
28
 
29
- - `read`
30
- - `grep`
31
- - `find`
32
- - `ls`
33
- - `submit_review` (internal result-submission tool)
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
34
31
 
35
32
  ## Review flow
36
33
 
37
34
  `/supi-review` walks you through:
38
35
 
39
- 1. choose a review mode
40
- 2. choose a review target
41
- 3. build a review brief
42
- 4. edit and approve the final review prompt
43
- 5. run the review with a live progress widget
44
- 6. show the result as a structured custom message
45
- 7. optionally trigger an auto-fix follow-up turn
36
+ 1. choose a review target
37
+ 2. choose the reviewer model
38
+ 3. optionally add a short note
39
+ 4. resolve the snapshot
40
+ 5. synthesize a review brief from the current session history
41
+ 6. preview the synthesized brief + prompt coverage
42
+ 7. run the review with a live progress widget
43
+ 8. show the structured result as a custom message
44
+ 9. if findings exist, hand off to the main agent so it can ask what to do next
46
45
 
47
- ## Review modes
46
+ ## Review targets
48
47
 
49
- ### Dynamic review
48
+ Current targets:
50
49
 
51
- You provide:
50
+ - working tree
51
+ - branch diff vs a selected local base branch
52
+ - one recent commit
52
53
 
53
- - what changed
54
- - the intended outcome
55
- - what the reviewer should focus on
54
+ ## Session-aware brief synthesis
56
55
 
57
- The package turns that into a review brief and lets you edit the final prompt before the review starts.
56
+ The generated review prompt is **not** just a static diff wrapper.
58
57
 
59
- ### Standard review
58
+ Before the actual review starts, the package:
60
59
 
61
- You choose one of the built-in profiles:
60
+ - resolves the **active session branch into the current LLM-visible context**
61
+ - extracts high-signal user/assistant/custom/compaction context from that resolved view
62
+ - synthesizes a structured brief with:
63
+ - summary
64
+ - intended outcome
65
+ - constraints to preserve
66
+ - focus areas
67
+ - risky files
68
+ - unresolved questions
62
69
 
63
- - `general`
64
- - `security`
65
- - `api-maintainability`
70
+ The synthesizer also receives a bounded diff excerpt from the snapshot so it can reason about actual code changes, not just filenames.
66
71
 
67
- The package builds the review brief from the selected profile and again lets you edit the final prompt before running.
72
+ That synthesized brief is then combined with the git snapshot into the final reviewer prompt.
68
73
 
69
- ## Review targets
74
+ ## Model selection
70
75
 
71
- Current target presets:
76
+ Every `/supi-review` run asks you to choose the reviewer model.
72
77
 
73
- - base branch diff
74
- - uncommitted changes
75
- - one commit
76
- - custom review instructions
78
+ - the picker only shows **scoped models** from Pi's `enabledModels` configuration
79
+ - the current session model is preselected only when it is inside that scoped set
80
+ - the selected model is used for both brief synthesis and the final review
81
+ - no review model is persisted in settings
77
82
 
78
83
  ## Result shape
79
84
 
@@ -83,36 +88,25 @@ A successful review includes:
83
88
  - overall explanation
84
89
  - overall confidence score
85
90
  - structured findings with title, body, priority, confidence score, and code location
91
+ - the synthesized brief that drove the review
86
92
 
87
93
  The renderer also handles failed, canceled, and timed-out reviews.
88
94
 
89
- ## Settings
90
-
91
- This package registers a **Review** section in `/supi-settings`.
92
-
93
- Available settings:
95
+ When a successful review contains findings, `supi-review` also injects an agent-visible hidden follow-up message that asks the main agent to decide the next step with the user. If `ask_user` is available, the main agent is instructed to use it and offer:
94
96
 
95
- - `reviewModel` — preselect the model used by `/supi-review`; empty means inherit the active session model
96
- - `maxDiffBytes` — maximum diff size before the prompt builder truncates the diff
97
- - `autoFix` — automatically send a follow-up user message to fix findings after a successful review with findings
98
-
99
- Defaults:
100
-
101
- ```json
102
- {
103
- "review": {
104
- "reviewModel": "",
105
- "maxDiffBytes": 100000,
106
- "autoFix": false
107
- }
108
- }
109
- ```
97
+ - Done
98
+ - Fix all
99
+ - Fix selected
100
+ - Verify findings
110
101
 
111
102
  ## Source
112
103
 
113
104
  - `src/review.ts` — command orchestration and interactive flow
114
- - `src/ui.ts` — TUI selection and approval steps
115
- - `src/profiles.ts` — built-in review profiles
116
- - `src/runner.ts` — managed reviewer session
117
- - `src/settings.ts` — `/supi-settings` integration
118
- - `src/renderer.ts` — structured result rendering
105
+ - `src/model.ts` — explicit model selection helpers
106
+ - `src/git.ts` — git snapshot resolution
107
+ - `src/history/collect.ts` — active-branch evidence extraction
108
+ - `src/history/synthesize.ts` — brief synthesis orchestration
109
+ - `src/target/packet.ts` — final reviewer packet builder
110
+ - `src/tool/brief-runner.ts` — brief synthesis child session
111
+ - `src/tool/review-runner.ts` — read-only reviewer child session
112
+ - `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.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi Review extension — structured code review via /supi-review command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,12 +19,6 @@
19
19
  "src/**/*.ts",
20
20
  "README.md"
21
21
  ],
22
- "dependencies": {
23
- "@mrclrchtr/supi-core": "1.4.0"
24
- },
25
- "bundledDependencies": [
26
- "@mrclrchtr/supi-core"
27
- ],
28
22
  "peerDependencies": {
29
23
  "@earendil-works/pi-ai": "*",
30
24
  "@earendil-works/pi-coding-agent": "*",
@@ -47,8 +41,7 @@
47
41
  },
48
42
  "pi": {
49
43
  "extensions": [
50
- "./src/extension.ts",
51
- "node_modules/@mrclrchtr/supi-core/src/extension.ts"
44
+ "./src/extension.ts"
52
45
  ]
53
46
  },
54
47
  "main": "src/api.ts",
package/src/git.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { basename } from "node:path";
2
3
  import { promisify } from "node:util";
4
+ import type { DiffStats, ReviewSnapshot } from "./types.ts";
3
5
 
4
6
  const execFileAsync = promisify(execFile);
5
7
 
@@ -23,6 +25,32 @@ function gitExecOptions(repoPath: string) {
23
25
  };
24
26
  }
25
27
 
28
+ /** Parse simple git diff statistics from diff/show text. */
29
+ export function parseDiffStats(text: string): DiffStats {
30
+ let files = 0;
31
+ let additions = 0;
32
+ let deletions = 0;
33
+ let inDiff = false;
34
+
35
+ for (const line of text.split("\n")) {
36
+ if (line.startsWith("diff --git ")) {
37
+ files++;
38
+ inDiff = true;
39
+ continue;
40
+ }
41
+
42
+ if (!inDiff) continue;
43
+
44
+ if (line.startsWith("+") && !line.startsWith("+++")) {
45
+ additions++;
46
+ } else if (line.startsWith("-") && !line.startsWith("---")) {
47
+ deletions++;
48
+ }
49
+ }
50
+
51
+ return { files, additions, deletions };
52
+ }
53
+
26
54
  export async function getMergeBase(repoPath: string, branch: string): Promise<string | undefined> {
27
55
  try {
28
56
  const { stdout } = await execFileAsync(
@@ -99,7 +127,7 @@ export async function getRecentCommits(repoPath: string, limit = 20): Promise<Co
99
127
  if (idx <= 0) return undefined;
100
128
  return { sha: line.slice(0, idx), subject: line.slice(idx + 1) };
101
129
  })
102
- .filter((e): e is CommitEntry => e !== undefined);
130
+ .filter((entry): entry is CommitEntry => entry !== undefined);
103
131
  }
104
132
 
105
133
  export async function getCommitShow(repoPath: string, sha: string): Promise<string> {
@@ -200,3 +228,81 @@ export async function getLocalBranches(repoPath: string): Promise<string[]> {
200
228
  sorted.push(...remaining);
201
229
  return sorted;
202
230
  }
231
+
232
+ /** Resolve the current working tree into a concrete review snapshot. */
233
+ export async function resolveWorkingTreeSnapshot(
234
+ repoPath: string,
235
+ ): Promise<ReviewSnapshot | undefined> {
236
+ const [diffText, changedFiles] = await Promise.all([
237
+ getUncommittedDiff(repoPath),
238
+ getUncommittedFileNames(repoPath),
239
+ ]);
240
+
241
+ if (!diffText.trim() && changedFiles.length === 0) {
242
+ return undefined;
243
+ }
244
+
245
+ return {
246
+ target: { kind: "working-tree" },
247
+ title: "Working tree changes",
248
+ changedFiles,
249
+ diffText,
250
+ stats: parseDiffStats(diffText),
251
+ };
252
+ }
253
+
254
+ /** Resolve a branch-vs-base diff into a concrete review snapshot. */
255
+ export async function resolveBranchSnapshot(
256
+ repoPath: string,
257
+ base: string,
258
+ ): Promise<ReviewSnapshot | undefined> {
259
+ const baseSha = await getMergeBase(repoPath, base);
260
+ if (!baseSha) {
261
+ return undefined;
262
+ }
263
+
264
+ const [diffText, changedFiles] = await Promise.all([
265
+ getDiff(repoPath, baseSha),
266
+ getDiffFileNames(repoPath, baseSha),
267
+ ]);
268
+
269
+ if (!diffText.trim() && changedFiles.length === 0) {
270
+ return undefined;
271
+ }
272
+
273
+ return {
274
+ target: { kind: "branch", base },
275
+ title: `Changes vs ${base}`,
276
+ changedFiles,
277
+ diffText,
278
+ stats: parseDiffStats(diffText),
279
+ };
280
+ }
281
+
282
+ /** Resolve one commit into a concrete review snapshot. */
283
+ export async function resolveCommitSnapshot(
284
+ repoPath: string,
285
+ sha: string,
286
+ ): Promise<ReviewSnapshot | undefined> {
287
+ const [diffText, changedFiles] = await Promise.all([
288
+ getCommitShow(repoPath, sha),
289
+ getCommitFileNames(repoPath, sha),
290
+ ]);
291
+
292
+ if (!diffText.trim() && changedFiles.length === 0) {
293
+ return undefined;
294
+ }
295
+
296
+ return {
297
+ target: { kind: "commit", sha },
298
+ title: `Commit ${sha.slice(0, 7)}`,
299
+ changedFiles,
300
+ diffText,
301
+ stats: parseDiffStats(diffText),
302
+ };
303
+ }
304
+
305
+ /** Convenience label for one changed file, used in synthesized prompts/UI. */
306
+ export function formatChangedFileLabel(file: string): string {
307
+ return basename(file) === file ? file : `${basename(file)} (${file})`;
308
+ }
@@ -0,0 +1,210 @@
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
+
7
+ type ResolvedSessionMessage = SessionContext["messages"][number];
8
+
9
+ /**
10
+ * Extract the highest-signal evidence from the resolved LLM-visible session context.
11
+ *
12
+ * The collector intentionally favors user intent, assistant plans, compaction
13
+ * summaries, and custom extension messages over raw tool chatter.
14
+ */
15
+ export function collectHistoryEvidence(
16
+ messages: ResolvedSessionMessage[],
17
+ snapshot: ReviewSnapshot,
18
+ note?: string,
19
+ ): HistoryEvidence[] {
20
+ const scoringContext = {
21
+ changedPathTokens: collectPathTokens(snapshot.changedFiles),
22
+ noteTokens: collectFreeformTokens(note),
23
+ total: messages.length,
24
+ };
25
+
26
+ const scored = messages
27
+ .map((message) => toEvidence(message))
28
+ .filter(
29
+ (evidence): evidence is Omit<HistoryEvidence, "score" | "reason"> => evidence !== undefined,
30
+ )
31
+ .map((evidence, index) => scoreEvidence(evidence, index, scoringContext))
32
+ .sort((a, b) => b.score - a.score || b.text.length - a.text.length);
33
+
34
+ return capEvidence(scored, 10, 4_500);
35
+ }
36
+
37
+ function toEvidence(
38
+ message: ResolvedSessionMessage,
39
+ ): Omit<HistoryEvidence, "score" | "reason"> | undefined {
40
+ switch (message.role) {
41
+ case "user":
42
+ case "assistant": {
43
+ return {
44
+ kind: message.role,
45
+ text: normalizeText(extractMessageText(message.content)),
46
+ };
47
+ }
48
+ case "custom": {
49
+ return {
50
+ kind: "custom",
51
+ text: normalizeText(extractMessageText(message.content)),
52
+ };
53
+ }
54
+ case "compactionSummary": {
55
+ return {
56
+ kind: "compaction",
57
+ text: normalizeText(message.summary),
58
+ };
59
+ }
60
+ case "branchSummary": {
61
+ return {
62
+ kind: "branch-summary",
63
+ text: normalizeText(message.summary),
64
+ };
65
+ }
66
+ default:
67
+ return undefined;
68
+ }
69
+ }
70
+
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
+ function extractMessageText(content: unknown): string {
113
+ if (typeof content === "string") {
114
+ return content;
115
+ }
116
+
117
+ if (!Array.isArray(content)) {
118
+ return "";
119
+ }
120
+
121
+ return content
122
+ .map((part) => {
123
+ if (typeof part !== "object" || !part) return "";
124
+ const text = (part as { text?: unknown }).text;
125
+ return typeof text === "string" ? text : "";
126
+ })
127
+ .filter((text) => text.length > 0)
128
+ .join("\n");
129
+ }
130
+
131
+ function normalizeText(text: string): string {
132
+ return text.replace(/\s+/g, " ").trim();
133
+ }
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
+ }
@@ -0,0 +1,109 @@
1
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import { runBriefSynthesis } from "../tool/brief-runner.ts";
3
+ import type { BriefSynthesisRunResult, ReviewProgress } from "../tool/runner-types.ts";
4
+ import type { HistoryEvidence, ReviewModelSelection, ReviewSnapshot } from "../types.ts";
5
+
6
+ const DIFF_EXCERPT_CHAR_BUDGET = 12_000;
7
+
8
+ export interface SynthesizeReviewBriefOptions {
9
+ model: ReviewModelSelection;
10
+ modelRegistry: ModelRegistry;
11
+ cwd: string;
12
+ snapshot: ReviewSnapshot;
13
+ evidence: HistoryEvidence[];
14
+ note?: string;
15
+ signal?: AbortSignal;
16
+ onProgress?: (progress: ReviewProgress) => void;
17
+ }
18
+
19
+ /** Synthesize a structured review brief from the current snapshot and session evidence. */
20
+ export function synthesizeReviewBrief(
21
+ options: SynthesizeReviewBriefOptions,
22
+ ): Promise<BriefSynthesisRunResult> {
23
+ const { model, modelRegistry, cwd, snapshot, evidence, note, signal, onProgress } = options;
24
+
25
+ return runBriefSynthesis({
26
+ prompt: buildBriefSynthesisPrompt(snapshot, evidence, note),
27
+ model: model.model,
28
+ modelRegistry,
29
+ cwd,
30
+ signal,
31
+ onProgress,
32
+ });
33
+ }
34
+
35
+ export function buildBriefSynthesisPrompt(
36
+ snapshot: ReviewSnapshot,
37
+ evidence: HistoryEvidence[],
38
+ note?: string,
39
+ ): string {
40
+ const diffExcerpt = buildDiffExcerpt(snapshot.diffText);
41
+ const parts: string[] = [
42
+ "# Review Brief Synthesis Input",
43
+ "",
44
+ "You are preparing a review brief for a second code-reviewing agent.",
45
+ "Infer the likely goal, constraints, and focus areas from the active session history.",
46
+ "Be concise, evidence-based, and avoid inventing requirements that are not supported by the input.",
47
+ "",
48
+ "## Snapshot",
49
+ `Target: ${snapshot.title}`,
50
+ `Files changed: ${snapshot.changedFiles.length}`,
51
+ `Diff stats: +${snapshot.stats.additions} / -${snapshot.stats.deletions}`,
52
+ "",
53
+ "### Changed files",
54
+ ...snapshot.changedFiles.map((file) => `- ${file}`),
55
+ ];
56
+
57
+ if (diffExcerpt.text) {
58
+ parts.push("", "### Diff excerpt", "```diff", diffExcerpt.text, "```");
59
+ if (diffExcerpt.truncated) {
60
+ parts.push(`> Note: diff excerpt truncated to ${diffExcerpt.text.length} characters.`);
61
+ }
62
+ }
63
+
64
+ if (note?.trim()) {
65
+ parts.push("", "## User note", note.trim());
66
+ }
67
+
68
+ parts.push("", "## Session evidence");
69
+ if (evidence.length === 0) {
70
+ parts.push(
71
+ "- No strong session evidence was extracted. Derive a minimal brief from the snapshot only.",
72
+ );
73
+ } else {
74
+ parts.push(
75
+ ...evidence.flatMap((item, index) => [
76
+ `${index + 1}. [${item.kind}] ${item.reason}`,
77
+ ` ${item.text}`,
78
+ ]),
79
+ );
80
+ }
81
+
82
+ parts.push(
83
+ "",
84
+ "## Output requirements",
85
+ "Call submit_review_brief with:",
86
+ "- summary: one-sentence summary of the likely change",
87
+ "- intendedOutcome: what the session seems to be trying to achieve",
88
+ "- constraints: invariants or requirements to preserve",
89
+ "- focusAreas: what the reviewer should inspect carefully",
90
+ "- riskyFiles: changed files that seem especially important or risky",
91
+ "- unresolvedQuestions: ambiguities or concerns that remain unclear",
92
+ );
93
+
94
+ return parts.join("\n");
95
+ }
96
+
97
+ function buildDiffExcerpt(diffText: string): { text: string; truncated: boolean } {
98
+ const trimmed = diffText.trim();
99
+ if (!trimmed) {
100
+ return { text: "", truncated: false };
101
+ }
102
+ if (trimmed.length <= DIFF_EXCERPT_CHAR_BUDGET) {
103
+ return { text: trimmed, truncated: false };
104
+ }
105
+ return {
106
+ text: `${trimmed.slice(0, DIFF_EXCERPT_CHAR_BUDGET)}\n[... diff excerpt truncated ...]`,
107
+ truncated: true,
108
+ };
109
+ }