opencode-magi 0.9.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
- - Multi-account review mode where each reviewer posts through its configured GitHub account, plus single-account review mode where one GitHub account posts the consensus result for multiple logical reviewers.
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,6 +61,7 @@ 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": {
@@ -75,7 +76,6 @@ Add the following content to the configuration file.
75
76
  }
76
77
  },
77
78
  "review": {
78
- "account": "your-account",
79
79
  "reviewers": [
80
80
  { "ref": "account-1" },
81
81
  { "ref": "account-2" },
@@ -85,14 +85,14 @@ Add the following content to the configuration file.
85
85
  }
86
86
  ```
87
87
 
88
- By default, `review.mode` is `"single"`. Magi uses one `review.account` to post reviewer-originated GitHub mutations while still running multiple logical reviewer agents and preserving majority voting, finding validation, and close reconsideration. The account must be authenticated with `gh auth token --user <account>`.
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
89
 
90
- For team setups that need separate GitHub review identities, set `review.mode: "multi"` and configure a unique account for each reviewer.
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
91
 
92
92
  ```json
93
93
  {
94
+ "mode": "multi",
94
95
  "review": {
95
- "mode": "multi",
96
96
  "reviewers": [
97
97
  { "id": "general", "model": "openai/gpt-5.5", "account": "account-1" },
98
98
  {
@@ -121,6 +121,7 @@ Add the following content to the configuration file.
121
121
  ```json
122
122
  {
123
123
  "$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
124
+ "account": "your-account",
124
125
  "github": {
125
126
  "owner": "your-owner",
126
127
  "repo": "your-repo"
@@ -128,16 +129,13 @@ Add the following content to the configuration file.
128
129
  "agents": {
129
130
  "refs": {
130
131
  "account-1": {
131
- "model": "openai/gpt-5.5",
132
- "account": "account-1"
132
+ "model": "openai/gpt-5.5"
133
133
  },
134
134
  "account-2": {
135
- "model": "anthropic/claude-opus-4-7",
136
- "account": "account-2"
135
+ "model": "anthropic/claude-opus-4-7"
137
136
  },
138
137
  "account-3": {
139
- "model": "opencode/kimi-k2-6",
140
- "account": "account-3"
138
+ "model": "opencode/kimi-k2-6"
141
139
  },
142
140
  "account-4": {
143
141
  "model": "openai/gpt-5.5",
@@ -182,7 +180,7 @@ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` o
182
180
  }
183
181
  ```
184
182
 
185
- After `refs` are expanded, `review.reviewers[].account` is the GitHub account used to post reviews and approvals in `multi` mode. Must be authenticated with `gh auth token --user <account>` and must be unique. In `single` mode, `review.account` is used for reviewer-originated review posts, approvals, change requests, close comments, reviewer replies, and reviewer thread resolutions. `merge.editor.account` is still 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.
186
184
 
187
185
  #### Validate config
188
186
 
@@ -113,9 +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.review.mode !== "multi"
117
- ? config.review.account
118
- : undefined;
116
+ const singleReviewAccount = config.review && config.mode !== "multi" ? config.account : undefined;
117
+ const singleTriageAccount = config.triage && config.mode !== "multi" ? config.account : undefined;
119
118
  return {
120
119
  editor: editor
121
120
  ? {
@@ -134,6 +133,7 @@ export function resolveAgents(config) {
134
133
  })),
135
134
  triage: (config.triage?.voters ?? []).map((agent, index) => ({
136
135
  ...agent,
136
+ account: singleTriageAccount ?? agent.account ?? "",
137
137
  key: triageAgentKey(agent, index),
138
138
  index,
139
139
  model: normalizedModel(agent.model),
@@ -142,7 +142,7 @@ export function resolveAgents(config) {
142
142
  triageCreator: creator
143
143
  ? {
144
144
  ...creator,
145
- account: creator.account ?? "",
145
+ account: singleTriageAccount ?? creator.account ?? "",
146
146
  model: normalizedModel(creator.model),
147
147
  permission: resolveTriageCreatorPermission(agents, creator),
148
148
  }
@@ -188,6 +188,8 @@ export function resolveRepository(config) {
188
188
  repo: config.github.repo,
189
189
  },
190
190
  language: config.language,
191
+ account: config.account,
192
+ mode: config.mode ?? "single",
191
193
  merge: {
192
194
  approvalPolicy: config.review?.merge?.approvalPolicy ?? "majority",
193
195
  method: config.review?.merge?.method ?? "squash",
@@ -208,10 +210,6 @@ export function resolveRepository(config) {
208
210
  review: config.review?.prompts?.review,
209
211
  reviewGuidelines: config.review?.prompts?.reviewGuidelines,
210
212
  },
211
- review: {
212
- account: config.review?.account,
213
- mode: config.review?.mode ?? "single",
214
- },
215
213
  reviewAutomation: {
216
214
  close: config.review?.automation?.close ?? false,
217
215
  merge: config.review?.automation?.merge ?? true,
@@ -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",
@@ -54,12 +56,10 @@ const TRIAGE_CREATOR_KEYS = new Set([
54
56
  const AUTHOR_KEYS = new Set(["email", "name"]);
55
57
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
56
58
  const REVIEW_KEYS = new Set([
57
- "account",
58
59
  "automation",
59
60
  "checks",
60
61
  "concurrency",
61
62
  "merge",
62
- "mode",
63
63
  "output",
64
64
  "prompts",
65
65
  "reviewers",
@@ -421,7 +421,7 @@ function validateReviewerList(reviewers, path, errors, catalog, mode = "single")
421
421
  }
422
422
  });
423
423
  }
424
- function validateTriageAgentList(voters, path, errors, catalog) {
424
+ function validateTriageAgentList(voters, path, errors, catalog, mode = "single") {
425
425
  if (voters == null)
426
426
  return;
427
427
  if (!Array.isArray(voters)) {
@@ -441,8 +441,10 @@ function validateTriageAgentList(voters, path, errors, catalog) {
441
441
  if (!agent.model)
442
442
  errors.push(`${path}[${index}].model is required`);
443
443
  validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
444
- if (!agent.account)
444
+ if (mode === "multi" && !agent.account)
445
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`);
446
448
  validateString(agent.account, `${path}[${index}].account`, errors);
447
449
  validateString(agent.persona, `${path}[${index}].persona`, errors);
448
450
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
@@ -469,26 +471,25 @@ function validateResolvedReviewers(reviewers, path, errors, mode = "single") {
469
471
  }
470
472
  }
471
473
  function reviewMode(config) {
472
- return config.review?.mode === "multi" ? "multi" : "single";
474
+ return config.mode === "multi" ? "multi" : "single";
473
475
  }
474
476
  function validateReviewIdentity(config, errors) {
475
- const mode = config.review?.mode;
477
+ const mode = config.mode;
476
478
  if (mode != null && mode !== "multi" && mode !== "single") {
477
- errors.push("review.mode must be multi or single");
479
+ errors.push("mode must be multi or single");
478
480
  }
479
- validateString(config.review?.account, "review.account", errors);
480
- if ((mode == null || mode === "single") && !config.review?.account) {
481
- errors.push("review.account is required when review.mode is single");
481
+ if ((mode == null || mode === "single") && !config.account) {
482
+ errors.push("account is required when mode is single");
482
483
  }
483
484
  }
484
- function validateResolvedTriageAgents(agents, path, errors) {
485
+ function validateResolvedTriageAgents(agents, path, errors, mode = "single") {
485
486
  const keys = new Set();
486
487
  const accounts = new Set();
487
488
  for (const agent of agents) {
488
489
  if (keys.has(agent.key))
489
490
  errors.push(`${path} has duplicate agent key: ${agent.key}`);
490
491
  keys.add(agent.key);
491
- if (accounts.has(agent.account))
492
+ if (mode === "multi" && accounts.has(agent.account))
492
493
  errors.push(`${path} has duplicate agent account: ${agent.account}`);
493
494
  accounts.add(agent.account);
494
495
  }
@@ -532,7 +533,7 @@ function validateEditor(editor, path, errors, catalog) {
532
533
  }
533
534
  }
534
535
  }
535
- function validateTriageCreator(creator, path, errors, catalog) {
536
+ function validateTriageCreator(creator, path, errors, catalog, mode = "single") {
536
537
  if (!creator)
537
538
  return;
538
539
  if (!isPlainObject(creator)) {
@@ -542,6 +543,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
542
543
  validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
543
544
  if (!creator.model)
544
545
  errors.push(`${path}.model is required`);
546
+ if (mode === "single" && creator.account)
547
+ errors.push(`${path}.account is not supported in single mode`);
545
548
  validateString(creator.account, `${path}.account`, errors);
546
549
  validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
547
550
  validateString(creator.persona, `${path}.persona`, errors);
@@ -830,6 +833,7 @@ function validatePromptObject(prompts, path, keys, errors) {
830
833
  }
831
834
  function validateTriage(config, errors, options) {
832
835
  const triage = config.triage;
836
+ const mode = reviewMode(config);
833
837
  if (!triage)
834
838
  return;
835
839
  if (!isPlainObject(triage)) {
@@ -844,7 +848,7 @@ function validateTriage(config, errors, options) {
844
848
  const safety = triage.safety;
845
849
  if (!triage.voters)
846
850
  errors.push("triage.voters is required");
847
- validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
851
+ validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog, mode);
848
852
  if (Array.isArray(triage.voters)) {
849
853
  const resolvedTriageAgents = triage.voters.map((agent, index) => ({
850
854
  account: agent && typeof agent === "object" && typeof agent.account === "string"
@@ -852,17 +856,21 @@ function validateTriage(config, errors, options) {
852
856
  : "",
853
857
  key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
854
858
  }));
855
- validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
856
- if (reporter != null &&
859
+ validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors, mode);
860
+ if (mode === "multi" &&
861
+ reporter != null &&
857
862
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
858
863
  errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
859
864
  }
860
865
  }
866
+ if (mode === "single" && reporter != null) {
867
+ errors.push("triage.reporter is not supported in single mode");
868
+ }
861
869
  validateString(triage.reporter, "triage.reporter", errors);
862
- validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog);
870
+ validateTriageCreator(creator, "triage.creator", errors, options.modelCatalog, mode);
863
871
  if (automation?.create && !creator)
864
872
  errors.push("triage.creator is required when triage.automation.create is true");
865
- if (automation?.create && creator && !creator.account)
873
+ if (mode === "multi" && automation?.create && creator && !creator.account)
866
874
  errors.push("triage.creator.account is required when triage.automation.create is true");
867
875
  if (automation != null && !isPlainObject(automation)) {
868
876
  errors.push("triage.automation must be an object");
@@ -924,8 +932,8 @@ async function validateAuth(config, exec, errors) {
924
932
  const accounts = new Set();
925
933
  const agents = resolveAgents(config);
926
934
  if (reviewMode(config) === "single") {
927
- if (config.review?.account)
928
- accounts.add(config.review.account);
935
+ if (config.account)
936
+ accounts.add(config.account);
929
937
  }
930
938
  else {
931
939
  for (const reviewer of agents.reviewers)
@@ -978,8 +986,8 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
978
986
  return;
979
987
  const agents = resolveAgents(config);
980
988
  const reviewAccounts = reviewMode(config) === "single"
981
- ? config.review?.account
982
- ? [config.review.account]
989
+ ? config.account
990
+ ? [config.account]
983
991
  : []
984
992
  : agents.reviewers.map((reviewer) => reviewer.account);
985
993
  await Promise.all(reviewAccounts.map(async (account) => {
@@ -1040,7 +1048,10 @@ export async function validateConfig(config, options = {}) {
1040
1048
  validateJsonSchema(config, errors);
1041
1049
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
1042
1050
  validateString(config.$schema, "$schema", errors);
1051
+ validateString(config.account, "account", errors);
1043
1052
  validateString(config.language, "language", errors);
1053
+ if (config.review || config.triage)
1054
+ validateReviewIdentity(config, errors);
1044
1055
  if (config.agents != null && !isPlainObject(config.agents)) {
1045
1056
  errors.push("agents must be an object");
1046
1057
  }
@@ -1059,7 +1070,6 @@ export async function validateConfig(config, options = {}) {
1059
1070
  else {
1060
1071
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
1061
1072
  }
1062
- validateReviewIdentity(config, errors);
1063
1073
  if (!config.review.reviewers)
1064
1074
  errors.push("review.reviewers is required");
1065
1075
  validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog, mode);
@@ -213,7 +213,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
213
213
  worktreePath,
214
214
  });
215
215
  const artifactDir = outputDir(input);
216
- const singleReviewMode = input.repository.review?.mode !== "multi";
216
+ const singleReviewMode = input.repository.mode !== "multi";
217
217
  const reviewerKeys = input.repository.agents.reviewers.map((reviewer) => reviewer.key);
218
218
  const singleModeThreads = singleReviewMode
219
219
  ? assignThreadsByReviewFindingMarker({
@@ -221,7 +221,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
221
221
  pr: input.pr,
222
222
  reviewerKeys,
223
223
  threads: options.dryRunThreads == null
224
- ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.review?.account ?? "")
224
+ ? await fetchUnresolvedThreads(input.exec, input.repository, input.pr, input.repository.account ?? "")
225
225
  : Object.values(options.dryRunThreads).flat(),
226
226
  })
227
227
  : undefined;
@@ -412,7 +412,7 @@ async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionI
412
412
  ? { consensus: `dry-run:would-post-single-review:${verdict}` }
413
413
  : {
414
414
  consensus: await (async () => {
415
- const account = input.repository.review?.account ?? "";
415
+ const account = input.repository.account ?? "";
416
416
  await Promise.all(entries.flatMap((entry) => entry.output.resolve.map((item) => resolveThread(input.exec, input.repository, account, item.threadId))));
417
417
  await Promise.all(entries.flatMap((entry) => entry.output.followUps.map((item) => postReply(input.exec, input.repository, input.pr, account, item.commentId, [
418
418
  `**Reviewer:** ${entry.reviewer}`,
@@ -16,12 +16,14 @@ import { formatReviewReport } from "./report";
16
16
  import { buildReviewContextSnapshot, renderReviewContext, } from "./review-context";
17
17
  import { checkSafetyGate, hasSafetyGate } from "./safety";
18
18
  function resolvedReviewMode(repository) {
19
- return repository.review?.mode === "multi" ? "multi" : "single";
19
+ return repository.mode === "multi" ? "multi" : "single";
20
20
  }
21
21
  export function reviewPostingAccount(repository, reviewer) {
22
- return resolvedReviewMode(repository) === "single"
23
- ? (repository.review?.account ?? reviewer.account)
24
- : reviewer.account;
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");
25
27
  }
26
28
  function reviewAssignmentKey(repository, reviewer) {
27
29
  return resolvedReviewMode(repository) === "single"
@@ -579,9 +581,9 @@ function singleFindingBody(input) {
579
581
  ].join("\n");
580
582
  }
581
583
  export async function postSingleConsensusReview(input) {
582
- const account = input.repository.review?.account;
584
+ const account = input.repository.account;
583
585
  if (!account)
584
- throw new Error("review.account is required for single review mode");
586
+ throw new Error("account is required for single review mode");
585
587
  const body = singleReviewBody(input);
586
588
  if (input.verdict === "MERGE") {
587
589
  return postApproval(input.exec, input.repository, input.pr, account, body);
@@ -927,7 +929,7 @@ export async function runReview(input) {
927
929
  const reviewerAccounts = input.repository.agents.reviewers.map((reviewer) => reviewer.account);
928
930
  const preliminaryMode = singleReviewMode
929
931
  ? resolveSingleAccountReviewMode({
930
- account: input.repository.review?.account ?? "",
932
+ account: input.repository.account ?? "",
931
933
  current: freshnessTarget,
932
934
  pr: input.pr,
933
935
  reviewerKeys,
@@ -942,7 +944,7 @@ export async function runReview(input) {
942
944
  return (preliminaryMode.assignments.get(reviewAssignmentKey(input.repository, reviewer))?.type === "skip");
943
945
  });
944
946
  if (singleReviewMode) {
945
- const account = input.repository.review?.account ?? "";
947
+ const account = input.repository.account ?? "";
946
948
  const threads = await fetchUnresolvedThreads(exec, input.repository, input.pr, account);
947
949
  const assigned = assignThreadsByReviewFindingMarker({
948
950
  fallbackReviewerKeys: reviewerKeys,
@@ -973,7 +975,7 @@ export async function runReview(input) {
973
975
  const mode = singleReviewMode
974
976
  ? pendingThreadReplyReviewers.size
975
977
  ? resolveSingleAccountReviewMode({
976
- account: input.repository.review?.account ?? "",
978
+ account: input.repository.account ?? "",
977
979
  current: freshnessTarget,
978
980
  pendingReviewers: pendingThreadReplyReviewers,
979
981
  pr: input.pr,
@@ -1368,7 +1370,7 @@ export async function runReview(input) {
1368
1370
  consensus: input.dryRun
1369
1371
  ? `dry-run:would-post-single-review:${verdict}`
1370
1372
  : await (async () => {
1371
- const account = input.repository.review?.account ?? "";
1373
+ const account = input.repository.account ?? "";
1372
1374
  await Promise.all(Object.values(activeOutputs).flatMap((output) => {
1373
1375
  if (!("resolve" in output))
1374
1376
  return [];
@@ -1414,7 +1416,7 @@ export async function runReview(input) {
1414
1416
  ]))),
1415
1417
  };
1416
1418
  const automationAccount = singleReviewMode
1417
- ? input.repository.review?.account
1419
+ ? input.repository.account
1418
1420
  : input.repository.agents.reviewers[0]?.account;
1419
1421
  const enableReviewAutomation = input.enableReviewAutomation ?? true;
1420
1422
  if (enableReviewAutomation &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.9.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>",
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,
@@ -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 },
@@ -366,8 +368,6 @@
366
368
  "type": "object",
367
369
  "additionalProperties": false,
368
370
  "properties": {
369
- "mode": { "enum": ["multi", "single"], "default": "single" },
370
- "account": { "type": "string", "minLength": 1 },
371
371
  "reviewers": {
372
372
  "type": "array",
373
373
  "minItems": 3,