opencode-magi 0.8.0 → 0.10.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.
package/README.md CHANGED
@@ -17,7 +17,7 @@ OpenCode Magi recreates the review cycle humans already run on GitHub: multiple
17
17
  - Multi-agent reviews with an odd-number majority of 3 or more reviewers.
18
18
  - Optional unanimous approval policy for merge automation when every reviewer must approve before a PR is merged.
19
19
  - Finding-level voting before posting change requests, so only findings accepted by reviewer majority are submitted.
20
- - Each reviewer acts through its configured GitHub account, posting real reviews, approvals, change requests, and follow-up comments.
20
+ - Single-account identity mode by default, where one GitHub account posts consensus-backed review and triage results for multiple logical agents, plus multi-account mode for setups that need separate GitHub identities.
21
21
  - Re-review support for edited PRs: fixed threads are resolved, satisfied reviewers approve, and remaining issues are posted as additional comments.
22
22
  - Optional merge and close automation where an editor agent responds on behalf of the author, fixes changes it agrees with, pushes commits when needed, and repeats the reviewer/editor cycle until the PR can be approved, queued, merged, or closed.
23
23
  - Per-agent OpenCode permissions for reviewer, CI classifier, and editor child sessions.
@@ -61,19 +61,17 @@ Add the following content to the configuration file.
61
61
  ```json
62
62
  {
63
63
  "$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
64
+ "account": "your-account",
64
65
  "agents": {
65
66
  "refs": {
66
67
  "account-1": {
67
- "model": "openai/gpt-5.5",
68
- "account": "account-1"
68
+ "model": "openai/gpt-5.5"
69
69
  },
70
70
  "account-2": {
71
- "model": "anthropic/claude-opus-4-7",
72
- "account": "account-2"
71
+ "model": "anthropic/claude-opus-4-7"
73
72
  },
74
73
  "account-3": {
75
- "model": "opencode/kimi-k2-6",
76
- "account": "account-3"
74
+ "model": "opencode/kimi-k2-6"
77
75
  }
78
76
  }
79
77
  },
@@ -87,7 +85,26 @@ Add the following content to the configuration file.
87
85
  }
88
86
  ```
89
87
 
90
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
88
+ By default, `mode` is `"single"`. Magi uses one top-level `account` to post reviewer- and triage-originated GitHub mutations while still running multiple logical agents and preserving majority voting, finding validation, and close reconsideration. The account must be authenticated with `gh auth token --user <account>`.
89
+
90
+ For advanced team setups that need GitHub to see separate review or triage identities, set top-level `mode: "multi"` and configure unique accounts for each reviewer or triage voter.
91
+
92
+ ```json
93
+ {
94
+ "mode": "multi",
95
+ "review": {
96
+ "reviewers": [
97
+ { "id": "general", "model": "openai/gpt-5.5", "account": "account-1" },
98
+ {
99
+ "id": "security",
100
+ "model": "anthropic/claude-opus-4-7",
101
+ "account": "account-2"
102
+ },
103
+ { "id": "compat", "model": "opencode/kimi-k2-6", "account": "account-3" }
104
+ ]
105
+ }
106
+ }
107
+ ```
91
108
 
92
109
  #### Set project config
93
110
 
@@ -104,6 +121,7 @@ Add the following content to the configuration file.
104
121
  ```json
105
122
  {
106
123
  "$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
124
+ "account": "your-account",
107
125
  "github": {
108
126
  "owner": "your-owner",
109
127
  "repo": "your-repo"
@@ -111,16 +129,13 @@ Add the following content to the configuration file.
111
129
  "agents": {
112
130
  "refs": {
113
131
  "account-1": {
114
- "model": "openai/gpt-5.5",
115
- "account": "account-1"
132
+ "model": "openai/gpt-5.5"
116
133
  },
117
134
  "account-2": {
118
- "model": "anthropic/claude-opus-4-7",
119
- "account": "account-2"
135
+ "model": "anthropic/claude-opus-4-7"
120
136
  },
121
137
  "account-3": {
122
- "model": "opencode/kimi-k2-6",
123
- "account": "account-3"
138
+ "model": "opencode/kimi-k2-6"
124
139
  },
125
140
  "account-4": {
126
141
  "model": "openai/gpt-5.5",
@@ -165,7 +180,7 @@ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` o
165
180
  }
