opencode-magi 0.6.0 → 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([
@@ -138,15 +134,14 @@ const MERGE_PROMPT_KEYS = new Set([
138
134
  const TRIAGE_PROMPT_KEYS = new Set([
139
135
  "acceptance",
140
136
  "category",
141
- "comment",
142
137
  "commentClassification",
143
138
  "create",
144
139
  "createGuidelines",
145
140
  "duplicate",
146
141
  "existingPr",
147
- "question",
148
142
  "reconsider",
149
143
  ]);
144
+ const MODEL_CANDIDATE_KEYS = new Set(["id", "options"]);
150
145
  function githubHost(config) {
151
146
  return config.github?.host ?? "github.com";
152
147
  }
@@ -192,14 +187,14 @@ function expandAgentRefs(config, errors) {
192
187
  const refsValue = isPlainObject(agents) ? agents.refs : undefined;
193
188
  const refsInvalid = refsValue != null && !isPlainObject(refsValue);
194
189
  const refs = isPlainObject(refsValue) ? refsValue : undefined;
195
- if (Array.isArray(magiConfig.review?.agents)) {
196
- 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));
197
192
  }
198
193
  if (isPlainObject(magiConfig.merge?.editor)) {
199
194
  magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
200
195
  }
201
- if (Array.isArray(magiConfig.triage?.agents)) {
202
- 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));
203
198
  }
204
199
  if (isPlainObject(magiConfig.triage?.creator)) {
205
200
  magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
@@ -277,26 +272,97 @@ function validatePermissionConfig(permission, path, errors) {
277
272
  }
278
273
  }
279
274
  }
