opencode-magi 0.6.1 → 0.7.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
@@ -78,7 +78,7 @@ Add the following content to the configuration file.
78
78
  }
79
79
  },
80
80
  "review": {
81
- "agents": [
81
+ "reviewers": [
82
82
  { "ref": "account-1" },
83
83
  { "ref": "account-2" },
84
84
  { "ref": "account-3" }
@@ -87,7 +87,7 @@ Add the following content to the configuration file.
87
87
  }
88
88
  ```
89
89
 
90
- After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
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.
91
91
 
92
92
  #### Set project config
93
93
 
@@ -133,7 +133,7 @@ Add the following content to the configuration file.
133
133
  }
134
134
  },
135
135
  "review": {
136
- "agents": [
136
+ "reviewers": [
137
137
  { "ref": "account-1" },
138
138
  { "ref": "account-2" },
139
139
  { "ref": "account-3" }
@@ -143,8 +143,7 @@ Add the following content to the configuration file.
143
143
  "editor": { "ref": "account-4" }
144
144
  },
145
145
  "triage": {
146
- "account": "account-5",
147
- "agents": [
146
+ "voters": [
148
147
  { "ref": "account-1" },
149
148
  { "ref": "account-2" },
150
149
  { "ref": "account-3" }
@@ -155,7 +154,18 @@ Add the following content to the configuration file.
155
154
 
156
155
  Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
157
156
 
158
- After refs are expanded, `review.agents[].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.
157
+ `model` can be a single `provider/model` string, a single object with `id` and `options`, or an ordered candidate array. Candidate arrays are resolved during validation against OpenCode's model catalog; the first available model is selected. Put provider-specific options on model objects, not on the agent role.
158
+
159
+ ```json
160
+ {
161
+ "model": {
162
+ "id": "openai/gpt-5.1",
163
+ "options": { "reasoningEffort": "high" }
164
+ }
165
+ }
166
+ ```
167
+
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.
159
169
 
160
170
  #### Validate config
161
171
 
@@ -33,6 +33,12 @@ export function triageAgentKey(agent, index) {
33
33
  export function validateReviewerId(id) {
34
34
  return ID_PATTERN.test(id);
35
35
  }
36
+ function normalizedModel(model) {
37
+ if (typeof model !== "string") {
38
+ throw new Error("model must be normalized before resolving agents");
39
+ }
40
+ return model;
41
+ }
36
42
  function clonePermissionValue(value) {
37
43
  return typeof value === "string" ? value : { ...value };
38
44
  }
@@ -86,25 +92,29 @@ export function resolveAgents(config) {
86
92
  editor: editor
87
93
  ? {
88
94
  ...editor,
95
+ model: normalizedModel(editor.model),
89
96
  permission: resolveEditorPermission(agents, editor),
90
97
  }
91
98
  : undefined,
92
- reviewers: (config.review?.agents ?? []).map((reviewer, index) => ({
99
+ reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
93
100
  ...reviewer,
94
101
  key: reviewerKey(reviewer, index),
95
102
  index,
103
+ model: normalizedModel(reviewer.model),
96
104
  permission: resolveReviewerPermission(agents, reviewer),
97
105
  })),
98
- triage: (config.triage?.agents ?? []).map((agent, index) => ({
106
+ triage: (config.triage?.voters ?? []).map((agent, index) => ({
99
107
  ...agent,
100
108
  key: triageAgentKey(agent, index),
101
109
  index,
110
+ model: normalizedModel(agent.model),
102
111
  permission: resolveTriageAgentPermission(agents, agent),
103
112
  })),
104
113
  triageCreator: creator
105
114
  ? {
106
115
  ...creator,
107
116
  account: creator.account ?? "",
117
+ model: normalizedModel(creator.model),
108
118
  permission: resolveTriageCreatorPermission(agents, creator),
109
119
  }
110
120
  : undefined,
@@ -4,7 +4,7 @@ import { access } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { isAbsolute, join } from "node:path";
6
6
  import schema from "../../schema.json" with { type: "json" };
7
- import { resolveAgents, validateReviewerId } from "./resolve";
7
+ import { resolveAgents, reviewerKey, triageAgentKey, validateReviewerId, } from "./resolve";
8
8
  const RESERVED_REVIEWER_KEYS = new Set(["editor", "orchestrator", "system"]);
9
9
  const PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
10
10
  const AJV = new Ajv2020({ allErrors: true, strict: false });
@@ -27,7 +27,6 @@ const REVIEWER_KEYS = new Set([
27
27
  "account",
28
28
  "id",
29
29
  "model",
30
- "options",
31
30
  "permissions",
32
31
  "persona",
33
32
  ]);
@@ -35,7 +34,6 @@ const EDITOR_KEYS = new Set([
35
34
  "account",
36
35
  "author",
37
36
  "model",
38
- "options",
39
37
  "permissions",
40
38
  "persona",
41
39
  ]);
@@ -43,7 +41,6 @@ const TRIAGE_AGENT_KEYS = new Set([
43
41
  "account",
44
42
  "id",
45
43
  "model",
46
- "options",
47
44
  "permissions",
48
45
  "persona",
49
46
  ]);
@@ -51,20 +48,19 @@ const TRIAGE_CREATOR_KEYS = new Set([
51
48
  "account",
52
49
  "author",
53
50
  "model",
54
- "options",
55
51
  "permissions",
56
52
  "persona",
57
53
  ]);
58
54
  const AUTHOR_KEYS = new Set(["email", "name"]);
59
55
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
60
56
  const REVIEW_KEYS = new Set([
61
- "agents",
62
57
  "automation",
63
58
  "checks",
64
59
  "concurrency",
65
60
  "merge",
66
61
  "output",
67
62
  "prompts",
63
+ "reviewers",
68
64
  "safety",
69
65
  "worktree",
70
66
  ]);
@@ -76,7 +72,6 @@ const MERGE_KEYS = new Set([
76
72
  "prompts",
77
73
  ]);
78
74
  const TRIAGE_KEYS = new Set([
79
- "agents",
80
75
  "automation",
81
76
  "categories",
82
77
  "concurrency",
@@ -85,6 +80,7 @@ const TRIAGE_KEYS = new Set([
85
80
  "prompts",
86
81
  "reporter",
87
82
  "safety",
83
+ "voters",
88
84
  "worktree",
89
85
  ]);
90
86
  const REVIEW_MERGE_KEYS = new Set([
@@ -145,6 +141,7 @@ const TRIAGE_PROMPT_KEYS = new Set([
145
141
  "existingPr",
146
142
  "reconsider",
147
143
  ]);
144
+ const MODEL_CANDIDATE_KEYS = new Set(["id", "options"]);
148
145
  function githubHost(config) {
149
146
  return config.github?.host ?? "github.com";
150
147
  }
@@ -190,14 +187,14 @@ function expandAgentRefs(config, errors) {
190
187
  const refsValue = isPlainObject(agents) ? agents.refs : undefined;
191
188
  const refsInvalid = refsValue != null && !isPlainObject(refsValue);
192
189
  const refs = isPlainObject(refsValue) ? refsValue : undefined;
193
- if (Array.isArray(magiConfig.review?.agents)) {
194
- magiConfig.review.agents = magiConfig.review.agents.map((agent, index) => expandAgentRefUse(agent, `review.agents[${index}]`, refs, refsInvalid, errors));
190
+ if (Array.isArray(magiConfig.review?.reviewers)) {
191
+ magiConfig.review.reviewers = magiConfig.review.reviewers.map((agent, index) => expandAgentRefUse(agent, `review.reviewers[${index}]`, refs, refsInvalid, errors));
195
192
  }
196
193
  if (isPlainObject(magiConfig.merge?.editor)) {
197
194
  magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
198
195
  }
199
- if (Array.isArray(magiConfig.triage?.agents)) {
200
- magiConfig.triage.agents = magiConfig.triage.agents.map((agent, index) => expandAgentRefUse(agent, `triage.agents[${index}]`, refs, refsInvalid, errors));
196
+ if (Array.isArray(magiConfig.triage?.voters)) {
197
+ magiConfig.triage.voters = magiConfig.triage.voters.map((agent, index) => expandAgentRefUse(agent, `triage.voters[${index}]`, refs, refsInvalid, errors));
201
198
  }
202
199
  if (isPlainObject(magiConfig.triage?.creator)) {
203
200
  magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
@@ -275,26 +272,97 @@ function validatePermissionConfig(permission, path, errors) {
275
272
  }
276
273
  }
277
274
  }
278
- function validateModel(model, path, errors, catalog) {
279
- if (!model)
280
- return;
275
+ function modelValidationError(model, path, catalog) {
281
276
  const slash = model.indexOf("/");
282
- if (slash <= 0 || slash === model.length - 1) {
283
- errors.push(`${path} must be a full OpenCode model ID in provider/model form`);
284
- return;
285
- }
277
+ if (slash <= 0 || slash === model.length - 1)
278
+ return `${path} must be a full OpenCode model ID in provider/model form`;
286
279
  if (!catalog)
287
- return;
280
+ return undefined;
288
281
  const providerId = model.slice(0, slash);
289
282
  const modelId = model.slice(slash + 1);
290
283
  const models = catalog[providerId];
291
- if (!models) {
292
- errors.push(`${path} uses unknown OpenCode provider: ${providerId}`);
284
+ if (!models)
285
+ return `${path} uses unknown OpenCode provider: ${providerId}`;
286
+ if (!models.includes(modelId))
287
+ return `${path} uses unknown OpenCode model: ${model}`;
288
+ return undefined;
289
+ }
290
+ function validateModelId(model, path, errors, catalog) {
291
+ const error = modelValidationError(model, path, catalog);
292
+ if (error) {
293
+ errors.push(error);
294
+ return false;
295
+ }
296
+ return true;
297
+ }
298
+ function readModelCandidate(value, path, errors) {
299
+ if (typeof value === "string")
300
+ return { id: value };
301
+ if (!isPlainObject(value)) {
302
+ errors.push(`${path} must be a string or an object`);
303
+ return undefined;
304
+ }
305
+ validateKnownKeys(value, path, MODEL_CANDIDATE_KEYS, errors);
306
+ if (typeof value.id !== "string") {
307
+ errors.push(`${path}.id must be a string`);
308
+ return undefined;
309
+ }
310
+ if (value.options != null && !isPlainObject(value.options)) {
311
+ errors.push(`${path}.options must be an object`);
312
+ return undefined;
313
+ }
314
+ return { id: value.id, options: value.options };
315
+ }
316
+ function validateAndNormalizeModel(target, path, errors, catalog) {
317
+ const model = target.model;
318
+ if (typeof model === "string") {
319
+ validateModelId(model, path, errors, catalog);
320
+ return;
321
+ }
322
+ if (isPlainObject(model)) {
323
+ const candidate = readModelCandidate(model, path, errors);
324
+ if (candidate &&
325
+ validateModelId(candidate.id, `${path}.id`, errors, catalog)) {
326
+ target.model = candidate.id;
327
+ if (candidate.options)
328
+ target.options = candidate.options;
329
+ else
330
+ delete target.options;
331
+ }
332
+ return;
333
+ }
334
+ if (!Array.isArray(model)) {
335
+ if (model != null)
336
+ errors.push(`${path} must be a string, an object, or an array`);
337
+ return;
338
+ }
339
+ if (!model.length) {
340
+ errors.push(`${path} must contain at least one model candidate`);
341
+ return;
342
+ }
343
+ if (!catalog) {
344
+ errors.push(`${path} requires an OpenCode model catalog`);
293
345
  return;
294
346
  }
295
- if (!models.includes(modelId)) {
296
- errors.push(`${path} uses unknown OpenCode model: ${model}`);
347
+ const candidateErrors = [];
348
+ for (const [index, value] of model.entries()) {
349
+ const candidatePath = `${path}[${index}]`;
350
+ const candidate = readModelCandidate(value, candidatePath, errors);
351
+ if (!candidate)
352
+ continue;
353
+ const idPath = isPlainObject(value) ? `${candidatePath}.id` : candidatePath;
354
+ const error = modelValidationError(candidate.id, idPath, catalog);
355
+ if (!error) {
356
+ target.model = candidate.id;
357
+ if (candidate.options)
358
+ target.options = candidate.options;
359
+ else
360
+ delete target.options;
361
+ return;
362
+ }
363
+ candidateErrors.push(error);
297
364
  }
365
+ errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
298
366
  }
299
367
  function validateReviewerList(reviewers, path, errors, catalog) {
300
368
  if (reviewers == null)
@@ -315,14 +383,11 @@ function validateReviewerList(reviewers, path, errors, catalog) {
315
383
  validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
316
384
  if (!reviewer.model)
317
385
  errors.push(`${path}[${index}].model is required`);
318
- validateString(reviewer.model, `${path}[${index}].model`, errors);
319
- validateModel(reviewer.model, `${path}[${index}].model`, errors, catalog);
386
+ validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
320
387
  if (!reviewer.account)
321
388
  errors.push(`${path}[${index}].account is required`);
322
389
  validateString(reviewer.account, `${path}[${index}].account`, errors);
323
390
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
324
- if (reviewer.options != null && !isPlainObject(reviewer.options))
325
- errors.push(`${path}[${index}].options must be an object`);
326
391
  validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
327
392
  if (reviewer.id) {
328
393
  if (!validateReviewerId(reviewer.id)) {
@@ -334,18 +399,18 @@ function validateReviewerList(reviewers, path, errors, catalog) {
334
399
  }
335
400
  });
336
401
  }
337
- function validateTriageAgentList(agents, path, errors, catalog) {
338
- if (agents == null)
402
+ function validateTriageAgentList(voters, path, errors, catalog) {
403
+ if (voters == null)
339
404
  return;
340
- if (!Array.isArray(agents)) {
405
+ if (!Array.isArray(voters)) {
341
406
  errors.push(`${path} must be an array`);
342
407
  return;
343
408
  }
344
- if (agents.length < 3)
345
- errors.push(`${path} must contain at least 3 agents`);
346
- if (agents.length % 2 === 0)
347
- errors.push(`${path} must contain an odd number of agents`);
348
- agents.forEach((agent, index) => {
409
+ if (voters.length < 3)
410
+ errors.push(`${path} must contain at least 3 voters`);
411
+ if (voters.length % 2 === 0)
412
+ errors.push(`${path} must contain an odd number of voters`);
413
+ voters.forEach((agent, index) => {
349
414
  if (!agent || typeof agent !== "object") {
350
415
  errors.push(`${path}[${index}] must be an object`);
351
416
  return;
@@ -353,14 +418,11 @@ function validateTriageAgentList(agents, path, errors, catalog) {
353
418
  validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
354
419
  if (!agent.model)
355
420
  errors.push(`${path}[${index}].model is required`);
356
- validateString(agent.model, `${path}[${index}].model`, errors);
357
- validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
421
+ validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
358
422
  if (!agent.account)
359
423
  errors.push(`${path}[${index}].account is required`);
360
424
  validateString(agent.account, `${path}[${index}].account`, errors);
361
425
  validateString(agent.persona, `${path}[${index}].persona`, errors);
362
- if (agent.options != null && !isPlainObject(agent.options))
363
- errors.push(`${path}[${index}].options must be an object`);
364
426
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
365
427
  if (agent.id) {
366
428
  if (!validateReviewerId(agent.id)) {
@@ -406,15 +468,11 @@ function validateEditor(editor, path, errors, catalog) {
406
468
  if (!editor.model)
407
469
  errors.push(`${path}.model is required`);
408
470
  validateKnownKeys(editor, path, EDITOR_KEYS, errors);
409
- validateString(editor.model, `${path}.model`, errors);
471
+ validateAndNormalizeModel(editor, `${path}.model`, errors, catalog);
410
472
  validateString(editor.account, `${path}.account`, errors);
411
473
  validateString(editor.persona, `${path}.persona`, errors);
412
- validateModel(editor.model, `${path}.model`, errors, catalog);
413
474
  if (!editor.account)
414
475
  errors.push(`${path}.account is required`);
415
- if (editor.options != null && !isPlainObject(editor.options)) {
416
- errors.push(`${path}.options must be an object`);
417
- }
418
476
  validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
419
477
  const author = editor.author;
420
478
  if (!author || !isPlainObject(author)) {
@@ -450,12 +508,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
450
508
  if (!creator.model)
451
509
  errors.push(`${path}.model is required`);
452
510
  validateString(creator.account, `${path}.account`, errors);
453
- validateString(creator.model, `${path}.model`, errors);
511
+ validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
454
512
  validateString(creator.persona, `${path}.persona`, errors);
455
- validateModel(creator.model, `${path}.model`, errors, catalog);
456
- if (creator.options != null && !isPlainObject(creator.options)) {
457
- errors.push(`${path}.options must be an object`);
458
- }
459
513
  validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
460
514
  const author = creator.author;
461
515
  if (!author || !isPlainObject(author)) {
@@ -680,15 +734,20 @@ function validateTriage(config, errors, options) {
680
734
  const creator = triage.creator;
681
735
  const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
682
736
  const safety = triage.safety;
683
- if (!triage.agents)
684
- errors.push("triage.agents is required");
685
- validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
686
- if (Array.isArray(triage.agents)) {
687
- const resolvedTriageAgents = resolveAgents(config).triage ?? [];
737
+ if (!triage.voters)
738
+ errors.push("triage.voters is required");
739
+ validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
740
+ if (Array.isArray(triage.voters)) {
741
+ const resolvedTriageAgents = triage.voters.map((agent, index) => ({
742
+ account: agent && typeof agent === "object" && typeof agent.account === "string"
743
+ ? agent.account
744
+ : "",
745
+ key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
746
+ }));
688
747
  validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
689
748
  if (reporter != null &&
690
749
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
691
- errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
750
+ errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
692
751
  }
693
752
  }
694
753
  validateString(triage.reporter, "triage.reporter", errors);
@@ -778,10 +837,10 @@ async function fetchPermissions(config, exec, account) {
778
837
  return JSON.parse(raw);
779
838
  }
780
839
  async function validateWorktreeConfig(config, exec, options, errors) {
781
- const agents = resolveAgents(config);
782
- const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
840
+ const checkEditor = Boolean(config.merge?.editor &&
841
+ (options.requireEditor || options.requireWorktreeConfig));
783
842
  const checkTriageCreator = Boolean(config.triage?.automation?.create &&
784
- agents.triageCreator &&
843
+ config.triage?.creator &&
785
844
  (options.requireTriage || options.requireWorktreeConfig));
786
845
  if (!checkEditor && !checkTriageCreator)
787
846
  return;
@@ -853,6 +912,9 @@ export async function validateConfig(config, options = {}) {
853
912
  const warnings = [];
854
913
  if (!config || typeof config !== "object")
855
914
  errors.push("config must be an object");
915
+ if (options.requireModelCatalog && !options.modelCatalog) {
916
+ errors.push("OpenCode model catalog could not be loaded");
917
+ }
856
918
  expandAgentRefs(config, errors);
857
919
  if (config && typeof config === "object")
858
920
  validateJsonSchema(config, errors);
@@ -876,11 +938,20 @@ export async function validateConfig(config, options = {}) {
876
938
  else {
877
939
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
878
940
  }
879
- if (!config.review.agents)
880
- errors.push("review.agents is required");
881
- validateReviewerList(config.review.agents, "review.agents", errors, options.modelCatalog);
882
- if (Array.isArray(config.review.agents)) {
883
- validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
941
+ if (!config.review.reviewers)
942
+ errors.push("review.reviewers is required");
943
+ validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
944
+ if (Array.isArray(config.review.reviewers)) {
945
+ validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
946
+ account: reviewer &&
947
+ typeof reviewer === "object" &&
948
+ typeof reviewer.account === "string"
949
+ ? reviewer.account
950
+ : "",
951
+ key: reviewer && typeof reviewer === "object"
952
+ ? reviewerKey(reviewer, index)
953
+ : "",
954
+ })), "review.resolvedReviewers", errors);
884
955
  }
885
956
  }
886
957
  if (options.requireTriage && !config.triage) {
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { loadConfig, mergeMagiConfig } from "./config/load";
9
9
  import { outputBaseDirs } from "./config/output";
10
10
  import { worktreeBaseDirs } from "./config/worktree";
11
11
  import { resolveRepository } from "./config/resolve";
12
- import { validateConfig } from "./config/validate";
12
+ import { validateConfig, } from "./config/validate";
13
13
  import { withGitHubApiRetry } from "./github/retry";
14
14
  import { mapPool } from "./orchestrator/pool";
15
15
  import { MagiRunManager } from "./orchestrator/run-manager";
@@ -286,23 +286,6 @@ function parseOptionalIssue(value) {
286
286
  function clearFlag(value) {
287
287
  return typeof value === "boolean" ? value : undefined;
288
288
  }
289
- function clearToolFlag(value) {
290
- if (value === true || value === "true")
291
- return true;
292
- if (value === "false")
293
- return false;
294
- return undefined;
295
- }
296
- function hasBlankSelector(args) {
297
- return !args.runId?.trim() && !args.pr?.trim();
298
- }
299
- function hasDefaultedFalseClearFlags(args) {
300
- return (hasBlankSelector(args) &&
301
- args.branch === "false" &&
302
- args.output === "false" &&
303
- args.session === "false" &&
304
- args.worktree === "false");
305
- }
306
289
  function parseQuestionAnswers(value) {
307
290
  const trimmed = value.trim();
308
291
  if (!trimmed)
@@ -334,6 +317,9 @@ function issueMarkdownLink(repository, issue) {
334
317
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
335
318
  return `[#${issue}](${url})`;
336
319
  }
320
+ function validationError(validation) {
321
+ return new Error(JSON.stringify(validation, null, 2));
322
+ }
337
323
  function isPlainObject(value) {
338
324
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
339
325
  }
@@ -395,7 +381,8 @@ export async function validateMagiConfigFiles(directory, options = {}) {
395
381
  ? withGitHubApiRetry(options.exec, mergedConfig.github?.apiRetryAttempts ?? 3)
396
382
  : undefined,
397
383
  modelCatalog: options.modelCatalog,
398
- requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
384
+ requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.reviewers),
385
+ requireModelCatalog: true,
399
386
  requireWorktreeConfig: true,
400
387
  });
401
388
  loadedFrom = existing.map((status) => status.path).join(", ");
@@ -447,7 +434,8 @@ export const MagiPlugin = async ({ client, directory }) => {
447
434
  .then(extractModelCatalog)
448
435
  .catch(() => catalogClient.provider
449
436
  ?.list({ query: { directory } })
450
- .then(extractModelCatalog));
437
+ .then(extractModelCatalog))
438
+ .catch(() => undefined);
451
439
  return modelCatalogPromise;
452
440
  }
453
441
  return {
@@ -492,9 +480,10 @@ export const MagiPlugin = async ({ client, directory }) => {
492
480
  exec: retryingExec,
493
481
  modelCatalog: await modelCatalog(),
494
482
  requireEditor: true,
483
+ requireModelCatalog: true,
495
484
  });
496
485
  if (!validation.ok)
497
- return JSON.stringify(validation, null, 2);
486
+ throw validationError(validation);
498
487
  const repository = resolveRepository(config);
499
488
  const sync = parsed.sync || args.sync === true;
500
489
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
@@ -534,9 +523,10 @@ export const MagiPlugin = async ({ client, directory }) => {
534
523
  directory,
535
524
  exec: retryingExec,
536
525
  modelCatalog: await modelCatalog(),
526
+ requireModelCatalog: true,
537
527
  });
538
528
  if (!validation.ok)
539
- return JSON.stringify(validation, null, 2);
529
+ throw validationError(validation);
540
530
  const repository = resolveRepository(config);
541
531
  const sync = parsed.sync || args.sync === true;
542
532
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
@@ -557,7 +547,7 @@ export const MagiPlugin = async ({ client, directory }) => {
557
547
  },
558
548
  }),
559
549
  magi_triage: tool({
560
- description: "Triage one or more GitHub issues with configured Magi triage agents.",
550
+ description: "Triage one or more GitHub issues with configured Magi triage voters.",
561
551
  args: {
562
552
  issues: tool.schema.string(),
563
553
  dryRun: tool.schema.boolean().optional(),
@@ -574,12 +564,13 @@ export const MagiPlugin = async ({ client, directory }) => {
574
564
  exec: retryingExec,
575
565
  modelCatalog: await modelCatalog(),
576
566
  requireEditor: config.triage?.automation?.merge === true,
567
+ requireModelCatalog: true,
577
568
  requireReview: config.triage?.automation?.review === true ||
578
569
  config.triage?.automation?.merge === true,
579
570
  requireTriage: true,
580
571
  });
581
572
  if (!validation.ok)
582
- return JSON.stringify(validation, null, 2);
573
+ throw validationError(validation);
583
574
  const repository = resolveRepository(config);
584
575
  if (!repository.triage)
585
576
  return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
@@ -692,41 +683,21 @@ export const MagiPlugin = async ({ client, directory }) => {
692
683
  }),
693
684
  magi_clear: tool({
694
685
  description: "Clear all inactive Magi runs by deleting configured sessions, worktrees, branches, and output artifacts.",
695
- args: {
696
- runId: tool.schema.string().optional(),
697
- pr: tool.schema.string().optional(),
698
- issue: tool.schema.string().optional(),
699
- branch: tool.schema.enum(["true", "false"]).optional(),
700
- output: tool.schema.enum(["true", "false"]).optional(),
701
- session: tool.schema.enum(["true", "false"]).optional(),
702
- worktree: tool.schema.enum(["true", "false"]).optional(),
703
- },
704
- async execute(args) {
686
+ args: {},
687
+ async execute() {
705
688
  const loaded = await loadConfig(directory).catch(() => undefined);
706
689
  const clear = loaded?.config.clear;
707
- const useConfiguredDefaults = hasDefaultedFalseClearFlags(args);
708
690
  const options = {
709
- branch: (useConfiguredDefaults
710
- ? undefined
711
- : clearToolFlag(args.branch)) ?? clearFlag(clear?.branch),
712
- output: (useConfiguredDefaults
713
- ? undefined
714
- : clearToolFlag(args.output)) ?? clearFlag(clear?.output),
715
- session: (useConfiguredDefaults
716
- ? undefined
717
- : clearToolFlag(args.session)) ?? clearFlag(clear?.session),
718
- worktree: (useConfiguredDefaults
719
- ? undefined
720
- : clearToolFlag(args.worktree)) ?? clearFlag(clear?.worktree),
691
+ branch: clearFlag(clear?.branch),
692
+ output: clearFlag(clear?.output),
693
+ session: clearFlag(clear?.session),
694
+ worktree: clearFlag(clear?.worktree),
721
695
  };
722
696
  return runManager.clear({
723
697
  options,
724
- issue: parseOptionalIssue(args.issue),
725
698
  outputDir: loaded
726
699
  ? outputBaseDirs(directory, loaded.config)
727
700
  : undefined,
728
- pr: parseOptionalPr(args.pr),
729
- runId: args.runId,
730
701
  worktreeDir: loaded
731
702
  ? worktreeBaseDirs(directory, loaded.config)
732
703
  : undefined,
@@ -1616,16 +1616,16 @@ export class MagiRunManager {
1616
1616
  }));
1617
1617
  }
1618
1618
  if (progress.type === "triage_agent_started") {
1619
- await this.notify(state, `**Triage agent ${progress.voter}** started ${progress.phase} for ${issue}.`);
1619
+ await this.notify(state, `**Triage voter ${progress.voter}** started ${progress.phase} for ${issue}.`);
1620
1620
  }
1621
1621
  if (progress.type === "triage_agent_repair") {
1622
- await this.notify(state, `**Triage agent ${progress.voter}** started JSON regeneration for ${issue}.`);
1622
+ await this.notify(state, `**Triage voter ${progress.voter}** started JSON regeneration for ${issue}.`);
1623
1623
  }
1624
1624
  if (progress.type === "triage_agent_completed") {
1625
- await this.notify(state, `**Triage agent ${progress.voter}** completed ${progress.phase} for ${issue}: ${progress.vote}.`);
1625
+ await this.notify(state, `**Triage voter ${progress.voter}** completed ${progress.phase} for ${issue}: ${progress.vote}.`);
1626
1626
  }
1627
1627
  if (progress.type === "triage_agent_failed") {
1628
- await this.notify(state, `**Triage agent ${progress.voter}** failed ${progress.phase} for ${issue}: ${redactSecrets(progress.error)}`);
1628
+ await this.notify(state, `**Triage voter ${progress.voter}** failed ${progress.phase} for ${issue}: ${redactSecrets(progress.error)}`);
1629
1629
  }
1630
1630
  if (progress.type === "comment_posting") {
1631
1631
  await this.notify(state, `Posting triage comment for ${issue}.`);
@@ -128,13 +128,14 @@ async function emitTriageModelProgress(input) {
128
128
  }
129
129
  }
130
130
  async function runVote(input) {
131
- const prompt = await input.prompt({
132
- context: input.context,
133
- directory: input.directory,
134
- issue: input.issue,
135
- repository: input.repository,
136
- voter: input.agent,
137
- });
131
+ const prompt = input.promptText ??
132
+ (await input.prompt({
133
+ context: input.context,
134
+ directory: input.directory,
135
+ issue: input.issue,
136
+ repository: input.repository,
137
+ voter: input.agent,
138
+ }));
138
139
  await emitProgress(input.run, {
139
140
  phase: input.phase,
140
141
  type: "triage_agent_started",
@@ -216,7 +217,7 @@ export function chooseDuplicateOutput(input) {
216
217
  async function runDuplicateVote(input) {
217
218
  const agents = input.input.repository.agents.triage;
218
219
  if (!agents?.length)
219
- throw new Error("triage.agents is required");
220
+ throw new Error("triage.voters is required");
220
221
  await emitProgress(input.input, { phase: "duplicate", type: "phase" });
221
222
  const outputs = await Promise.all(agents.map((agent) => runVote({
222
223
  agent,
@@ -258,9 +259,16 @@ async function runDuplicateVote(input) {
258
259
  async function runPhaseVote(input) {
259
260
  const agents = input.input.repository.agents.triage;
260
261
  if (!agents?.length)
261
- throw new Error("triage.agents is required");
262
+ throw new Error("triage.voters is required");
262
263
  await emitProgress(input.input, { phase: input.phase, type: "phase" });
263
- const outputs = await Promise.all(agents.map((agent) => runVote({
264
+ const promptTexts = await Promise.all(agents.map((agent) => input.prompt({
265
+ context: input.context,
266
+ directory: input.input.directory,
267
+ issue: input.input.issue,
268
+ repository: input.input.repository,
269
+ voter: agent,
270
+ })));
271
+ const outputs = await Promise.all(agents.map((agent, index) => runVote({
264
272
  agent,
265
273
  client: input.input.client,
266
274
  context: input.context,
@@ -269,6 +277,7 @@ async function runPhaseVote(input) {
269
277
  parse: input.parse,
270
278
  phase: input.phase,
271
279
  prompt: input.prompt,
280
+ promptText: promptTexts[index],
272
281
  repository: input.input.repository,
273
282
  run: input.input,
274
283
  schemaName: input.schemaName,
@@ -285,7 +294,16 @@ async function runPhaseVote(input) {
285
294
  voter: agents[index].key,
286
295
  })));
287
296
  await writeJson(join(input.outputDir, `${input.phase}-majority.json`), majority);
288
- return { outputs, vote: majority.vote };
297
+ return {
298
+ outputs,
299
+ reason: chooseDecisionReason({
300
+ outputs,
301
+ threshold: majority.threshold,
302
+ vote: majority.vote,
303
+ voters: majority.vote ? majority.voters[majority.vote] : undefined,
304
+ }),
305
+ vote: majority.vote,
306
+ };
289
307
  }
290
308
  async function relationshipScan(input, issue) {
291
309
  const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
@@ -507,7 +525,7 @@ async function runReconsiderationVote(input) {
507
525
  function triageReporter(repository, issue) {
508
526
  const agents = repository.agents.triage ?? [];
509
527
  if (!agents.length)
510
- throw new Error("triage.agents is required");
528
+ throw new Error("triage.voters is required");
511
529
  const configured = repository.triage?.reporter;
512
530
  const reporter = configured
513
531
  ? agents.find((agent) => agent.key === configured)
@@ -518,26 +536,46 @@ function triageReporter(repository, issue) {
518
536
  }
519
537
  function decisionCommentBody(input) {
520
538
  const reason = input.reason?.trim();
521
- const result = JSON.stringify(input.result);
522
539
  return reason
523
- ? `Magi triage decision: ${result}\n\nReason: ${reason}`
524
- : `Magi triage decision: ${result}\n\nAction: ${input.action}`;
540
+ ? reason
541
+ : decisionCommentFallback({ action: input.action, result: input.result });
542
+ }
543
+ function decisionCommentFallback(input) {
544
+ if (input.result.disposition === "accepted") {
545
+ const category = input.result.category
546
+ ? `${input.result.category} issue`
547
+ : "issue";
548
+ return input.action === "PR"
549
+ ? `Magi accepted this ${category} and will prepare an implementation pull request.`
550
+ : `Magi accepted this ${category}.`;
551
+ }
552
+ if (input.result.disposition === "rejected") {
553
+ const category = input.result.category
554
+ ? `${input.result.category} issue`
555
+ : "issue";
556
+ return `Magi does not plan to act on this ${category}.`;
557
+ }
558
+ if (input.result.disposition === "duplicate") {
559
+ return "Magi marked this issue as a duplicate.";
560
+ }
561
+ return "Magi completed triage for this issue.";
525
562
  }
526
563
  function agentForKey(repository, key) {
527
564
  const agent = repository.agents.triage?.find((item) => item.key === key);
528
565
  if (!agent)
529
- throw new Error(`Unknown triage agent: ${key}`);
566
+ throw new Error(`Unknown triage voter: ${key}`);
530
567
  return agent;
531
568
  }
532
569
  function askOutputs(outputs) {
533
570
  return (outputs ?? []).filter((output) => output.vote === "ASK");
534
571
  }
535
572
  function chooseDecisionReason(input) {
536
- return (input.outputs?.find((output) => output.voter === input.reporter.key &&
537
- output.vote === input.vote &&
538
- output.reason)?.reason ??
539
- input.outputs?.find((output) => output.vote === input.vote)?.reason ??
540
- input.outputs?.find((output) => output.voter === input.reporter.key)?.reason);
573
+ if (!input.vote)
574
+ return undefined;
575
+ const canonicalVoter = input.voters?.[input.threshold - 1];
576
+ const canonicalReason = input.outputs?.find((output) => output.voter === canonicalVoter && output.vote === input.vote);
577
+ return (canonicalReason?.reason ??
578
+ input.outputs?.find((output) => output.vote === input.vote)?.reason);
541
579
  }
542
580
  async function postMarkedIssueComment(input) {
543
581
  const posted = await postIssueComment(input.exec, input.repository, input.issue, input.account, input.body);
@@ -867,7 +905,7 @@ export async function runTriage(input) {
867
905
  throw new Error("triage configuration is required");
868
906
  const agents = input.repository.agents.triage;
869
907
  if (!agents?.length)
870
- throw new Error("triage.agents is required");
908
+ throw new Error("triage.voters is required");
871
909
  const runId = input.runId ?? `run-${Date.now().toString(36)}`;
872
910
  const outputDir = issueRunOutputDir({
873
911
  config: input.config,
@@ -985,12 +1023,7 @@ export async function runTriage(input) {
985
1023
  input,
986
1024
  outputDir,
987
1025
  });
988
- const reporter = triageReporter(input.repository, issue.number);
989
- commentReason = chooseDecisionReason({
990
- outputs: reconsideration.outputs,
991
- reporter,
992
- vote: reconsideration.vote ?? "ASK",
993
- });
1026
+ commentReason = reconsideration.reason;
994
1027
  result =
995
1028
  reconsideration.vote === "YES"
996
1029
  ? { category: previous.category, disposition: "accepted" }
@@ -1034,11 +1067,7 @@ export async function runTriage(input) {
1034
1067
  postComment: true,
1035
1068
  };
1036
1069
  return finishWithResult({
1037
- commentReason: chooseDecisionReason({
1038
- outputs: existingPr.outputs,
1039
- reporter: triageReporter(input.repository, issue.number),
1040
- vote: "RELATED_PR_HANDLES_ISSUE",
1041
- }),
1070
+ commentReason: existingPr.reason,
1042
1071
  context,
1043
1072
  input,
1044
1073
  issue,
@@ -1119,12 +1148,7 @@ export async function runTriage(input) {
1119
1148
  schemaName: "triage acceptance",
1120
1149
  votes: BINARY_VOTES,
1121
1150
  });
1122
- const reporter = triageReporter(input.repository, issue.number);
1123
- commentReason = chooseDecisionReason({
1124
- outputs: acceptance.outputs,
1125
- reporter,
1126
- vote: acceptance.vote ?? "ASK",
1127
- });
1151
+ commentReason = acceptance.reason;
1128
1152
  result =
1129
1153
  acceptance.vote === "YES"
1130
1154
  ? { category, disposition: "accepted" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.6.1",
3
+ "version": "0.7.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
@@ -60,8 +60,7 @@
60
60
  "additionalProperties": false,
61
61
  "properties": {
62
62
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
63
- "model": { "type": "string", "minLength": 1 },
64
- "options": { "type": "object", "additionalProperties": true },
63
+ "model": { "$ref": "#/$defs/modelConfig" },
65
64
  "account": { "type": "string", "minLength": 1 },
66
65
  "author": {
67
66
  "type": "object",
@@ -83,8 +82,7 @@
83
82
  "properties": {
84
83
  "ref": { "type": "string", "minLength": 1 },
85
84
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
86
- "model": { "type": "string", "minLength": 1 },
87
- "options": { "type": "object", "additionalProperties": true },
85
+ "model": { "$ref": "#/$defs/modelConfig" },
88
86
  "account": { "type": "string", "minLength": 1 },
89
87
  "permissions": { "$ref": "#/$defs/permissions" },
90
88
  "persona": { "type": "string" }
@@ -97,8 +95,7 @@
97
95
  "additionalProperties": false,
98
96
  "properties": {
99
97
  "ref": { "type": "string", "minLength": 1 },
100
- "model": { "type": "string", "minLength": 1 },
101
- "options": { "type": "object", "additionalProperties": true },
98
+ "model": { "$ref": "#/$defs/modelConfig" },
102
99
  "account": { "type": "string", "minLength": 1 },
103
100
  "author": {
104
101
  "type": "object",
@@ -121,9 +118,8 @@
121
118
  "properties": {
122
119
  "ref": { "type": "string", "minLength": 1 },
123
120
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
124
- "model": { "type": "string", "minLength": 1 },
121
+ "model": { "$ref": "#/$defs/modelConfig" },
125
122
  "account": { "type": "string", "minLength": 1 },
126
- "options": { "type": "object", "additionalProperties": true },
127
123
  "permissions": { "$ref": "#/$defs/permissions" },
128
124
  "persona": { "type": "string" }
129
125
  }
@@ -136,8 +132,7 @@
136
132
  "properties": {
137
133
  "ref": { "type": "string", "minLength": 1 },
138
134
  "account": { "type": "string", "minLength": 1 },
139
- "model": { "type": "string", "minLength": 1 },
140
- "options": { "type": "object", "additionalProperties": true },
135
+ "model": { "$ref": "#/$defs/modelConfig" },
141
136
  "author": {
142
137
  "type": "object",
143
138
  "required": ["name", "email"],
@@ -151,6 +146,31 @@
151
146
  "persona": { "type": "string" }
152
147
  }
153
148
  },
149
+ "modelConfig": {
150
+ "oneOf": [
151
+ { "type": "string", "minLength": 1 },
152
+ { "$ref": "#/$defs/modelCandidate" },
153
+ {
154
+ "type": "array",
155
+ "minItems": 1,
156
+ "items": {
157
+ "oneOf": [
158
+ { "type": "string", "minLength": 1 },
159
+ { "$ref": "#/$defs/modelCandidate" }
160
+ ]
161
+ }
162
+ }
163
+ ]
164
+ },
165
+ "modelCandidate": {
166
+ "type": "object",
167
+ "required": ["id"],
168
+ "additionalProperties": false,
169
+ "properties": {
170
+ "id": { "type": "string", "minLength": 1 },
171
+ "options": { "type": "object", "additionalProperties": true }
172
+ }
173
+ },
154
174
  "automation": {
155
175
  "type": "object",
156
176
  "additionalProperties": false,
@@ -297,7 +317,7 @@
297
317
  "type": "object",
298
318
  "additionalProperties": false,
299
319
  "properties": {
300
- "agents": {
320
+ "reviewers": {
301
321
  "type": "array",
302
322
  "minItems": 3,
303
323
  "items": { "$ref": "#/$defs/reviewer" }
@@ -332,7 +352,7 @@
332
352
  "type": "object",
333
353
  "additionalProperties": false,
334
354
  "properties": {
335
- "agents": {
355
+ "voters": {
336
356
  "type": "array",
337
357
  "minItems": 3,
338
358
  "items": { "$ref": "#/$defs/triageAgent" }