166
181
  ```
167
182
 
168
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
183
+ After `refs` are expanded, top-level `account` is the GitHub account used for reviewer- and triage-originated posts and mutations in `single` mode. In `multi` mode, `review.reviewers[].account` and `triage.voters[].account` are used instead and must be unique within their agent lists. `merge.editor.account` is still used by `/magi:merge` to push fixes, close PRs, and merge PRs.
169
184
 
170
185
  #### Validate config
171
186
 
@@ -113,6 +113,8 @@ export function resolveAgents(config) {
113
113
  const agents = config.agents ?? {};
114
114
  const editor = config.merge?.editor;
115
115
  const creator = config.triage?.creator;
116
+ const singleReviewAccount = config.review && config.mode !== "multi" ? config.account : undefined;
117
+ const singleTriageAccount = config.triage && config.mode !== "multi" ? config.account : undefined;
116
118
  return {
117
119
  editor: editor
118
120
  ? {
@@ -123,6 +125,7 @@ export function resolveAgents(config) {
123
125
  : undefined,
124
126
  reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
125
127
  ...reviewer,
128
+ account: singleReviewAccount ?? reviewer.account ?? "",
126
129
  key: reviewerKey(reviewer, index),
127
130
  index,
128
131
  model: normalizedModel(reviewer.model),
@@ -130,6 +133,7 @@ export function resolveAgents(config) {
130
133
  })),
131
134
  triage: (config.triage?.voters ?? []).map((agent, index) => ({
132
135
  ...agent,
136
+ account: singleTriageAccount ?? agent.account ?? "",
133
137
  key: triageAgentKey(agent, index),
134
138
  index,
135
139
  model: normalizedModel(agent.model),
@@ -138,7 +142,7 @@ export function resolveAgents(config) {
138
142
  triageCreator: creator
139
143
  ? {
140
144
  ...creator,
141
- account: creator.account ?? "",
145
+ account: singleTriageAccount ?? creator.account ?? "",
142
146
  model: normalizedModel(creator.model),
143
147
  permission: resolveTriageCreatorPermission(agents, creator),
144
148
  }
@@ -184,6 +188,8 @@ export function resolveRepository(config) {
184
188
  repo: config.github.repo,
185
189
  },
186
190
  language: config.language,
191
+ account: config.account,
192
+ mode: config.mode ?? "single",
187
193
  merge: {
188
194
  approvalPolicy: config.review?.merge?.approvalPolicy ?? "majority",
189
195
  method: config.review?.merge?.method ?? "squash",
@@ -13,11 +13,13 @@ const TRIAGE_CATEGORY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
13
13
  const RESERVED_TRIAGE_CATEGORY_IDS = new Set(["ASK", "none"]);
14
14
  const CONFIG_KEYS = new Set([
15
15
  "$schema",
16
+ "account",
16
17
  "agents",
17
18
  "clear",
18
19
  "github",
19
20
  "language",
20
21
  "merge",
22
+ "mode",
21
23
  "output",
22
24
  "review",
23
25
  "triage",
@@ -384,7 +386,7 @@ function validateAndNormalizeModel(target, path, errors, catalog) {
384
386
  }
385
387
  errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
386
388
  }
387
- function validateReviewerList(reviewers, path, errors, catalog) {
389
+ function validateReviewerList(reviewers, path, errors, catalog, mode = "single") {
388
390
  if (reviewers == null)
389
391
  return;
390
392
  if (!Array.isArray(reviewers)) {
@@ -404,7 +406,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
404
406
  if (!reviewer.model)
405
407
  errors.push(`${path}[${index}].model is required`);
406
408
  validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
407
- if (!reviewer.account)
409
+ if (mode === "multi" && !reviewer.account)
408
410
  errors.push(`${path}[${index}].account is required`);
409
411
  validateString(reviewer.account, `${path}[${index}].account`, errors);
410
412
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
@@ -419,7 +421,7 @@ function validateReviewerList(reviewers, path, errors, catalog) {
419
421
  }
420
422
  });
421
423
  }
422
- function validateTriageAgentList(voters, path, errors, catalog) {
424
+ function validateTriageAgentList(voters, path, errors, catalog, mode = "single") {
423
425
  if (voters == null)
424
426
  return;
425
427
  if (!Array.isArray(voters)) {
@@ -439,8 +441,10 @@ function validateTriageAgentList(voters, path, errors, catalog) {
439
441
  if (!agent.model)
440
442
  errors.push(`${path}[${index}].model is required`);
441
443
  validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
442
- if (!agent.account)
444
+ if (mode === "multi" && !agent.account)
443
445
  errors.push(`${path}[${index}].account is required`);
446
+ if (mode === "single" && agent.account)
447
+ errors.push(`${path}[${index}].account is not supported in single mode`);
444
448
  validateString(agent.account, `${path}[${index}].account`, errors);
445
449
  validateString(agent.persona, `${path}[${index}].persona`, errors);
446
450
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
@@ -454,26 +458,38 @@ function validateTriageAgentList(voters, path, errors, catalog) {
454
458
  }
455
459
  });
456
460
  }
457
- function validateResolvedReviewers(reviewers, path, errors) {
461
+ function validateResolvedReviewers(reviewers, path, errors, mode = "single") {
458
462
  const keys = new Set();
459
463
  const accounts = new Set();
460
464
  for (const reviewer of reviewers) {
461
465
  if (keys.has(reviewer.key))
462
466
  errors.push(`${path} has duplicate reviewer key: ${reviewer.key}`);
463
467
  keys.add(reviewer.key);
464
- if (accounts.has(reviewer.account))
468
+ if (mode === "multi" && accounts.has(reviewer.account))
465
469
  errors.push(`${path} has duplicate reviewer account: ${reviewer.account}`);
466
470
  accounts.add(reviewer.account);
467
471
  }
468
472
  }
469
- function validateResolvedTriageAgents(agents, path, errors) {
473
+ function reviewMode(config) {
474
+ return config.mode === "multi" ? "multi" : "single";
475
+ }
476
+ function validateReviewIdentity(config, errors) {
477
+ const mode = config.mode;
478
+ if (mode != null && mode !== "multi" && mode !== "single") {
479
+ errors.push("mode must be multi or single");
480
+ }
481
+ if ((mode == null || mode === "single") && !config.account) {
482
+ errors.push("account is required when mode is single");
483
+ }
484
+ }
485
+ function validateResolvedTriageAgents(agents, path, errors, mode = "single") {
470
486
  const keys = new Set();
471
487
  const accounts = new Set();
472
488
  for (const agent of agents) {
473
489
  if (keys.has(agent.key))
474
490
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
475
491
  keys.add(agent.key);
476
- if (accounts.has(agent.account))
492
+ if (mode === "multi" && accounts.has(agent.account))
477
493
  errors.push(`${path} has duplicate agent account: ${agent.account}`);
478
494
  accounts.add(agent.account);
479
495
  }
@@ -517,7 +533,7 @@ function validateEditor(editor, path, errors, catalog) {
517
533
  }
518
534
  }
519
535
  }
520
- function validateTriageCreator(creator, path, errors, catalog) {
536
+ function validateTriageCreator(creator, path, errors, catalog, mode = "single") {
521
537
  if (!creator)
522
538
  return;
523
539
  if (!isPlainObject(creator)) {
@@ -527,6 +543,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
527
543
  validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
528
544
  if (!creator.model)
529
545
  errors.push(`${path}.model is required`);
546
+ if (mode === "single" && creator.account)
547
+ errors.push(`${path}.account is not supported in single mode`);
530
548
  validateString(creator.account, `${path}.account`, errors);
531
549
  validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
532
550
  validateString(creator.persona, `${path}.persona`, errors);
@@ -815,6 +833,7 @@ function validatePromptObject(prompts, path, keys, errors) {
815
833
  }
816
834
  function validateTriage(config, errors, options) {
817
835
  const triage = config.triage;
836
+ const mode = reviewMode(config);
818
837
  if (!triage)
819
838
  return;
820
839
  if (!isPlainObject(triage)) {
@@ -829,7 +848,7 @@ function validateTriage(config, errors, options) {
829
848
  const safety = triage.safety;
830
849
  if (!triage.voters)
831
850
  errors.push("triage.voters is required");
832
- validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
851
+ validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog, mode);
833
852
  if (Array.isArray(triage.voters)) {
834
853
  const resolvedTriageAgents = triage.voters.map((agent, index) => ({
835
854
  account: agent && typeof agent === "object" && typeof agent.account === "string"
@@ -837,17 +856,21 @@ function validateTriage(config, errors, options) {
837
856
  : "",
838
857
  key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
839
858
  }));
840
- validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
841
- if (reporter != null &&
859
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors, mode);
860
+ if (mode === "multi" &&
861
+ reporter != null &&
842
862
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
843
863
  errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
844
864
  }
845
865
  }
866
+ if (mode === "single" && reporter != null) {
867
+ errors.push("triage.reporter is not supported in single mode");
868
+ }
846
869
  validateString(triage.reporter, "triage.reporter", errors);
847
- validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
870
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog, mode);
848
871
  if (automation?.create && !creator)
849
872
  errors.push("triage.creator is required when triage.automation.create is true");
850
- if (automation?.create && creator && !creator.account)
873
+ if (mode === "multi" && automation?.create && creator && !creator.account)
851
874
  errors.push("triage.creator.account is required when triage.automation.create is true");
852
875
  if (automation != null && !isPlainObject(automation)) {
853
876
  errors.push("triage.automation must be an object");
@@ -908,8 +931,14 @@ async function validatePrompts(config, errors, directory) {
908
931
  async function validateAuth(config, exec, errors) {
909
932
  const accounts = new Set();
910
933
  const agents = resolveAgents(config);
911
- for (const reviewer of agents.reviewers)
912
- accounts.add(reviewer.account);
934
+ if (reviewMode(config) === "single") {
935
+ if (config.account)
936
+ accounts.add(config.account);
937
+ }
938
+ else {
939
+ for (const reviewer of agents.reviewers)
940
+ accounts.add(reviewer.account);
941
+ }
913
942
  for (const agent of agents.triage ?? [])
914
943
  accounts.add(agent.account);
915
944
  if (agents.editor)
@@ -956,15 +985,20 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
956
985
  if (!config.github?.owner || !config.github.repo)
957
986
  return;
958
987
  const agents = resolveAgents(config);
959
- await Promise.all(agents.reviewers.map(async (reviewer) => {
988
+ const reviewAccounts = reviewMode(config) === "single"
989
+ ? config.account
990
+ ? [config.account]
991
+ : []
992
+ : agents.reviewers.map((reviewer) => reviewer.account);
993
+ await Promise.all(reviewAccounts.map(async (account) => {
960
994
  try {
961
- const permissions = await fetchPermissions(config, exec, reviewer.account);
995
+ const permissions = await fetchPermissions(config, exec, account);
962
996
  if (!permissions.pull) {
963
- errors.push(`GitHub account cannot read repository for PR review: ${reviewer.account}`);
997
+ errors.push(`GitHub account cannot read repository for PR review: ${account}`);
964
998
  }
965
999
  }
966
1000
  catch (error) {
967
- warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
1001
+ warnings.push(`Could not validate repository permissions for GitHub account: ${account} (${error.message})`);
968
1002
  }
969
1003
  }));
970
1004
  await Promise.all((agents.triage ?? []).map(async (agent) => {
@@ -1014,7 +1048,10 @@ export async function validateConfig(config, options = {}) {
1014
1048
  validateJsonSchema(config, errors);
1015
1049
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
1016
1050
  validateString(config.$schema, "$schema", errors);
1051
+ validateString(config.account, "account", errors);
1017
1052
  validateString(config.language, "language", errors);
1053
+ if (config.review || config.triage)
1054
+ validateReviewIdentity(config, errors);
1018
1055
  if (config.agents != null && !isPlainObject(config.agents)) {
1019
1056
  errors.push("agents must be an object");
1020
1057
  }
@@ -1026,6 +1063,7 @@ export async function validateConfig(config, options = {}) {
1026
1063
  errors.push("review is required");
1027
1064
  }
1028
1065
  else if (config.review) {
1066
+ const mode = reviewMode(config);
1029
1067
  if (!isPlainObject(config.review)) {
1030
1068
  errors.push("review must be an object");
1031
1069
  }
@@ -1034,7 +1072,7 @@ export async function validateConfig(config, options = {}) {
1034
1072
  }
1035
1073
  if (!config.review.reviewers)
1036
1074
  errors.push("review.reviewers is required");
1037
- validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
1075
+ validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
1038
1076
  if (Array.isArray(config.review.reviewers)) {
1039
1077
  validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
1040
1078
  account: reviewer &&
@@ -1045,7 +1083,7 @@ export async function validateConfig(config, options = {}) {
1045
1083
  key: reviewer && typeof reviewer === "object"
1046
1084
  ? reviewerKey(reviewer, index)
1047
1085
  : "",
1048
- })), "review.resolvedReviewers", errors);
1086
+ })), "review.resolvedReviewers", errors, mode);
1049
1087
  }
1050
1088
  }
1051
1089
  if (options.requireTriage && !config.triage) {
@@ -592,8 +592,18 @@ export async function removeWorktree(exec, worktreePath) {
592
592
  export async function removeBranch(exec, branch) {
593
593
  await exec(`git branch -D ${shellQuote(branch)}`);
594
594
  }
595
- export async function postApproval(exec, repository, pr, account) {
595
+ export async function postApproval(exec, repository, pr, account, body) {
596
596
  const token = await ghToken(exec, repository, account);
597
+ if (body != null) {
598
+ const payloadPath = join(tmpdir(), `magi-approve-${process.pid}-${Date.now()}.json`);
599
+ await writeFile(payloadPath, JSON.stringify({ body, event: "APPROVE" }));
600
+ try {
601
+ return await exec(`gh api${ghHostOption(repository)} repos/${repository.github.owner}/${repository.github.repo}/pulls/${pr}/reviews --method POST --input ${shellQuote(payloadPath)} --jq .html_url`, ghTokenEnv(token));
602
+ }
603
+ finally {
604
+ await rm(payloadPath, { force: true });
605
+ }
606
+ }
597
607
  return exec(`gh pr review ${pr} --repo ${shellQuote(repoSpecifier(repository))} --approve`, ghTokenEnv(token));
598
608
  }
599
609
  export async function postCloseComment(exec, repository, pr, account, body) {
@@ -607,9 +617,9 @@ export async function postCloseComment(exec, repository, pr, account, body) {
607
617
  await rm(payloadPath, { force: true });
608
618
  }
609
619
  }
610
- function findingComment(finding) {
620
+ function findingComment(finding, body) {
611
621
  const comment = {
612
- body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
622
+ body: body ?? `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
613
623
  line: finding.line,
