opencode-magi 0.7.0 → 0.9.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.
@@ -2,13 +2,14 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { issueRunOutputDir } from "../config/output";
4
4
  import { issueRunWorktreeDir } from "../config/worktree";
5
- import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
- import { composeTriageAcceptancePrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
- import { parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
5
+ import { addIssueLabels, assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
+ import { composeTriageAcceptancePrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, composeTriageSignalPrompt, } from "../prompts/compose";
7
+ import { parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageSignalOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
9
  import { runModelWithRepair, } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
11
11
  const BINARY_VOTES = ["ASK", "NO", "YES"];
12
+ const ACCEPTANCE_VOTES = ["ASK", "INVALID", "NO", "YES"];
12
13
  const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
13
14
  const EXISTING_PR_VOTES = [
14
15
  "RELATED_PR_DOES_NOT_HANDLE_ISSUE",
@@ -20,10 +21,29 @@ const RECONSIDERATION_CLASSES = new Set([
20
21
  "OBJECTION",
21
22
  ]);
22
23
  function marker(input) {
23
- const askReason = input.decision.askReason
24
- ? ` askReason=${input.decision.askReason}`
25
- : "";
26
- return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition}${askReason} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
24
+ return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition} signals=${input.decision.signals.join(",")} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
25
+ }
26
+ function markerDisposition(input) {
27
+ switch (input.disposition) {
28
+ case "accepted":
29
+ case "rejected":
30
+ case "invalid":
31
+ case "duplicate":
32
+ case "already_handled":
33
+ case "needs_category":
34
+ case "needs_acceptance":
35
+ case "blocked":
36
+ case "failed":
37
+ return input.disposition;
38
+ case "ask":
39
+ return input.askReason === "category_unclear"
40
+ ? "needs_category"
41
+ : "needs_acceptance";
42
+ case "clear_only":
43
+ return "already_handled";
44
+ default:
45
+ return undefined;
46
+ }
27
47
  }
28
48
  export function parseTriageMarker(body) {
29
49
  const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
@@ -41,30 +61,28 @@ export function parseTriageMarker(body) {
41
61
  const version = Number(entries.v);
42
62
  if (version !== 1 && version !== 2)
43
63
  return undefined;
64
+ const askReason = entries.askReason === "acceptance_unclear" ||
65
+ entries.askReason === "category_unclear"
66
+ ? entries.askReason
67
+ : undefined;
44
68
  return {
45
69
  action: entries.action,
46
- askReason: entries.askReason === "acceptance_unclear" ||
47
- entries.askReason === "category_unclear"
48
- ? entries.askReason
49
- : undefined,
70
+ askReason,
50
71
  category: entries.category === "none" ? null : entries.category || undefined,
51
72
  checkpoint: entries.checkpoint && Number.isFinite(Number(entries.checkpoint))
52
73
  ? Number(entries.checkpoint)
53
74
  : undefined,
54
- disposition: entries.disposition === "accepted" ||
55
- entries.disposition === "rejected" ||
56
- entries.disposition === "ask" ||
57
- entries.disposition === "duplicate" ||
58
- entries.disposition === "clear_only" ||
59
- entries.disposition === "failed"
60
- ? entries.disposition
61
- : undefined,
75
+ disposition: markerDisposition({
76
+ askReason,
77
+ disposition: entries.disposition,
78
+ }),
62
79
  issue: entries.issue ? Number(entries.issue) : undefined,
63
80
  pr: entries.pr,
64
81
  processed: entries.processed
65
82
  ? entries.processed.split(",").filter(Boolean).map(Number)
66
83
  : [],
67
84
  result: entries.result,
85
+ signals: entries.signals ? entries.signals.split(",").filter(Boolean) : [],
68
86
  v: version,
69
87
  };
70
88
  }
@@ -72,9 +90,46 @@ function labelsContain(labels, targets) {
72
90
  const set = new Set(labels.map((label) => label.toLowerCase()));
73
91
  return targets.some((target) => set.has(target.toLowerCase()));
74
92
  }
75
- function existingClearLabels(issue, labels) {
76
- const existing = new Set(issue.labels.map((label) => label.toLowerCase()));
77
- return labels.filter((label) => existing.has(label.toLowerCase()));
93
+ function addLabel(labels, label) {
94
+ const key = label.toLowerCase();
95
+ if (!labels.has(key))
96
+ labels.set(key, label);
97
+ }
98
+ function labelRuleMatches(rule, result) {
99
+ const when = rule.when;
100
+ if (when.disposition && when.disposition !== result.disposition)
101
+ return false;
102
+ if (when.category && when.category !== result.category)
103
+ return false;
104
+ if (when.signals?.length) {
105
+ const signals = new Set(result.signals);
106
+ if (when.signals.some((signal) => !signals.has(signal)))
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ export function triageLabelChanges(input) {
112
+ const add = new Map();
113
+ const remove = new Map();
114
+ for (const rule of input.rules) {
115
+ if (!labelRuleMatches(rule, input.result))
116
+ continue;
117
+ for (const label of rule.add ?? [])
118
+ addLabel(add, label);
119
+ for (const label of rule.remove ?? [])
120
+ addLabel(remove, label);
121
+ }
122
+ for (const key of add.keys())
123
+ remove.delete(key);
124
+ const existing = new Set(input.issueLabels.map((label) => label.toLowerCase()));
125
+ return {
126
+ add: [...add]
127
+ .filter(([key]) => !existing.has(key))
128
+ .map(([, label]) => label),
129
+ remove: [...remove]
130
+ .filter(([key]) => existing.has(key))
131
+ .map(([, label]) => label),
132
+ };
78
133
  }
79
134
  export function resolveIssueCategory(issue, repository) {
80
135
  const triage = repository.triage;
@@ -305,6 +360,86 @@ async function runPhaseVote(input) {
305
360
  vote: majority.vote,
306
361
  };
307
362
  }
363
+ async function runSignalVote(input) {
364
+ const agents = input.input.repository.agents.triage;
365
+ const signals = input.input.repository.triage?.signals ?? [];
366
+ if (!agents?.length)
367
+ throw new Error("triage.voters is required");
368
+ if (!signals.length)
369
+ return [];
370
+ await emitProgress(input.input, { phase: "signal", type: "phase" });
371
+ const signalIds = signals.map((signal) => signal.id);
372
+ const promptTexts = await Promise.all(agents.map((agent) => composeTriageSignalPrompt({
373
+ context: input.context,
374
+ directory: input.input.directory,
375
+ issue: input.input.issue,
376
+ repository: input.input.repository,
377
+ voter: agent,
378
+ })));
379
+ const outputs = await Promise.all(agents.map(async (agent, index) => {
380
+ const prompt = promptTexts[index];
381
+ await emitProgress(input.input, {
382
+ phase: "signal",
383
+ type: "triage_agent_started",
384
+ voter: agent.key,
385
+ });
386
+ const result = await runModelWithRepair({
387
+ client: input.input.client,
388
+ model: agent.model,
389
+ onProgress: (progress) => emitTriageModelProgress({
390
+ phase: "signal",
391
+ progress,
392
+ run: input.input,
393
+ voter: agent.key,
394
+ }),
395
+ options: agent.options,
396
+ parentSessionId: input.input.parentSessionId,
397
+ parse: (text) => parseTriageSignalOutput(text, signalIds),
398
+ permission: agent.permission,
399
+ prompt,
400
+ repairAttempts: input.input.config.output?.repairAttempts ?? 3,
401
+ schemaName: "triage signal",
402
+ signal: input.input.signal,
403
+ title: `Magi triage signal #${input.input.issue} (${agent.key})`,
404
+ });
405
+ await emitProgress(input.input, {
406
+ phase: "signal",
407
+ sessionId: result.sessionId,
408
+ type: "triage_agent_completed",
409
+ voter: agent.key,
410
+ vote: result.value.signals.map((signal) => signal.id).join(",") || "none",
411
+ });
412
+ return {
413
+ ...result.value,
414
+ promptText: prompt,
415
+ raw: result.raw,
416
+ sessionId: result.sessionId,
417
+ voter: agent.key,
418
+ };
419
+ }));
420
+ const threshold = majorityThreshold(outputs.length);
421
+ const counts = new Map();
422
+ for (const output of outputs) {
423
+ for (const id of new Set(output.signals.map((signal) => signal.id))) {
424
+ counts.set(id, (counts.get(id) ?? 0) + 1);
425
+ }
426
+ }
427
+ const selected = signalIds.filter((id) => (counts.get(id) ?? 0) >= threshold);
428
+ await Promise.all(outputs.map((output) => {
429
+ const base = join(input.outputDir, `${output.voter}.signal`);
430
+ return Promise.all([
431
+ writeFile(`${base}.prompt.txt`, `${output.promptText}\n`),
432
+ writeFile(`${base}.raw.txt`, `${output.raw}\n`),
433
+ writeJson(`${base}.json`, { signals: output.signals }),
434
+ ]);
435
+ }));
436
+ await writeJson(join(input.outputDir, "signal-majority.json"), {
437
+ counts: Object.fromEntries(counts),
438
+ selected,
439
+ threshold,
440
+ });
441
+ return selected;
442
+ }
308
443
  async function relationshipScan(input, issue) {
309
444
  const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
310
445
  fetchIssueComments(input.exec, input.repository, input.issue),
@@ -384,40 +519,40 @@ export function eligibleMentionReplies(input) {
384
519
  function finalResultFromMarker(marker) {
385
520
  if (marker.disposition) {
386
521
  return {
387
- askReason: marker.askReason,
388
522
  category: marker.category ?? null,
389
523
  disposition: marker.disposition,
524
+ signals: marker.signals,
390
525
  };
391
526
  }
392
527
  switch (marker.result) {
393
528
  case "BUG_ACCEPTED":
394
529
  case "RESOLVED_BY_MERGED_PR":
395
- return { category: "bug", disposition: "accepted" };
530
+ return { category: "bug", disposition: "accepted", signals: [] };
396
531
  case "BUG_REJECTED":
397
- return { category: "bug", disposition: "rejected" };
532
+ return { category: "bug", disposition: "rejected", signals: [] };
398
533
  case "FEATURE_ACCEPTED":
399
- return { category: "feature", disposition: "accepted" };
534
+ return { category: "feature", disposition: "accepted", signals: [] };
400
535
  case "FEATURE_REJECTED":
401
- return { category: "feature", disposition: "rejected" };
536
+ return { category: "feature", disposition: "rejected", signals: [] };
402
537
  case "ASK":
403
538
  return {
404
- askReason: "acceptance_unclear",
405
539
  category: null,
406
- disposition: "ask",
540
+ disposition: "needs_acceptance",
541
+ signals: [],
407
542
  };
408
543
  case "CLEAR_ONLY":
409
- return { category: null, disposition: "clear_only" };
544
+ return { category: null, disposition: "already_handled", signals: [] };
410
545
  case "DUPLICATE":
411
- return { category: null, disposition: "duplicate" };
546
+ return { category: null, disposition: "duplicate", signals: [] };
412
547
  default:
413
- return { category: null, disposition: "failed" };
548
+ return { category: null, disposition: "failed", signals: [] };
414
549
  }
415
550
  }
416
551
  function decisionText(decision) {
417
552
  return JSON.stringify(decision);
418
553
  }
419
554
  function actionPlan(input) {
420
- if (input.result.disposition === "clear_only") {
555
+ if (input.result.disposition === "already_handled") {
421
556
  return {
422
557
  action: "CLEAR_ONLY",
423
558
  allowedActions: ["CLEAR_ONLY"],
@@ -427,11 +562,12 @@ function actionPlan(input) {
427
562
  postComment: false,
428
563
  };
429
564
  }
430
- if (input.result.disposition === "ask") {
565
+ if (input.result.disposition === "needs_category" ||
566
+ input.result.disposition === "needs_acceptance") {
431
567
  return {
432
568
  action: "ASK",
433
569
  allowedActions: ["ASK"],
434
- clearLabels: false,
570
+ clearLabels: true,
435
571
  closeIssue: false,
436
572
  createPr: false,
437
573
  postComment: true,
@@ -439,6 +575,7 @@ function actionPlan(input) {
439
575
  }
440
576
  const closeIssue = input.triage.automation.close &&
441
577
  (input.result.disposition === "rejected" ||
578
+ input.result.disposition === "invalid" ||
442
579
  input.result.disposition === "duplicate");
443
580
  const createPr = input.triage.automation.create && input.result.disposition === "accepted";
444
581
  return {
@@ -452,8 +589,13 @@ function actionPlan(input) {
452
589
  }
453
590
  function previousAutomationPlan(input) {
454
591
  const base = actionPlan({ result: input.result, triage: input.triage });
592
+ const labelChanges = triageLabelChanges({
593
+ issueLabels: input.issue.labels,
594
+ result: input.result,
595
+ rules: input.triage.automation.label,
596
+ });
455
597
  const clearLabels = base.clearLabels &&
456
- existingClearLabels(input.issue, input.triage.automation.clear).length > 0;
598
+ (labelChanges.add.length > 0 || labelChanges.remove.length > 0);
457
599
  const closeIssue = input.marker.action === "CLOSE" &&
458
600
  base.closeIssue &&
459
601
  input.issue.state === "OPEN";
@@ -558,6 +700,9 @@ function decisionCommentFallback(input) {
558
700
  if (input.result.disposition === "duplicate") {
559
701
  return "Magi marked this issue as a duplicate.";
560
702
  }
703
+ if (input.result.disposition === "invalid") {
704
+ return "Magi marked this issue as invalid or not actionable.";
705
+ }
561
706
  return "Magi completed triage for this issue.";
562
707
  }
563
708
  function agentForKey(repository, key) {
@@ -675,7 +820,9 @@ async function finishWithResult(input) {
675
820
  });
676
821
  let prUrl;
677
822
  const reporter = triageReporter(input.input.repository, input.issue.number);
678
- const comment = plan.postComment && input.result.disposition !== "ask"
823
+ const comment = plan.postComment &&
824
+ input.result.disposition !== "needs_category" &&
825
+ input.result.disposition !== "needs_acceptance"
679
826
  ? `${decisionCommentBody({
680
827
  action: plan.action,
681
828
  reason: input.commentReason,
@@ -691,7 +838,19 @@ async function finishWithResult(input) {
691
838
  if (comment) {
692
839
  await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
693
840
  }
694
- if (input.result.disposition === "ask" && input.askOutputs) {
841
+ const labelChanges = plan.clearLabels
842
+ ? triageLabelChanges({
843
+ issueLabels: input.issue.labels,
844
+ result: input.result,
845
+ rules: triage.automation.label,
846
+ })
847
+ : { add: [], remove: [] };
848
+ if (plan.clearLabels) {
849
+ await writeJson(join(input.outputDir, "label-changes.json"), labelChanges);
850
+ }
851
+ if ((input.result.disposition === "needs_category" ||
852
+ input.result.disposition === "needs_acceptance") &&
853
+ input.askOutputs) {
695
854
  await postAskComments({
696
855
  action: plan.action,
697
856
  dryRun: input.input.dryRun,
@@ -723,9 +882,11 @@ async function finishWithResult(input) {
723
882
  });
724
883
  }
725
884
  if (plan.clearLabels) {
726
- const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
727
- if (clearLabels.length) {
728
- await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, reporter.account);
885
+ if (labelChanges.remove.length) {
886
+ await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.remove, reporter.account);
887
+ }
888
+ if (labelChanges.add.length) {
889
+ await addIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.add, reporter.account);
729
890
  }
730
891
  }
731
892
  if (plan.closeIssue) {
@@ -934,7 +1095,7 @@ export async function runTriage(input) {
934
1095
  issue: input.issue,
935
1096
  outputDir,
936
1097
  report,
937
- result: { category: null, disposition: "failed" },
1098
+ result: { category: null, disposition: "blocked", signals: [] },
938
1099
  };
939
1100
  }
940
1101
  let context = issueContext({ issue, relationship });
@@ -1016,8 +1177,7 @@ export async function runTriage(input) {
1016
1177
  });
1017
1178
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
1018
1179
  const previous = finalResultFromMarker(relationship.previousMarker);
1019
- if (previous.disposition !== "ask" ||
1020
- previous.askReason !== "acceptance_unclear") {
1180
+ if (previous.disposition !== "needs_acceptance") {
1021
1181
  const reconsideration = await runReconsiderationVote({
1022
1182
  context,
1023
1183
  input,
@@ -1026,15 +1186,23 @@ export async function runTriage(input) {
1026
1186
  commentReason = reconsideration.reason;
1027
1187
  result =
1028
1188
  reconsideration.vote === "YES"
1029
- ? { category: previous.category, disposition: "accepted" }
1189
+ ? {
1190
+ category: previous.category,
1191
+ disposition: "accepted",
1192
+ signals: [],
1193
+ }
1030
1194
  : reconsideration.vote === "NO"
1031
- ? { category: previous.category, disposition: "rejected" }
1195
+ ? {
1196
+ category: previous.category,
1197
+ disposition: "rejected",
1198
+ signals: [],
1199
+ }
1032
1200
  : {
1033
- askReason: "acceptance_unclear",
1034
1201
  category: previous.category,
1035
- disposition: "ask",
1202
+ disposition: "needs_acceptance",
1203
+ signals: [],
1036
1204
  };
1037
- if (result.disposition === "ask") {
1205
+ if (result.disposition === "needs_acceptance") {
1038
1206
  askCommentOutputs = askOutputs(reconsideration.outputs);
1039
1207
  markAskComments = true;
1040
1208
  }
@@ -1057,6 +1225,7 @@ export async function runTriage(input) {
1057
1225
  const relatedPrDecision = {
1058
1226
  category: resolveIssueCategory(issue, input.repository) ?? null,
1059
1227
  disposition: "accepted",
1228
+ signals: [],
1060
1229
  };
1061
1230
  const plan = {
1062
1231
  action: "CLOSE",
@@ -1086,7 +1255,7 @@ export async function runTriage(input) {
1086
1255
  outputDir,
1087
1256
  processed,
1088
1257
  relationship,
1089
- result: { category: null, disposition: "clear_only" },
1258
+ result: { category: null, disposition: "already_handled", signals: [] },
1090
1259
  runId,
1091
1260
  });
1092
1261
  }
@@ -1101,7 +1270,7 @@ export async function runTriage(input) {
1101
1270
  if (duplicate) {
1102
1271
  context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
1103
1272
  commentReason = duplicate.reason;
1104
- result = { category: null, disposition: "duplicate" };
1273
+ result = { category: null, disposition: "duplicate", signals: [] };
1105
1274
  }
1106
1275
  }
1107
1276
  if (!result) {
@@ -1125,9 +1294,9 @@ export async function runTriage(input) {
1125
1294
  const category = resolvedCategory ?? categoryVote?.vote ?? "ASK";
1126
1295
  if (category === "ASK") {
1127
1296
  result = {
1128
- askReason: "category_unclear",
1129
1297
  category: null,
1130
- disposition: "ask",
1298
+ disposition: "needs_category",
1299
+ signals: [],
1131
1300
  };
1132
1301
  askCommentOutputs = askOutputs(categoryVote?.outputs);
1133
1302
  markAskComments = false;
@@ -1146,25 +1315,41 @@ export async function runTriage(input) {
1146
1315
  phase: "acceptance",
1147
1316
  prompt: composeTriageAcceptancePrompt,
1148
1317
  schemaName: "triage acceptance",
1149
- votes: BINARY_VOTES,
1318
+ votes: ACCEPTANCE_VOTES,
1150
1319
  });
1151
1320
  commentReason = acceptance.reason;
1152
1321
  result =
1153
1322
  acceptance.vote === "YES"
1154
- ? { category, disposition: "accepted" }
1323
+ ? { category, disposition: "accepted", signals: [] }
1155
1324
  : acceptance.vote === "NO"
1156
- ? { category, disposition: "rejected" }
1157
- : {
1158
- askReason: "acceptance_unclear",
1159
- category,
1160
- disposition: "ask",
1161
- };
1162
- if (result.disposition === "ask") {
1325
+ ? { category, disposition: "rejected", signals: [] }
1326
+ : acceptance.vote === "INVALID"
1327
+ ? { category, disposition: "invalid", signals: [] }
1328
+ : {
1329
+ category,
1330
+ disposition: "needs_acceptance",
1331
+ signals: [],
1332
+ };
1333
+ if (result.disposition === "needs_acceptance") {
1163
1334
  askCommentOutputs = askOutputs(acceptance.outputs);
1164
1335
  markAskComments = true;
1165
1336
  }
1166
1337
  }
1167
1338
  }
1339
+ if (result &&
1340
+ result.disposition !== "blocked" &&
1341
+ result.disposition !== "failed" &&
1342
+ triage.signals.length) {
1343
+ const signalContext = `${context}\n\nFinal triage result: ${JSON.stringify(result)}`;
1344
+ result = {
1345
+ ...result,
1346
+ signals: await runSignalVote({
1347
+ context: signalContext,
1348
+ input,
1349
+ outputDir,
1350
+ }),
1351
+ };
1352
+ }
1168
1353
  return finishWithResult({
1169
1354
  askOutputs: askCommentOutputs,
1170
1355
  commentReason,
@@ -1176,9 +1361,9 @@ export async function runTriage(input) {
1176
1361
  processed,
1177
1362
  relationship,
1178
1363
  result: result ?? {
1179
- askReason: "acceptance_unclear",
1180
1364
  category: null,
1181
- disposition: "ask",
1365
+ disposition: "needs_acceptance",
1366
+ signals: [],
1182
1367
  },
1183
1368
  runId,
1184
1369
  });
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
- import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageCreatePrOutputContract, triageDuplicateOutputContract, triageVoteOutputContract, } from "./contracts";
4
+ import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageCreatePrOutputContract, triageDuplicateOutputContract, triageSignalOutputContract, triageVoteOutputContract, } from "./contracts";
5
5
  async function readOptionalPrompt(directory, path, values = {}) {
6
6
  if (!path)
7
7
  return "";
@@ -35,6 +35,7 @@ function repositoryValues(repository) {
35
35
  }
36
36
  function reviewValues(input) {
37
37
  const ciFailureContext = input.ciFailureContext?.trim() ?? "";
38
+ const mergeConflictContext = input.mergeConflictContext?.trim() ?? "";
38
39
  return {
39
40
  ...repositoryValues(input.repository),
40
41
  baseSha: input.baseSha,
@@ -44,6 +45,10 @@ function reviewValues(input) {
44
45
  : "",
45
46
  headSha: input.headSha,
46
47
  jsonEncodedWorktreePath: JSON.stringify(input.worktreePath),
48
+ mergeConflictContext,
49
+ mergeConflictContextBlock: mergeConflictContext
50
+ ? `<merge_conflict_context>\n${mergeConflictContext}\n</merge_conflict_context>`
51
+ : "",
47
52
  pr: String(input.pr),
48
53
  reviewContext: input.reviewContext ?? "",
49
54
  worktreePath: input.worktreePath,
@@ -67,6 +72,17 @@ function editValues(input) {
67
72
  worktreePath: input.worktreePath,
68
73
  };
69
74
  }
75
+ function mergeConflictValues(input) {
76
+ return {
77
+ ...repositoryValues(input.repository),
78
+ baseBranch: input.baseBranch,
79
+ baseSha: input.baseSha,
80
+ conflictedFiles: input.conflictedFiles,
81
+ headSha: input.headSha,
82
+ pr: String(input.pr),
83
+ worktreePath: input.worktreePath,
84
+ };
85
+ }
70
86
  function triageValues(input) {
71
87
  const categories = input.repository.triage?.categories ?? [];
72
88
  const categoryOptions = categories
@@ -74,12 +90,16 @@ function triageValues(input) {
74
90
  ? `- ${category.id}: ${category.description}`
75
91
  : `- ${category.id}`)
76
92
  .join("\n");
93
+ const signalOptions = (input.repository.triage?.signals ?? [])
94
+ .map((signal) => `- ${signal.id}: ${signal.description}`)
95
+ .join("\n");
77
96
  return {
78
97
  ...repositoryValues(input.repository),
79
98
  author: input.author ?? "",
80
99
  categoryOptions,
81
100
  context: input.context,
82
101
  issue: String(input.issue),
102
+ signalOptions,
83
103
  worktreePath: input.worktreePath ?? "",
84
104
  };
85
105
  }
@@ -97,6 +117,12 @@ function previousReviewBlock(previousReview) {
97
117
  function reviewContextBlock(reviewContext) {
98
118
  return reviewContext?.trim() ? reviewContext.trim() : "";
99
119
  }
120
+ function mergeConflictContextBlock(mergeConflictContext) {
121
+ const body = mergeConflictContext?.trim();
122
+ return body
123
+ ? `<merge_conflict_context>\n${body}\n</merge_conflict_context>`
124
+ : "";
125
+ }
100
126
  async function reviewGuidelinesBlock(input) {
101
127
  const body = (await readOptionalPrompt(input.directory, input.path, input.values)).trim();
102
128
  return body ? `<review_guidelines>\n${body}\n</review_guidelines>` : "";
@@ -136,6 +162,7 @@ export async function composeReviewPrompt(input) {
136
162
  return [
137
163
  task,
138
164
  reviewContextBlock(input.reviewContext),
165
+ mergeConflictContextBlock(input.mergeConflictContext),
139
166
  languageBlock(input.repository.language),
140
167
  personaBlock(input.reviewer.persona),
141
168
  await reviewGuidelinesBlock({
@@ -159,6 +186,7 @@ export async function composeRereviewPrompt(input) {
159
186
  return [
160
187
  task,
161
188
  reviewContextBlock(input.reviewContext),
189
+ mergeConflictContextBlock(input.mergeConflictContext),
162
190
  input.includeSessionContext === false
163
191
  ? ""
164
192
  : languageBlock(input.repository.language),
@@ -200,6 +228,28 @@ export async function composeEditPrompt(input) {
200
228
  .filter(Boolean)
201
229
  .join("\n\n");
202
230
  }
231
+ export async function composeMergeConflictPrompt(input) {
232
+ const values = mergeConflictValues(input);
233
+ const task = await taskBlock({
234
+ builtin: "merge/conflict",
235
+ directory: input.directory,
236
+ values,
237
+ });
238
+ const persona = input.repository.agents.editor?.persona;
239
+ return [
240
+ task,
241
+ languageBlock(input.repository.language),
242
+ personaBlock(persona),
243
+ await editGuidelinesBlock({
244
+ directory: input.directory,
245
+ path: input.repository.prompts.editGuidelines,
246
+ values,
247
+ }),
248
+ editOutputContract,
249
+ ]
250
+ .filter(Boolean)
251
+ .join("\n\n");
252
+ }
203
253
  export async function composeFindingValidationPrompt(input) {
204
254
  const values = { ...reviewValues(input), findings: input.findings };
205
255
  const task = await taskBlock({
@@ -395,7 +445,14 @@ export async function composeTriageAcceptancePrompt(input) {
395
445
  ...input,
396
446
  builtin: "acceptance",
397
447
  customPath: input.repository.triage?.prompts.acceptance,
398
- outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
448
+ outputContract: triageVoteOutputContract('"YES" | "NO" | "INVALID" | "ASK"'),
449
+ });
450
+ }
451
+ export async function composeTriageSignalPrompt(input) {
452
+ return composeTriageVotePrompt({
453
+ ...input,
454
+ builtin: "signal",
455
+ outputContract: triageSignalOutputContract,
399
456
  });
400
457
  }
401
458
  export async function composeTriageCommentClassificationPrompt(input) {