tau-coding-agent 0.1.4 → 0.1.5
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 +1 -1
- package/extensions/answer.ts +48 -42
- package/extensions/branch-term.ts +8 -13
- package/extensions/btw.ts +4 -4
- package/extensions/ghostty.ts +64 -11
- package/extensions/insights.ts +4 -3
- package/extensions/loop.ts +37 -9
- package/extensions/memory.ts +5 -10
- package/extensions/notify.ts +24 -5
- package/extensions/openai-fast.ts +6 -4
- package/extensions/openai-verbosity.ts +6 -4
- package/extensions/review/fix.ts +239 -0
- package/extensions/review/git.ts +517 -0
- package/extensions/review/index.ts +232 -0
- package/extensions/review/message-queue.ts +249 -0
- package/extensions/review/models.ts +589 -0
- package/extensions/review/prompts.ts +337 -0
- package/extensions/review/renderers/inline.ts +126 -0
- package/extensions/review/request.ts +504 -0
- package/extensions/review/review.ts +1305 -0
- package/extensions/review/runner.ts +522 -0
- package/extensions/review/runtime.ts +275 -0
- package/extensions/review/schema.ts +160 -0
- package/extensions/review/submit-review-tool.ts +66 -0
- package/extensions/review/submit-triage-tool.ts +59 -0
- package/extensions/review/triage.ts +746 -0
- package/extensions/sandbox/index.ts +399 -102
- package/extensions/{interlude.ts → stash.ts} +13 -11
- package/extensions/tool-display-mode.ts +2 -3
- package/extensions/usage/index.ts +8 -4
- package/extensions/websearch/README.md +1 -1
- package/extensions/websearch/browser/chromium.ts +42 -31
- package/extensions/websearch/browser/discovery.ts +11 -9
- package/extensions/websearch/browser/firefox.ts +10 -7
- package/extensions/websearch/browser/fs.ts +10 -0
- package/extensions/websearch/browser/sqlite.ts +22 -15
- package/extensions/websearch/config.ts +2 -2
- package/extensions/websearch/index.ts +6 -6
- package/extensions/worktree.ts +33 -33
- package/package.json +3 -3
- package/skills/git-clean-history/SKILL.md +1 -0
- package/skills/git-commit/SKILL.md +2 -2
- package/skills/oracle/scripts/oracle +28 -11
- package/themes/tau-dark.json +2 -1
- package/extensions/review.ts +0 -4420
|
@@ -0,0 +1,1305 @@
|
|
|
1
|
+
import {
|
|
2
|
+
keyText,
|
|
3
|
+
type ExtensionCommandContext,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type ExtensionAPI,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
getKeybindings,
|
|
9
|
+
isKeyRelease,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
type Component,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
buildAdditionalContextSection,
|
|
17
|
+
buildProjectReviewGuidelinesSection,
|
|
18
|
+
REVIEW_DEDUP_PROMPT,
|
|
19
|
+
REVIEW_FOCUSES,
|
|
20
|
+
REVIEW_FOCUS_PROMPT,
|
|
21
|
+
REVIEW_OUTPUT_CONTRACT_PROMPT,
|
|
22
|
+
} from "./prompts.js";
|
|
23
|
+
import {
|
|
24
|
+
buildReviewedScopeLine,
|
|
25
|
+
buildReviewFailuresMarkdown,
|
|
26
|
+
buildReviewFindingsMarkdown,
|
|
27
|
+
formatDuration,
|
|
28
|
+
} from "./renderers/inline.js";
|
|
29
|
+
import { buildRequestSignature } from "./request.js";
|
|
30
|
+
import {
|
|
31
|
+
appendErrorDetails,
|
|
32
|
+
asRecord,
|
|
33
|
+
buildProviderErrorMessage,
|
|
34
|
+
classifyTaskError,
|
|
35
|
+
getSubmittedPayload,
|
|
36
|
+
parseJsonFromText,
|
|
37
|
+
REVIEW_INSPECTION_TOOLS,
|
|
38
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS,
|
|
39
|
+
REVIEW_TASK_TIMEOUT_MS,
|
|
40
|
+
runPiOneShotTask,
|
|
41
|
+
runPiSubmitToolTask,
|
|
42
|
+
type TaskErrorKind,
|
|
43
|
+
withJitter,
|
|
44
|
+
} from "./runner.js";
|
|
45
|
+
import {
|
|
46
|
+
computeCurrentFingerprint,
|
|
47
|
+
fingerprintsEqual,
|
|
48
|
+
buildScopeInstructions,
|
|
49
|
+
isGitRepo,
|
|
50
|
+
loadProjectReviewGuidelines,
|
|
51
|
+
resolveScope,
|
|
52
|
+
type ResolvedScope,
|
|
53
|
+
} from "./git.js";
|
|
54
|
+
import {
|
|
55
|
+
appendModelThinkingSuffix,
|
|
56
|
+
buildResolvedReviewModelLabel,
|
|
57
|
+
buildResolvedReviewStatusModelLabel,
|
|
58
|
+
buildReviewProgressModelLabel,
|
|
59
|
+
clearProviderCandidateProbe,
|
|
60
|
+
getFallbackThinkingLevels,
|
|
61
|
+
getProviderCandidateAvailability,
|
|
62
|
+
getProviderCandidateProbe,
|
|
63
|
+
getResolvedReviewCurrentThinkingLevel,
|
|
64
|
+
getResolvedReviewPrimaryProviderCandidate,
|
|
65
|
+
selectReviewDedupModel,
|
|
66
|
+
setProviderCandidateAvailability,
|
|
67
|
+
setProviderCandidateProbe,
|
|
68
|
+
resolveModels,
|
|
69
|
+
type ResolvedReviewModel,
|
|
70
|
+
type ResolvedReviewProviderCandidate,
|
|
71
|
+
type ReviewThinkingLevel,
|
|
72
|
+
} from "./models.js";
|
|
73
|
+
import {
|
|
74
|
+
notify,
|
|
75
|
+
REVIEW_CANCELLED_ERROR,
|
|
76
|
+
REVIEW_STATUS_KEY,
|
|
77
|
+
STATUS_SPINNER_FRAMES,
|
|
78
|
+
STATUS_SPINNER_INTERVAL_MS,
|
|
79
|
+
withManagedReviewRun,
|
|
80
|
+
withSpinner,
|
|
81
|
+
type ReviewExecutionControl,
|
|
82
|
+
} from "./runtime.js";
|
|
83
|
+
import {
|
|
84
|
+
REVIEW_FOCUS_NAMES,
|
|
85
|
+
type FocusFinding,
|
|
86
|
+
type ReviewFocus,
|
|
87
|
+
type FocusOutput,
|
|
88
|
+
type ParsedRequest,
|
|
89
|
+
type Priority,
|
|
90
|
+
type ReviewDedupGroup,
|
|
91
|
+
type ReviewFingerprint,
|
|
92
|
+
type ReviewMessageDetails,
|
|
93
|
+
type ReviewMessageKind,
|
|
94
|
+
type ReviewReportFinding,
|
|
95
|
+
type ReviewRequestMode,
|
|
96
|
+
type ReviewRunResult,
|
|
97
|
+
type ReviewRunSource,
|
|
98
|
+
type ReviewStaleness,
|
|
99
|
+
} from "./schema.js";
|
|
100
|
+
import { SUBMIT_REVIEW_EXTENSION_PATH } from "./submit-review-tool.js";
|
|
101
|
+
|
|
102
|
+
const SUBMIT_REVIEW_TOOL = "submit_review";
|
|
103
|
+
const REVIEW_TOOLS = `${REVIEW_INSPECTION_TOOLS},${SUBMIT_REVIEW_TOOL}`;
|
|
104
|
+
|
|
105
|
+
const REVIEW_STALE_REVIEW_WARNING = "Repository changed while this review was running.";
|
|
106
|
+
const REVIEW_STALE_REVIEW_NEXT_STEP =
|
|
107
|
+
"Results are shown anyway. Run /review again to refresh them.";
|
|
108
|
+
const REVIEW_STALE_FIX_WARNING = "Repository changed while /fix was gathering findings.";
|
|
109
|
+
const REVIEW_STALE_FIX_NEXT_STEP =
|
|
110
|
+
"No fixes were applied. Run /fix again to continue, or /review first to refresh.";
|
|
111
|
+
const REVIEW_STALE_PAYLOAD_WARNING = "Repository changed since this review was generated.";
|
|
112
|
+
const REVIEW_STALE_PAYLOAD_NEXT_STEP = "Run /review first to refresh it.";
|
|
113
|
+
|
|
114
|
+
const REVIEW_PROGRESS_WIDGET_KEY = "review-progress";
|
|
115
|
+
|
|
116
|
+
type ReviewTheme = ExtensionContext["ui"]["theme"];
|
|
117
|
+
|
|
118
|
+
type FocusTask = {
|
|
119
|
+
model: ResolvedReviewModel;
|
|
120
|
+
focus: ReviewFocus;
|
|
121
|
+
prompt: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type FocusTaskAttempt = FocusTask & {
|
|
125
|
+
providerCandidate: ResolvedReviewProviderCandidate;
|
|
126
|
+
currentThinkingLevel?: ReviewThinkingLevel;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type FocusTaskErrorKind = TaskErrorKind;
|
|
130
|
+
|
|
131
|
+
type FocusTaskResult = {
|
|
132
|
+
focus: ReviewFocus;
|
|
133
|
+
model: string;
|
|
134
|
+
ok: boolean;
|
|
135
|
+
output?: FocusOutput;
|
|
136
|
+
error?: string;
|
|
137
|
+
errorKind?: FocusTaskErrorKind;
|
|
138
|
+
missingApiProvider?: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
type ReviewProgressStatus = "running" | "success" | "failure";
|
|
142
|
+
|
|
143
|
+
type ReviewProgressTask = {
|
|
144
|
+
focus: ReviewFocus;
|
|
145
|
+
model: string;
|
|
146
|
+
status: ReviewProgressStatus;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
type ReviewProgressState = {
|
|
150
|
+
startedAtMs: number;
|
|
151
|
+
expanded: boolean;
|
|
152
|
+
frame: number;
|
|
153
|
+
tasks: ReviewProgressTask[];
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type ReviewProgressController = {
|
|
157
|
+
update: (task: FocusTask, result: FocusTaskResult) => void;
|
|
158
|
+
stop: () => void;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type PreparedReviewRun = {
|
|
162
|
+
scope: ResolvedScope;
|
|
163
|
+
includeUntracked: boolean;
|
|
164
|
+
baselineFingerprint: ReviewFingerprint;
|
|
165
|
+
models: ResolvedReviewModel[];
|
|
166
|
+
tasks: FocusTask[];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function createReviewProgress(ctx: ExtensionContext, tasks: FocusTask[]): ReviewProgressController {
|
|
170
|
+
if (!ctx.hasUI) {
|
|
171
|
+
return {
|
|
172
|
+
update: () => {},
|
|
173
|
+
stop: () => {},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const state: ReviewProgressState = {
|
|
178
|
+
startedAtMs: Date.now(),
|
|
179
|
+
expanded: false,
|
|
180
|
+
frame: 0,
|
|
181
|
+
tasks: tasks.map((task) => ({
|
|
182
|
+
focus: task.focus,
|
|
183
|
+
model: buildReviewProgressModelLabel(task.model),
|
|
184
|
+
status: "running",
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
const progressTasks = new Map(tasks.map((task, index) => [task, state.tasks[index]!]));
|
|
188
|
+
|
|
189
|
+
const render = () => {
|
|
190
|
+
if (state.expanded) {
|
|
191
|
+
ctx.ui.setStatus(REVIEW_STATUS_KEY, undefined);
|
|
192
|
+
ctx.ui.setWidget(
|
|
193
|
+
REVIEW_PROGRESS_WIDGET_KEY,
|
|
194
|
+
(_tui, theme) => new ReviewProgressComponent(state, theme),
|
|
195
|
+
{ placement: "belowEditor" },
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
201
|
+
ctx.ui.setStatus(REVIEW_STATUS_KEY, buildCollapsedReviewProgressStatus(state));
|
|
202
|
+
};
|
|
203
|
+
const unsubscribeToggle = ctx.ui.onTerminalInput((data) => {
|
|
204
|
+
if (isKeyRelease(data)) return undefined;
|
|
205
|
+
if (!getKeybindings().matches(data, "app.tools.expand")) return undefined;
|
|
206
|
+
state.expanded = !state.expanded;
|
|
207
|
+
render();
|
|
208
|
+
return { consume: true };
|
|
209
|
+
});
|
|
210
|
+
const timer = setInterval(() => {
|
|
211
|
+
state.frame = (state.frame + 1) % STATUS_SPINNER_FRAMES.length;
|
|
212
|
+
render();
|
|
213
|
+
}, STATUS_SPINNER_INTERVAL_MS);
|
|
214
|
+
|
|
215
|
+
render();
|
|
216
|
+
return {
|
|
217
|
+
update: (task, result) => {
|
|
218
|
+
const progressTask = progressTasks.get(task);
|
|
219
|
+
if (!progressTask) return;
|
|
220
|
+
progressTask.status = result.ok ? "success" : "failure";
|
|
221
|
+
render();
|
|
222
|
+
},
|
|
223
|
+
stop: () => {
|
|
224
|
+
clearInterval(timer);
|
|
225
|
+
unsubscribeToggle();
|
|
226
|
+
ctx.ui.setStatus(REVIEW_STATUS_KEY, undefined);
|
|
227
|
+
ctx.ui.setWidget(REVIEW_PROGRESS_WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
class ReviewProgressComponent implements Component {
|
|
233
|
+
constructor(
|
|
234
|
+
private state: ReviewProgressState,
|
|
235
|
+
private theme: ReviewTheme,
|
|
236
|
+
) {}
|
|
237
|
+
|
|
238
|
+
invalidate(): void {}
|
|
239
|
+
|
|
240
|
+
render(width: number): string[] {
|
|
241
|
+
return buildExpandedReviewProgressLines(this.state, this.theme, width);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function buildCollapsedReviewProgressStatus(state: ReviewProgressState): string {
|
|
246
|
+
const counts = countReviewProgressTasks(state.tasks);
|
|
247
|
+
const spinner = getProgressSpinner(state);
|
|
248
|
+
const failedText = counts.failed > 0 ? `, ${counts.failed} failed` : "";
|
|
249
|
+
return `${spinner} reviewing ${counts.finished}/${counts.total}${failedText} (${keyText("app.tools.expand")} to expand)`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function buildExpandedReviewProgressLines(
|
|
253
|
+
state: ReviewProgressState,
|
|
254
|
+
theme: ReviewTheme,
|
|
255
|
+
width: number,
|
|
256
|
+
): string[] {
|
|
257
|
+
const counts = countReviewProgressTasks(state.tasks);
|
|
258
|
+
const spinner = getProgressSpinner(state);
|
|
259
|
+
const hint = `${theme.fg("muted", `(${formatDuration(Date.now() - state.startedAtMs)}, `)}${theme.fg(
|
|
260
|
+
"dim",
|
|
261
|
+
keyText("app.tools.expand"),
|
|
262
|
+
)}${theme.fg("muted", " to collapse)")}`;
|
|
263
|
+
const header = truncateToWidth(
|
|
264
|
+
`${theme.fg("accent", spinner)} reviewing ${counts.finished}/${counts.total} ${hint}`,
|
|
265
|
+
width,
|
|
266
|
+
theme.fg("muted", "…"),
|
|
267
|
+
);
|
|
268
|
+
const groupedTasks = groupReviewProgressTasks(state.tasks);
|
|
269
|
+
const fullRows = buildReviewProgressRows(groupedTasks, theme, width, "full", spinner);
|
|
270
|
+
const rows = fullRows.fits
|
|
271
|
+
? fullRows.lines
|
|
272
|
+
: buildReviewProgressRows(groupedTasks, theme, width, "short", spinner).lines;
|
|
273
|
+
return [header, ...rows, theme.fg("borderMuted", "─".repeat(Math.max(0, width)))];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildReviewProgressRows(
|
|
277
|
+
groupedTasks: Map<string, ReviewProgressTask[]>,
|
|
278
|
+
theme: ReviewTheme,
|
|
279
|
+
width: number,
|
|
280
|
+
labelStyle: "full" | "short",
|
|
281
|
+
spinner: string,
|
|
282
|
+
): { fits: boolean; lines: string[] } {
|
|
283
|
+
const focuses = getReviewProgressFocuses(groupedTasks);
|
|
284
|
+
const rowData = Array.from(groupedTasks, ([model, tasks]) => ({
|
|
285
|
+
model,
|
|
286
|
+
chips: focuses
|
|
287
|
+
.map((focus) => {
|
|
288
|
+
const task = tasks.find((candidate) => candidate.focus === focus);
|
|
289
|
+
return formatReviewProgressChip(
|
|
290
|
+
task?.status ?? "running",
|
|
291
|
+
focus,
|
|
292
|
+
labelStyle,
|
|
293
|
+
spinner,
|
|
294
|
+
theme,
|
|
295
|
+
);
|
|
296
|
+
})
|
|
297
|
+
.join(" "),
|
|
298
|
+
}));
|
|
299
|
+
const maxModelWidth = Math.max(0, ...rowData.map((row) => visibleWidth(row.model)));
|
|
300
|
+
const maxChipWidth = Math.max(0, ...rowData.map((row) => visibleWidth(row.chips)));
|
|
301
|
+
const fullModelColumnWidth = Math.max(maxModelWidth, 1);
|
|
302
|
+
const fullRowsFit = rowData.every(
|
|
303
|
+
(row) => fullModelColumnWidth + 1 + visibleWidth(row.chips) <= width,
|
|
304
|
+
);
|
|
305
|
+
const availableModelWidth = width - 1 - maxChipWidth;
|
|
306
|
+
const minimumUsefulModelWidth = Math.min(16, fullModelColumnWidth);
|
|
307
|
+
const canFit = fullRowsFit || availableModelWidth >= minimumUsefulModelWidth;
|
|
308
|
+
const modelColumnWidth = fullRowsFit ? fullModelColumnWidth : Math.max(8, availableModelWidth);
|
|
309
|
+
const lines = rowData.map((row) => {
|
|
310
|
+
const model = padToWidth(truncateToWidth(row.model, modelColumnWidth, "…"), modelColumnWidth);
|
|
311
|
+
return truncateToWidth(`${model} ${row.chips}`, width, "…");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return { fits: canFit, lines };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function getReviewProgressFocuses(groupedTasks: Map<string, ReviewProgressTask[]>): ReviewFocus[] {
|
|
318
|
+
const selected = new Set<ReviewFocus>();
|
|
319
|
+
for (const tasks of groupedTasks.values()) {
|
|
320
|
+
for (const task of tasks) {
|
|
321
|
+
selected.add(task.focus);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return REVIEW_FOCUS_NAMES.filter((focus) => selected.has(focus));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function formatReviewProgressChip(
|
|
328
|
+
status: ReviewProgressStatus,
|
|
329
|
+
focus: ReviewFocus,
|
|
330
|
+
labelStyle: "full" | "short",
|
|
331
|
+
spinner: string,
|
|
332
|
+
theme: ReviewTheme,
|
|
333
|
+
): string {
|
|
334
|
+
const label = labelStyle === "full" ? focus : getShortReviewFocusLabel(focus);
|
|
335
|
+
switch (status) {
|
|
336
|
+
case "success":
|
|
337
|
+
return `${theme.fg("success", "✓")} ${label}`;
|
|
338
|
+
case "failure":
|
|
339
|
+
return `${theme.fg("error", "✕")} ${label}`;
|
|
340
|
+
case "running":
|
|
341
|
+
return `${spinner} ${label}`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function countReviewProgressTasks(tasks: ReviewProgressTask[]): {
|
|
346
|
+
total: number;
|
|
347
|
+
finished: number;
|
|
348
|
+
running: number;
|
|
349
|
+
failed: number;
|
|
350
|
+
} {
|
|
351
|
+
const failed = tasks.filter((task) => task.status === "failure").length;
|
|
352
|
+
const succeeded = tasks.filter((task) => task.status === "success").length;
|
|
353
|
+
const finished = failed + succeeded;
|
|
354
|
+
return {
|
|
355
|
+
total: tasks.length,
|
|
356
|
+
finished,
|
|
357
|
+
running: tasks.length - finished,
|
|
358
|
+
failed,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function groupReviewProgressTasks(tasks: ReviewProgressTask[]): Map<string, ReviewProgressTask[]> {
|
|
363
|
+
const grouped = new Map<string, ReviewProgressTask[]>();
|
|
364
|
+
for (const task of tasks) {
|
|
365
|
+
const group = grouped.get(task.model) ?? [];
|
|
366
|
+
group.push(task);
|
|
367
|
+
grouped.set(task.model, group);
|
|
368
|
+
}
|
|
369
|
+
return grouped;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function getProgressSpinner(state: ReviewProgressState): string {
|
|
373
|
+
return STATUS_SPINNER_FRAMES[state.frame % STATUS_SPINNER_FRAMES.length];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function getShortReviewFocusLabel(focus: ReviewFocus): string {
|
|
377
|
+
switch (focus) {
|
|
378
|
+
case "general":
|
|
379
|
+
return "gen";
|
|
380
|
+
case "security":
|
|
381
|
+
return "sec";
|
|
382
|
+
case "reuse":
|
|
383
|
+
return "reuse";
|
|
384
|
+
case "quality":
|
|
385
|
+
return "qual";
|
|
386
|
+
case "testing":
|
|
387
|
+
return "test";
|
|
388
|
+
case "efficiency":
|
|
389
|
+
return "eff";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function padToWidth(value: string, width: number): string {
|
|
394
|
+
return `${value}${" ".repeat(Math.max(0, width - visibleWidth(value)))}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function priorityRank(priority: Priority): number {
|
|
398
|
+
return priority.charCodeAt(1) - 48; // '0' = 48
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildFocusPrompt(
|
|
402
|
+
focus: ReviewFocus,
|
|
403
|
+
scopeInstructions: string,
|
|
404
|
+
projectGuidelines: string | null,
|
|
405
|
+
additionalContext: string | undefined,
|
|
406
|
+
): string {
|
|
407
|
+
const additionalContextSection = buildAdditionalContextSection(additionalContext);
|
|
408
|
+
const projectGuidelinesSection = buildProjectReviewGuidelinesSection(projectGuidelines);
|
|
409
|
+
|
|
410
|
+
const def = REVIEW_FOCUSES[focus];
|
|
411
|
+
|
|
412
|
+
return REVIEW_FOCUS_PROMPT.replace("{FOCUS_SUFFIX}", () => def.suffix)
|
|
413
|
+
.replace("{FOCUS_QUALIFIER}", () => def.qualifier)
|
|
414
|
+
.replace("{SCOPE_INSTRUCTIONS}", () => scopeInstructions)
|
|
415
|
+
.replace("{FOCUS_CONTEXT}", () => def.context)
|
|
416
|
+
.replace("{ADDITIONAL_CONTEXT_SECTION}", () => additionalContextSection)
|
|
417
|
+
.replace("{PROJECT_GUIDELINES_SECTION}", () => projectGuidelinesSection)
|
|
418
|
+
.replace("{OUTPUT_CONTRACT}", () => REVIEW_OUTPUT_CONTRACT_PROMPT);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function buildReviewDedupPrompt(findings: ReviewReportFinding[]): string {
|
|
422
|
+
const findingsWithIds = findings.map((finding, index) => ({ id: index + 1, ...finding }));
|
|
423
|
+
return REVIEW_DEDUP_PROMPT.replace("{REVIEW_FINDINGS_JSON}", () =>
|
|
424
|
+
JSON.stringify({ findings: findingsWithIds }, null, 2),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// --- Focus task execution ---
|
|
429
|
+
|
|
430
|
+
function parseReviewDedupOutput(parsed: unknown, totalFindings: number): ReviewDedupGroup[] | null {
|
|
431
|
+
if (
|
|
432
|
+
!parsed ||
|
|
433
|
+
typeof parsed !== "object" ||
|
|
434
|
+
!("groups" in parsed) ||
|
|
435
|
+
!Array.isArray(parsed.groups)
|
|
436
|
+
) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const groups: ReviewDedupGroup[] = [];
|
|
441
|
+
const seenIds = new Set<number>();
|
|
442
|
+
|
|
443
|
+
for (const group of parsed.groups) {
|
|
444
|
+
if (
|
|
445
|
+
typeof group !== "object" ||
|
|
446
|
+
group === null ||
|
|
447
|
+
!("ids" in group) ||
|
|
448
|
+
!Array.isArray(group.ids)
|
|
449
|
+
) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const normalizedIds = group.ids
|
|
454
|
+
.map((value: unknown) => Number(value))
|
|
455
|
+
.filter((id: number): id is number => Number.isInteger(id) && id >= 1 && id <= totalFindings);
|
|
456
|
+
const ids = [...new Set<number>(normalizedIds)].sort((a, b) => a - b);
|
|
457
|
+
if (ids.length < 2) {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const id of ids) {
|
|
462
|
+
if (seenIds.has(id)) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
seenIds.add(id);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Reason is requested to make duplicate judgments explicit; the host only needs ids.
|
|
469
|
+
groups.push({ ids });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return groups;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function formatThinkingFallbackError(
|
|
476
|
+
result: FocusTaskResult,
|
|
477
|
+
triedLevels: ReviewThinkingLevel[],
|
|
478
|
+
): FocusTaskResult {
|
|
479
|
+
if (triedLevels.length <= 1 || !result.error) return result;
|
|
480
|
+
return {
|
|
481
|
+
...result,
|
|
482
|
+
error: `Automatic reasoning fallback failed after trying ${triedLevels.join(" -> ")}. Last error: ${result.error}`,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getExplicitThinkingSupportError(attempt: FocusTaskAttempt): string | undefined {
|
|
487
|
+
if (attempt.model.thinkingSource !== "explicit" || !attempt.currentThinkingLevel)
|
|
488
|
+
return undefined;
|
|
489
|
+
if (
|
|
490
|
+
attempt.currentThinkingLevel !== "off" &&
|
|
491
|
+
attempt.providerCandidate.supportsThinking === false
|
|
492
|
+
) {
|
|
493
|
+
return `Reasoning level '${attempt.currentThinkingLevel}' is not supported by this model.`;
|
|
494
|
+
}
|
|
495
|
+
if (attempt.currentThinkingLevel === "max" && attempt.providerCandidate.supportsMax === false) {
|
|
496
|
+
return "Reasoning level 'max' is not supported by this model.";
|
|
497
|
+
}
|
|
498
|
+
if (
|
|
499
|
+
attempt.currentThinkingLevel === "xhigh" &&
|
|
500
|
+
attempt.providerCandidate.supportsXhigh === false
|
|
501
|
+
) {
|
|
502
|
+
return "Reasoning level 'xhigh' is not supported by this model.";
|
|
503
|
+
}
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function createUnsupportedReasoningFocusAttemptResult(
|
|
508
|
+
attempt: FocusTaskAttempt,
|
|
509
|
+
error: string,
|
|
510
|
+
): FocusTaskResult {
|
|
511
|
+
return {
|
|
512
|
+
focus: attempt.focus,
|
|
513
|
+
model: buildFocusTaskAttemptModelLabel(attempt),
|
|
514
|
+
ok: false,
|
|
515
|
+
error,
|
|
516
|
+
errorKind: "unsupported_reasoning",
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function createCancelledFocusAttemptResult(attempt: FocusTaskAttempt): FocusTaskResult {
|
|
521
|
+
return {
|
|
522
|
+
focus: attempt.focus,
|
|
523
|
+
model: buildFocusTaskAttemptModelLabel(attempt),
|
|
524
|
+
ok: false,
|
|
525
|
+
error: REVIEW_CANCELLED_ERROR,
|
|
526
|
+
errorKind: "other",
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function createCancelledFocusResult(task: FocusTask): FocusTaskResult {
|
|
531
|
+
return createCancelledFocusAttemptResult(
|
|
532
|
+
createFocusTaskAttempt(task, getResolvedReviewPrimaryProviderCandidate(task.model)),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function buildFocusTaskAttemptModelLabel(attempt: FocusTaskAttempt): string {
|
|
537
|
+
return buildResolvedReviewModelLabel(attempt.model, attempt.currentThinkingLevel);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function getFocusTaskAttemptModelArg(attempt: FocusTaskAttempt): string | undefined {
|
|
541
|
+
if (attempt.providerCandidate.modelArg) return attempt.providerCandidate.modelArg;
|
|
542
|
+
return attempt.providerCandidate.baseModelArg
|
|
543
|
+
? appendModelThinkingSuffix(
|
|
544
|
+
attempt.providerCandidate.baseModelArg,
|
|
545
|
+
attempt.currentThinkingLevel,
|
|
546
|
+
)
|
|
547
|
+
: undefined;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function createFocusTaskAttempt(
|
|
551
|
+
task: FocusTask,
|
|
552
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
553
|
+
currentThinkingLevel = getResolvedReviewCurrentThinkingLevel(task.model, providerCandidate),
|
|
554
|
+
): FocusTaskAttempt {
|
|
555
|
+
return {
|
|
556
|
+
...task,
|
|
557
|
+
providerCandidate,
|
|
558
|
+
currentThinkingLevel,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function runFocusTaskOnce(
|
|
563
|
+
task: FocusTaskAttempt,
|
|
564
|
+
cwd: string,
|
|
565
|
+
control?: ReviewExecutionControl,
|
|
566
|
+
): Promise<FocusTaskResult> {
|
|
567
|
+
const modelLabel = buildFocusTaskAttemptModelLabel(task);
|
|
568
|
+
const modelArg = getFocusTaskAttemptModelArg(task);
|
|
569
|
+
const args = [
|
|
570
|
+
"--mode",
|
|
571
|
+
"json",
|
|
572
|
+
"-p",
|
|
573
|
+
"--tools",
|
|
574
|
+
REVIEW_TOOLS,
|
|
575
|
+
"--no-extensions",
|
|
576
|
+
"--extension",
|
|
577
|
+
SUBMIT_REVIEW_EXTENSION_PATH,
|
|
578
|
+
"--no-skills",
|
|
579
|
+
"--no-prompt-templates",
|
|
580
|
+
"--no-themes",
|
|
581
|
+
];
|
|
582
|
+
if (modelArg) {
|
|
583
|
+
args.push("--model", modelArg);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const taskResult = await runPiSubmitToolTask({
|
|
587
|
+
args,
|
|
588
|
+
prompt: task.prompt,
|
|
589
|
+
cwd,
|
|
590
|
+
timeoutMs: REVIEW_TASK_TIMEOUT_MS,
|
|
591
|
+
control,
|
|
592
|
+
submitTool: SUBMIT_REVIEW_TOOL,
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (taskResult.status === "cancelled") {
|
|
596
|
+
return createCancelledFocusAttemptResult(task);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (taskResult.status === "timeout") {
|
|
600
|
+
return {
|
|
601
|
+
focus: task.focus,
|
|
602
|
+
model: modelLabel,
|
|
603
|
+
ok: false,
|
|
604
|
+
error: "Review timed out after 30 minutes.",
|
|
605
|
+
errorKind: "other",
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (taskResult.status === "spawn_error") {
|
|
610
|
+
const error = `Failed to start focus process: ${taskResult.error ?? "unknown error"}`;
|
|
611
|
+
return {
|
|
612
|
+
focus: task.focus,
|
|
613
|
+
model: modelLabel,
|
|
614
|
+
ok: false,
|
|
615
|
+
error,
|
|
616
|
+
...classifyTaskError(error),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (taskResult.status === "non_zero_exit") {
|
|
621
|
+
const stderr = taskResult.stderr.trim();
|
|
622
|
+
const error = `Focus exited with code ${taskResult.exitCode ?? 1}${stderr ? `: ${stderr}` : ""}`;
|
|
623
|
+
return {
|
|
624
|
+
focus: task.focus,
|
|
625
|
+
model: modelLabel,
|
|
626
|
+
ok: false,
|
|
627
|
+
error,
|
|
628
|
+
...classifyTaskError(`${taskResult.stderr}\n${error}`),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (taskResult.status === "assistant_error") {
|
|
633
|
+
const classification = classifyTaskError(taskResult.error ?? "");
|
|
634
|
+
const error =
|
|
635
|
+
classification.errorKind === "missing_api_key"
|
|
636
|
+
? `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`
|
|
637
|
+
: classification.errorKind === "rate_limit"
|
|
638
|
+
? appendErrorDetails(
|
|
639
|
+
"Focus failed due to rate limiting. Try again later or switch models.",
|
|
640
|
+
taskResult.error,
|
|
641
|
+
)
|
|
642
|
+
: buildProviderErrorMessage("Focus failed due to a provider error.", taskResult.error);
|
|
643
|
+
return {
|
|
644
|
+
focus: task.focus,
|
|
645
|
+
model: modelLabel,
|
|
646
|
+
ok: false,
|
|
647
|
+
error,
|
|
648
|
+
...classification,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const submittedPayload = getSubmittedPayload({
|
|
653
|
+
submittedPayloads: taskResult.submittedPayloads,
|
|
654
|
+
assistantOutput: taskResult.assistantOutput,
|
|
655
|
+
submitTool: SUBMIT_REVIEW_TOOL,
|
|
656
|
+
taskLabel: "Focus",
|
|
657
|
+
});
|
|
658
|
+
if (!submittedPayload.ok) {
|
|
659
|
+
return {
|
|
660
|
+
focus: task.focus,
|
|
661
|
+
model: modelLabel,
|
|
662
|
+
ok: false,
|
|
663
|
+
error: submittedPayload.error,
|
|
664
|
+
errorKind: "other",
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
focus: task.focus,
|
|
670
|
+
model: modelLabel,
|
|
671
|
+
ok: true,
|
|
672
|
+
output: {
|
|
673
|
+
focus: task.focus,
|
|
674
|
+
model: modelLabel,
|
|
675
|
+
findings: (submittedPayload.payload as { findings: FocusFinding[] }).findings,
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function runFocusTaskWithRetry(
|
|
681
|
+
task: FocusTaskAttempt,
|
|
682
|
+
cwd: string,
|
|
683
|
+
control?: ReviewExecutionControl,
|
|
684
|
+
): Promise<FocusTaskResult> {
|
|
685
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
686
|
+
const result = await runFocusTaskOnce(task, cwd, control);
|
|
687
|
+
if (result.ok || attempt >= REVIEW_STARTUP_RETRY_DELAYS_MS.length) return result;
|
|
688
|
+
|
|
689
|
+
if (result.errorKind !== "lock_contention") return result;
|
|
690
|
+
if (control?.isCancelled()) {
|
|
691
|
+
return createCancelledFocusAttemptResult(task);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const baseDelayMs =
|
|
695
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[attempt] ??
|
|
696
|
+
REVIEW_STARTUP_RETRY_DELAYS_MS[REVIEW_STARTUP_RETRY_DELAYS_MS.length - 1];
|
|
697
|
+
await new Promise((resolve) => setTimeout(resolve, withJitter(baseDelayMs)));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function getFocusTaskThinkingLevels(
|
|
702
|
+
task: FocusTask,
|
|
703
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
704
|
+
): Array<ReviewThinkingLevel | undefined> {
|
|
705
|
+
const currentThinkingLevel = getResolvedReviewCurrentThinkingLevel(task.model, providerCandidate);
|
|
706
|
+
if (task.model.thinkingSource !== "inherited" || !currentThinkingLevel) {
|
|
707
|
+
return [currentThinkingLevel];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return getFallbackThinkingLevels(currentThinkingLevel);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function getProviderCandidateAvailabilityFromResult(
|
|
714
|
+
result: FocusTaskResult,
|
|
715
|
+
): "supported" | "unsupported" {
|
|
716
|
+
return result.errorKind === "unsupported_model" || result.errorKind === "unsupported_reasoning"
|
|
717
|
+
? "unsupported"
|
|
718
|
+
: "supported";
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function runFocusTaskForProviderCandidate(
|
|
722
|
+
task: FocusTask,
|
|
723
|
+
providerCandidate: ResolvedReviewProviderCandidate,
|
|
724
|
+
cwd: string,
|
|
725
|
+
control?: ReviewExecutionControl,
|
|
726
|
+
): Promise<FocusTaskResult> {
|
|
727
|
+
const thinkingLevels = getFocusTaskThinkingLevels(task, providerCandidate);
|
|
728
|
+
const triedLevels: ReviewThinkingLevel[] = [];
|
|
729
|
+
let lastResult: FocusTaskResult | undefined;
|
|
730
|
+
|
|
731
|
+
for (let index = 0; index < thinkingLevels.length; index += 1) {
|
|
732
|
+
const currentThinkingLevel = thinkingLevels[index];
|
|
733
|
+
const attempt = createFocusTaskAttempt(task, providerCandidate, currentThinkingLevel);
|
|
734
|
+
const explicitThinkingSupportError = getExplicitThinkingSupportError(attempt);
|
|
735
|
+
if (explicitThinkingSupportError) {
|
|
736
|
+
return createUnsupportedReasoningFocusAttemptResult(attempt, explicitThinkingSupportError);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const result = await runFocusTaskWithRetry(attempt, cwd, control);
|
|
740
|
+
if (result.ok) return result;
|
|
741
|
+
|
|
742
|
+
lastResult = result;
|
|
743
|
+
if (currentThinkingLevel !== undefined) {
|
|
744
|
+
triedLevels.push(currentThinkingLevel);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (result.errorKind === "unsupported_model") {
|
|
748
|
+
return result;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const hasLowerThinkingLevel = index < thinkingLevels.length - 1;
|
|
752
|
+
const shouldFallback = hasLowerThinkingLevel && result.errorKind === "unsupported_reasoning";
|
|
753
|
+
if (!shouldFallback) {
|
|
754
|
+
return formatThinkingFallbackError(result, triedLevels);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return lastResult ?? createCancelledFocusResult(task);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function runFocusTask(
|
|
762
|
+
task: FocusTask,
|
|
763
|
+
cwd: string,
|
|
764
|
+
control?: ReviewExecutionControl,
|
|
765
|
+
): Promise<FocusTaskResult> {
|
|
766
|
+
if (control?.isCancelled()) {
|
|
767
|
+
return createCancelledFocusResult(task);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let lastResult: FocusTaskResult | undefined;
|
|
771
|
+
|
|
772
|
+
for (const providerCandidate of task.model.providerCandidates) {
|
|
773
|
+
const availability = getProviderCandidateAvailability(task.model, providerCandidate);
|
|
774
|
+
if (availability === "unsupported") {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (availability === "supported") {
|
|
778
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
779
|
+
if (result.ok) return result;
|
|
780
|
+
lastResult = result;
|
|
781
|
+
if (
|
|
782
|
+
result.errorKind === "unsupported_model" ||
|
|
783
|
+
result.errorKind === "unsupported_reasoning"
|
|
784
|
+
) {
|
|
785
|
+
setProviderCandidateAvailability(task.model, providerCandidate, "unsupported");
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const existingProbe = getProviderCandidateProbe(task.model, providerCandidate);
|
|
792
|
+
if (existingProbe) {
|
|
793
|
+
const probedAvailability = await existingProbe;
|
|
794
|
+
if (probedAvailability === "unsupported") {
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
799
|
+
if (result.ok) return result;
|
|
800
|
+
lastResult = result;
|
|
801
|
+
if (
|
|
802
|
+
result.errorKind === "unsupported_model" ||
|
|
803
|
+
result.errorKind === "unsupported_reasoning"
|
|
804
|
+
) {
|
|
805
|
+
setProviderCandidateAvailability(task.model, providerCandidate, "unsupported");
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
return result;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
let resolveProbe: ((value: "supported" | "unsupported") => void) | undefined;
|
|
812
|
+
let rejectProbe: ((reason?: unknown) => void) | undefined;
|
|
813
|
+
const probe = new Promise<"supported" | "unsupported">((resolve, reject) => {
|
|
814
|
+
resolveProbe = resolve;
|
|
815
|
+
rejectProbe = reject;
|
|
816
|
+
});
|
|
817
|
+
setProviderCandidateProbe(task.model, providerCandidate, probe);
|
|
818
|
+
|
|
819
|
+
try {
|
|
820
|
+
const result = await runFocusTaskForProviderCandidate(task, providerCandidate, cwd, control);
|
|
821
|
+
lastResult = result;
|
|
822
|
+
const probedAvailability = getProviderCandidateAvailabilityFromResult(result);
|
|
823
|
+
setProviderCandidateAvailability(task.model, providerCandidate, probedAvailability);
|
|
824
|
+
resolveProbe?.(probedAvailability);
|
|
825
|
+
if (result.ok) return result;
|
|
826
|
+
if (probedAvailability === "unsupported") {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
return result;
|
|
830
|
+
} catch (error) {
|
|
831
|
+
rejectProbe?.(error);
|
|
832
|
+
throw error;
|
|
833
|
+
} finally {
|
|
834
|
+
clearProviderCandidateProbe(task.model, providerCandidate);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return lastResult ?? createCancelledFocusResult(task);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
async function runReviewDedupTask(options: {
|
|
842
|
+
ctx: ExtensionCommandContext;
|
|
843
|
+
cwd: string;
|
|
844
|
+
findings: ReviewReportFinding[];
|
|
845
|
+
control?: ReviewExecutionControl;
|
|
846
|
+
}): Promise<ReviewDedupGroup[] | null> {
|
|
847
|
+
const { ctx, cwd, findings, control } = options;
|
|
848
|
+
if (findings.length <= 1) return [];
|
|
849
|
+
if (control?.isCancelled()) return null;
|
|
850
|
+
|
|
851
|
+
const model = selectReviewDedupModel(ctx);
|
|
852
|
+
if (!model) return null;
|
|
853
|
+
|
|
854
|
+
const taskResult = await withSpinner(
|
|
855
|
+
ctx,
|
|
856
|
+
() => `deduplicating ${findings.length} review findings`,
|
|
857
|
+
() =>
|
|
858
|
+
runPiOneShotTask({
|
|
859
|
+
args: [
|
|
860
|
+
"--mode",
|
|
861
|
+
"json",
|
|
862
|
+
"-p",
|
|
863
|
+
"--no-session",
|
|
864
|
+
"--no-tools",
|
|
865
|
+
"--no-extensions",
|
|
866
|
+
"--no-skills",
|
|
867
|
+
"--no-prompt-templates",
|
|
868
|
+
"--no-themes",
|
|
869
|
+
"--model",
|
|
870
|
+
model.modelArg,
|
|
871
|
+
],
|
|
872
|
+
prompt: buildReviewDedupPrompt(findings),
|
|
873
|
+
cwd,
|
|
874
|
+
timeoutMs: REVIEW_TASK_TIMEOUT_MS,
|
|
875
|
+
control,
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
|
|
879
|
+
if (taskResult.status !== "ok") {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
return parseReviewDedupOutput(parseJsonFromText(taskResult.assistantOutput), findings.length);
|
|
885
|
+
} catch {
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const SCOPE_MODES = new Set(["working-tree", "branch-diff", "commit", "folder", "custom"]);
|
|
891
|
+
|
|
892
|
+
function isScopeMode(value: unknown): value is ResolvedScope["kind"] {
|
|
893
|
+
return typeof value === "string" && SCOPE_MODES.has(value);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const REQUEST_MODE_PREFIXES = ["branch:", "commit:", "pr:", "folder:"];
|
|
897
|
+
|
|
898
|
+
function isReviewRequestMode(value: unknown): value is ReviewRequestMode {
|
|
899
|
+
if (typeof value !== "string") return false;
|
|
900
|
+
if (value === "auto" || value === "uncommitted" || value === "custom") return true;
|
|
901
|
+
return REQUEST_MODE_PREFIXES.some(
|
|
902
|
+
(prefix) => value.startsWith(prefix) && value.length > prefix.length,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function parseReviewStaleness(value: unknown): ReviewStaleness | undefined | null {
|
|
907
|
+
if (value === undefined) return undefined;
|
|
908
|
+
const staleness = asRecord(value);
|
|
909
|
+
if (!staleness) return null;
|
|
910
|
+
if (staleness.status !== "stale") return null;
|
|
911
|
+
if (typeof staleness.warning !== "string") return null;
|
|
912
|
+
if (typeof staleness.nextStep !== "string") return null;
|
|
913
|
+
return {
|
|
914
|
+
status: "stale",
|
|
915
|
+
warning: staleness.warning,
|
|
916
|
+
nextStep: staleness.nextStep,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function buildReviewStaleness(source: ReviewRunSource): ReviewStaleness {
|
|
921
|
+
if (source === "fix") {
|
|
922
|
+
return {
|
|
923
|
+
status: "stale",
|
|
924
|
+
warning: REVIEW_STALE_FIX_WARNING,
|
|
925
|
+
nextStep: REVIEW_STALE_FIX_NEXT_STEP,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
status: "stale",
|
|
931
|
+
warning: REVIEW_STALE_REVIEW_WARNING,
|
|
932
|
+
nextStep: REVIEW_STALE_REVIEW_NEXT_STEP,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function buildStalePayloadStaleness(): ReviewStaleness {
|
|
937
|
+
return {
|
|
938
|
+
status: "stale",
|
|
939
|
+
warning: REVIEW_STALE_PAYLOAD_WARNING,
|
|
940
|
+
nextStep: REVIEW_STALE_PAYLOAD_NEXT_STEP,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function buildReviewFooterNotes(staleness: ReviewStaleness | undefined): string[] {
|
|
945
|
+
if (!staleness) return [];
|
|
946
|
+
return [staleness.warning, staleness.nextStep];
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function parseReviewMessageDetails(value: unknown): ReviewMessageDetails | null {
|
|
950
|
+
if (!value || typeof value !== "object") return null;
|
|
951
|
+
const details = value as ReviewMessageDetails;
|
|
952
|
+
if (details.kind !== undefined && details.kind !== "report") return null;
|
|
953
|
+
if (!isReviewRequestMode(details.request?.mode)) return null;
|
|
954
|
+
if (typeof details.request?.signature !== "string") return null;
|
|
955
|
+
if (!isScopeMode(details.scope?.mode)) return null;
|
|
956
|
+
if (
|
|
957
|
+
!details.fingerprint ||
|
|
958
|
+
typeof details.fingerprint !== "object" ||
|
|
959
|
+
typeof details.fingerprint.headSha !== "string" ||
|
|
960
|
+
typeof details.fingerprint.branch !== "string" ||
|
|
961
|
+
typeof details.fingerprint.trackedDiffHash !== "string" ||
|
|
962
|
+
typeof details.fingerprint.untrackedHash !== "string"
|
|
963
|
+
) {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const staleness = parseReviewStaleness(details.staleness);
|
|
968
|
+
if (staleness === null) return null;
|
|
969
|
+
|
|
970
|
+
if (!Array.isArray(details.focusStatus) || !Array.isArray(details.findings)) return null;
|
|
971
|
+
return {
|
|
972
|
+
...details,
|
|
973
|
+
staleness,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export function getLastMessageReviewDetails(ctx: ExtensionContext): ReviewMessageDetails | null {
|
|
978
|
+
const entry = ctx.sessionManager.getBranch().at(-1);
|
|
979
|
+
if (!entry || entry.type !== "custom_message" || entry.customType !== "review") {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return parseReviewMessageDetails(entry.details);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// --- Review pipeline ---
|
|
987
|
+
|
|
988
|
+
function buildReviewTasks(
|
|
989
|
+
scope: ResolvedScope,
|
|
990
|
+
guidelines: string | null,
|
|
991
|
+
additionalContext: string | undefined,
|
|
992
|
+
models: ResolvedReviewModel[],
|
|
993
|
+
focuses: readonly ReviewFocus[],
|
|
994
|
+
): FocusTask[] {
|
|
995
|
+
const scopeInstructions = buildScopeInstructions(scope);
|
|
996
|
+
const tasks: FocusTask[] = [];
|
|
997
|
+
|
|
998
|
+
for (const model of models) {
|
|
999
|
+
for (const focus of focuses) {
|
|
1000
|
+
tasks.push({
|
|
1001
|
+
model,
|
|
1002
|
+
focus,
|
|
1003
|
+
prompt: buildFocusPrompt(focus, scopeInstructions, guidelines, additionalContext),
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return tasks;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function prepareReviewRun(
|
|
1012
|
+
pi: ExtensionAPI,
|
|
1013
|
+
ctx: ExtensionCommandContext,
|
|
1014
|
+
request: ParsedRequest,
|
|
1015
|
+
): Promise<{ ok: false; error: string } | { ok: true; data: PreparedReviewRun }> {
|
|
1016
|
+
if (!(await isGitRepo(pi))) {
|
|
1017
|
+
return { ok: false, error: "Not a git repository." };
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const resolved = await resolveScope(pi, request.target, (message, type) =>
|
|
1021
|
+
notify(ctx, message, type),
|
|
1022
|
+
);
|
|
1023
|
+
if (!resolved.scope) {
|
|
1024
|
+
return { ok: false, error: resolved.error ?? "Failed to resolve review scope." };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const scope = resolved.scope;
|
|
1028
|
+
const includeUntracked = scope.kind === "working-tree" || scope.kind === "folder";
|
|
1029
|
+
const scopeUntrackedFiles = scope.kind === "working-tree" ? scope.untrackedFiles : undefined;
|
|
1030
|
+
const [baselineFingerprint, guidelines, models] = await Promise.all([
|
|
1031
|
+
computeCurrentFingerprint(pi, ctx.cwd, includeUntracked, scopeUntrackedFiles),
|
|
1032
|
+
loadProjectReviewGuidelines(ctx.cwd),
|
|
1033
|
+
resolveModels(ctx, request.models, pi.getThinkingLevel()),
|
|
1034
|
+
]);
|
|
1035
|
+
|
|
1036
|
+
return {
|
|
1037
|
+
ok: true,
|
|
1038
|
+
data: {
|
|
1039
|
+
scope,
|
|
1040
|
+
includeUntracked,
|
|
1041
|
+
baselineFingerprint,
|
|
1042
|
+
models,
|
|
1043
|
+
tasks: buildReviewTasks(
|
|
1044
|
+
scope,
|
|
1045
|
+
guidelines,
|
|
1046
|
+
request.additionalContext,
|
|
1047
|
+
models,
|
|
1048
|
+
request.focuses,
|
|
1049
|
+
),
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function runFocusTasks(
|
|
1055
|
+
ctx: ExtensionCommandContext,
|
|
1056
|
+
cwd: string,
|
|
1057
|
+
tasks: FocusTask[],
|
|
1058
|
+
control: ReviewExecutionControl,
|
|
1059
|
+
): Promise<FocusTaskResult[]> {
|
|
1060
|
+
const progress = createReviewProgress(ctx, tasks);
|
|
1061
|
+
try {
|
|
1062
|
+
return await Promise.all(
|
|
1063
|
+
tasks.map(async (task) => {
|
|
1064
|
+
const result = control.isCancelled()
|
|
1065
|
+
? createCancelledFocusResult(task)
|
|
1066
|
+
: await runFocusTask(task, cwd, control);
|
|
1067
|
+
progress.update(task, result);
|
|
1068
|
+
return result;
|
|
1069
|
+
}),
|
|
1070
|
+
);
|
|
1071
|
+
} finally {
|
|
1072
|
+
progress.stop();
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function sortReviewFindings(findings: ReviewReportFinding[]): void {
|
|
1077
|
+
findings.sort((a, b) => {
|
|
1078
|
+
const prio = priorityRank(a.priority) - priorityRank(b.priority);
|
|
1079
|
+
if (prio !== 0) return prio;
|
|
1080
|
+
|
|
1081
|
+
const locationCmp = a.location.localeCompare(b.location);
|
|
1082
|
+
if (locationCmp !== 0) return locationCmp;
|
|
1083
|
+
|
|
1084
|
+
if (a.focus !== b.focus) return a.focus.localeCompare(b.focus);
|
|
1085
|
+
return a.model.localeCompare(b.model);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function applyReviewDedupGroups(
|
|
1090
|
+
findings: ReviewReportFinding[],
|
|
1091
|
+
groups: ReviewDedupGroup[],
|
|
1092
|
+
): ReviewReportFinding[] {
|
|
1093
|
+
if (groups.length === 0) return findings;
|
|
1094
|
+
|
|
1095
|
+
const next = findings.slice();
|
|
1096
|
+
const dropped = new Set<number>();
|
|
1097
|
+
|
|
1098
|
+
for (const group of groups) {
|
|
1099
|
+
// Dedup ids are 1-based positions in priority-sorted order, so the lowest id is the preferred survivor.
|
|
1100
|
+
const keepId = Math.min(...group.ids);
|
|
1101
|
+
const groupedFindings = group.ids.map((id) => findings[id - 1]);
|
|
1102
|
+
const survivor = findings[keepId - 1];
|
|
1103
|
+
const priorities = groupedFindings
|
|
1104
|
+
.map((finding) => finding.priority)
|
|
1105
|
+
.sort((a, b) => priorityRank(a) - priorityRank(b));
|
|
1106
|
+
const focuses = Array.from(new Set(groupedFindings.map((finding) => finding.focus))).join(", ");
|
|
1107
|
+
const models = Array.from(new Set(groupedFindings.map((finding) => finding.model))).join(", ");
|
|
1108
|
+
|
|
1109
|
+
next[keepId - 1] = {
|
|
1110
|
+
...survivor,
|
|
1111
|
+
priority: priorities[0],
|
|
1112
|
+
focus: focuses,
|
|
1113
|
+
model: models,
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
for (const id of group.ids) {
|
|
1117
|
+
if (id !== keepId) {
|
|
1118
|
+
dropped.add(id);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return next.filter((_, index) => !dropped.has(index + 1));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function buildReviewFindings(
|
|
1127
|
+
ctx: ExtensionCommandContext,
|
|
1128
|
+
cwd: string,
|
|
1129
|
+
successfulFocuses: Array<FocusTaskResult & { output: FocusOutput }>,
|
|
1130
|
+
control?: ReviewExecutionControl,
|
|
1131
|
+
): Promise<ReviewReportFinding[]> {
|
|
1132
|
+
const findings = successfulFocuses.flatMap((focus) =>
|
|
1133
|
+
focus.output.findings.map((finding) => ({
|
|
1134
|
+
...finding,
|
|
1135
|
+
focus: focus.focus,
|
|
1136
|
+
model: focus.model,
|
|
1137
|
+
})),
|
|
1138
|
+
);
|
|
1139
|
+
sortReviewFindings(findings);
|
|
1140
|
+
|
|
1141
|
+
if (findings.length <= 1) {
|
|
1142
|
+
return findings;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const dedupGroups = await runReviewDedupTask({
|
|
1146
|
+
ctx,
|
|
1147
|
+
cwd,
|
|
1148
|
+
findings,
|
|
1149
|
+
control,
|
|
1150
|
+
});
|
|
1151
|
+
return dedupGroups ? applyReviewDedupGroups(findings, dedupGroups) : findings;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export async function runReviewPipeline(
|
|
1155
|
+
pi: ExtensionAPI,
|
|
1156
|
+
ctx: ExtensionCommandContext,
|
|
1157
|
+
request: ParsedRequest,
|
|
1158
|
+
source: ReviewRunSource,
|
|
1159
|
+
): Promise<ReviewRunResult> {
|
|
1160
|
+
const startedAtMs = Date.now();
|
|
1161
|
+
const prepared = await prepareReviewRun(pi, ctx, request);
|
|
1162
|
+
if (!prepared.ok) {
|
|
1163
|
+
return { ok: false, error: prepared.error };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return withManagedReviewRun(pi, ctx, source, async (managed) => {
|
|
1167
|
+
const { scope, includeUntracked, baselineFingerprint, models, tasks } = prepared.data;
|
|
1168
|
+
if (source === "review") {
|
|
1169
|
+
const modelsText = models
|
|
1170
|
+
.map((model) => buildResolvedReviewStatusModelLabel(model))
|
|
1171
|
+
.join(", ");
|
|
1172
|
+
notify(ctx, `Review focuses: ${request.focuses.join(", ")} · models: ${modelsText}.`, "info");
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const focusResults = await runFocusTasks(ctx, ctx.cwd, tasks, managed.control);
|
|
1176
|
+
if (managed.control.isCancelled()) {
|
|
1177
|
+
managed.markCancelled();
|
|
1178
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const failedFocuses = focusResults.filter((focus) => !focus.ok);
|
|
1182
|
+
const failedCount = failedFocuses.length;
|
|
1183
|
+
const totalReviews = focusResults.length;
|
|
1184
|
+
const completedReviews = totalReviews - failedCount;
|
|
1185
|
+
const successfulFocuses = focusResults.filter(
|
|
1186
|
+
(result): result is FocusTaskResult & { output: FocusOutput } =>
|
|
1187
|
+
Boolean(result.ok && result.output),
|
|
1188
|
+
);
|
|
1189
|
+
if (successfulFocuses.length === 0) {
|
|
1190
|
+
if (failedCount > 0) {
|
|
1191
|
+
const reviewedScopeLine = buildReviewedScopeLine(scope, Date.now() - startedAtMs);
|
|
1192
|
+
const failureReport = `${reviewedScopeLine}\n\n${buildReviewFailuresMarkdown(failedFocuses)}`;
|
|
1193
|
+
pi.sendMessage(
|
|
1194
|
+
{
|
|
1195
|
+
customType: "review",
|
|
1196
|
+
content: failureReport,
|
|
1197
|
+
display: true,
|
|
1198
|
+
details: { kind: "failure" satisfies ReviewMessageKind },
|
|
1199
|
+
},
|
|
1200
|
+
{ deliverAs: "followUp" },
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const missingApiProvider = failedFocuses.find(
|
|
1205
|
+
(focus) => focus.errorKind === "missing_api_key" && Boolean(focus.missingApiProvider),
|
|
1206
|
+
)?.missingApiProvider;
|
|
1207
|
+
if (missingApiProvider) {
|
|
1208
|
+
return {
|
|
1209
|
+
ok: false,
|
|
1210
|
+
error: `All reviews failed. Missing API key for provider '${missingApiProvider}'. Use /login or configure credentials for that provider.`,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const sampleError =
|
|
1215
|
+
focusResults.find((focus) => focus.error)?.error ?? "Unknown focus failure";
|
|
1216
|
+
return {
|
|
1217
|
+
ok: false,
|
|
1218
|
+
error: `All reviews failed. ${sampleError}`,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const findings = await buildReviewFindings(ctx, ctx.cwd, successfulFocuses, managed.control);
|
|
1223
|
+
|
|
1224
|
+
const endingFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, includeUntracked);
|
|
1225
|
+
const reviewStaleness = !fingerprintsEqual(baselineFingerprint, endingFingerprint)
|
|
1226
|
+
? buildReviewStaleness(source)
|
|
1227
|
+
: undefined;
|
|
1228
|
+
|
|
1229
|
+
if (managed.control.isCancelled()) {
|
|
1230
|
+
managed.markCancelled();
|
|
1231
|
+
return { ok: false, error: REVIEW_CANCELLED_ERROR };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const reviewedScopeLine = buildReviewedScopeLine(scope, Date.now() - startedAtMs);
|
|
1235
|
+
let findingsMarkdown = buildReviewFindingsMarkdown(
|
|
1236
|
+
reviewedScopeLine,
|
|
1237
|
+
findings,
|
|
1238
|
+
completedReviews,
|
|
1239
|
+
totalReviews,
|
|
1240
|
+
buildReviewFooterNotes(reviewStaleness),
|
|
1241
|
+
);
|
|
1242
|
+
if (failedCount > 0) {
|
|
1243
|
+
findingsMarkdown = `${findingsMarkdown.trimEnd()}\n\n${buildReviewFailuresMarkdown(failedFocuses)}`;
|
|
1244
|
+
}
|
|
1245
|
+
const details: ReviewMessageDetails = {
|
|
1246
|
+
kind: "report",
|
|
1247
|
+
request: {
|
|
1248
|
+
mode: request.mode,
|
|
1249
|
+
signature: buildRequestSignature(request),
|
|
1250
|
+
},
|
|
1251
|
+
scope: {
|
|
1252
|
+
mode: scope.kind,
|
|
1253
|
+
description: scope.description,
|
|
1254
|
+
},
|
|
1255
|
+
fingerprint: reviewStaleness ? baselineFingerprint : endingFingerprint,
|
|
1256
|
+
staleness: reviewStaleness,
|
|
1257
|
+
focusStatus: focusResults.map((focus) => ({
|
|
1258
|
+
focus: focus.focus,
|
|
1259
|
+
model: focus.model,
|
|
1260
|
+
ok: focus.ok,
|
|
1261
|
+
error: focus.error,
|
|
1262
|
+
})),
|
|
1263
|
+
findings,
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
pi.sendMessage(
|
|
1267
|
+
{
|
|
1268
|
+
customType: "review",
|
|
1269
|
+
content: findingsMarkdown,
|
|
1270
|
+
display: true,
|
|
1271
|
+
details,
|
|
1272
|
+
},
|
|
1273
|
+
{ deliverAs: "followUp" },
|
|
1274
|
+
);
|
|
1275
|
+
|
|
1276
|
+
if (reviewStaleness) {
|
|
1277
|
+
if (failedCount > 0) {
|
|
1278
|
+
const failedLabel = `review${failedCount === 1 ? "" : "s"}`;
|
|
1279
|
+
notify(
|
|
1280
|
+
ctx,
|
|
1281
|
+
`Review completed with stale partial results: ${failedCount} ${failedLabel} failed.`,
|
|
1282
|
+
"warning",
|
|
1283
|
+
);
|
|
1284
|
+
} else {
|
|
1285
|
+
notify(
|
|
1286
|
+
ctx,
|
|
1287
|
+
`Review completed with stale results: ${findings.length} finding(s).`,
|
|
1288
|
+
"warning",
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
} else if (failedCount > 0) {
|
|
1292
|
+
const failedLabel = `review${failedCount === 1 ? "" : "s"}`;
|
|
1293
|
+
notify(
|
|
1294
|
+
ctx,
|
|
1295
|
+
`Review completed with partial results: ${failedCount} ${failedLabel} failed.`,
|
|
1296
|
+
"warning",
|
|
1297
|
+
);
|
|
1298
|
+
} else {
|
|
1299
|
+
notify(ctx, `Review completed: ${findings.length} finding(s).`, "info");
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
managed.markSuccessful();
|
|
1303
|
+
return { ok: true, details };
|
|
1304
|
+
});
|
|
1305
|
+
}
|