614
624
  path: finding.path,
615
625
  side: "RIGHT",
@@ -625,13 +635,13 @@ function changesRequestedBody(findings) {
625
635
  ? "Changes requested: 1 inline comment."
626
636
  : `Changes requested: ${findings.length} inline comments.`;
627
637
  }
628
- export async function postChangesRequested(exec, repository, pr, account, findings) {
638
+ export async function postChangesRequested(exec, repository, pr, account, findings, options = {}) {
629
639
  const token = await ghToken(exec, repository, account);
630
640
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
631
- const body = changesRequestedBody(findings);
641
+ const body = options.body ?? changesRequestedBody(findings);
632
642
  await writeFile(payloadPath, JSON.stringify({
633
643
  body,
634
- comments: findings.map(findingComment),
644
+ comments: findings.map((finding, index) => findingComment(finding, options.commentBodies?.[index])),
635
645
  event: "REQUEST_CHANGES",
636
646
  }));
637
647
  try {
@@ -11,7 +11,7 @@ import { closeMinorityReviewers, mergeVerdictForPolicy } from "./majority";
11
11
  import { runModelWithRepair } from "./model";
12
12
  import { mapPool } from "./pool";
13
13
  import { formatMergeReport } from "./report";
14
- import { inlineCommentTargetsForDiff, runReview, } from "./review";
14
+ import { inlineCommentTargetsForDiff, assignThreadsByReviewFindingMarker, formatReviewMarker, postSingleConsensusReview, runReview, reviewPostingAccount, } from "./review";
15
15
  import { checkSafetyGate, hasSafetyGate } from "./safety";
16
16
  function outputDir(input) {
17
17
  return prRunOutputDir({
@@ -131,6 +131,7 @@ async function postRereviewOutput(input, reviewerKey, output) {
131
131
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
132
132
  if (!reviewer)
133
133
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
134
+ const account = reviewPostingAccount(input.repository, reviewer);
134
135
  if (input.dryRun) {
135
136
  if (output.verdict === "MERGE")
136
137
  return `dry-run:would-approve:${reviewerKey}`;
@@ -139,16 +140,16 @@ async function postRereviewOutput(input, reviewerKey, output) {
139
140
  }
140
141
  return `dry-run:would-request-changes:${reviewerKey}`;
141
142
  }
142
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
143
- const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
143
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
144
+ const replies = await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
144
145
  if (output.verdict === "MERGE") {
145
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
146
+ return postApproval(input.exec, input.repository, input.pr, account);
146
147
  }
147
148
  if (output.verdict === "CLOSE") {
148
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
149
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
149
150
  }
150
151
  if (output.newFindings.length) {
151
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
152
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
152
153
  fix: "Please address this before merging.",
153
154
  issue: finding.body,
154
155
  path: finding.path,
@@ -212,10 +213,23 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
212
213
  worktreePath,
213
214
  });
214
215
  const artifactDir = outputDir(input);
216
+ const singleReviewMode = input.repository.mode !== "multi";
217
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
218
+ const singleModeThreads = singleReviewMode
219
+ ? assignThreadsByReviewFindingMarker({
220
+ fallbackReviewerKeys: reviewerKeys,
221
+ pr: input.pr,
222
+ reviewerKeys,
223
+ threads: options.dryRunThreads == null
224
+ ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.account ?? "")
225
+ : Object.values(options.dryRunThreads).flat(),
226
+ })
227
+ : undefined;
215
228
  let entries = await mapPool(input.repository.agents.reviewers, input.repository.concurrency.reviewers, async (reviewer) => {
216
229
  throwIfAborted(input.signal);
217
- const unresolved = options.dryRunThreads?.[reviewer.key] ??
218
- (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewer.account));
230
+ const unresolved = singleModeThreads?.[reviewer.key] ??
231
+ options.dryRunThreads?.[reviewer.key] ??
232
+ (await fetchUnresolvedThreads(input.exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
219
233
  const hasReviewerSession = Boolean(sessionIds[reviewer.key]);
220
234
  const prompt = await composeRereviewPrompt({
221
235
  baseSha: meta.baseRefOid,
@@ -388,14 +402,44 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
388
402
  };
389
403
  }));
390
404
  }
391
- const posted = Object.fromEntries(await Promise.all(entries.map(async (entry) => [
392
- entry.reviewer,
393
- await postRereviewOutput(input, entry.reviewer, entry.output),
394
- ])));
395
405
  const verdict = mergeVerdictForPolicy(entries.map((entry) => ({
396
406
  reviewer: entry.reviewer,
397
407
  verdict: entry.verdict,
398
408
  })), input.repository.merge.approvalPolicy);
409
+ const outputs = Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output]));
410
+ const posted = singleReviewMode
411
+ ? input.dryRun
412
+ ? { consensus: `dry-run:would-post-single-review:${verdict}` }
413
+ : {
414
+ consensus: await (async () => {
415
+ const account = input.repository.account ?? "";
416
+ await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
417
+ await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
418
+ `**Reviewer:** ${entry.reviewer}`,
419
+ "",
420
+ item.body,
421
+ "",
422
+ formatReviewMarker({
423
+ head: headSha,
424
+ pr: input.pr,
425
+ reviewer: entry.reviewer,
426
+ verdict: entry.output.verdict,
427
+ }),
428
+ ].join("\n")))));
429
+ return postSingleConsensusReview({
430
+ exec: input.exec,
431
+ headSha,
432
+ outputs,
433
+ pr: input.pr,
434
+ repository: input.repository,
435
+ verdict,
436
+ });
437
+ })(),
438
+ }
439
+ : Object.fromEntries(await Promise.all(entries.map(async (entry) => [
440
+ entry.reviewer,
441
+ await postRereviewOutput(input, entry.reviewer, entry.output),
442
+ ])));
399
443
  await writeFile(join(artifactDir, `rereview-majority.cycle-${cycle}.json`), JSON.stringify({
400
444
  approvalPolicy: input.repository.merge.approvalPolicy,
401
445
  verdict,
@@ -405,7 +449,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
405
449
  })),