280
- function validateModel(model, path, errors, catalog) {
281
- if (!model)
282
- return;
275
+ function modelValidationError(model, path, catalog) {
283
276
  const slash = model.indexOf("/");
284
- if (slash <= 0 || slash === model.length - 1) {
285
- errors.push(`${path} must be a full OpenCode model ID in provider/model form`);
286
- return;
287
- }
277
+ if (slash <= 0 || slash === model.length - 1)
278
+ return `${path} must be a full OpenCode model ID in provider/model form`;
288
279
  if (!catalog)
289
- return;
280
+ return undefined;
290
281
  const providerId = model.slice(0, slash);
291
282
  const modelId = model.slice(slash + 1);
292
283
  const models = catalog[providerId];
293
- if (!models) {
294
- 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`);
295
345
  return;
296
346
  }
297
- if (!models.includes(modelId)) {
298
- 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);
299
364
  }
365
+ errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
300
366
  }
301
367
  function validateReviewerList(reviewers, path, errors, catalog) {
302
368
  if (reviewers == null)
@@ -317,14 +383,11 @@ function validateReviewerList(reviewers, path, errors, catalog) {
317
383
  validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
318
384
  if (!reviewer.model)
319
385
  errors.push(`${path}[${index}].model is required`);
320
- validateString(reviewer.model, `${path}[${index}].model`, errors);
321
- validateModel(reviewer.model, `${path}[${index}].model`, errors, catalog);
386
+ validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
322
387
  if (!reviewer.account)
323
388
  errors.push(`${path}[${index}].account is required`);
324
389
  validateString(reviewer.account, `${path}[${index}].account`, errors);
325
390
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
326
- if (reviewer.options != null && !isPlainObject(reviewer.options))
327
- errors.push(`${path}[${index}].options must be an object`);
328
391
  validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
329
392
  if (reviewer.id) {
330
393
  if (!validateReviewerId(reviewer.id)) {
@@ -336,18 +399,18 @@ function validateReviewerList(reviewers, path, errors, catalog) {
336
399
  }
337
400
  });
338
401
  }
339
- function validateTriageAgentList(agents, path, errors, catalog) {
340
- if (agents == null)
402
+ function validateTriageAgentList(voters, path, errors, catalog) {
403
+ if (voters == null)
341
404
  return;
342
- if (!Array.isArray(agents)) {
405
+ if (!Array.isArray(voters)) {
343
406
  errors.push(`${path} must be an array`);
344
407
  return;
345
408
  }
346
- if (agents.length < 3)
347
- errors.push(`${path} must contain at least 3 agents`);
348
- if (agents.length % 2 === 0)
349
- errors.push(`${path} must contain an odd number of agents`);
350
- 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) => {
351
414
  if (!agent || typeof agent !== "object") {
352
415
  errors.push(`${path}[${index}] must be an object`);
353
416
  return;
@@ -355,14 +418,11 @@ function validateTriageAgentList(agents, path, errors, catalog) {
355
418
  validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
356
419
  if (!agent.model)
357
420
  errors.push(`${path}[${index}].model is required`);
358
- validateString(agent.model, `${path}[${index}].model`, errors);
359
- validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
421
+ validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
360
422
  if (!agent.account)
361
423
  errors.push(`${path}[${index}].account is required`);
362
424
  validateString(agent.account, `${path}[${index}].account`, errors);
363
425
  validateString(agent.persona, `${path}[${index}].persona`, errors);
364
- if (agent.options != null && !isPlainObject(agent.options))
365
- errors.push(`${path}[${index}].options must be an object`);
366
426
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
367
427
  if (agent.id) {
368
428
  if (!validateReviewerId(agent.id)) {
@@ -408,15 +468,11 @@ function validateEditor(editor, path, errors, catalog) {
408
468
  if (!editor.model)
409
469
  errors.push(`${path}.model is required`);
410
470
  validateKnownKeys(editor, path, EDITOR_KEYS, errors);
411
- validateString(editor.model, `${path}.model`, errors);
471
+ validateAndNormalizeModel(editor, `${path}.model`, errors, catalog);
412
472
  validateString(editor.account, `${path}.account`, errors);
413
473
  validateString(editor.persona, `${path}.persona`, errors);
414
- validateModel(editor.model, `${path}.model`, errors, catalog);
415
474
  if (!editor.account)
416
475
  errors.push(`${path}.account is required`);
417
- if (editor.options != null && !isPlainObject(editor.options)) {
418
- errors.push(`${path}.options must be an object`);
419
- }
420
476
  validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
421
477
  const author = editor.author;
422
478
  if (!author || !isPlainObject(author)) {
@@ -452,12 +508,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
452
508
  if (!creator.model)
453
509
  errors.push(`${path}.model is required`);
454
510
  validateString(creator.account, `${path}.account`, errors);
455
- validateString(creator.model, `${path}.model`, errors);
511
+ validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
456
512
  validateString(creator.persona, `${path}.persona`, errors);
457
- validateModel(creator.model, `${path}.model`, errors, catalog);
458
- if (creator.options != null && !isPlainObject(creator.options)) {
459
- errors.push(`${path}.options must be an object`);
460
- }
461
513
  validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
462
514
  const author = creator.author;
463
515
  if (!author || !isPlainObject(author)) {
@@ -682,15 +734,20 @@ function validateTriage(config, errors, options) {
682
734
  const creator = triage.creator;
683
735
  const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
684
736
  const safety = triage.safety;
685
- if (!triage.agents)
686
- errors.push("triage.agents is required");
687
- validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
688
- if (Array.isArray(triage.agents)) {
689
- 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
+ }));
690
747
  validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
691
748
  if (reporter != null &&
692
749
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
693
- errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
750
+ errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
694
751
  }
695
752
  }
696
753
  validateString(triage.reporter, "triage.reporter", errors);
@@ -780,10 +837,10 @@ async function fetchPermissions(config, exec, account) {
780
837
  return JSON.parse(raw);
781
838
  }
782
839
  async function validateWorktreeConfig(config, exec, options, errors) {
783
- const agents = resolveAgents(config);
784
- const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
840
+ const checkEditor = Boolean(config.merge?.editor &&
841
+ (options.requireEditor || options.requireWorktreeConfig));
785
842
  const checkTriageCreator = Boolean(config.triage?.automation?.create &&
786
- agents.triageCreator &&
843
+ config.triage?.creator &&
787
844
  (options.requireTriage || options.requireWorktreeConfig));
788
845
  if (!checkEditor && !checkTriageCreator)
789
846
  return;
@@ -855,6 +912,9 @@ export async function validateConfig(config, options = {}) {
855
912
  const warnings = [];
856
913
  if (!config || typeof config !== "object")
857
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
+ }
858
918
  expandAgentRefs(config, errors);
859
919
  if (config && typeof config === "object")
860
920
  validateJsonSchema(config, errors);
@@ -878,11 +938,20 @@ export async function validateConfig(config, options = {}) {
878
938
  else {
879
939
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
880
940
  }
881
- if (!config.review.agents)
882
- errors.push("review.agents is required");
883
- validateReviewerList(config.review.agents, "review.agents", errors, options.modelCatalog);
884
- if (Array.isArray(config.review.agents)) {
885
- 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);
886
955
  }
887
956
  }
888
957
  if (options.requireTriage && !config.triage) {
@@ -20,6 +20,20 @@ function errorText(error) {
20
20
  .filter((item) => typeof item === "string")
21
21
  .join("\n");
22
22
  }
23
+ function isIssueTypeUnavailableText(text) {
24
+ return (/cannot query field ["']?issueType["']? on type ["']?Issue["']?/i.test(text) ||
25
+ /field ["']?issueType["']?.*(does not exist|doesn't exist|is not defined|not found).*type ["']?Issue["']?/i.test(text) ||
26
+ /undefinedField.*issueType/i.test(text) ||
27
+ /issueType.*unsupported field|unsupported field.*issueType/i.test(text));
28
+ }
29
+ function isIssueTypeUnavailableError(error) {
30
+ return isIssueTypeUnavailableText(errorText(error));
31
+ }
32
+ function isIssueTypeUnavailableGraphqlResponse(data) {
33
+ return (data.errors?.some((error) => isIssueTypeUnavailableText([error.message, error.type]
34
+ .filter((item) => typeof item === "string")
35
+ .join("\n"))) ?? false);
36
+ }
23
37
  async function localCommitExists(exec, worktreePath, sha) {
24
38
  try {
25
39
  await exec(`git cat-file -e ${shellQuote(`${sha}^{commit}`)}`, {
@@ -184,26 +198,34 @@ export async function fetchPullRequestClosingIssues(exec, repository, pr) {
184
198
  }
185
199
  export async function fetchIssue(exec, repository, issue) {
186
200
  const query = `query($owner: String!, $repo: String!, $issue: Int!) { repository(owner: $owner, name: $repo) { issue(number: $issue) { number title body url state author { login } labels(first: 100) { nodes { name } } issueType { name } } } }`;
201
+ let raw;
187
202
  try {
188
- const raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F issue=${issue}`);
189
- const data = JSON.parse(raw);
190
- const graphqlIssue = data.data?.repository?.issue;
191
- if (!graphqlIssue)
192
- throw new Error(`Could not fetch issue #${issue}`);
193
- return {
194
- author: graphqlIssue.author?.login ?? "",
195
- body: graphqlIssue.body ?? "",
196
- labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
197
- number: graphqlIssue.number,
198
- state: graphqlIssue.state,
199
- title: graphqlIssue.title,
200
- type: graphqlIssue.issueType?.name,
201
- url: graphqlIssue.url,
202
- };
203
+ raw = await exec(`gh api${ghHostOption(repository)} graphql -f query=${shellQuote(query)} -F owner=${shellQuote(repository.github.owner)} -F repo=${shellQuote(repository.github.repo)} -F issue=${issue}`);
203
204
  }
204
- catch {
205
- return fetchIssueWithCli(exec, repository, issue);
205
+ catch (error) {
206
+ if (isIssueTypeUnavailableError(error)) {
207
+ return fetchIssueWithCli(exec, repository, issue);
208
+ }
209
+ throw error;
210
+ }
211
+ const data = JSON.parse(raw);
212
+ const graphqlIssue = data.data?.repository?.issue;
213
+ if (!graphqlIssue) {
214
+ if (isIssueTypeUnavailableGraphqlResponse(data)) {
215
+ return fetchIssueWithCli(exec, repository, issue);
216
+ }
217
+ throw new Error(`Could not fetch issue #${issue}`);
206
218
  }
219
+ return {
220
+ author: graphqlIssue.author?.login ?? "",
221
+ body: graphqlIssue.body ?? "",
222
+ labels: graphqlIssue.labels?.nodes?.map((label) => label.name) ?? [],
223
+ number: graphqlIssue.number,
224
+ state: graphqlIssue.state,
225
+ title: graphqlIssue.title,
226
+ type: graphqlIssue.issueType?.name,
227
+ url: graphqlIssue.url,
228
+ };
207
229
  }
208
230
  async function fetchIssueWithCli(exec, repository, issue) {
209
231
  const raw = await exec(`gh issue view ${issue} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,body,url,state,author,labels`);
@@ -458,8 +480,9 @@ export async function fetchPullRequestSafetyMeta(exec, repository, pr) {
458
480
  }
459
481
  return { author, changedFiles, files, labels };
460
482
  }
461
- export async function watchChecks(exec, repository, pr) {
462
- await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch`);
483
+ export async function watchChecks(exec, repository, pr, options = {}) {
484
+ const requiredFlag = options.requiredOnly ? " --required" : "";
485
+ await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --watch${requiredFlag}`);
463
486
  }
