opencode-magi 0.6.1 → 0.8.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
 
@@ -24,6 +24,31 @@ const DEFAULT_TRIAGE_CATEGORIES = [
24
24
  types: ["Feature"],
25
25
  },
26
26
  ];
27
+ export const DEFAULT_TRIAGE_LABEL_RULES = [
28
+ { remove: ["triage"], when: { disposition: "accepted" } },
29
+ {
30
+ add: ["duplicate"],
31
+ remove: ["triage"],
32
+ when: { disposition: "duplicate" },
33
+ },
34
+ {
35
+ add: ["duplicate"],
36
+ remove: ["triage"],
37
+ when: { disposition: "already_handled" },
38
+ },
39
+ {
40
+ add: ["wontfix"],
41
+ remove: ["triage"],
42
+ when: { disposition: "rejected" },
43
+ },
44
+ {
45
+ add: ["invalid"],
46
+ remove: ["triage"],
47
+ when: { disposition: "invalid" },
48
+ },
49
+ { add: ["question"], when: { disposition: "needs_category" } },
50
+ { add: ["question"], when: { disposition: "needs_acceptance" } },
51
+ ];
27
52
  export function reviewerKey(reviewer, index) {
28
53
  return reviewer.id ?? `reviewer-${index + 1}`;
29
54
  }