406
450
  }, null, 2));
407
451
  return {
408
- outputs: Object.fromEntries(entries.map((entry) => [entry.reviewer, entry.output])),
452
+ outputs,
409
453
  posted,
410
454
  verdict,
411
455
  };
@@ -15,6 +15,87 @@ import { mapPool } from "./pool";
15
15
  import { formatReviewReport } from "./report";
16
16
  import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
17
17
  import { checkSafetyGate, hasSafetyGate } from "./safety";
18
+ function resolvedReviewMode(repository) {
19
+ return repository.mode === "multi" ? "multi" : "single";
20
+ }
21
+ export function reviewPostingAccount(repository, reviewer) {
22
+ if (resolvedReviewMode(repository) !== "single")
23
+ return reviewer.account;
24
+ if (repository.account)
25
+ return repository.account;
26
+ throw new Error("account is required for single review mode");
27
+ }
28
+ function reviewAssignmentKey(repository, reviewer) {
29
+ return resolvedReviewMode(repository) === "single"
30
+ ? reviewer.key
31
+ : reviewer.account;
32
+ }
33
+ function parseMarkerFields(text) {
34
+ const fields = Object.fromEntries(text
35
+ .trim()
36
+ .split(/\s+/)
37
+ .flatMap((part) => {
38
+ const index = part.indexOf("=");
39
+ return index > 0 ? [[part.slice(0, index), part.slice(index + 1)]] : [];
40
+ }));
41
+ return fields.v === "1" && fields.mode === "single" ? fields : undefined;
42
+ }
43
+ function isMarkerVerdict(value) {
44
+ return value === "CHANGES_REQUESTED" || value === "CLOSE" || value === "MERGE";
45
+ }
46
+ export function formatReviewMarker(marker) {
47
+ return `<!-- opencode-magi:review v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} verdict=${marker.verdict} head=${marker.head} -->`;
48
+ }
49
+ export function parseReviewMarkers(body) {
50
+ const markers = [];
51
+ const regex = /<!--\s*opencode-magi:review\s+([^>]*)-->/g;
52
+ for (const match of body?.matchAll(regex) ?? []) {
53
+ const fields = parseMarkerFields(match[1] ?? "");
54
+ const pr = Number(fields?.pr);
55
+ if (!fields ||
56
+ !Number.isInteger(pr) ||
57
+ !fields.reviewer ||
58
+ !fields.head ||
59
+ !isMarkerVerdict(fields.verdict)) {
60
+ continue;
61
+ }
62
+ markers.push({
63
+ head: fields.head,
64
+ pr,
65
+ reviewer: fields.reviewer,
66
+ verdict: fields.verdict,
67
+ });
68
+ }
69
+ return markers;
70
+ }
71
+ export function formatReviewFindingMarker(marker) {
72
+ return `<!-- opencode-magi:review-finding v=1 mode=single pr=${marker.pr} reviewer=${marker.reviewer} finding=${marker.finding} head=${marker.head} -->`;
73
+ }
74
+ export function parseReviewFindingMarkers(body) {
75
+ const markers = [];
76
+ const regex = /<!--\s*opencode-magi:review-finding\s+([^>]*)-->/g;
77
+ for (const match of body?.matchAll(regex) ?? []) {
78
+ const fields = parseMarkerFields(match[1] ?? "");
79
+ const pr = Number(fields?.pr);
80
+ const finding = Number(fields?.finding);
81
+ if (!fields ||
82
+ !Number.isInteger(pr) ||
83
+ !Number.isInteger(finding) ||
84
+ !fields.reviewer ||
85
+ !fields.head) {
86
+ continue;
87
+ }
88
+ markers.push({ finding, head: fields.head, pr, reviewer: fields.reviewer });
89
+ }
90
+ return markers;
91
+ }
92
+ function markerReviewState(verdict) {
93
+ if (verdict === "MERGE")
94
+ return "APPROVED";
95
+ if (verdict === "CHANGES_REQUESTED")
96
+ return "CHANGES_REQUESTED";
97
+ return "CLOSE";
98
+ }
18
99
  function errorMessage(error) {
19
100
  return error instanceof Error ? error.message : String(error);
20
101
  }