464
487
  export function isCancelledCheck(check) {
465
488
  return check.bucket === "cancel" || check.state === "CANCELLED";
@@ -469,8 +492,9 @@ export function isFailedCheck(check) {
469
492
  }
470
493
  export async function fetchPullRequestChecks(exec, repository, pr, options = {}) {
471
494
  let raw;
495
+ const requiredFlag = options.requiredOnly ? " --required" : "";
472
496
  try {
473
- raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow`);
497
+ raw = await exec(`gh pr checks ${pr} --repo ${shellQuote(repoSpecifier(repository))} --json name,state,bucket,link,workflow${requiredFlag}`);
474
498
  }
475
499
  catch (error) {
476
500
  if (options.tolerateMissingChecks &&
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,
@@ -229,7 +229,7 @@ async function watchRerunRuns(exec, repository, checks) {
229
229
  await Promise.all(runIds.map((runId) => watchRun(exec, repository, runId)));
230
230
  }
231
231
  async function checksForHead(input) {
232
- const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr, { tolerateMissingChecks: Boolean(input.headSha) });
232
+ const checks = await fetchPullRequestChecks(input.exec, input.repository, input.pr, { requiredOnly: true, tolerateMissingChecks: Boolean(input.headSha) });
233
233
  const targetChecks = [];
234
234
  let hasAnyActionCheck = false;
235
235
  let hasTargetActionCheck = false;
@@ -254,7 +254,6 @@ async function checksForHead(input) {
254
254
  return {
255
255
  blocking: targetChecks.filter((check) => isFailedCheck(check) || isCancelledCheck(check)),
256
256
  hasAnyActionCheck,
257
- hasAnyCheck: checks.length > 0,
258
257
  hasPending: targetChecks.some(isPendingCheck),
259
258
  hasTargetActionCheck,
260
259
  };
@@ -463,15 +462,17 @@ export async function waitForChecksWithClassification(input) {
463
462
  await input.onProgress?.("waiting for CI checks");
464
463
  for (let attempt = 0;; attempt += 1) {
465
464
  try {
466
- await watchChecks(input.exec, input.repository, input.pr);
465
+ await watchChecks(input.exec, input.repository, input.pr, {
466
+ requiredOnly: true,
467
+ });
467
468
  }
468
469
  catch {
469
470
  // gh exits non-zero for pending checks too; re-read check state below.
470
471
  }
471
472
  const target = await readTargetChecks();
472
473
  const waitingForTargetHead = Boolean(input.headSha) &&
473
- (!target.hasAnyCheck ||
474
- (target.hasAnyActionCheck && !target.hasTargetActionCheck));
474
+ target.hasAnyActionCheck &&
475
+ !target.hasTargetActionCheck;
475
476
  if (!waitingForTargetHead && !target.hasPending) {
476
477
  await assignBlockingChecks(target.blocking);
477
478
  break;
@@ -552,8 +553,11 @@ export async function waitForChecksWithClassification(input) {
552
553
  try {
553
554
  await input.onProgress?.("waiting for rerun CI checks");
554
555
  await watchRerunRuns(input.exec, input.repository, rerunnable);
555
- if (input.wait)
556
- await watchChecks(input.exec, input.repository, input.pr);
556
+ if (input.wait) {
557
+ await watchChecks(input.exec, input.repository, input.pr, {
558
+ requiredOnly: true,
559
+ });
560
+ }
557
561
  }
558
562
  catch {
559
563
  // Re-read the PR checks below so stale failed checks are not trusted.
@@ -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",
@@ -156,7 +157,7 @@ async function runVote(input) {
156
157
  parse: input.parse,
157
158
  permission: input.agent.permission,
158
159
  prompt,
159
- repairAttempts: 3,
160
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
160
161
  schemaName: input.schemaName,
161
162
  signal: input.signal,
162
163
  title: `Magi triage ${input.schemaName} #${input.issue} (${input.agent.key})`,
@@ -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([
@@ -484,7 +502,7 @@ async function classifyMentionReplies(input) {
484
502
  parse: parseTriageCommentClassificationOutput,
485
503
  permission: agent.permission,
486
504
  prompt,
487
- repairAttempts: 3,
505
+ repairAttempts: input.input.config.output?.repairAttempts ?? 3,
488
506
  schemaName: "triage comment classification",
489
507
  signal: input.input.signal,
490
508
  title: `Magi triage comment classification #${input.input.issue} (${agent.key})`,
@@ -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);
@@ -823,7 +861,7 @@ async function createImplementationPr(input) {
823
861
  parse: parseTriageCreatePrOutput,
824
862
  permission: creator.permission,
825
863
  prompt,
826
- repairAttempts: 3,
864
+ repairAttempts: input.input.config.output?.repairAttempts ?? 3,
827
865
  schemaName: "triage create PR",
828
866
  signal: input.input.signal,
829
867
  title: `Magi triage create PR #${input.issue.number}`,
@@ -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" }
@@ -339,38 +339,6 @@ async function composeTriageVotePrompt(input) {
339
339
  .filter(Boolean)
340
340
  .join("\n\n");
341
341
  }
342
- export async function composeTriageCommentPrompt(input) {
343
- const values = triageValues(input);
344
- const task = await taskBlock({
345
- builtin: "triage/comment",
346
- customPath: input.repository.triage?.prompts.comment,
347
- directory: input.directory,
348
- values,
349
- });
350
- return [
351
- task,
352
- languageBlock(input.repository.language),
353
- `<context>\n${input.context}\n</context>`,
354
- ]
355
- .filter(Boolean)
356
- .join("\n\n");
357
- }
358
- export async function composeTriageQuestionPrompt(input) {
359
- const values = triageValues(input);
360
- const task = await taskBlock({
361
- builtin: "triage/question",
362
- customPath: input.repository.triage?.prompts.question,
363
- directory: input.directory,
364
- values,
365
- });
366
- return [
367
- task,
368
- languageBlock(input.repository.language),
369
- `<context>\n${input.context}\n</context>`,
370
- ]
371
- .filter(Boolean)
372
- .join("\n\n");
373
- }
374
342
  export async function composeTriageCreatePrPrompt(input) {
375
343
  const values = triageValues(input);
376
344
  const task = await taskBlock({
@@ -1,6 +1,6 @@
1
1
  Decide whether an existing related pull request already handles issue #{issue} in {owner}/{repo}.
2
2
 
3
- Use only the provided context. Return HANDLE only when the PR clearly addresses the issue.
3
+ Use only the provided context. Return RELATED_PR_HANDLES_ISSUE only when the PR clearly addresses the issue. Otherwise return RELATED_PR_DOES_NOT_HANDLE_ISSUE.
4
4
 
5
5
  <context>
6
6
  {context}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.6.0",
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,
@@ -229,8 +249,6 @@
229
249
  "duplicate": { "type": "string" },
230
250
  "category": { "type": "string" },
231
251
  "acceptance": { "type": "string" },
232
- "question": { "type": "string" },
233
- "comment": { "type": "string" },
234
252
  "commentClassification": { "type": "string" },
235
253
  "reconsider": { "type": "string" },
236
254
  "create": { "type": "string" },
@@ -299,7 +317,7 @@
299
317
  "type": "object",
300
318
  "additionalProperties": false,
301
319
  "properties": {
302
- "agents": {
320
+ "reviewers": {
303
321
  "type": "array",
304
322
  "minItems": 3,
305
323
  "items": { "$ref": "#/$defs/reviewer" }
@@ -334,7 +352,7 @@
334
352
  "type": "object",
335
353
  "additionalProperties": false,
336
354
  "properties": {
337
- "agents": {
355
+ "voters": {
338
356
  "type": "array",
339
357
  "minItems": 3,
340
358
  "items": { "$ref": "#/$defs/triageAgent" }
@@ -1,5 +0,0 @@
1
- Compose one concise final triage comment for issue #{issue} in {owner}/{repo}. Mention @{author}. Do not include markdown fences.
2
-
3
- <context>
4
- {context}
5
- </context>
@@ -1,5 +0,0 @@
1
- Compose concrete, actionable questions for issue #{issue} in {owner}/{repo}. Mention @{author}. Do not include markdown fences.
2
-
3
- <context>
4
- {context}
5
- </context>