opencode-magi 0.6.1 → 0.8.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;
@@ -128,13 +183,14 @@ async function emitTriageModelProgress(input) {
128
183
  }
129
184
  }
130
185
  async function runVote(input) {
131
- const prompt = await input.prompt({
132
- context: input.context,
133
- directory: input.directory,
134
- issue: input.issue,
135
- repository: input.repository,
136
- voter: input.agent,
137
- });
186
+ const prompt = input.promptText ??
187
+ (await input.prompt({
188
+ context: input.context,
189
+ directory: input.directory,
190
+ issue: input.issue,
191
+ repository: input.repository,
192
+ voter: input.agent,
193
+ }));
138
194
  await emitProgress(input.run, {
139
195
  phase: input.phase,
140
196
  type: "triage_agent_started",
@@ -216,7 +272,7 @@ export function chooseDuplicateOutput(input) {
216
272
  async function runDuplicateVote(input) {
217
273
  const agents = input.input.repository.agents.triage;
218
274
  if (!agents?.length)
219
- throw new Error("triage.agents is required");
275
+ throw new Error("triage.voters is required");
220
276
  await emitProgress(input.input, { phase: "duplicate", type: "phase" });
221
277
  const outputs = await Promise.all(agents.map((agent) => runVote({
222
278
  agent,
@@ -258,9 +314,16 @@ async function runDuplicateVote(input) {
258
314
  async function runPhaseVote(input) {
259
315
  const agents = input.input.repository.agents.triage;
260
316
  if (!agents?.length)
261
- throw new Error("triage.agents is required");
317
+ throw new Error("triage.voters is required");
262
318
  await emitProgress(input.input, { phase: input.phase, type: "phase" });
263
- const outputs = await Promise.all(agents.map((agent) => runVote({
319
+ const promptTexts = await Promise.all(agents.map((agent) => input.prompt({
320
+ context: input.context,
321
+ directory: input.input.directory,
322
+ issue: input.input.issue,
323
+ repository: input.input.repository,
324
+ voter: agent,
325
+ })));
326
+ const outputs = await Promise.all(agents.map((agent, index) => runVote({
264
327
  agent,
265
328
  client: input.input.client,
266
329
  context: input.context,
@@ -269,6 +332,7 @@ async function runPhaseVote(input) {
269
332
  parse: input.parse,
270
333
  phase: input.phase,
271
334
  prompt: input.prompt,
335
+ promptText: promptTexts[index],
272
336
  repository: input.input.repository,
273
337
  run: input.input,
274
338
  schemaName: input.schemaName,
@@ -285,7 +349,96 @@ async function runPhaseVote(input) {
285
349
  voter: agents[index].key,
286
350
  })));
287
351
  await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
288
- return { outputs, vote: majority.vote };
352
+ return {
353
+ outputs,
354
+ reason: chooseDecisionReason({
355
+ outputs,
356
+ threshold: majority.threshold,
357
+ vote: majority.vote,
358
+ voters: majority.vote ? majority.voters[majority.vote] : undefined,
359
+ }),
360
+ vote: majority.vote,
361
+ };
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;
289
442
  }
290
443
  async function relationshipScan(input, issue) {
291
444
  const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
@@ -366,40 +519,40 @@ export function eligibleMentionReplies(input) {
366
519
  function finalResultFromMarker(marker) {
367
520
  if (marker.disposition) {
368
521
  return {
369
- askReason: marker.askReason,
370
522
  category: marker.category ?? null,
371
523
  disposition: marker.disposition,
524
+ signals: marker.signals,
372
525
  };
373
526
  }
374
527
  switch (marker.result) {
375
528
  case "BUG_ACCEPTED":
376
529
  case "RESOLVED_BY_MERGED_PR":
377
- return { category: "bug", disposition: "accepted" };
530
+ return { category: "bug", disposition: "accepted", signals: [] };
378
531
  case "BUG_REJECTED":
379
- return { category: "bug", disposition: "rejected" };
532
+ return { category: "bug", disposition: "rejected", signals: [] };
380
533
  case "FEATURE_ACCEPTED":
381
- return { category: "feature", disposition: "accepted" };
534
+ return { category: "feature", disposition: "accepted", signals: [] };
382
535
  case "FEATURE_REJECTED":
383
- return { category: "feature", disposition: "rejected" };
536
+ return { category: "feature", disposition: "rejected", signals: [] };
384
537
  case "ASK":
385
538
  return {
386
- askReason: "acceptance_unclear",
387
539
  category: null,
388
- disposition: "ask",
540
+ disposition: "needs_acceptance",
541
+ signals: [],
389
542
  };
390
543
  case "CLEAR_ONLY":
391
- return { category: null, disposition: "clear_only" };
544
+ return { category: null, disposition: "already_handled", signals: [] };
392
545
  case "DUPLICATE":
393
- return { category: null, disposition: "duplicate" };
546
+ return { category: null, disposition: "duplicate", signals: [] };
394
547
  default:
395
- return { category: null, disposition: "failed" };
548
+ return { category: null, disposition: "failed", signals: [] };
396
549
  }
397
550
  }
398
551
  function decisionText(decision) {
399
552
  return JSON.stringify(decision);
400
553
  }
401
554
  function actionPlan(input) {
402
- if (input.result.disposition === "clear_only") {
555
+ if (input.result.disposition === "already_handled") {
403
556
  return {
404
557
  action: "CLEAR_ONLY",
405
558
  allowedActions: ["CLEAR_ONLY"],
@@ -409,11 +562,12 @@ function actionPlan(input) {
409
562
  postComment: false,
410
563
  };
411
564
  }
412
- if (input.result.disposition === "ask") {
565
+ if (input.result.disposition === "needs_category" ||
566
+ input.result.disposition === "needs_acceptance") {
413
567
  return {
414
568
  action: "ASK",
415
569
  allowedActions: ["ASK"],
416
- clearLabels: false,
570
+ clearLabels: true,
417
571
  closeIssue: false,
418
572
  createPr: false,
419
573
  postComment: true,
@@ -421,6 +575,7 @@ function actionPlan(input) {
421
575
  }
422
576
  const closeIssue = input.triage.automation.close &&
423
577
  (input.result.disposition === "rejected" ||
578
+ input.result.disposition === "invalid" ||
424
579
  input.result.disposition === "duplicate");
425
580
  const createPr = input.triage.automation.create && input.result.disposition === "accepted";
426
581
  return {
@@ -434,8 +589,13 @@ function actionPlan(input) {
434
589
  }
435
590
  function previousAutomationPlan(input) {
436
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
+ });
437
597
  const clearLabels = base.clearLabels &&
438
- existingClearLabels(input.issue, input.triage.automation.clear).length > 0;
598
+ (labelChanges.add.length > 0 || labelChanges.remove.length > 0);
439
599
  const closeIssue = input.marker.action === "CLOSE" &&
440
600
  base.closeIssue &&
441
601
  input.issue.state === "OPEN";
@@ -507,7 +667,7 @@ async function runReconsiderationVote(input) {
507
667
  function triageReporter(repository, issue) {
508
668
  const agents = repository.agents.triage ?? [];
509
669
  if (!agents.length)
510
- throw new Error("triage.agents is required");
670
+ throw new Error("triage.voters is required");
511
671
  const configured = repository.triage?.reporter;
512
672
  const reporter = configured
513
673
  ? agents.find((agent) => agent.key === configured)
@@ -518,26 +678,49 @@ function triageReporter(repository, issue) {
518
678
  }
519
679
  function decisionCommentBody(input) {
520
680
  const reason = input.reason?.trim();
521
- const result = JSON.stringify(input.result);
522
681
  return reason
523
- ? `Magi triage decision: ${result}\n\nReason: ${reason}`
524
- : `Magi triage decision: ${result}\n\nAction: ${input.action}`;
682
+ ? reason
683
+ : decisionCommentFallback({ action: input.action, result: input.result });
684
+ }
685
+ function decisionCommentFallback(input) {
686
+ if (input.result.disposition === "accepted") {
687
+ const category = input.result.category
688
+ ? `${input.result.category} issue`
689
+ : "issue";
690
+ return input.action === "PR"
691
+ ? `Magi accepted this ${category} and will prepare an implementation pull request.`
692
+ : `Magi accepted this ${category}.`;
693
+ }
694
+ if (input.result.disposition === "rejected") {
695
+ const category = input.result.category
696
+ ? `${input.result.category} issue`
697
+ : "issue";
698
+ return `Magi does not plan to act on this ${category}.`;
699
+ }
700
+ if (input.result.disposition === "duplicate") {
701
+ return "Magi marked this issue as a duplicate.";
702
+ }
703
+ if (input.result.disposition === "invalid") {
704
+ return "Magi marked this issue as invalid or not actionable.";
705
+ }
706
+ return "Magi completed triage for this issue.";
525
707
  }
526
708
  function agentForKey(repository, key) {
527
709
  const agent = repository.agents.triage?.find((item) => item.key === key);
528
710
  if (!agent)
529
- throw new Error(`Unknown triage agent: ${key}`);
711
+ throw new Error(`Unknown triage voter: ${key}`);
530
712
  return agent;
531
713
  }
532
714
  function askOutputs(outputs) {
533
715
  return (outputs ?? []).filter((output) => output.vote === "ASK");
534
716
  }
535
717
  function chooseDecisionReason(input) {
536
- return (input.outputs?.find((output) => output.voter === input.reporter.key &&
537
- output.vote === input.vote &&
538
- output.reason)?.reason ??
539
- input.outputs?.find((output) => output.vote === input.vote)?.reason ??
540
- input.outputs?.find((output) => output.voter === input.reporter.key)?.reason);
718
+ if (!input.vote)
719
+ return undefined;
720
+ const canonicalVoter = input.voters?.[input.threshold - 1];
721
+ const canonicalReason = input.outputs?.find((output) => output.voter === canonicalVoter && output.vote === input.vote);
722
+ return (canonicalReason?.reason ??
723
+ input.outputs?.find((output) => output.vote === input.vote)?.reason);
541
724
  }
542
725
  async function postMarkedIssueComment(input) {
543
726
  const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
@@ -637,7 +820,9 @@ async function finishWithResult(input) {
637
820
  });
638
821
  let prUrl;
639
822
  const reporter = triageReporter(input.input.repository, input.issue.number);
640
- 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"
641
826
  ? `${decisionCommentBody({
642
827
  action: plan.action,
643
828
  reason: input.commentReason,
@@ -653,7 +838,19 @@ async function finishWithResult(input) {
653
838
  if (comment) {
654
839
  await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
655
840
  }
656
- 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) {
657
854
  await postAskComments({
658
855
  action: plan.action,
659
856
  dryRun: input.input.dryRun,
@@ -685,9 +882,11 @@ async function finishWithResult(input) {
685
882
  });
686
883
  }
687
884
  if (plan.clearLabels) {
688
- const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
689
- if (clearLabels.length) {
690
- 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);
691
890
  }
692
891
  }
693
892
  if (plan.closeIssue) {
@@ -867,7 +1066,7 @@ export async function runTriage(input) {
867
1066
  throw new Error("triage configuration is required");
868
1067
  const agents = input.repository.agents.triage;
869
1068
  if (!agents?.length)
870
- throw new Error("triage.agents is required");
1069
+ throw new Error("triage.voters is required");
871
1070
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
872
1071
  const outputDir = issueRunOutputDir({
873
1072
  config: input.config,
@@ -896,7 +1095,7 @@ export async function runTriage(input) {
896
1095
  issue: input.issue,
897
1096
  outputDir,
898
1097
  report,
899
- result: { category: null, disposition: "failed" },
1098
+ result: { category: null, disposition: "blocked", signals: [] },
900
1099
  };
901
1100
  }
902
1101
  let context = issueContext({ issue, relationship });
@@ -978,30 +1177,32 @@ export async function runTriage(input) {
978
1177
  });
979
1178
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
980
1179
  const previous = finalResultFromMarker(relationship.previousMarker);
981
- if (previous.disposition !== "ask" ||
982
- previous.askReason !== "acceptance_unclear") {
1180
+ if (previous.disposition !== "needs_acceptance") {
983
1181
  const reconsideration = await runReconsiderationVote({
984
1182
  context,
985
1183
  input,
986
1184
  outputDir,
987
1185
  });
988
- const reporter = triageReporter(input.repository, issue.number);
989
- commentReason = chooseDecisionReason({
990
- outputs: reconsideration.outputs,
991
- reporter,
992
- vote: reconsideration.vote ?? "ASK",
993
- });
1186
+ commentReason = reconsideration.reason;
994
1187
  result =
995
1188
  reconsideration.vote === "YES"
996
- ? { category: previous.category, disposition: "accepted" }
1189
+ ? {
1190
+ category: previous.category,
1191
+ disposition: "accepted",
1192
+ signals: [],
1193
+ }
997
1194
  : reconsideration.vote === "NO"
998
- ? { category: previous.category, disposition: "rejected" }
1195
+ ? {
1196
+ category: previous.category,
1197
+ disposition: "rejected",
1198
+ signals: [],
1199
+ }
999
1200
  : {
1000
- askReason: "acceptance_unclear",
1001
1201
  category: previous.category,
1002
- disposition: "ask",
1202
+ disposition: "needs_acceptance",
1203
+ signals: [],
1003
1204
  };
1004
- if (result.disposition === "ask") {
1205
+ if (result.disposition === "needs_acceptance") {
1005
1206
  askCommentOutputs = askOutputs(reconsideration.outputs);
1006
1207
  markAskComments = true;
1007
1208
  }
@@ -1024,6 +1225,7 @@ export async function runTriage(input) {
1024
1225
  const relatedPrDecision = {
1025
1226
  category: resolveIssueCategory(issue, input.repository) ?? null,
1026
1227
  disposition: "accepted",
1228
+ signals: [],
1027
1229
  };
1028
1230
  const plan = {
1029
1231
  action: "CLOSE",
@@ -1034,11 +1236,7 @@ export async function runTriage(input) {
1034
1236
  postComment: true,
1035
1237
  };
1036
1238
  return finishWithResult({
1037
- commentReason: chooseDecisionReason({
1038
- outputs: existingPr.outputs,
1039
- reporter: triageReporter(input.repository, issue.number),
1040
- vote: "RELATED_PR_HANDLES_ISSUE",
1041
- }),
1239
+ commentReason: existingPr.reason,
1042
1240
  context,
1043
1241
  input,
1044
1242
  issue,
@@ -1057,7 +1255,7 @@ export async function runTriage(input) {
1057
1255
  outputDir,
1058
1256
  processed,
1059
1257
  relationship,
1060
- result: { category: null, disposition: "clear_only" },
1258
+ result: { category: null, disposition: "already_handled", signals: [] },
1061
1259
  runId,
1062
1260
  });
1063
1261
  }
@@ -1072,7 +1270,7 @@ export async function runTriage(input) {
1072
1270
  if (duplicate) {
1073
1271
  context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
1074
1272
  commentReason = duplicate.reason;
1075
- result = { category: null, disposition: "duplicate" };
1273
+ result = { category: null, disposition: "duplicate", signals: [] };
1076
1274
  }
1077
1275
  }
1078
1276
  if (!result) {
@@ -1096,9 +1294,9 @@ export async function runTriage(input) {
1096
1294
  const category = resolvedCategory ?? categoryVote?.vote ?? "ASK";
1097
1295
  if (category === "ASK") {
1098
1296
  result = {
1099
- askReason: "category_unclear",
1100
1297
  category: null,
1101
- disposition: "ask",
1298
+ disposition: "needs_category",
1299
+ signals: [],
1102
1300
  };
1103
1301
  askCommentOutputs = askOutputs(categoryVote?.outputs);
1104
1302
  markAskComments = false;
@@ -1117,30 +1315,41 @@ export async function runTriage(input) {
1117
1315
  phase: "acceptance",
1118
1316
  prompt: composeTriageAcceptancePrompt,
1119
1317
  schemaName: "triage acceptance",
1120
- votes: BINARY_VOTES,
1121
- });
1122
- const reporter = triageReporter(input.repository, issue.number);
1123
- commentReason = chooseDecisionReason({
1124
- outputs: acceptance.outputs,
1125
- reporter,
1126
- vote: acceptance.vote ?? "ASK",
1318
+ votes: ACCEPTANCE_VOTES,
1127
1319
  });
1320
+ commentReason = acceptance.reason;
1128
1321
  result =
1129
1322
  acceptance.vote === "YES"
1130
- ? { category, disposition: "accepted" }
1323
+ ? { category, disposition: "accepted", signals: [] }
1131
1324
  : acceptance.vote === "NO"
1132
- ? { category, disposition: "rejected" }
1133
- : {
1134
- askReason: "acceptance_unclear",
1135
- category,
1136
- disposition: "ask",
1137
- };
1138
- 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") {
1139
1334
  askCommentOutputs = askOutputs(acceptance.outputs);
1140
1335
  markAskComments = true;
1141
1336
  }
1142
1337
  }
1143
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
+ }
1144
1353
  return finishWithResult({
1145
1354
  askOutputs: askCommentOutputs,
1146
1355
  commentReason,
@@ -1152,9 +1361,9 @@ export async function runTriage(input) {
1152
1361
  processed,
1153
1362
  relationship,
1154
1363
  result: result ?? {
1155
- askReason: "acceptance_unclear",
1156
1364
  category: null,
1157
- disposition: "ask",
1365
+ disposition: "needs_acceptance",
1366
+ signals: [],
1158
1367
  },
1159
1368
  runId,
1160
1369
  });