@@ -35,11 +116,12 @@ async function postReviewOutput(input, reviewerKey, output) {
35
116
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
36
117
  if (!reviewer)
37
118
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
119
+ const account = reviewPostingAccount(input.repository, reviewer);
38
120
  if (output.verdict === "MERGE")
39
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
121
+ return postApproval(input.exec, input.repository, input.pr, account);
40
122
  if (output.verdict === "CLOSE")
41
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
42
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
123
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
124
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.findings);
43
125
  }
44
126
  function dryRunReviewPost(key, output) {
45
127
  if (output.verdict === "MERGE")
@@ -86,6 +168,56 @@ export function resolveReviewMode(reviews, accounts, current, accountsWithPendin
86
168
  return { assignments, type: "already_reviewed" };
87
169
  return { assignments, type: "active" };
88
170
  }
171
+ export function resolveSingleAccountReviewMode(input) {
172
+ const reviewerKeySet = new Set(input.reviewerKeys);
173
+ const pendingReviewers = input.pendingReviewers ?? new Set();
174
+ const latest = new Map();
175
+ for (const review of input.reviews) {
176
+ if (review.author.login !== input.account)
177
+ continue;
178
+ if (review.state === "DISMISSED")
179
+ continue;
180
+ for (const marker of parseReviewMarkers(review.body)) {
181
+ if (marker.pr !== input.pr || !reviewerKeySet.has(marker.reviewer)) {
182
+ continue;
183
+ }
184
+ const synthetic = {
185
+ ...review,
186
+ commit: { oid: marker.head },
187
+ comments: (review.comments ?? []).filter((comment) => parseReviewFindingMarkers(comment.body).some((findingMarker) => findingMarker.pr === input.pr &&
188
+ findingMarker.reviewer === marker.reviewer &&
189
+ findingMarker.head === marker.head)),
190
+ state: markerReviewState(marker.verdict),
191
+ };
192
+ const current = latest.get(marker.reviewer);
193
+ if (!current ||
194
+ current.submittedAt.localeCompare(review.submittedAt) < 0) {
195
+ latest.set(marker.reviewer, synthetic);
196
+ }
197
+ }
198
+ }
199
+ const reviewedHead = input.reviewerKeys.every((reviewer) => {
200
+ return (isReviewCurrent(latest.get(reviewer), input.current) &&
201
+ !pendingReviewers.has(reviewer));
202
+ });
203
+ const assignments = new Map();
204
+ for (const reviewer of input.reviewerKeys) {
205
+ const review = latest.get(reviewer);
206
+ if (!review) {
207
+ assignments.set(reviewer, { type: "initial" });
208
+ continue;
209
+ }
210
+ if (isReviewCurrent(review, input.current) &&
211
+ !pendingReviewers.has(reviewer)) {
212
+ assignments.set(reviewer, { review, type: "skip" });
213
+ continue;
214
+ }
215
+ assignments.set(reviewer, { review, type: "rereview" });
216
+ }
217
+ if (latest.size && reviewedHead)
218
+ return { assignments, type: "already_reviewed" };
219
+ return { assignments, type: "active" };
220
+ }
89
221
  export function reviewFreshnessTarget(commits, headSha) {
90
222
  const latestNonMerge = [...commits]
91
223
  .reverse()
@@ -112,6 +244,8 @@ function reviewStateToVerdict(state) {
112
244
  return "MERGE";
113
245
  if (state === "CHANGES_REQUESTED")
114
246
  return "CHANGES_REQUESTED";
247
+ if (state === "CLOSE")
248
+ return "CLOSE";
115
249
  throw new Error(`Unsupported GitHub review state: ${state}`);
116
250
  }
117
251
  function hasBlockingCiReports(reports) {
@@ -295,7 +429,10 @@ function reviewFindingsFromBody(body) {
295
429
  return { findings };
296
430
  }
297
431
  function parsePostedFindingComment(body) {
298
- const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]+?)\s*$/.exec(body);
432
+ const visibleBody = body
433
+ .replace(/<!--\s*opencode-magi:review-finding\s+[^>]*-->/g, "")
434
+ .trim();
435
+ const match = /^\*\*Issue:\*\*\s*([\s\S]*?)\s*\r?\n\r?\n\*\*Fix:\*\*\s*([\s\S]*?)(?:\s*\r?\n\r?\n\*\*Reviewer:\*\*[\s\S]*)?\s*$/.exec(visibleBody);
299
436
  if (!match)
300
437
  return undefined;
301
438
  return {
@@ -350,19 +487,136 @@ export function hasPendingThreadReply(threads, reviewerAccount) {
350
487
  comment.createdAt.localeCompare(latestReviewerComment.createdAt) > 0);
351
488
  });
352
489
  }
