opencode-magi 0.0.0-dev-20260522033138 → 0.0.0-dev-20260522071924

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.
@@ -104,7 +104,7 @@ export function resolveAgents(config) {
104
104
  triageCreator: creator
105
105
  ? {
106
106
  ...creator,
107
- account: creator.account ?? config.triage?.account ?? "",
107
+ account: creator.account ?? "",
108
108
  permission: resolveTriageCreatorPermission(agents, creator),
109
109
  }
110
110
  : undefined,
@@ -179,7 +179,6 @@ export function resolveRepository(config) {
179
179
  requiredLabels: config.review?.safety?.requiredLabels ?? [],
180
180
  },
181
181
  triage: {
182
- account: config.triage?.account,
183
182
  automation: {
184
183
  clear: config.triage?.automation?.clear ?? ["triage"],
185
184
  close: config.triage?.automation?.close ?? false,
@@ -193,6 +192,7 @@ export function resolveRepository(config) {
193
192
  },
194
193
  output: config.triage?.output,
195
194
  prompts: config.triage?.prompts ?? {},
195
+ reporter: config.triage?.reporter,
196
196
  safety: {
197
197
  allowAuthors: config.triage?.safety?.allowAuthors ?? [],
198
198
  allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
@@ -40,6 +40,7 @@ const EDITOR_KEYS = new Set([
40
40
  "persona",
41
41
  ]);
42
42
  const TRIAGE_AGENT_KEYS = new Set([
43
+ "account",
43
44
  "id",
44
45
  "model",
45
46
  "options",
@@ -75,7 +76,6 @@ const MERGE_KEYS = new Set([
75
76
  "prompts",
76
77
  ]);
77
78
  const TRIAGE_KEYS = new Set([
78
- "account",
79
79
  "agents",
80
80
  "automation",
81
81
  "categories",
@@ -83,6 +83,7 @@ const TRIAGE_KEYS = new Set([
83
83
  "creator",
84
84
  "output",
85
85
  "prompts",
86
+ "reporter",
86
87
  "safety",
87
88
  "worktree",
88
89
  ]);
@@ -357,6 +358,9 @@ function validateTriageAgentList(agents, path, errors, catalog) {
357
358
  errors.push(`${path}[${index}].model is required`);
358
359
  validateString(agent.model, `${path}[${index}].model`, errors);
359
360
  validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
361
+ if (!agent.account)
362
+ errors.push(`${path}[${index}].account is required`);
363
+ validateString(agent.account, `${path}[${index}].account`, errors);
360
364
  validateString(agent.persona, `${path}[${index}].persona`, errors);
361
365
  if (agent.options != null && !isPlainObject(agent.options))
362
366
  errors.push(`${path}[${index}].options must be an object`);
@@ -383,12 +387,16 @@ function validateResolvedReviewers(reviewers, path, errors) {
383
387
  accounts.add(reviewer.account);
384
388
  }
385
389
  }
386
- function validateResolvedAgentKeys(agents, path, errors) {
390
+ function validateResolvedTriageAgents(agents, path, errors) {
387
391
  const keys = new Set();
392
+ const accounts = new Set();
388
393
  for (const agent of agents) {
389
394
  if (keys.has(agent.key))
390
395
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
391
396
  keys.add(agent.key);
397
+ if (accounts.has(agent.account))
398
+ errors.push(`${path} has duplicate agent account: ${agent.account}`);
399
+ accounts.add(agent.account);
392
400
  }
393
401
  }
394
402
  function validateEditor(editor, path, errors, catalog) {
@@ -672,19 +680,26 @@ function validateTriage(config, errors, options) {
672
680
  validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
673
681
  const automation = triage.automation;
674
682
  const concurrency = triage.concurrency;
683
+ const creator = triage.creator;
684
+ const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
675
685
  const safety = triage.safety;
676
- if (!triage.account)
677
- errors.push("triage.account is required");
678
- validateString(triage.account, "triage.account", errors);
679
686
  if (!triage.agents)
680
687
  errors.push("triage.agents is required");
681
688
  validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
682
689
  if (Array.isArray(triage.agents)) {
683
- validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
690
+ const resolvedTriageAgents = resolveAgents(config).triage ?? [];
691
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
692
+ if (reporter != null &&
693
+ !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
694
+ errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
695
+ }
684
696
  }
685
- validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
686
- if (automation?.create && !triage.creator)
697
+ validateString(triage.reporter, "triage.reporter", errors);
698
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
699
+ if (automation?.create && !creator)
687
700
  errors.push("triage.creator is required when triage.automation.create is true");
701
+ if (automation?.create && creator && !creator.account)
702
+ errors.push("triage.creator.account is required when triage.automation.create is true");
688
703
  if (automation != null && !isPlainObject(automation)) {
689
704
  errors.push("triage.automation must be an object");
690
705
  }
@@ -745,10 +760,10 @@ async function validateAuth(config, exec, errors) {
745
760
  const agents = resolveAgents(config);
746
761
  for (const reviewer of agents.reviewers)
747
762
  accounts.add(reviewer.account);
763
+ for (const agent of agents.triage ?? [])
764
+ accounts.add(agent.account);
748
765
  if (agents.editor)
749
766
  accounts.add(agents.editor.account);
750
- if (config.triage?.account)
751
- accounts.add(config.triage.account);
752
767
  if (agents.triageCreator?.account)
753
768
  accounts.add(agents.triageCreator.account);
754
769
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
@@ -802,19 +817,18 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
802
817
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
803
818
  }
804
819
  }));
805
- if (config.triage?.account) {
820
+ await Promise.all((agents.triage ?? []).map(async (agent) => {
806
821
  try {
807
- const permissions = await fetchPermissions(config, exec, config.triage.account);
822
+ const permissions = await fetchPermissions(config, exec, agent.account);
808
823
  if (!permissions.pull) {
809
- errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
824
+ errors.push(`GitHub account cannot read repository for issue triage: ${agent.account}`);
810
825
  }
811
826
  }
812
827
  catch (error) {
813
- warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
828
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agent.account} (${error.message})`);
814
829
  }
815
- }
816
- if (agents.triageCreator?.account &&
817
- agents.triageCreator.account !== config.triage?.account) {
830
+ }));
831
+ if (agents.triageCreator?.account) {
818
832
  try {
819
833
  const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
820
834
  if (!permissions.push) {
@@ -3,10 +3,10 @@ import { dirname, join } from "node:path";
3
3
  import { issueRunOutputDir } from "../config/output";
4
4
  import { worktreeBaseDir } from "../config/worktree";
5
5
  import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
- import { composeTriageAcceptancePrompt, composeTriageActionPrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
6
+ import { composeTriageAcceptancePrompt, composeTriageActionPrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
7
  import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
- import { runModelText, runModelWithRepair, } from "./model";
9
+ import { runModelWithRepair, } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
11
11
  const BINARY_VOTES = ["ASK", "NO", "YES"];
12
12
  const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
@@ -181,6 +181,7 @@ async function runVote(input) {
181
181
  ...result.value,
182
182
  promptText: prompt,
183
183
  raw: result.raw,
184
+ reviewer: input.agent.key,
184
185
  sessionId: result.sessionId,
185
186
  };
186
187
  }
@@ -189,6 +190,7 @@ async function writeVoteArtifacts(input) {
189
190
  await writeFile(`${base}.prompt.txt`, `${input.output.promptText}\n`);
190
191
  await writeFile(`${base}.raw.txt`, `${input.output.raw}\n`);
191
192
  await writeJson(`${base}.json`, {
193
+ body: input.output.body,
192
194
  reason: input.output.reason,
193
195
  vote: input.output.vote,
194
196
  });
@@ -282,7 +284,7 @@ async function runPhaseVote(input) {
282
284
  reviewer: agents[index].key,
283
285
  })));
284
286
  await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
285
- return majority.vote;
287
+ return { outputs, vote: majority.vote };
286
288
  }
287
289
  async function relationshipScan(input, issue) {
288
290
  const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
@@ -290,17 +292,20 @@ async function relationshipScan(input, issue) {
290
292
  fetchRelatedPullRequests(input.exec, input.repository, input.issue),
291
293
  searchDuplicateIssues(input.exec, input.repository, issue),
292
294
  ]);
295
+ const triageAccounts = new Set((input.repository.agents.triage ?? []).map((agent) => agent.account));
293
296
  const markers = comments
294
- .filter((comment) => comment.author === input.repository.triage?.account)
297
+ .filter((comment) => triageAccounts.has(comment.author))
295
298
  .map((comment) => {
296
299
  const parsed = parseTriageMarker(comment.body);
297
- return parsed ? { ...parsed, commentId: comment.id } : undefined;
300
+ return parsed
301
+ ? { ...parsed, account: comment.author, commentId: comment.id }
302
+ : undefined;
298
303
  })
299
304
  .filter(Boolean);
300
305
  const previousMarker = markers.at(-1);
301
306
  const mentionReplies = previousMarker
302
307
  ? eligibleMentionReplies({
303
- account: input.repository.triage?.account ?? "",
308
+ account: previousMarker.account ?? "",
304
309
  comments,
305
310
  marker: previousMarker,
306
311
  processed: previousMarker.processed,
@@ -523,54 +528,41 @@ async function runReconsiderationVote(input) {
523
528
  votes: BINARY_VOTES,
524
529
  });
525
530
  }
526
- async function composeResultComment(input) {
527
- const agents = input.input.repository.agents.triage;
528
- if (!agents?.length)
531
+ function triageReporter(repository, issue) {
532
+ const agents = repository.agents.triage ?? [];
533
+ if (!agents.length)
529
534
  throw new Error("triage.agents is required");
530
- if (input.result.disposition === "ask" &&
531
- input.result.askReason === "category_unclear") {
532
- const language = input.input.repository.language?.toLowerCase() ?? "";
533
- const body = language.includes("ja") || language.includes("japanese")
534
- ? `@${input.issue.author} 現在の説明だけでは、何をすべきか判断できません。\n\n期待する動作、実際の動作、必要な理由、関連する例・ログ・スクリーンショットなどを追記してください。`
535
- : `@${input.issue.author} I can't determine what should be done from the current description.\n\nPlease add more detail, such as the expected behavior, the actual behavior, the reason this is needed, or any relevant examples, logs, or screenshots.`;
536
- const comment = `${body}\n\n${marker({
537
- action: input.action,
538
- checkpoint: "pending",
539
- decision: input.result,
540
- issue: input.issue.number,
541
- processed: input.processed,
542
- })}`;
543
- await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
544
- return comment;
545
- }
546
- const prompt = await (input.result.disposition === "ask"
547
- ? composeTriageQuestionPrompt
548
- : composeTriageCommentPrompt)({
549
- author: input.issue.author,
550
- context: input.context,
551
- directory: input.input.directory,
552
- issue: input.issue.number,
553
- repository: input.input.repository,
554
- });
555
- const comment = (await runModelText({
556
- allowEmpty: false,
557
- client: input.input.client,
558
- model: agents[0].model,
559
- options: agents[0].options,
560
- permission: agents[0].permission,
561
- prompt,
562
- signal: input.input.signal,
563
- title: `Magi triage comment #${input.issue.number}`,
564
- })).raw +
565
- `\n\n${marker({
566
- action: input.action,
567
- checkpoint: "pending",
568
- decision: input.result,
569
- issue: input.issue.number,
570
- processed: input.processed,
571
- })}`;
572
- await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
573
- return comment;
535
+ const configured = repository.triage?.reporter;
536
+ const reporter = configured
537
+ ? agents.find((agent) => agent.key === configured)
538
+ : agents[Math.abs(issue) % agents.length];
539
+ if (!reporter)
540
+ throw new Error(`Unknown triage reporter: ${configured}`);
541
+ return reporter;
542
+ }
543
+ function decisionCommentBody(input) {
544
+ const reason = input.reason?.trim();
545
+ const result = JSON.stringify(input.result);
546
+ return reason
547
+ ? `Magi triage decision: ${result}\n\nReason: ${reason}`
548
+ : `Magi triage decision: ${result}\n\nAction: ${input.action}`;
549
+ }
550
+ function agentForKey(repository, key) {
551
+ const agent = repository.agents.triage?.find((item) => item.key === key);
552
+ if (!agent)
553
+ throw new Error(`Unknown triage agent: ${key}`);
554
+ return agent;
555
+ }
556
+ function askOutputs(outputs) {
557
+ return (outputs ?? []).filter((output) => output.vote === "ASK");
558
+ }
559
+ function chooseDecisionReason(input) {
560
+ return (input.outputs?.find((output) => output.reviewer === input.reporter.key &&
561
+ output.vote === input.vote &&
562
+ output.reason)?.reason ??
563
+ input.outputs?.find((output) => output.vote === input.vote)?.reason ??
564
+ input.outputs?.find((output) => output.reviewer === input.reporter.key)
565
+ ?.reason);
574
566
  }
575
567
  async function postMarkedIssueComment(input) {
576
568
  const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
@@ -578,9 +570,20 @@ async function postMarkedIssueComment(input) {
578
570
  const updated = body === input.body
579
571
  ? posted
580
572
  : await updateIssueComment(input.exec, input.repository, posted.id, input.account, body);
581
- await writeJson(join(input.outputDir, "posted.json"), updated);
573
+ await writeJson(join(input.outputDir, `posted-${updated.id}.json`), {
574
+ account: input.account,
575
+ ...updated,
576
+ });
582
577
  return updated;
583
578
  }
579
+ async function postPlainIssueComment(input) {
580
+ const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
581
+ await writeJson(join(input.outputDir, `posted-${posted.id}.json`), {
582
+ account: input.account,
583
+ ...posted,
584
+ });
585
+ return posted;
586
+ }
584
587
  async function persistProcessedMarker(input) {
585
588
  if (!input.marker.commentId)
586
589
  return;
@@ -604,6 +607,49 @@ async function persistProcessedMarker(input) {
604
607
  updated,
605
608
  });
606
609
  }
610
+ async function postAskComments(input) {
611
+ const urls = [];
612
+ for (const output of askOutputs(input.outputs)) {
613
+ const agent = agentForKey(input.repository, output.reviewer);
614
+ const body = input.mark
615
+ ? `${output.body}\n\n${marker({
616
+ action: input.action,
617
+ checkpoint: "pending",
618
+ decision: input.result,
619
+ issue: input.issue.number,
620
+ processed: input.processed,
621
+ })}`
622
+ : output.body;
623
+ if (!body?.trim())
624
+ continue;
625
+ await writeFile(join(input.outputDir, `${agent.key}.ask-comment.md`), `${body}\n`);
626
+ if (input.dryRun) {
627
+ urls.push(`dry-run:would-comment:${agent.key}`);
628
+ continue;
629
+ }
630
+ await emitProgress(input.run, { type: "comment_posting" });
631
+ const posted = input.mark
632
+ ? await postMarkedIssueComment({
633
+ account: agent.account,
634
+ body,
635
+ exec: input.exec,
636
+ issue: input.issue.number,
637
+ outputDir: input.outputDir,
638
+ repository: input.repository,
639
+ })
640
+ : await postPlainIssueComment({
641
+ account: agent.account,
642
+ body,
643
+ exec: input.exec,
644
+ issue: input.issue.number,
645
+ outputDir: input.outputDir,
646
+ repository: input.repository,
647
+ });
648
+ urls.push(posted.url);
649
+ await emitProgress(input.run, { type: "comment_posted", url: posted.url });
650
+ }
651
+ return urls;
652
+ }
607
653
  async function finishWithResult(input) {
608
654
  const triage = input.input.repository.triage;
609
655
  if (!triage)
@@ -622,22 +668,43 @@ async function finishWithResult(input) {
622
668
  result: input.result,
623
669
  });
624
670
  let prUrl;
625
- const comment = plan.postComment
626
- ? await composeResultComment({
671
+ const reporter = triageReporter(input.input.repository, input.issue.number);
672
+ const comment = plan.postComment && input.result.disposition !== "ask"
673
+ ? `${decisionCommentBody({
674
+ action: plan.action,
675
+ reason: input.commentReason,
676
+ result: input.result,
677
+ })}\n\n${marker({
627
678
  action: plan.action,
628
- context: `Result: ${decisionText(input.result)}\nAction: ${plan.action}\n\n${input.context}`,
629
- input: input.input,
679
+ checkpoint: "pending",
680
+ decision: input.result,
681
+ issue: input.issue.number,
682
+ processed: input.processed,
683
+ })}`
684
+ : undefined;
685
+ if (comment) {
686
+ await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
687
+ }
688
+ if (input.result.disposition === "ask" && input.askOutputs) {
689
+ await postAskComments({
690
+ action: plan.action,
691
+ dryRun: input.input.dryRun,
692
+ exec: input.input.exec,
630
693
  issue: input.issue,
694
+ mark: input.markAskComments ?? false,
695
+ outputs: input.askOutputs,
631
696
  outputDir: input.outputDir,
632
697
  processed: input.processed,
698
+ repository: input.input.repository,
633
699
  result: input.result,
634
- })
635
- : undefined;
700
+ run: input.input,
701
+ });
702
+ }
636
703
  if (!input.input.dryRun) {
637
704
  if (comment) {
638
705
  await emitProgress(input.input, { type: "comment_posting" });
639
706
  const posted = await postMarkedIssueComment({
640
- account: triage.account ?? "",
707
+ account: reporter.account,
641
708
  body: comment,
642
709
  exec: input.input.exec,
643
710
  issue: input.issue.number,
@@ -652,18 +719,18 @@ async function finishWithResult(input) {
652
719
  if (plan.clearLabels) {
653
720
  const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
654
721
  if (clearLabels.length) {
655
- await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, triage.account ?? "");
722
+ await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, reporter.account);
656
723
  }
657
724
  }
658
725
  if (plan.closeIssue) {
659
726
  const closedPrs = [];
660
727
  for (const pr of input.relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
661
- await closePullRequest(input.input.exec, input.input.repository, pr.number, triage.account ?? "");
728
+ await closePullRequest(input.input.exec, input.input.repository, pr.number, reporter.account);
662
729
  closedPrs.push(pr.number);
663
730
  }
664
731
  if (closedPrs.length)
665
732
  await writeJson(join(input.outputDir, "closed-prs.json"), closedPrs);
666
- await closeIssue(input.input.exec, input.input.repository, input.issue.number, triage.account ?? "");
733
+ await closeIssue(input.input.exec, input.input.repository, input.issue.number, reporter.account);
667
734
  }
668
735
  if (plan.createPr) {
669
736
  prUrl = await createImplementationPr({
@@ -679,7 +746,7 @@ async function finishWithResult(input) {
679
746
  }
680
747
  if (input.previousMarker && prUrl) {
681
748
  await persistProcessedMarker({
682
- account: triage.account ?? "",
749
+ account: input.previousMarker.account ?? reporter.account,
683
750
  comments: input.relationship.comments,
684
751
  exec: input.input.exec,
685
752
  issue: input.issue,
@@ -734,13 +801,10 @@ async function createImplementationPr(input) {
734
801
  const creator = input.input.repository.agents.triageCreator;
735
802
  if (!creator)
736
803
  return undefined;
737
- const triage = input.input.repository.triage;
738
- if (!triage?.account)
739
- throw new Error("triage.account is required");
740
804
  await emitProgress(input.input, { type: "pr_creation_started" });
741
805
  await emitProgress(input.input, { type: "triage_creator_started" });
742
806
  try {
743
- await assignIssue(input.input.exec, input.input.repository, input.issue.number, triage.account);
807
+ await assignIssue(input.input.exec, input.input.repository, input.issue.number, creator.account);
744
808
  const branch = `magi/issue-${input.issue.number}-${Date.now().toString(36)}`;
745
809
  const worktreePath = join(worktreeBaseDir(input.input.directory, input.input.config, "issue"), `issue-${input.issue.number}`);
746
810
  await mkdir(dirname(worktreePath), { recursive: true });
@@ -824,8 +888,8 @@ async function createImplementationPr(input) {
824
888
  }
825
889
  export async function runTriage(input) {
826
890
  const triage = input.repository.triage;
827
- if (!triage?.account)
828
- throw new Error("triage.account is required");
891
+ if (!triage)
892
+ throw new Error("triage configuration is required");
829
893
  const agents = input.repository.agents.triage;
830
894
  if (!agents?.length)
831
895
  throw new Error("triage.agents is required");
@@ -865,6 +929,9 @@ export async function runTriage(input) {
865
929
  await emitProgress(input, { phase: "triaging", type: "phase" });
866
930
  let processed = relationship.previousMarker?.processed ?? [];
867
931
  let result;
932
+ let askCommentOutputs;
933
+ let commentReason;
934
+ let markAskComments = false;
868
935
  if (relationship.previousMarker) {
869
936
  if (!relationship.mentionReplies.length) {
870
937
  const result = finalResultFromMarker(relationship.previousMarker);
@@ -909,7 +976,7 @@ export async function runTriage(input) {
909
976
  if (!triggeringComments.length) {
910
977
  if (!input.dryRun) {
911
978
  await persistProcessedMarker({
912
- account: triage.account,
979
+ account: relationship.previousMarker.account ?? "",
913
980
  comments: relationship.comments,
914
981
  exec: input.exec,
915
982
  issue,
@@ -934,21 +1001,38 @@ export async function runTriage(input) {
934
1001
  },
935
1002
  });
936
1003
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
937
- const vote = await runReconsiderationVote({ context, input, outputDir });
938
1004
  const previous = finalResultFromMarker(relationship.previousMarker);
939
- result =
940
- vote === "YES"
941
- ? { category: previous.category, disposition: "accepted" }
942
- : vote === "NO"
943
- ? { category: previous.category, disposition: "rejected" }
944
- : {
945
- askReason: "acceptance_unclear",
946
- category: previous.category,
947
- disposition: "ask",
948
- };
1005
+ if (previous.disposition !== "ask" ||
1006
+ previous.askReason !== "acceptance_unclear") {
1007
+ const reconsideration = await runReconsiderationVote({
1008
+ context,
1009
+ input,
1010
+ outputDir,
1011
+ });
1012
+ const reporter = triageReporter(input.repository, issue.number);
1013
+ commentReason = chooseDecisionReason({
1014
+ outputs: reconsideration.outputs,
1015
+ reporter,
1016
+ vote: reconsideration.vote ?? "ASK",
1017
+ });
1018
+ result =
1019
+ reconsideration.vote === "YES"
1020
+ ? { category: previous.category, disposition: "accepted" }
1021
+ : reconsideration.vote === "NO"
1022
+ ? { category: previous.category, disposition: "rejected" }
1023
+ : {
1024
+ askReason: "acceptance_unclear",
1025
+ category: previous.category,
1026
+ disposition: "ask",
1027
+ };
1028
+ if (result.disposition === "ask") {
1029
+ askCommentOutputs = askOutputs(reconsideration.outputs);
1030
+ markAskComments = true;
1031
+ }
1032
+ }
949
1033
  }
950
1034
  if (!result && relationship.relatedPullRequests.length) {
951
- const vote = await runPhaseVote({
1035
+ const existingPr = await runPhaseVote({
952
1036
  context,
953
1037
  input,
954
1038
  outputDir,
@@ -958,7 +1042,7 @@ export async function runTriage(input) {
958
1042
  schemaName: "triage existing PR",
959
1043
  votes: EXISTING_PR_VOTES,
960
1044
  });
961
- if (vote === "RELATED_PR_HANDLES_ISSUE") {
1045
+ if (existingPr.vote === "RELATED_PR_HANDLES_ISSUE") {
962
1046
  const merged = relationship.relatedPullRequests.some((pr) => pr.state === "MERGED");
963
1047
  if (merged && triage.automation.close) {
964
1048
  const relatedPrDecision = {
@@ -973,59 +1057,21 @@ export async function runTriage(input) {
973
1057
  createPr: false,
974
1058
  postComment: true,
975
1059
  };
976
- await emitProgress(input, {
977
- action: plan.action,
978
- result: relatedPrDecision,
979
- type: "decision",
980
- });
981
- await runActionPrompt({
1060
+ return finishWithResult({
1061
+ commentReason: chooseDecisionReason({
1062
+ outputs: existingPr.outputs,
1063
+ reporter: triageReporter(input.repository, issue.number),
1064
+ vote: "RELATED_PR_HANDLES_ISSUE",
1065
+ }),
982
1066
  context,
983
1067
  input,
984
- outputDir,
985
- plan,
986
- result: relatedPrDecision,
987
- });
988
- const body = await composeResultComment({
989
- action: "CLOSE",
990
- context: `Result: ${decisionText(relatedPrDecision)}\nAction: CLOSE\n\n${context}`,
991
- input,
992
1068
  issue,
993
1069
  outputDir,
1070
+ plan,
994
1071
  processed,
1072
+ relationship,
995
1073
  result: relatedPrDecision,
996
1074
  });
997
- if (!input.dryRun) {
998
- await emitProgress(input, { type: "comment_posting" });
999
- const posted = await postMarkedIssueComment({
1000
- account: triage.account,
1001
- body,
1002
- exec: input.exec,
1003
- issue: issue.number,
1004
- outputDir,
1005
- repository: input.repository,
1006
- });
1007
- await emitProgress(input, { type: "comment_posted", url: posted.url });
1008
- const clearLabels = existingClearLabels(issue, triage.automation.clear);
1009
- if (clearLabels.length) {
1010
- await removeIssueLabels(input.exec, input.repository, issue.number, clearLabels, triage.account);
1011
- }
1012
- const closedPrs = [];
1013
- for (const pr of relationship.relatedPullRequests.filter((pr) => pr.state === "OPEN")) {
1014
- await closePullRequest(input.exec, input.repository, pr.number, triage.account);
1015
- closedPrs.push(pr.number);
1016
- }
1017
- if (closedPrs.length)
1018
- await writeJson(join(outputDir, "closed-prs.json"), closedPrs);
1019
- await closeIssue(input.exec, input.repository, issue.number, triage.account);
1020
- }
1021
- const report = `Magi triage closed #${issue.number} because a related PR was merged.`;
1022
- await writeFile(join(outputDir, "report.md"), `${report}\n`);
1023
- return {
1024
- issue: issue.number,
1025
- outputDir,
1026
- report,
1027
- result: relatedPrDecision,
1028
- };
1029
1075
  }
1030
1076
  return finishWithResult({
1031
1077
  context,
@@ -1047,6 +1093,7 @@ export async function runTriage(input) {
1047
1093
  });
1048
1094
  if (duplicate) {
1049
1095
  context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
1096
+ commentReason = duplicate.reason;
1050
1097
  result = { category: null, disposition: "duplicate" };
1051
1098
  }
1052
1099
  }
@@ -1056,8 +1103,9 @@ export async function runTriage(input) {
1056
1103
  category: resolvedCategory,
1057
1104
  source: resolvedCategory ? "config" : "vote",
1058
1105
  });
1059
- const category = resolvedCategory ??
1060
- (await runPhaseVote({
1106
+ const categoryVote = resolvedCategory
1107
+ ? undefined
1108
+ : await runPhaseVote({
1061
1109
  context,
1062
1110
  input,
1063
1111
  outputDir,
@@ -1066,14 +1114,16 @@ export async function runTriage(input) {
1066
1114
  prompt: composeTriageCategoryPrompt,
1067
1115
  schemaName: "triage category",
1068
1116
  votes: ["ASK", ...triage.categories.map((item) => item.id)],
1069
- })) ??
1070
- "ASK";
1117
+ });
1118
+ const category = resolvedCategory ?? categoryVote?.vote ?? "ASK";
1071
1119
  if (category === "ASK") {
1072
1120
  result = {
1073
1121
  askReason: "category_unclear",
1074
1122
  category: null,
1075
1123
  disposition: "ask",
1076
1124
  };
1125
+ askCommentOutputs = askOutputs(categoryVote?.outputs);
1126
+ markAskComments = false;
1077
1127
  }
1078
1128
  else {
1079
1129
  const categoryConfig = triage.categories.find((item) => item.id === category);
@@ -1081,7 +1131,7 @@ export async function runTriage(input) {
1081
1131
  category: categoryConfig,
1082
1132
  triageContext: context,
1083
1133
  }, null, 2);
1084
- const vote = await runPhaseVote({
1134
+ const acceptance = await runPhaseVote({
1085
1135
  context: voteContext,
1086
1136
  input,
1087
1137
  outputDir,
@@ -1091,22 +1141,35 @@ export async function runTriage(input) {
1091
1141
  schemaName: "triage acceptance",
1092
1142
  votes: BINARY_VOTES,
1093
1143
  });
1144
+ const reporter = triageReporter(input.repository, issue.number);
1145
+ commentReason = chooseDecisionReason({
1146
+ outputs: acceptance.outputs,
1147
+ reporter,
1148
+ vote: acceptance.vote ?? "ASK",
1149
+ });
1094
1150
  result =
1095
- vote === "YES"
1151
+ acceptance.vote === "YES"
1096
1152
  ? { category, disposition: "accepted" }
1097
- : vote === "NO"
1153
+ : acceptance.vote === "NO"
1098
1154
  ? { category, disposition: "rejected" }
1099
1155
  : {
1100
1156
  askReason: "acceptance_unclear",
1101
1157
  category,
1102
1158
  disposition: "ask",
1103
1159
  };
1160
+ if (result.disposition === "ask") {
1161
+ askCommentOutputs = askOutputs(acceptance.outputs);
1162
+ markAskComments = true;
1163
+ }
1104
1164
  }
1105
1165
  }
1106
1166
  return finishWithResult({
1167
+ askOutputs: askCommentOutputs,
1168
+ commentReason,
1107
1169
  context,
1108
1170
  input,
1109
1171
  issue,
1172
+ markAskComments,
1110
1173
  outputDir,
1111
1174
  processed,
1112
1175
  relationship,
@@ -217,8 +217,13 @@ Return exactly one JSON object and nothing else. Do not wrap it in markdown.
217
217
  The object must match this shape:
218
218
  {
219
219
  "vote": ${votes},
220
- "reason": "Short rationale."
220
+ "reason": "Short rationale.",
221
+ "body": "Required only when vote is ASK. Public issue comment body asking for the missing information."
221
222
  }
223
+
224
+ Rules:
225
+ - body is required when vote is ASK and must be written for the issue author.
226
+ - Omit body when vote is not ASK.
222
227
  </output_contract>`.trim();
223
228
  }
224
229
  export const triageDuplicateOutputContract = `
@@ -92,9 +92,14 @@ function parseTriageVote(text, votes) {
92
92
  const data = extractJson(text);
93
93
  if (!data || typeof data !== "object")
94
94
  throw new Error("triage vote output must be an object");
95
+ const vote = requireOneOf(data.vote, "vote", votes);
96
+ const body = data.body == null ? undefined : requireString(data.body, "body");
97
+ if (vote === "ASK" && !body?.trim())
98
+ throw new Error("ASK requires body");
95
99
  return {
100
+ body,
96
101
  reason: requireString(data.reason, "reason"),
97
- vote: requireOneOf(data.vote, "vote", votes),
102
+ vote,
98
103
  };
99
104
  }
100
105
  export function parseTriageExistingPrOutput(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260522033138",
3
+ "version": "0.0.0-dev-20260522071924",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
package/schema.json CHANGED
@@ -116,12 +116,13 @@
116
116
  "triageAgent": {
117
117
  "type": "object",
118
118
  "if": { "not": { "required": ["ref"] } },
119
- "then": { "required": ["model"] },
119
+ "then": { "required": ["model", "account"] },
120
120
  "additionalProperties": false,
121
121
  "properties": {
122
122
  "ref": { "type": "string", "minLength": 1 },
123
123
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
124
124
  "model": { "type": "string", "minLength": 1 },
125
+ "account": { "type": "string", "minLength": 1 },
125
126
  "options": { "type": "object", "additionalProperties": true },
126
127
  "permissions": { "$ref": "#/$defs/permissions" },
127
128
  "persona": { "type": "string" }
@@ -334,7 +335,6 @@
334
335
  "type": "object",
335
336
  "additionalProperties": false,
336
337
  "properties": {
337
- "account": { "type": "string", "minLength": 1 },
338
338
  "agents": {
339
339
  "type": "array",
340
340
  "minItems": 3,
@@ -349,6 +349,7 @@
349
349
  "safety": { "$ref": "#/$defs/triageSafety" },
350
350
  "concurrency": { "$ref": "#/$defs/triageConcurrency" },
351
351
  "prompts": { "$ref": "#/$defs/triagePrompts" },
352
+ "reporter": { "type": "string", "minLength": 1 },
352
353
  "output": { "type": "string" },
353
354
  "worktree": { "type": "string" }
354
355
  }