tau-coding-agent 0.1.3 → 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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/extensions/answer.ts +49 -43
  3. package/extensions/branch-term.ts +11 -16
  4. package/extensions/btw.ts +8 -8
  5. package/extensions/ghostty.ts +65 -12
  6. package/extensions/git-diff-stats.ts +1 -1
  7. package/extensions/git-pr-status.ts +1 -1
  8. package/extensions/insights.ts +6 -5
  9. package/extensions/loop.ts +39 -11
  10. package/extensions/memory.ts +6 -5
  11. package/extensions/notify.ts +25 -6
  12. package/extensions/openai-fast.ts +6 -4
  13. package/extensions/openai-verbosity.ts +6 -4
  14. package/extensions/review/fix.ts +239 -0
  15. package/extensions/review/git.ts +517 -0
  16. package/extensions/review/index.ts +232 -0
  17. package/extensions/review/message-queue.ts +249 -0
  18. package/extensions/review/models.ts +589 -0
  19. package/extensions/review/prompts.ts +337 -0
  20. package/extensions/review/renderers/inline.ts +126 -0
  21. package/extensions/review/request.ts +504 -0
  22. package/extensions/review/review.ts +1305 -0
  23. package/extensions/review/runner.ts +522 -0
  24. package/extensions/review/runtime.ts +275 -0
  25. package/extensions/review/schema.ts +160 -0
  26. package/extensions/review/submit-review-tool.ts +66 -0
  27. package/extensions/review/submit-triage-tool.ts +59 -0
  28. package/extensions/review/triage.ts +746 -0
  29. package/extensions/sandbox/index.ts +428 -103
  30. package/extensions/{interlude.ts → stash.ts} +13 -11
  31. package/extensions/tool-display-mode.ts +173 -17
  32. package/extensions/usage/anthropic.ts +1 -1
  33. package/extensions/usage/github-copilot.ts +1 -1
  34. package/extensions/usage/index.ts +9 -5
  35. package/extensions/usage/minimax.ts +1 -1
  36. package/extensions/usage/openai-codex.ts +1 -1
  37. package/extensions/usage/openrouter.ts +1 -1
  38. package/extensions/usage/providers.ts +0 -2
  39. package/extensions/usage/types.ts +1 -2
  40. package/extensions/usage/zai.ts +1 -1
  41. package/extensions/websearch/README.md +1 -1
  42. package/extensions/websearch/browser/chromium.ts +42 -31
  43. package/extensions/websearch/browser/discovery.ts +11 -9
  44. package/extensions/websearch/browser/firefox.ts +10 -7
  45. package/extensions/websearch/browser/fs.ts +10 -0
  46. package/extensions/websearch/browser/sqlite.ts +22 -15
  47. package/extensions/websearch/config.ts +2 -2
  48. package/extensions/websearch/index.ts +9 -9
  49. package/extensions/websearch/providers/anthropic.pi.ts +1 -1
  50. package/extensions/websearch/providers/gemini.pi.ts +1 -1
  51. package/extensions/websearch/providers/openai-codex.pi.ts +1 -1
  52. package/extensions/websearch/providers/pi-model.shared.ts +2 -2
  53. package/extensions/worktree.ts +36 -36
  54. package/package.json +7 -7
  55. package/skills/git-clean-history/SKILL.md +1 -0
  56. package/skills/git-commit/SKILL.md +2 -2
  57. package/skills/oracle/scripts/oracle +28 -12
  58. package/themes/tau-dark.json +2 -1
  59. package/extensions/review.ts +0 -4347
  60. package/extensions/usage/google-gemini-cli.ts +0 -232