490
+ export function assignThreadsByReviewFindingMarker(input) {
491
+ const reviewerKeys = new Set(input.reviewerKeys);
492
+ const assigned = Object.fromEntries(input.reviewerKeys.map((reviewer) => [reviewer, []]));
493
+ for (const thread of input.threads) {
494
+ const markers = [
495
+ thread.body,
496
+ thread.latestBody,
497
+ ...thread.comments.map((comment) => comment.body),
498
+ ]
499
+ .flatMap(parseReviewFindingMarkers)
500
+ .filter((marker) => {
501
+ return (marker.pr === input.pr &&
502
+ reviewerKeys.has(marker.reviewer) &&
503
+ (!input.headSha || marker.head === input.headSha));
504
+ });
505
+ const reviewers = markers.length
506
+ ? [...new Set(markers.map((marker) => marker.reviewer))]
507
+ : input.fallbackReviewerKeys;
508
+ for (const reviewer of reviewers)
509
+ assigned[reviewer]?.push(thread);
510
+ }
511
+ return assigned;
512
+ }
513
+ function outputFindings(reviewer, output) {
514
+ if (output.verdict !== "CHANGES_REQUESTED")
515
+ return [];
516
+ if ("findings" in output) {
517
+ return output.findings.map((finding, index) => ({
518
+ finding,
519
+ index,
520
+ reviewer,
521
+ }));
522
+ }
523
+ return output.newFindings.map((finding, index) => ({
524
+ finding: {
525
+ fix: "Please address this before merging.",
526
+ issue: finding.body,
527
+ line: finding.line,
528
+ path: finding.path,
529
+ startLine: finding.startLine,
530
+ },
531
+ index,
532
+ reviewer,
533
+ }));
534
+ }
535
+ function singleReviewBody(input) {
536
+ const outputs = Object.entries(input.outputs).sort(([a], [b]) => a.localeCompare(b));
537
+ const closeReasons = outputs.flatMap(([reviewer, output]) => output.verdict === "CLOSE"
538
+ ? [`- ${reviewer}: ${output.reason ?? "Close requested."}`]
539
+ : []);
540
+ const acceptedFindings = outputs.flatMap(([reviewer, output]) => outputFindings(reviewer, output).map(({ finding, index }) => {
541
+ const line = finding.startLine == null || finding.startLine === finding.line
542
+ ? String(finding.line)
543
+ : `${finding.startLine}-${finding.line}`;
544
+ return `- ${reviewer} #${index + 1} ${finding.path}:${line}: ${finding.issue} Fix: ${finding.fix}`;
545
+ }));
546
+ const lines = [
547
+ `Magi single-account review result: ${input.verdict}.`,
548
+ "",
549
+ "Logical reviewer verdicts:",
550
+ ...outputs.map(([reviewer, output]) => `- ${reviewer}: ${output.verdict}`),
551
+ ...(input.verdict === "CLOSE" && closeReasons.length
552
+ ? ["", "Close reasons:", ...closeReasons]
553
+ : []),
554
+ ...(input.verdict === "CHANGES_REQUESTED" && acceptedFindings.length
555
+ ? ["", "Accepted change requests:", ...acceptedFindings]
556
+ : []),
557
+ "",
558
+ ...outputs.map(([reviewer, output]) => formatReviewMarker({
559
+ head: input.headSha,
560
+ pr: input.pr,
561
+ reviewer,
562
+ verdict: output.verdict,
563
+ })),
564
+ ];
565
+ return lines.join("\n");
566
+ }
567
+ function singleFindingBody(input) {
568
+ return [
569
+ `**Issue:** ${input.finding.issue}`,
570
+ "",
571
+ `**Fix:** ${input.finding.fix}`,
572
+ "",
573
+ `**Reviewer:** ${input.reviewer}`,
574
+ "",
575
+ formatReviewFindingMarker({
576
+ finding: input.index,
577
+ head: input.headSha,
578
+ pr: input.pr,
579
+ reviewer: input.reviewer,
580
+ }),
581
+ ].join("\n");
582
+ }
583
+ export async function postSingleConsensusReview(input) {
584
+ const account = input.repository.account;
585
+ if (!account)
586
+ throw new Error("account is required for single review mode");
587
+ const body = singleReviewBody(input);
588
+ if (input.verdict === "MERGE") {
589
+ return postApproval(input.exec, input.repository, input.pr, account, body);
590
+ }
591
+ if (input.verdict === "CLOSE") {
592
+ return postCloseComment(input.exec, input.repository, input.pr, account, body);
593
+ }
594
+ const findings = Object.entries(input.outputs).flatMap(([reviewer, output]) => outputFindings(reviewer, output));
595
+ return postChangesRequested(input.exec, input.repository, input.pr, account, findings.map((item) => item.finding), {
596
+ body,
597
+ commentBodies: findings.map((item) => singleFindingBody({
598
+ finding: item.finding,
599
+ headSha: input.headSha,
600
+ index: item.index,
601
+ pr: input.pr,
602
+ reviewer: item.reviewer,
603
+ })),
604
+ });
605
+ }
353
606
  async function postRereviewOutput(input, reviewerKey, output) {
354
607
  const reviewer = input.repository.agents.reviewers.find((item) => item.key === reviewerKey);
355
608
  if (!reviewer)
356
609
  throw new Error(`Unknown reviewer: ${reviewerKey}`);
357
- await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, reviewer.account, item.threadId)));
358
- await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, reviewer.account, item.commentId, item.body)));
610
+ const account = reviewPostingAccount(input.repository, reviewer);
611
+ await Promise.all(output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId)));
612
+ await Promise.all(output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, item.body)));
359
613
  if (output.verdict === "MERGE")
360
- return postApproval(input.exec, input.repository, input.pr, reviewer.account);
614
+ return postApproval(input.exec, input.repository, input.pr, account);
361
615
  if (output.verdict === "CLOSE")
362
- return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
616
+ return postCloseComment(input.exec, input.repository, input.pr, account, output.reason ?? "Close requested.");
363
617
  if (!output.newFindings.length)
364
618
  return "";