@@ -33,6 +58,12 @@ export function triageAgentKey(agent, index) {
33
58
  export function validateReviewerId(id) {
34
59
  return ID_PATTERN.test(id);
35
60
  }
61
+ function normalizedModel(model) {
62
+ if (typeof model !== "string") {
63
+ throw new Error("model must be normalized before resolving agents");
64
+ }
65
+ return model;
66
+ }
36
67
  function clonePermissionValue(value) {
37
68
  return typeof value === "string" ? value : { ...value };
38
69
  }
@@ -86,25 +117,29 @@ export function resolveAgents(config) {
86
117
  editor: editor
87
118
  ? {
88
119
  ...editor,
120
+ model: normalizedModel(editor.model),
89
121
  permission: resolveEditorPermission(agents, editor),
90
122
  }
91
123
  : undefined,
92
- reviewers: (config.review?.agents ?? []).map((reviewer, index) => ({
124
+ reviewers: (config.review?.reviewers ?? []).map((reviewer, index) => ({
93
125
  ...reviewer,
94
126
  key: reviewerKey(reviewer, index),
95
127
  index,
128
+ model: normalizedModel(reviewer.model),
96
129
  permission: resolveReviewerPermission(agents, reviewer),
97
130
  })),
98
- triage: (config.triage?.agents ?? []).map((agent, index) => ({
131
+ triage: (config.triage?.voters ?? []).map((agent, index) => ({
99
132
  ...agent,
100
133
  key: triageAgentKey(agent, index),
101
134
  index,
135
+ model: normalizedModel(agent.model),
102
136
  permission: resolveTriageAgentPermission(agents, agent),
103
137
  })),
104
138
  triageCreator: creator
105
139
  ? {
106
140
  ...creator,
107
141
  account: creator.account ?? "",
142
+ model: normalizedModel(creator.model),
108
143
  permission: resolveTriageCreatorPermission(agents, creator),
109
144
  }
110
145
  : undefined,
@@ -128,6 +163,7 @@ export function resolveRepository(config) {
128
163
  agents: resolveAgents(config),
129
164
  automation: {
130
165
  close: config.merge?.automation?.close ?? false,
166
+ conflict: config.merge?.automation?.conflict ?? false,
131
167
  merge: config.merge?.automation?.merge ?? true,
132
168
  },
133
169
  checks: {
@@ -180,9 +216,9 @@ export function resolveRepository(config) {
180
216
  },
181
217
  triage: {
182
218
  automation: {
183
- clear: config.triage?.automation?.clear ?? ["triage"],
184
219
  close: config.triage?.automation?.close ?? false,
185
220
  create: config.triage?.automation?.create ?? false,
221
+ label: config.triage?.automation?.label ?? DEFAULT_TRIAGE_LABEL_RULES,
186
222
  merge: config.triage?.automation?.merge ?? false,
187
223
  review: config.triage?.automation?.review ?? false,
188
224
  },
@@ -205,6 +241,7 @@ export function resolveRepository(config) {
205
241
  blockedLabels: config.triage?.safety?.blockedLabels ?? [],
206
242
  requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
207
243
  },
244
+ signals: config.triage?.signals ?? [],
208
245
  worktree: config.triage?.worktree,
209
246
  },
210
247
  };
@@ -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,8 @@ const TRIAGE_KEYS = new Set([
85
80
  "prompts",
86
81
  "reporter",
87
82
  "safety",
83
+ "signals",
84
+ "voters",
88
85
  "worktree",
89
86
  ]);
90
87
  const REVIEW_MERGE_KEYS = new Set([
@@ -97,17 +94,24 @@ const REVIEW_MERGE_KEYS = new Set([
97
94
  const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
98
95
  const MERGE_CHECKS_KEYS = new Set(["wait"]);
99
96
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
97
+ const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
100
98
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
101
99
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
102
100
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
103
101
  const TRIAGE_AUTOMATION_KEYS = new Set([
104
- "clear",
105
102
  "close",
106
103
  "create",
104
+ "label",
107
105
  "merge",
108
106
  "review",
109
107
  ]);
110
108
  const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
109
+ const TRIAGE_LABEL_RULE_KEYS = new Set(["add", "remove", "when"]);
110
+ const TRIAGE_LABEL_RULE_WHEN_KEYS = new Set([
111
+ "category",
112
+ "disposition",
113
+ "signals",
114
+ ]);
111
115
  const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
112
116
  const TRIAGE_SAFETY_KEYS = new Set([
113
117
  "allowAuthors",
@@ -116,6 +120,18 @@ const TRIAGE_SAFETY_KEYS = new Set([
116
120
  "blockedLabels",
117
121
  "requiredLabels",
118
122
  ]);
123
+ const TRIAGE_SIGNAL_KEYS = new Set(["description", "id"]);
124
+ const TRIAGE_DISPOSITIONS = new Set([
125
+ "accepted",
126
+ "rejected",
127
+ "invalid",
128
+ "duplicate",
129
+ "already_handled",
130
+ "needs_category",
131
+ "needs_acceptance",
132
+ "blocked",
133
+ "failed",
134
+ ]);
119
135
  const SAFETY_KEYS = new Set([
120
136
  "allowAuthors",
121
137
  "blockedPaths",
@@ -145,6 +161,7 @@ const TRIAGE_PROMPT_KEYS = new Set([
145
161
  "existingPr",
146
162
  "reconsider",
147
163
  ]);
164
+ const MODEL_CANDIDATE_KEYS = new Set(["id", "options"]);
148
165
  function githubHost(config) {
149
166
  return config.github?.host ?? "github.com";
150
167
  }
@@ -190,14 +207,14 @@ function expandAgentRefs(config, errors) {
190
207
  const refsValue = isPlainObject(agents) ? agents.refs : undefined;
191
208
  const refsInvalid = refsValue != null && !isPlainObject(refsValue);
192
209
  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));
210
+ if (Array.isArray(magiConfig.review?.reviewers)) {
211
+ magiConfig.review.reviewers = magiConfig.review.reviewers.map((agent, index) => expandAgentRefUse(agent, `review.reviewers[${index}]`, refs, refsInvalid, errors));
195
212
  }
196
213
  if (isPlainObject(magiConfig.merge?.editor)) {
197
214
  magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
198
215
  }
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));
216
+ if (Array.isArray(magiConfig.triage?.voters)) {
217
+ magiConfig.triage.voters = magiConfig.triage.voters.map((agent, index) => expandAgentRefUse(agent, `triage.voters[${index}]`, refs, refsInvalid, errors));
201
218
  }
202
219
  if (isPlainObject(magiConfig.triage?.creator)) {
203
220
  magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
@@ -275,26 +292,97 @@ function validatePermissionConfig(permission, path, errors) {
275
292
  }
276
293
  }
277
294
  }
278
- function validateModel(model, path, errors, catalog) {
279
- if (!model)
280
- return;
295
+ function modelValidationError(model, path, catalog) {
281
296
  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
- }
297
+ if (slash <= 0 || slash === model.length - 1)
298
+ return `${path} must be a full OpenCode model ID in provider/model form`;
286
299
  if (!catalog)
287
- return;
300
+ return undefined;
288
301
  const providerId = model.slice(0, slash);
289
302
  const modelId = model.slice(slash + 1);
290
303
  const models = catalog[providerId];
291
- if (!models) {
292
- errors.push(`${path} uses unknown OpenCode provider: ${providerId}`);
304
+ if (!models)
305
+ return `${path} uses unknown OpenCode provider: ${providerId}`;
306
+ if (!models.includes(modelId))
307
+ return `${path} uses unknown OpenCode model: ${model}`;
308
+ return undefined;
309
+ }
310
+ function validateModelId(model, path, errors, catalog) {
311
+ const error = modelValidationError(model, path, catalog);
312
+ if (error) {
313
+ errors.push(error);
314
+ return false;
315
+ }
316
+ return true;
317
+ }
318
+ function readModelCandidate(value, path, errors) {
319
+ if (typeof value === "string")
320
+ return { id: value };
321
+ if (!isPlainObject(value)) {
322
+ errors.push(`${path} must be a string or an object`);
323
+ return undefined;
324
+ }
325
+ validateKnownKeys(value, path, MODEL_CANDIDATE_KEYS, errors);
326
+ if (typeof value.id !== "string") {
327
+ errors.push(`${path}.id must be a string`);
328
+ return undefined;
329
+ }
330
+ if (value.options != null && !isPlainObject(value.options)) {
331
+ errors.push(`${path}.options must be an object`);
332
+ return undefined;
333
+ }
334
+ return { id: value.id, options: value.options };
335
+ }
336
+ function validateAndNormalizeModel(target, path, errors, catalog) {
337
+ const model = target.model;
338
+ if (typeof model === "string") {
339
+ validateModelId(model, path, errors, catalog);
340
+ return;
341
+ }
342
+ if (isPlainObject(model)) {
343
+ const candidate = readModelCandidate(model, path, errors);
344
+ if (candidate &&
345
+ validateModelId(candidate.id, `${path}.id`, errors, catalog)) {
346
+ target.model = candidate.id;
347
+ if (candidate.options)
348
+ target.options = candidate.options;
349
+ else
350
+ delete target.options;
351
+ }
352
+ return;
353
+ }
354
+ if (!Array.isArray(model)) {
355
+ if (model != null)
356
+ errors.push(`${path} must be a string, an object, or an array`);
357
+ return;
358
+ }
359
+ if (!model.length) {
360
+ errors.push(`${path} must contain at least one model candidate`);
293
361
  return;
294
362
  }
295
- if (!models.includes(modelId)) {
296
- errors.push(`${path} uses unknown OpenCode model: ${model}`);
363
+ if (!catalog) {
364
+ errors.push(`${path} requires an OpenCode model catalog`);
365
+ return;
297
366
  }
367
+ const candidateErrors = [];
368
+ for (const [index, value] of model.entries()) {
369
+ const candidatePath = `${path}[${index}]`;
370
+ const candidate = readModelCandidate(value, candidatePath, errors);
371
+ if (!candidate)
372
+ continue;
373
+ const idPath = isPlainObject(value) ? `${candidatePath}.id` : candidatePath;
374
+ const error = modelValidationError(candidate.id, idPath, catalog);
375
+ if (!error) {
376
+ target.model = candidate.id;
377
+ if (candidate.options)
378
+ target.options = candidate.options;
379
+ else
380
+ delete target.options;
381
+ return;
382
+ }
383
+ candidateErrors.push(error);
384
+ }
385
+ errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
298
386
  }
299
387
  function validateReviewerList(reviewers, path, errors, catalog) {
300
388
  if (reviewers == null)
@@ -315,14 +403,11 @@ function validateReviewerList(reviewers, path, errors, catalog) {
315
403
  validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
316
404
  if (!reviewer.model)
317
405
  errors.push(`${path}[${index}].model is required`);
318
- validateString(reviewer.model, `${path}[${index}].model`, errors);
319
- validateModel(reviewer.model, `${path}[${index}].model`, errors, catalog);
406
+ validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
320
407
  if (!reviewer.account)
321
408
  errors.push(`${path}[${index}].account is required`);
322
409
  validateString(reviewer.account, `${path}[${index}].account`, errors);
323
410
  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
411
  validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
327
412
  if (reviewer.id) {
328
413
  if (!validateReviewerId(reviewer.id)) {
@@ -334,18 +419,18 @@ function validateReviewerList(reviewers, path, errors, catalog) {
334
419
  }
335
420
  });
336
421
  }
337
- function validateTriageAgentList(agents, path, errors, catalog) {
338
- if (agents == null)
422
+ function validateTriageAgentList(voters, path, errors, catalog) {
423
+ if (voters == null)
339
424
  return;
340
- if (!Array.isArray(agents)) {
425
+ if (!Array.isArray(voters)) {
341
426
  errors.push(`${path} must be an array`);
342
427
  return;
343
428
  }
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) => {
429
+ if (voters.length < 3)
430
+ errors.push(`${path} must contain at least 3 voters`);
431
+ if (voters.length % 2 === 0)
432
+ errors.push(`${path} must contain an odd number of voters`);
433
+ voters.forEach((agent, index) => {
349
434
  if (!agent || typeof agent !== "object") {
350
435
  errors.push(`${path}[${index}] must be an object`);
351
436
  return;
@@ -353,14 +438,11 @@ function validateTriageAgentList(agents, path, errors, catalog) {
353
438
  validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
354
439
  if (!agent.model)
355
440
  errors.push(`${path}[${index}].model is required`);
356
- validateString(agent.model, `${path}[${index}].model`, errors);
357
- validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
441
+ validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
358
442
  if (!agent.account)
359
443
  errors.push(`${path}[${index}].account is required`);
360
444
  validateString(agent.account, `${path}[${index}].account`, errors);
361
445
  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
446
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
365
447
  if (agent.id) {
366
448
  if (!validateReviewerId(agent.id)) {
@@ -406,15 +488,11 @@ function validateEditor(editor, path, errors, catalog) {
406
488
  if (!editor.model)
407
489
  errors.push(`${path}.model is required`);
408
490
  validateKnownKeys(editor, path, EDITOR_KEYS, errors);
409
- validateString(editor.model, `${path}.model`, errors);
491
+ validateAndNormalizeModel(editor, `${path}.model`, errors, catalog);
410
492
  validateString(editor.account, `${path}.account`, errors);
411
493
  validateString(editor.persona, `${path}.persona`, errors);
412
- validateModel(editor.model, `${path}.model`, errors, catalog);
413
494
  if (!editor.account)
414
495
  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
496
  validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
419
497
  const author = editor.author;
420
498
  if (!author || !isPlainObject(author)) {
@@ -450,12 +528,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
450
528
  if (!creator.model)
451
529
  errors.push(`${path}.model is required`);
452
530
  validateString(creator.account, `${path}.account`, errors);
453
- validateString(creator.model, `${path}.model`, errors);
531
+ validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
454
532
  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
533
  validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
460
534
  const author = creator.author;
461
535
  if (!author || !isPlainObject(author)) {
@@ -498,7 +572,7 @@ function validateMerge(config, errors, options) {
498
572
  errors.push("merge must be an object");
499
573
  }
500
574
  validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
501
- validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
575
+ validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
502
576
  const checks = merge?.checks;
503
577
  validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
504
578
  validateBoolean(checks?.wait, "merge.checks.wait", errors);
@@ -637,6 +711,79 @@ function validateTriageCategories(categories, path, errors) {
637
711
  validateString(category.description, `${itemPath}.description`, errors);
638
712
  });
639
713
  }
714
+ function validateTriageSignals(signals, path, errors) {
715
+ if (signals == null)
716
+ return;
717
+ if (!Array.isArray(signals)) {
718
+ errors.push(`${path} must be an array`);
719
+ return;
720
+ }
721
+ const ids = new Set();
722
+ signals.forEach((item, index) => {
723
+ const itemPath = `${path}[${index}]`;
724
+ if (!isPlainObject(item)) {
725
+ errors.push(`${itemPath} must be an object`);
726
+ return;
727
+ }
728
+ const signal = item;
729
+ validateKnownKeys(signal, itemPath, TRIAGE_SIGNAL_KEYS, errors);
730
+ if (!signal.id) {
731
+ errors.push(`${itemPath}.id is required`);
732
+ }
733
+ else if (typeof signal.id !== "string") {
734
+ errors.push(`${itemPath}.id must be a string`);
735
+ }
736
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(signal.id)) {
737
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
738
+ }
739
+ else if (ids.has(signal.id)) {
740
+ errors.push(`${itemPath}.id must be unique`);
741
+ }
742
+ else {
743
+ ids.add(signal.id);
744
+ }
745
+ if (!signal.description) {
746
+ errors.push(`${itemPath}.description is required`);
747
+ }
748
+ else {
749
+ validateString(signal.description, `${itemPath}.description`, errors);
750
+ }
751
+ });
752
+ }
753
+ function validateTriageLabelRules(rules, path, errors) {
754
+ if (rules == null)
755
+ return;
756
+ if (!Array.isArray(rules)) {
757
+ errors.push(`${path} must be an array`);
758
+ return;
759
+ }
760
+ rules.forEach((item, index) => {
761
+ const itemPath = `${path}[${index}]`;
762
+ if (!isPlainObject(item)) {
763
+ errors.push(`${itemPath} must be an object`);
764
+ return;
765
+ }
766
+ const rule = item;
767
+ validateKnownKeys(rule, itemPath, TRIAGE_LABEL_RULE_KEYS, errors);
768
+ validateStringArray(rule.add, `${itemPath}.add`, errors);
769
+ validateStringArray(rule.remove, `${itemPath}.remove`, errors);
770
+ if (!isPlainObject(rule.when)) {
771
+ errors.push(`${itemPath}.when must be an object`);
772
+ return;
773
+ }
774
+ validateKnownKeys(rule.when, `${itemPath}.when`, TRIAGE_LABEL_RULE_WHEN_KEYS, errors);
775
+ if (!Object.keys(rule.when).length) {
776
+ errors.push(`${itemPath}.when must not be empty`);
777
+ }
778
+ if (rule.when.disposition != null &&
779
+ (typeof rule.when.disposition !== "string" ||
780
+ !TRIAGE_DISPOSITIONS.has(rule.when.disposition))) {
781
+ errors.push(`${itemPath}.when.disposition must be a triage disposition`);
782
+ }
783
+ validateString(rule.when.category, `${itemPath}.when.category`, errors);
784
+ validateStringArray(rule.when.signals, `${itemPath}.when.signals`, errors);
785
+ });
786
+ }
640
787
  function validateSafety(config, errors) {
641
788
  const safety = config.review?.safety;
642
789
  if (safety != null && !isPlainObject(safety)) {
@@ -680,15 +827,20 @@ function validateTriage(config, errors, options) {
680
827
  const creator = triage.creator;
681
828
  const reporter = typeof triage.reporter === "string" ? triage.reporter : undefined;
682
829
  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 ?? [];
830
+ if (!triage.voters)
831
+ errors.push("triage.voters is required");
832
+ validateTriageAgentList(triage.voters, "triage.voters", errors, options.modelCatalog);
833
+ if (Array.isArray(triage.voters)) {
834
+ const resolvedTriageAgents = triage.voters.map((agent, index) => ({
835
+ account: agent && typeof agent === "object" && typeof agent.account === "string"
836
+ ? agent.account
837
+ : "",
838
+ key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
839
+ }));
688
840
  validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
689
841
  if (reporter != null &&
690
842
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
691
- errors.push(`triage.reporter must match a triage agent key: ${reporter}`);
843
+ errors.push(`triage.reporter must match a triage voter key: ${reporter}`);
692
844
  }
693
845
  }
694
846
  validateString(triage.reporter, "triage.reporter", errors);
@@ -705,7 +857,7 @@ function validateTriage(config, errors, options) {
705
857
  validateBoolean(automation?.create, "triage.automation.create", errors);
706
858
  validateBoolean(automation?.merge, "triage.automation.merge", errors);
707
859
  validateBoolean(automation?.review, "triage.automation.review", errors);
708
- validateStringArray(automation?.clear, "triage.automation.clear", errors);
860
+ validateTriageLabelRules(automation?.label, "triage.automation.label", errors);
709
861
  if (automation?.review && !automation.create) {
710
862
  errors.push("triage.automation.review requires triage.automation.create to be true");
711
863
  }
@@ -720,6 +872,7 @@ function validateTriage(config, errors, options) {
720
872
  errors.push("triage.concurrency.runs must be a positive integer");
721
873
  }
722
874
  validateTriageCategories(triage.categories, "triage.categories", errors);
875
+ validateTriageSignals(triage.signals, "triage.signals", errors);
723
876
  validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
724
877
  validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
725
878
  validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
@@ -778,10 +931,10 @@ async function fetchPermissions(config, exec, account) {
778
931
  return JSON.parse(raw);
779
932
  }
780
933
  async function validateWorktreeConfig(config, exec, options, errors) {
781
- const agents = resolveAgents(config);
782
- const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
934
+ const checkEditor = Boolean(config.merge?.editor &&
935
+ (options.requireEditor || options.requireWorktreeConfig));
783
936
  const checkTriageCreator = Boolean(config.triage?.automation?.create &&
784
- agents.triageCreator &&
937
+ config.triage?.creator &&
785
938
  (options.requireTriage || options.requireWorktreeConfig));
786
939
  if (!checkEditor && !checkTriageCreator)
787
940
  return;
@@ -853,6 +1006,9 @@ export async function validateConfig(config, options = {}) {
853
1006
  const warnings = [];
854
1007
  if (!config || typeof config !== "object")
855
1008
  errors.push("config must be an object");
1009
+ if (options.requireModelCatalog && !options.modelCatalog) {
1010
+ errors.push("OpenCode model catalog could not be loaded");
1011
+ }
856
1012
  expandAgentRefs(config, errors);
857
1013
  if (config && typeof config === "object")
858
1014
  validateJsonSchema(config, errors);
@@ -876,11 +1032,20 @@ export async function validateConfig(config, options = {}) {
876
1032
  else {
877
1033
  validateKnownKeys(config.review, "review", REVIEW_KEYS, errors);
878
1034
  }
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);
1035
+ if (!config.review.reviewers)
1036
+ errors.push("review.reviewers is required");
1037
+ validateReviewerList(config.review.reviewers, "review.reviewers", errors, options.modelCatalog);
1038
+ if (Array.isArray(config.review.reviewers)) {
1039
+ validateResolvedReviewers(config.review.reviewers.map((reviewer, index) => ({
1040
+ account: reviewer &&
1041
+ typeof reviewer === "object" &&
1042
+ typeof reviewer.account === "string"
1043
+ ? reviewer.account
1044
+ : "",
1045
+ key: reviewer && typeof reviewer === "object"
1046
+ ? reviewerKey(reviewer, index)
1047
+ : "",
1048
+ })), "review.resolvedReviewers", errors);
884
1049
  }
885
1050
  }
886
1051
  if (options.requireTriage && !config.triage) {