@@ -0,0 +1,746 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ buildProjectReviewGuidelinesSection,
5
+ TRIAGE_METADATA_QUERY,
6
+ TRIAGE_PROMPT,
7
+ TRIAGE_THREADS_QUERY,
8
+ } from "./prompts.js";
9
+ import { buildTriageMarkdown } from "./renderers/inline.js";
10
+ import { parsePrReference } from "./request.js";
11
+ import {
12
+ appendErrorDetails,
13
+ asRecord,
14
+ buildProviderErrorMessage,
15
+ classifyTaskError,
16
+ getSubmittedPayload,
17
+ REVIEW_INSPECTION_TOOLS,
18
+ REVIEW_STARTUP_RETRY_DELAYS_MS,
19
+ REVIEW_TASK_TIMEOUT_MS,
20
+ runPiSubmitToolTask,
21
+ withJitter,
22
+ } from "./runner.js";
23
+ import {
24
+ computeCurrentFingerprint,
25
+ fingerprintsEqual,
26
+ buildScopeInstructions,
27
+ getPrCheckoutBlockedError,
28
+ isGitRepo,
29
+ loadProjectReviewGuidelines,
30
+ preparePrCheckoutScope,
31
+ type ResolvedScope,
32
+ } from "./git.js";
33
+ import {
34
+ buildResolvedReviewStatusModelLabel,
35
+ getResolvedReviewStatusModelArg,
36
+ resolveModels,
37
+ type ResolvedReviewModel,
38
+ } from "./models.js";
39
+ import {
40
+ notify,
41
+ REVIEW_CANCELLED_ERROR,
42
+ withManagedReviewRun,
43
+ withSpinner,
44
+ type ReviewExecutionControl,
45
+ } from "./runtime.js";
46
+ import type { ReviewFingerprint } from "./schema.js";
47
+ import { SUBMIT_TRIAGE_EXTENSION_PATH } from "./submit-triage-tool.js";
48
+
49
+ const SUBMIT_TRIAGE_TOOL = "submit_triage";
50
+ const TRIAGE_TOOLS = `${REVIEW_INSPECTION_TOOLS},${SUBMIT_TRIAGE_TOOL}`;
51
+
52
+ type TriageFeedbackKind = "review-thread" | "review-summary" | "pr-comment";
53
+ type TriageDecision = "address" | "push_back" | "research" | "ignore";
54
+
55
+ type TriageFeedbackComment = {
56
+ author: string;
57
+ body: string;
58
+ url?: string;
59
+ createdAt?: string;
60
+ };
61
+
62
+ type TriageFeedbackItem = {
63
+ id: string;
64
+ kind: TriageFeedbackKind;
65
+ author: string;
66
+ location: string;
67
+ url?: string;
68
+ body?: string;
69
+ state?: string;
70
+ isResolved?: boolean;
71
+ isOutdated?: boolean;
72
+ comments?: TriageFeedbackComment[];
73
+ };
74
+
75
+ type TriageItem = {
76
+ id: string;
77
+ decision: TriageDecision;
78
+ summary: string;
79
+ rationale: string;
80
+ action: string;
81
+ };
82
+
83
+ type TriageMessageItem = TriageItem & {
84
+ feedbackKind: TriageFeedbackKind;
85
+ location: string;
86
+ author: string;
87
+ url?: string;
88
+ };
89
+
90
+ type TriageMessageDetails = {
91
+ kind: "triage";
92
+ generatedAt: string;
93
+ pr: {
94
+ number: number;
95
+ url: string;
96
+ title: string;
97
+ baseBranch: string;
98
+ headBranch: string;
99
+ ref: string;
100
+ };
101
+ scope: {
102
+ mode: ResolvedScope["kind"];
103
+ description: string;
104
+ };
105
+ feedbackCount: number;
106
+ items: TriageMessageItem[];
107
+ };
108
+
109
+ export type TriageRunResult =
110
+ | { ok: false; error: string }
111
+ | { ok: true; details: TriageMessageDetails };
112
+
113
+ type TriagePrContext = {
114
+ prNumber: number;
115
+ prRef: string;
116
+ prUrl: string;
117
+ title: string;
118
+ body: string;
119
+ baseBranch: string;
120
+ headBranch: string;
121
+ author: string;
122
+ scope: Extract<ResolvedScope, { kind: "branch-diff" }>;
123
+ baselineFingerprint: ReviewFingerprint;
124
+ feedbackItems: TriageFeedbackItem[];
125
+ };
126
+
127
+ // --- Helpers ---
128
+
129
+ function getConnectionNodes(value: unknown): unknown[] {
130
+ const record = asRecord(value);
131
+ return record && Array.isArray(record.nodes) ? record.nodes : [];
132
+ }
133
+
134
+ function getNestedRecord(value: unknown, ...keys: string[]): Record<string, unknown> | null {
135
+ let current: unknown = value;
136
+ for (const key of keys) {
137
+ const record = asRecord(current);
138
+ if (!record) return null;
139
+ current = record[key];
140
+ }
141
+ return asRecord(current);
142
+ }
143
+
144
+ function getOptionalString(value: unknown): string | undefined {
145
+ return typeof value === "string" ? value : undefined;
146
+ }
147
+
148
+ function getRequiredString(value: unknown): string | null {
149
+ return typeof value === "string" ? value : null;
150
+ }
151
+
152
+ function getOptionalNumber(value: unknown): number | undefined {
153
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
154
+ }
155
+
156
+ function getOptionalBoolean(value: unknown): boolean | undefined {
157
+ return typeof value === "boolean" ? value : undefined;
158
+ }
159
+
160
+ function getAuthorLogin(value: unknown): string {
161
+ const author = asRecord(value);
162
+ return typeof author?.login === "string" ? author.login : "unknown";
163
+ }
164
+
165
+ async function fetchPrTriageMetadata(
166
+ pi: ExtensionAPI,
167
+ prNumber: number,
168
+ ): Promise<{
169
+ prNumber: number;
170
+ prUrl: string;
171
+ title: string;
172
+ body: string;
173
+ baseBranch: string;
174
+ headBranch: string;
175
+ author: string;
176
+ comments: TriageFeedbackItem[];
177
+ reviews: TriageFeedbackItem[];
178
+ } | null> {
179
+ const { stdout, code } = await pi.exec("gh", [
180
+ "api",
181
+ "graphql",
182
+ "-F",
183
+ "owner={owner}",
184
+ "-F",
185
+ "name={repo}",
186
+ "-F",
187
+ `number=${prNumber}`,
188
+ "-f",
189
+ `query=${TRIAGE_METADATA_QUERY}`,
190
+ ]);
191
+ if (code !== 0) return null;
192
+
193
+ try {
194
+ const root = asRecord(JSON.parse(stdout));
195
+ const pr = getNestedRecord(root, "data", "repository", "pullRequest");
196
+ if (!pr) return null;
197
+
198
+ const number = getOptionalNumber(pr.number);
199
+ const prUrl = getRequiredString(pr.url);
200
+ const title = getRequiredString(pr.title);
201
+ const body = getRequiredString(pr.body) ?? "";
202
+ const baseBranch = getRequiredString(pr.baseRefName);
203
+ const headBranch = getRequiredString(pr.headRefName);
204
+ if (!number || !prUrl || !title || !baseBranch || !headBranch) return null;
205
+
206
+ const prAuthor = getAuthorLogin(pr.author);
207
+ const comments = getConnectionNodes(pr.comments)
208
+ .map((node, index): TriageFeedbackItem | null => {
209
+ const record = asRecord(node);
210
+ if (!record) return null;
211
+ const bodyText = (getRequiredString(record.body) ?? "").trim();
212
+ const author = getAuthorLogin(record.author);
213
+ if (!bodyText || author === prAuthor) return null;
214
+ return {
215
+ id: `comment-${index + 1}`,
216
+ kind: "pr-comment",
217
+ author,
218
+ location: "PR conversation",
219
+ url: getOptionalString(record.url),
220
+ body: bodyText,
221
+ };
222
+ })
223
+ .filter((item): item is TriageFeedbackItem => item !== null);
224
+
225
+ const reviews = getConnectionNodes(pr.reviews)
226
+ .map((node, index): TriageFeedbackItem | null => {
227
+ const record = asRecord(node);
228
+ if (!record) return null;
229
+ const bodyText = (getRequiredString(record.body) ?? "").trim();
230
+ if (!bodyText) return null;
231
+ return {
232
+ id: `review-${index + 1}`,
233
+ kind: "review-summary",
234
+ author: getAuthorLogin(record.author),
235
+ location: "Review summary",
236
+ url: getOptionalString(record.url),
237
+ body: bodyText,
238
+ state: getOptionalString(record.state),
239
+ };
240
+ })
241
+ .filter((item): item is TriageFeedbackItem => item !== null);
242
+
243
+ return {
244
+ prNumber: number,
245
+ prUrl,
246
+ title,
247
+ body,
248
+ baseBranch,
249
+ headBranch,
250
+ author: prAuthor,
251
+ comments,
252
+ reviews,
253
+ };
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ function formatThreadLocation(options: {
260
+ path?: string;
261
+ line?: number;
262
+ originalLine?: number;
263
+ startLine?: number;
264
+ originalStartLine?: number;
265
+ }): string {
266
+ const path = options.path?.trim();
267
+ const endLine = options.line ?? options.originalLine;
268
+ const startLine = options.startLine ?? options.originalStartLine;
269
+ if (!path) return endLine ? `review thread:${endLine}` : "review thread";
270
+ if (!endLine) return path;
271
+ if (startLine && startLine !== endLine) return `${path}:${startLine}-${endLine}`;
272
+ return `${path}:${endLine}`;
273
+ }
274
+
275
+ function pickThreadAuthor(comments: TriageFeedbackComment[], prAuthor: string): string {
276
+ const externalComment = comments.find(
277
+ (comment) => comment.author !== prAuthor && comment.author !== "unknown",
278
+ );
279
+ if (externalComment) return externalComment.author;
280
+ return comments[0]?.author ?? "unknown";
281
+ }
282
+
283
+ async function fetchPrReviewThreads(
284
+ pi: ExtensionAPI,
285
+ prNumber: number,
286
+ prAuthor: string,
287
+ ): Promise<TriageFeedbackItem[] | null> {
288
+ const { stdout, code } = await pi.exec("gh", [
289
+ "api",
290
+ "graphql",
291
+ "--paginate",
292
+ "--slurp",
293
+ "-F",
294
+ "owner={owner}",
295
+ "-F",
296
+ "name={repo}",
297
+ "-F",
298
+ `number=${prNumber}`,
299
+ "-f",
300
+ `query=${TRIAGE_THREADS_QUERY}`,
301
+ ]);
302
+ if (code !== 0) return null;
303
+
304
+ try {
305
+ const pages = JSON.parse(stdout);
306
+ if (!Array.isArray(pages)) return null;
307
+
308
+ const items: TriageFeedbackItem[] = [];
309
+ for (const page of pages) {
310
+ const threads = getConnectionNodes(
311
+ getNestedRecord(page, "data", "repository", "pullRequest", "reviewThreads"),
312
+ );
313
+ for (const thread of threads) {
314
+ const record = asRecord(thread);
315
+ if (!record) continue;
316
+ const comments = getConnectionNodes(record.comments)
317
+ .map((node): TriageFeedbackComment | null => {
318
+ const comment = asRecord(node);
319
+ if (!comment) return null;
320
+ const body = (getRequiredString(comment.body) ?? "").trim();
321
+ if (!body) return null;
322
+ return {
323
+ author: getAuthorLogin(comment.author),
324
+ body,
325
+ url: getOptionalString(comment.url),
326
+ createdAt: getOptionalString(comment.createdAt),
327
+ };
328
+ })
329
+ .filter((comment): comment is TriageFeedbackComment => comment !== null);
330
+ if (comments.length === 0) continue;
331
+
332
+ items.push({
333
+ id: `thread-${items.length + 1}`,
334
+ kind: "review-thread",
335
+ author: pickThreadAuthor(comments, prAuthor),
336
+ location: formatThreadLocation({
337
+ path: getOptionalString(record.path),
338
+ line: getOptionalNumber(record.line),
339
+ originalLine: getOptionalNumber(record.originalLine),
340
+ startLine: getOptionalNumber(record.startLine),
341
+ originalStartLine: getOptionalNumber(record.originalStartLine),
342
+ }),
343
+ url: comments[comments.length - 1]?.url,
344
+ isResolved: getOptionalBoolean(record.isResolved),
345
+ isOutdated: getOptionalBoolean(record.isOutdated),
346
+ comments,
347
+ });
348
+ }
349
+ }
350
+
351
+ return items;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ function buildTriageFeedbackItems(metadata: {
358
+ comments: TriageFeedbackItem[];
359
+ reviews: TriageFeedbackItem[];
360
+ threads: TriageFeedbackItem[];
361
+ }): TriageFeedbackItem[] {
362
+ return [...metadata.threads, ...metadata.reviews, ...metadata.comments];
363
+ }
364
+
365
+ function buildTriagePrompt(context: TriagePrContext, projectGuidelines: string | null): string {
366
+ const projectGuidelinesSection = buildProjectReviewGuidelinesSection(projectGuidelines);
367
+ const triageInput = JSON.stringify(
368
+ {
369
+ pr: {
370
+ number: context.prNumber,
371
+ url: context.prUrl,
372
+ title: context.title,
373
+ body: context.body,
374
+ base_branch: context.baseBranch,
375
+ head_branch: context.headBranch,
376
+ author: context.author,
377
+ },
378
+ feedback: context.feedbackItems,
379
+ },
380
+ null,
381
+ 2,
382
+ );
383
+
384
+ return TRIAGE_PROMPT.replace("{SCOPE_INSTRUCTIONS}", () => buildScopeInstructions(context.scope))
385
+ .replace("{PROJECT_GUIDELINES_SECTION}", () => projectGuidelinesSection)
386
+ .replace("{TRIAGE_INPUT_JSON}", () => triageInput);
387
+ }
388
+
389
+ function validateTriageOutput(parsed: unknown, feedbackItems: TriageFeedbackItem[]): TriageItem[] {
390
+ const items = (parsed as { items?: TriageItem[] }).items;
391
+ if (!Array.isArray(items)) {
392
+ throw new Error("submit_triage payload must include an items array.");
393
+ }
394
+
395
+ const knownIds = new Set(feedbackItems.map((item) => item.id));
396
+ const triageById = new Map<string, TriageItem>();
397
+ const unknownIds = new Set<string>();
398
+ for (const item of items) {
399
+ if (!knownIds.has(item.id)) {
400
+ unknownIds.add(item.id);
401
+ continue;
402
+ }
403
+ if (triageById.has(item.id)) {
404
+ throw new Error(`Triage output contains duplicate id ${item.id}.`);
405
+ }
406
+ triageById.set(item.id, item);
407
+ }
408
+
409
+ if (unknownIds.size > 0) {
410
+ const preview = Array.from(unknownIds).slice(0, 5).join(", ");
411
+ const suffix = unknownIds.size > 5 ? ", ..." : "";
412
+ throw new Error(`Triage output contains ${unknownIds.size} unknown id(s): ${preview}${suffix}`);
413
+ }
414
+
415
+ const missingIds = feedbackItems
416
+ .filter((item) => !triageById.has(item.id))
417
+ .map((item) => item.id);
418
+ if (missingIds.length > 0) {
419
+ const preview = missingIds.slice(0, 5).join(", ");
420
+ const suffix = missingIds.length > 5 ? ", ..." : "";
421
+ throw new Error(
422
+ `Triage output is missing ${missingIds.length} feedback item(s): ${preview}${suffix}`,
423
+ );
424
+ }
425
+
426
+ return feedbackItems.map((item) => triageById.get(item.id) as TriageItem);
427
+ }
428
+
429
+ async function runTriageTask(options: {
430
+ ctx: ExtensionCommandContext;
431
+ cwd: string;
432
+ prompt: string;
433
+ model: ResolvedReviewModel;
434
+ feedbackItems: TriageFeedbackItem[];
435
+ control?: ReviewExecutionControl;
436
+ }): Promise<{ ok: true; items: TriageItem[] } | { ok: false; error: string }> {
437
+ const { ctx, cwd, prompt, model, feedbackItems, control } = options;
438
+ const args = [
439
+ "--mode",
440
+ "json",
441
+ "-p",
442
+ "--tools",
443
+ TRIAGE_TOOLS,
444
+ "--no-extensions",
445
+ "--extension",
446
+ SUBMIT_TRIAGE_EXTENSION_PATH,
447
+ "--no-skills",
448
+ "--no-prompt-templates",
449
+ "--no-themes",
450
+ ];
451
+ const modelArg = getResolvedReviewStatusModelArg(model);
452
+ if (modelArg) {
453
+ args.push("--model", modelArg);
454
+ }
455
+
456
+ for (let attempt = 0; ; attempt += 1) {
457
+ if (control?.isCancelled()) {
458
+ return { ok: false, error: REVIEW_CANCELLED_ERROR };
459
+ }
460
+
461
+ const taskResult = await withSpinner(
462
+ ctx,
463
+ () => `triaging PR feedback (${feedbackItems.length} items)`,
464
+ () =>
465
+ runPiSubmitToolTask({
466
+ args,
467
+ prompt,
468
+ cwd,
469
+ timeoutMs: REVIEW_TASK_TIMEOUT_MS,
470
+ control,
471
+ submitTool: SUBMIT_TRIAGE_TOOL,
472
+ }),
473
+ );
474
+
475
+ if (taskResult.status === "cancelled") {
476
+ return { ok: false, error: REVIEW_CANCELLED_ERROR };
477
+ }
478
+ if (taskResult.status === "timeout") {
479
+ return { ok: false, error: "PR triage timed out after 30 minutes." };
480
+ }
481
+ if (taskResult.status === "spawn_error") {
482
+ return {
483
+ ok: false,
484
+ error: `Failed to start triage process: ${taskResult.error ?? "unknown error"}`,
485
+ };
486
+ }
487
+ if (taskResult.status === "non_zero_exit") {
488
+ const stderr = taskResult.stderr.trim();
489
+ const error = `Triage exited with code ${taskResult.exitCode ?? 1}${stderr ? `: ${stderr}` : ""}`;
490
+ const classification = classifyTaskError(`${taskResult.stderr}\n${error}`);
491
+ if (classification.errorKind === "missing_api_key") {
492
+ return {
493
+ ok: false,
494
+ error: `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`,
495
+ };
496
+ }
497
+ if (
498
+ classification.errorKind === "lock_contention" &&
499
+ attempt < REVIEW_STARTUP_RETRY_DELAYS_MS.length
500
+ ) {
501
+ const baseDelayMs =
502
+ REVIEW_STARTUP_RETRY_DELAYS_MS[attempt] ??
503
+ REVIEW_STARTUP_RETRY_DELAYS_MS[REVIEW_STARTUP_RETRY_DELAYS_MS.length - 1];
504
+ await new Promise((resolve) => setTimeout(resolve, withJitter(baseDelayMs)));
505
+ continue;
506
+ }
507
+ return { ok: false, error };
508
+ }
509
+ if (taskResult.status === "assistant_error") {
510
+ const classification = classifyTaskError(taskResult.error ?? "");
511
+ if (classification.errorKind === "missing_api_key") {
512
+ return {
513
+ ok: false,
514
+ error: `Missing API key for provider '${classification.missingApiProvider ?? "unknown"}'. Use /login or configure credentials for that provider.`,
515
+ };
516
+ }
517
+ if (classification.errorKind === "rate_limit") {
518
+ return {
519
+ ok: false,
520
+ error: appendErrorDetails(
521
+ "Triage failed due to rate limiting. Try again later or switch models.",
522
+ taskResult.error,
523
+ ),
524
+ };
525
+ }
526
+ return {
527
+ ok: false,
528
+ error: buildProviderErrorMessage(
529
+ "Triage failed due to a provider error.",
530
+ taskResult.error,
531
+ ),
532
+ };
533
+ }
534
+
535
+ const submittedPayload = getSubmittedPayload({
536
+ submittedPayloads: taskResult.submittedPayloads,
537
+ assistantOutput: taskResult.assistantOutput,
538
+ submitTool: SUBMIT_TRIAGE_TOOL,
539
+ taskLabel: "Triage",
540
+ });
541
+ if (!submittedPayload.ok) {
542
+ return { ok: false, error: submittedPayload.error };
543
+ }
544
+
545
+ try {
546
+ return {
547
+ ok: true,
548
+ items: validateTriageOutput(submittedPayload.payload, feedbackItems),
549
+ };
550
+ } catch (error) {
551
+ return {
552
+ ok: false,
553
+ error: `submit_triage payload is invalid: ${error instanceof Error ? error.message : String(error)}`,
554
+ };
555
+ }
556
+ }
557
+ }
558
+
559
+ async function prepareTriageContext(
560
+ pi: ExtensionAPI,
561
+ ctx: ExtensionCommandContext,
562
+ prRef: string,
563
+ ): Promise<{ ok: false; error: string } | { ok: true; data: TriagePrContext }> {
564
+ if (!(await isGitRepo(pi))) {
565
+ return { ok: false, error: "Not a git repository." };
566
+ }
567
+
568
+ const blockedError = await getPrCheckoutBlockedError(pi);
569
+ if (blockedError) {
570
+ return { ok: false, error: blockedError };
571
+ }
572
+
573
+ const prNumber = parsePrReference(prRef);
574
+ if (!prNumber) {
575
+ return { ok: false, error: `Invalid PR reference: ${prRef}` };
576
+ }
577
+
578
+ notify(ctx, `Fetching PR #${prNumber} feedback...`, "info");
579
+ const metadata = await fetchPrTriageMetadata(pi, prNumber);
580
+ if (!metadata) {
581
+ return {
582
+ ok: false,
583
+ error: `Could not load PR #${prNumber}. Ensure gh is authenticated and PR exists.`,
584
+ };
585
+ }
586
+
587
+ const [preparedPrScope, threads] = await Promise.all([
588
+ preparePrCheckoutScope(pi, (message, type) => notify(ctx, message, type), {
589
+ prNumber,
590
+ baseBranch: metadata.baseBranch,
591
+ headBranch: metadata.headBranch,
592
+ }),
593
+ fetchPrReviewThreads(pi, prNumber, metadata.author),
594
+ ]);
595
+ if (!preparedPrScope.ok) {
596
+ return { ok: false, error: preparedPrScope.error };
597
+ }
598
+ if (!threads) {
599
+ return {
600
+ ok: false,
601
+ error: `Could not load review threads for PR #${prNumber}. Ensure gh is authenticated and GraphQL access is available.`,
602
+ };
603
+ }
604
+
605
+ const feedbackItems = buildTriageFeedbackItems({
606
+ comments: metadata.comments,
607
+ reviews: metadata.reviews,
608
+ threads,
609
+ });
610
+ const baselineFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, false);
611
+
612
+ return {
613
+ ok: true,
614
+ data: {
615
+ prNumber,
616
+ prRef,
617
+ prUrl: metadata.prUrl,
618
+ title: metadata.title,
619
+ body: metadata.body,
620
+ baseBranch: metadata.baseBranch,
621
+ headBranch: metadata.headBranch,
622
+ author: metadata.author,
623
+ scope: preparedPrScope.scope,
624
+ baselineFingerprint,
625
+ feedbackItems,
626
+ },
627
+ };
628
+ }
629
+
630
+ function buildTriageMessageDetails(
631
+ context: TriagePrContext,
632
+ items: TriageMessageItem[],
633
+ ): TriageMessageDetails {
634
+ return {
635
+ kind: "triage",
636
+ generatedAt: new Date().toISOString(),
637
+ pr: {
638
+ number: context.prNumber,
639
+ url: context.prUrl,
640
+ title: context.title,
641
+ baseBranch: context.baseBranch,
642
+ headBranch: context.headBranch,
643
+ ref: context.prRef,
644
+ },
645
+ scope: {
646
+ mode: context.scope.kind,
647
+ description: context.scope.description,
648
+ },
649
+ feedbackCount: context.feedbackItems.length,
650
+ items,
651
+ };
652
+ }
653
+
654
+ export async function runTriagePipeline(
655
+ pi: ExtensionAPI,
656
+ ctx: ExtensionCommandContext,
657
+ prRef: string,
658
+ ): Promise<TriageRunResult> {
659
+ const startedAtMs = Date.now();
660
+ const prepared = await prepareTriageContext(pi, ctx, prRef);
661
+ if (!prepared.ok) {
662
+ return { ok: false, error: prepared.error };
663
+ }
664
+
665
+ return withManagedReviewRun(pi, ctx, "triage", async (managed) => {
666
+ const context = prepared.data;
667
+ if (context.feedbackItems.length === 0) {
668
+ const details = buildTriageMessageDetails(context, []);
669
+ pi.sendMessage(
670
+ {
671
+ customType: "review",
672
+ content: buildTriageMarkdown(context, [], Date.now() - startedAtMs),
673
+ display: true,
674
+ details,
675
+ },
676
+ { deliverAs: "followUp" },
677
+ );
678
+ notify(ctx, "No PR feedback items found for triage.", "info");
679
+ managed.markSuccessful();
680
+ return { ok: true, details };
681
+ }
682
+
683
+ const [projectGuidelines, models] = await Promise.all([
684
+ loadProjectReviewGuidelines(ctx.cwd),
685
+ resolveModels(ctx, [], pi.getThinkingLevel()),
686
+ ]);
687
+ const model = models[0];
688
+ notify(
689
+ ctx,
690
+ `Triaging ${context.feedbackItems.length} feedback item(s) with ${buildResolvedReviewStatusModelLabel(model)}.`,
691
+ "info",
692
+ );
693
+
694
+ const triageResult = await runTriageTask({
695
+ ctx,
696
+ cwd: ctx.cwd,
697
+ prompt: buildTriagePrompt(context, projectGuidelines),
698
+ model,
699
+ feedbackItems: context.feedbackItems,
700
+ control: managed.control,
701
+ });
702
+ if (!triageResult.ok) {
703
+ if (triageResult.error === REVIEW_CANCELLED_ERROR) {
704
+ managed.markCancelled();
705
+ }
706
+ return triageResult;
707
+ }
708
+ if (managed.control.isCancelled()) {
709
+ managed.markCancelled();
710
+ return { ok: false, error: REVIEW_CANCELLED_ERROR };
711
+ }
712
+
713
+ const endingFingerprint = await computeCurrentFingerprint(pi, ctx.cwd, false);
714
+ if (!fingerprintsEqual(context.baselineFingerprint, endingFingerprint)) {
715
+ return {
716
+ ok: false,
717
+ error: "PR triage became stale while running (repository changed). Rerun /triage.",
718
+ };
719
+ }
720
+
721
+ const items: TriageMessageItem[] = context.feedbackItems.map((feedback, index) => {
722
+ const triageItem = triageResult.items[index];
723
+ return {
724
+ ...triageItem,
725
+ feedbackKind: feedback.kind,
726
+ location: feedback.location,
727
+ author: feedback.author,
728
+ url: feedback.url,
729
+ };
730
+ });
731
+ const details = buildTriageMessageDetails(context, items);
732
+
733
+ pi.sendMessage(
734
+ {
735
+ customType: "review",
736
+ content: buildTriageMarkdown(context, items, Date.now() - startedAtMs),
737
+ display: true,
738
+ details,
739
+ },
740
+ { deliverAs: "followUp" },
741
+ );
742
+ notify(ctx, `Triage completed: ${items.length} feedback item(s).`, "info");
743
+ managed.markSuccessful();
744
+ return { ok: true, details };
745
+ });
746
+ }