365
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
619
+ return postChangesRequested(input.exec, input.repository, input.pr, account, output.newFindings.map((finding) => ({
366
620
  fix: "Please address this before merging.",
367
621
  issue: finding.body,
368
622
  path: finding.path,
@@ -670,23 +924,68 @@ export async function runReview(input) {
670
924
  const reviews = await fetchPullRequestReviews(exec, input.repository, input.pr);
671
925
  const commits = await fetchPullRequestCommits(exec, input.repository, input.pr);
672
926
  const freshnessTarget = reviewFreshnessTarget(commits, meta.headRefOid);
927
+ const singleReviewMode = resolvedReviewMode(input.repository) === "single";
928
+ const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
673
929
  const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
674
- const preliminaryMode = resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
930
+ const preliminaryMode = singleReviewMode
931
+ ? resolveSingleAccountReviewMode({
932
+ account: input.repository.account ?? "",
933
+ current: freshnessTarget,
934
+ pr: input.pr,
935
+ reviewerKeys,
936
+ reviews,
937
+ })
938
+ : resolveReviewMode(reviews, reviewerAccounts, freshnessTarget);
675
939
  const unresolvedThreadsByAccount = new Map();
940
+ const unresolvedThreadsByReviewer = new Map();
676
941
  const pendingThreadReplyAccounts = new Set();
942
+ const pendingThreadReplyReviewers = new Set();
677
943
  const skippedReviewers = input.repository.agents.reviewers.filter((reviewer) => {
678
- return preliminaryMode.assignments.get(reviewer.account)?.type === "skip";
944
+ return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
679
945
  });
680
- await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
681
- const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
682
- unresolvedThreadsByAccount.set(reviewer.account, threads);
683
- if (hasPendingThreadReply(threads, reviewer.account)) {
684
- pendingThreadReplyAccounts.add(reviewer.account);
946
+ if (singleReviewMode) {
947
+ const account = input.repository.account ?? "";
948
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
949
+ const assigned = assignThreadsByReviewFindingMarker({
950
+ fallbackReviewerKeys: reviewerKeys,
951
+ pr: input.pr,
952
+ reviewerKeys,
953
+ threads,
954
+ });
955
+ for (const reviewer of input.repository.agents.reviewers) {
956
+ const reviewerThreads = assigned[reviewer.key] ?? [];
957
+ unresolvedThreadsByReviewer.set(reviewer.key, reviewerThreads);
958
+ if (preliminaryMode.assignments.get(reviewer.key)?.type !== "skip") {
959
+ continue;
960
+ }
961
+ if (hasPendingThreadReply(reviewerThreads, account)) {
962
+ pendingThreadReplyReviewers.add(reviewer.key);
963
+ }
685
964
  }
686
- }, { signal: input.signal });
687
- const mode = pendingThreadReplyAccounts.size
688
- ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
689
- : preliminaryMode;
965
+ }
966
+ else {
967
+ await mapPool(skippedReviewers, input.repository.concurrency.reviewers, async (reviewer) => {
968
+ const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account);
969
+ unresolvedThreadsByAccount.set(reviewer.account, threads);
970
+ if (hasPendingThreadReply(threads, reviewer.account)) {
971
+ pendingThreadReplyAccounts.add(reviewer.account);
972
+ }
973
+ }, { signal: input.signal });
974
+ }
975
+ const mode = singleReviewMode
976
+ ? pendingThreadReplyReviewers.size
977
+ ? resolveSingleAccountReviewMode({
978
+ account: input.repository.account ?? "",
979
+ current: freshnessTarget,
980
+ pendingReviewers: pendingThreadReplyReviewers,
981
+ pr: input.pr,
982
+ reviewerKeys,
983
+ reviews,
984
+ })
985
+ : preliminaryMode
986
+ : pendingThreadReplyAccounts.size
987
+ ? resolveReviewMode(reviews, reviewerAccounts, freshnessTarget, pendingThreadReplyAccounts)
988
+ : preliminaryMode;
690
989
  if (mode.type === "already_reviewed" && !input.allowAlreadyReviewed)
691
990
  throw new Error("PR has already been reviewed by all configured accounts");
692
991
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
@@ -776,7 +1075,7 @@ export async function runReview(input) {
776
1075
  try {
777
1076
  throwIfAborted(input.signal);
778
1077
  const activeReviewers = input.repository.agents.reviewers.flatMap((reviewer) => {
779
- const assignment = mode.assignments.get(reviewer.account);
1078
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
780
1079
  if (!assignment || assignment.type === "skip")
781
1080
  return [];
782
1081
  return [{ assignment, reviewer }];
@@ -801,7 +1100,7 @@ export async function runReview(input) {
801
1100
  worktreePath,
802
1101
  });
803
1102
  for (const reviewer of input.repository.agents.reviewers) {
804
- const assignment = mode.assignments.get(reviewer.account);
1103
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
805
1104
  if (assignment?.type !== "skip")
806
1105
  continue;
807
1106
  await input.onProgress?.({
@@ -834,8 +1133,9 @@ export async function runReview(input) {
834
1133
  const rereviewInlineCommentTargets = mergeConflictContext
835
1134
  ? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
836
1135
  : inlineCommentTargets;
837
- const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
838
- (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
1136
+ const unresolved = unresolvedThreadsByReviewer.get(reviewer.key) ??
1137
+ unresolvedThreadsByAccount.get(reviewer.account) ??
1138
+ (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewPostingAccount(input.repository, reviewer)));
839
1139
  const prompt = await composeRereviewPrompt({
840
1140
  baseSha: meta.baseRefOid,
841
1141
  ciFailureContext,
@@ -981,7 +1281,7 @@ export async function runReview(input) {
981
1281
  throwIfAborted(input.signal);
982
1282
  const sessionIds = Object.fromEntries(entries.map((entry) => [entry.key, entry.sessionId]));
983
1283
  const skippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
984
- const assignment = mode.assignments.get(reviewer.account);
1284
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
985
1285
  if (assignment?.type !== "skip")
986
1286
  return [];
987
1287
  return [
@@ -999,7 +1299,7 @@ export async function runReview(input) {
999
1299
  })),
1000
1300
  ]);
1001
1301
  const skippedCloseEntries = input.repository.agents.reviewers.flatMap((reviewer) => {
1002
- const assignment = mode.assignments.get(reviewer.account);
1302
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1003
1303
  if (assignment?.type !== "skip" || !closeTargets.includes(reviewer.key))
1004
1304
  return [];
1005
1305
  return [
@@ -1033,14 +1333,14 @@ export async function runReview(input) {
1033
1333
  });
1034
1334
  const activeOutputs = validation.outputs;
1035
1335
  const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1036
- const assignment = mode.assignments.get(reviewer.account);
1336
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1037
1337
  return assignment?.type === "skip"
1038
1338
  ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
1039
1339
  : [];
1040
1340
  }));
1041
1341
  const outputs = { ...skippedOutputs, ...activeOutputs };
1042
1342
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
1043
- const assignment = mode.assignments.get(reviewer.account);
1343
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1044
1344
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
1045
1345
  return [];
1046
1346
  return [
@@ -1056,23 +1356,68 @@ export async function runReview(input) {
1056
1356
  }));
1057
1357
  const verdict = mergeVerdictForPolicy([...remainingSkippedVerdicts, ...activeVerdicts], input.approvalPolicy ?? "majority");
1058
1358
  await input.onProgress?.({ phase: "posting reviews", type: "phase" });
1059
- const posted = {
1060
- ...Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1061
- const assignment = mode.assignments.get(reviewer.account);
1062
- return assignment?.type === "skip"
1063
- ? [[reviewer.key, "skipped: already reviewed current head"]]
1064
- : [];
1065
- })),
1066
- ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
1067
- key,
1068
- input.dryRun
1069
- ? dryRunReviewPost(key, output)
1070
- : "resolve" in output
1071
- ? await postRereviewOutput({ ...input, exec }, key, output)
1072
- : await postReviewOutput({ ...input, exec }, key, output),
1073
- ]))),
1074
- };
1075
- const automationAccount = input.repository.agents.reviewers[0]?.account;
1359
+ const skippedPosted = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
1360
+ const assignment = mode.assignments.get(reviewAssignmentKey(input.repository, reviewer));
1361
+ return assignment?.type === "skip"
1362
+ ? [[reviewer.key, "skipped: already reviewed current head"]]
1363
+ : [];
1364
+ }));
1365
+ const posted = singleReviewMode
1366
+ ? {
1367
+ ...skippedPosted,
1368
+ ...(Object.keys(activeOutputs).length
1369
+ ? {
1370
+ consensus: input.dryRun
1371
+ ? `dry-run:would-post-single-review:${verdict}`
1372
+ : await (async () => {
1373
+ const account = input.repository.account ?? "";
1374
+ await Promise.all(Object.values(activeOutputs).flatMap((output) => {
1375
+ if (!("resolve" in output))
1376
+ return [];
1377
+ return output.resolve.map((item) => resolveThread(exec, input.repository, account, item.threadId));
1378
+ }));
1379
+ await Promise.all(Object.entries(activeOutputs).flatMap(([key, output]) => {
1380
+ if (!("followUps" in output))
1381
+ return [];
1382
+ return output.followUps.map((item) => postReply(exec, input.repository, input.pr, account, item.commentId, [
1383
+ `**Reviewer:** ${key}`,
1384
+ "",
1385
+ item.body,
1386
+ "",
1387
+ formatReviewMarker({
1388
+ head: meta.headRefOid,
1389
+ pr: input.pr,
1390
+ reviewer: key,
1391
+ verdict: output.verdict,
1392
+ }),
1393
+ ].join("\n")));
1394
+ }));
1395
+ return postSingleConsensusReview({
1396
+ exec,
1397
+ headSha: meta.headRefOid,
1398
+ outputs,
1399
+ pr: input.pr,
1400
+ repository: input.repository,
1401
+ verdict,
1402
+ });
1403
+ })(),
1404
+ }
1405
+ : {}),
1406
+ }
1407
+ : {
1408
+ ...skippedPosted,
1409
+ ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
1410
+ key,
1411
+ input.dryRun
1412
+ ? dryRunReviewPost(key, output)
1413
+ : "resolve" in output
1414
+ ? await postRereviewOutput({ ...input, exec }, key, output)
1415
+ : await postReviewOutput({ ...input, exec }, key, output),
1416
+ ]))),
1417
+ };
1418
+ const automationAccount = singleReviewMode
1419
+ ? input.repository.account
1420
+ : input.repository.agents.reviewers[0]?.account;
1076
1421
  const enableReviewAutomation = input.enableReviewAutomation ?? true;
1077
1422
  if (enableReviewAutomation &&
1078
1423
  verdict === "MERGE" &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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>",
@@ -28,26 +28,26 @@
28
28
  "schema.json"
29
29
  ],
30
30
  "dependencies": {
31
- "@opencode-ai/plugin": "^1.15.5",
31
+ "@opencode-ai/plugin": "^1.15.10",
32
32
  "ajv": "^8.20.0",
33
33
  "picomatch": "^4.0.4",
34
- "valibot": "^1.4.0"
34
+ "valibot": "^1.4.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@changesets/changelog-github": "^0.7.0",
38
38
  "@changesets/cli": "^2.30.0",
39
39
  "@commitlint/cli": "^21.0.1",
40
40
  "@commitlint/config-conventional": "^21.0.1",
41
- "@types/node": "^25.9.0",
41
+ "@types/node": "^25.9.1",
42
42
  "@types/picomatch": "^4.0.3",
43
- "@typescript/native-preview": "7.0.0-dev.20260518.1",
43
+ "@typescript/native-preview": "7.0.0-dev.20260525.1",
44
44
  "@vitest/coverage-v8": "^4.1.7",
45
45
  "@vitest/ui": "^4.1.7",
46
46
  "eslint-plugin-perfectionist": "^5.9.0",
47
47
  "eslint-plugin-unused-imports": "^4.4.1",
48
- "lefthook": "^2.1.6",
49
- "oxfmt": "^0.50.0",
50
- "oxlint": "^1.65.0",
48
+ "lefthook": "^2.1.8",
49
+ "oxfmt": "^0.51.0",
50
+ "oxlint": "^1.66.0",
51
51
  "oxlint-tsgolint": "^0.23.0",
52
52
  "rimraf": "^6.1.3",
53
53
  "vitest": "^4.1.7"
package/schema.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "additionalProperties": false,
6
6
  "properties": {
7
7
  "$schema": { "type": "string" },
8
+ "account": { "type": "string", "minLength": 1 },
8
9
  "agents": {
9
10
  "type": "object",
10
11
  "additionalProperties": false,
@@ -44,6 +45,7 @@
44
45
  },
45
46
  "language": { "type": "string" },
46
47
  "merge": { "$ref": "#/$defs/merge" },
48
+ "mode": { "enum": ["multi", "single"], "default": "single" },
47
49
  "output": {
48
50
  "type": "object",
49
51
  "additionalProperties": false,
@@ -77,7 +79,7 @@
77
79
  "reviewer": {
78
80
  "type": "object",
79
81
  "if": { "not": { "required": ["ref"] } },
80
- "then": { "required": ["model", "account"] },
82
+ "then": { "required": ["model"] },
81
83
  "additionalProperties": false,
82
84
  "properties": {
83
85
  "ref": { "type": "string", "minLength": 1 },
@@ -113,7 +115,7 @@
113
115
  "triageAgent": {
114
116
  "type": "object",
115
117
  "if": { "not": { "required": ["ref"] } },
116
- "then": { "required": ["model", "account"] },
118
+ "then": { "required": ["model"] },
117
119
  "additionalProperties": false,
118
120
  "properties": {
119
121
  "ref": { "type": "string", "minLength": 1 },