opencode-magi 0.2.0 → 0.4.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.
Files changed (36) hide show
  1. package/README.md +19 -0
  2. package/dist/commands.js +4 -0
  3. package/dist/config/output.js +11 -2
  4. package/dist/config/resolve.js +81 -1
  5. package/dist/config/validate.js +341 -3
  6. package/dist/config/worktree.js +8 -2
  7. package/dist/github/commands.js +381 -19
  8. package/dist/index.js +252 -26
  9. package/dist/orchestrator/ci.js +1 -1
  10. package/dist/orchestrator/findings.js +4 -3
  11. package/dist/orchestrator/inline-comments.js +79 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +108 -34
  14. package/dist/orchestrator/report.js +25 -7
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +122 -14
  17. package/dist/orchestrator/run-manager.js +408 -17
  18. package/dist/orchestrator/triage.js +1119 -0
  19. package/dist/permissions/editor.json +8 -1
  20. package/dist/prompts/compose.js +163 -1
  21. package/dist/prompts/contracts.js +131 -18
  22. package/dist/prompts/output.js +173 -22
  23. package/dist/prompts/templates/merge/edit.md +12 -5
  24. package/dist/prompts/templates/review/review.md +6 -0
  25. package/dist/prompts/templates/triage/acceptance.md +7 -0
  26. package/dist/prompts/templates/triage/action.md +5 -0
  27. package/dist/prompts/templates/triage/category.md +10 -0
  28. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  29. package/dist/prompts/templates/triage/comment.md +5 -0
  30. package/dist/prompts/templates/triage/create.md +7 -0
  31. package/dist/prompts/templates/triage/duplicate.md +7 -0
  32. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  33. package/dist/prompts/templates/triage/question.md +5 -0
  34. package/dist/prompts/templates/triage/reconsider.md +5 -0
  35. package/package.json +5 -2
  36. package/schema.json +162 -5
package/README.md CHANGED
@@ -126,6 +126,23 @@ Add the following content to the configuration file.
126
126
  "email": "your-email@example.com"
127
127
  }
128
128
  }
129
+ },
130
+ "triage": {
131
+ "account": "your-triage-account",
132
+ "agents": [
133
+ {
134
+ "id": "general",
135
+ "model": "openai/gpt-5.5"
136
+ },
137
+ {
138
+ "id": "maintenance",
139
+ "model": "anthropic/claude-opus-4-7"
140
+ },
141
+ {
142
+ "id": "product",
143
+ "model": "opencode/kimi-k2-6"
144
+ }
145
+ ]
129
146
  }
130
147
  }
131
148
  ```
@@ -149,6 +166,8 @@ Run commands from OpenCode.
149
166
  /magi:review --dry-run 123
150
167
  /magi:merge 123
151
168
  /magi:merge --dry-run 123
169
+ /magi:triage 47 48
170
+ /magi:triage --dry-run 47
152
171
  /magi:clear
153
172
  ```
154
173
 
package/dist/commands.js CHANGED
@@ -7,6 +7,10 @@ export const MAGI_COMMANDS = {
7
7
  description: "Review and merge pull requests with Magi",
8
8
  template: [`Call the \`magi_merge\` tool.`, "PR: $ARGUMENTS"].join("\n"),
9
9
  },
10
+ "magi:triage": {
11
+ description: "Triage GitHub issues with Magi",
12
+ template: [`Call the \`magi_triage\` tool.`, "Issue: $ARGUMENTS"].join("\n"),
13
+ },
10
14
  "magi:review": {
11
15
  description: "Review pull requests with Magi",
12
16
  template: [`Call the \`magi_review\` tool.`, "PR: $ARGUMENTS"].join("\n"),
@@ -1,16 +1,25 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  const DEFAULT_OUTPUT_DIRS = {
3
+ issue: ".magi/runs/issue",
3
4
  pr: ".magi/runs/pr",
4
5
  };
5
6
  function resolvePath(directory, path) {
6
7
  return isAbsolute(path) ? path : join(directory, path);
7
8
  }
8
9
  export function outputBaseDir(directory, config, kind) {
9
- return resolvePath(directory, config.review?.output ?? DEFAULT_OUTPUT_DIRS[kind]);
10
+ return resolvePath(directory, kind === "issue"
11
+ ? (config.triage?.output ?? DEFAULT_OUTPUT_DIRS[kind])
12
+ : (config.review?.output ?? DEFAULT_OUTPUT_DIRS[kind]));
10
13
  }
11
14
  export function outputBaseDirs(directory, config) {
12
- return [outputBaseDir(directory, config, "pr")];
15
+ return [
16
+ outputBaseDir(directory, config, "pr"),
17
+ outputBaseDir(directory, config, "issue"),
18
+ ];
13
19
  }
14
20
  export function prRunOutputDir(input) {
15
21
  return join(outputBaseDir(input.directory, input.config, "pr"), String(input.pr), ...(input.runId ? [input.runId] : []));
16
22
  }
23
+ export function issueRunOutputDir(input) {
24
+ return join(outputBaseDir(input.directory, input.config, "issue"), String(input.issue), ...(input.runId ? [input.runId] : []));
25
+ }
@@ -4,9 +4,32 @@ const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
4
4
  const DEFAULT_COMMON_PERMISSION = commonPermission;
5
5
  const DEFAULT_REVIEWER_PERMISSION = DEFAULT_COMMON_PERMISSION;
6
6
  const DEFAULT_EDITOR_PERMISSION = mergePermissions(DEFAULT_COMMON_PERMISSION, editorPermission);
7
+ const DEFAULT_TRIAGE_CATEGORIES = [
8
+ {
9
+ description: "Something is broken or behaves incorrectly.",
10
+ id: "bug",
11
+ labels: ["bug"],
12
+ types: ["Bug"],
13
+ },
14
+ {
15
+ description: "Maintenance, refactoring, chores, or planned work.",
16
+ id: "task",
17
+ labels: ["task"],
18
+ types: ["Task"],
19
+ },
20
+ {
21
+ description: "New or improved user-facing capability.",
22
+ id: "feature",
23
+ labels: ["enhancement"],
24
+ types: ["Feature"],
25
+ },
26
+ ];
7
27
  export function reviewerKey(reviewer, index) {
8
28
  return reviewer.id ?? `reviewer-${index + 1}`;
9
29
  }
30
+ export function triageAgentKey(agent, index) {
31
+ return agent.id ?? `triage-${index + 1}`;
32
+ }
10
33
  export function validateReviewerId(id) {
11
34
  return ID_PATTERN.test(id);
12
35
  }
@@ -49,9 +72,16 @@ export function resolveReviewerPermission(agents, reviewer) {
49
72
  export function resolveEditorPermission(agents, editor) {
50
73
  return mergePermissions(mergePermissions(DEFAULT_EDITOR_PERMISSION, agents.permissions), editor.permissions);
51
74
  }
75
+ export function resolveTriageAgentPermission(agents, agent) {
76
+ return mergePermissions(mergePermissions(DEFAULT_REVIEWER_PERMISSION, agents.permissions), agent.permissions);
77
+ }
78
+ export function resolveTriageCreatorPermission(agents, creator) {
79
+ return mergePermissions(mergePermissions(DEFAULT_EDITOR_PERMISSION, agents.permissions), creator.permissions);
80
+ }
52
81
  export function resolveAgents(config) {
53
82
  const agents = config.agents ?? {};
54
83
  const editor = config.merge?.editor;
84
+ const creator = config.triage?.creator;
55
85
  return {
56
86
  editor: editor
57
87
  ? {
@@ -65,8 +95,29 @@ export function resolveAgents(config) {
65
95
  index,
66
96
  permission: resolveReviewerPermission(agents, reviewer),
67
97
  })),
98
+ triage: (config.triage?.agents ?? []).map((agent, index) => ({
99
+ ...agent,
100
+ key: triageAgentKey(agent, index),
101
+ index,
102
+ permission: resolveTriageAgentPermission(agents, agent),
103
+ })),
104
+ triageCreator: creator
105
+ ? {
106
+ ...creator,
107
+ account: creator.account ?? config.triage?.account ?? "",
108
+ permission: resolveTriageCreatorPermission(agents, creator),
109
+ }
110
+ : undefined,
68
111
  };
69
112
  }
113
+ function resolveTriageCategories(config) {
114
+ return (config.triage?.categories ?? DEFAULT_TRIAGE_CATEGORIES).map((category) => ({
115
+ description: category.description,
116
+ id: category.id ?? "",
117
+ labels: category.labels ?? [],
118
+ types: category.types ?? [],
119
+ }));
120
+ }
70
121
  export function resolveRepository(config) {
71
122
  if (!config.github?.owner)
72
123
  throw new Error("github.owner is required");
@@ -119,7 +170,7 @@ export function resolveRepository(config) {
119
170
  },
120
171
  reviewAutomation: {
121
172
  close: config.review?.automation?.close ?? false,
122
- merge: config.review?.automation?.merge ?? false,
173
+ merge: config.review?.automation?.merge ?? true,
123
174
  },
124
175
  safety: {
125
176
  allowAuthors: config.review?.safety?.allowAuthors ?? [],
@@ -127,5 +178,34 @@ export function resolveRepository(config) {
127
178
  maxChangedFiles: config.review?.safety?.maxChangedFiles,
128
179
  requiredLabels: config.review?.safety?.requiredLabels ?? [],
129
180
  },
181
+ triage: {
182
+ account: config.triage?.account,
183
+ automation: {
184
+ clear: config.triage?.automation?.clear ?? ["triage"],
185
+ close: config.triage?.automation?.close ?? false,
186
+ create: config.triage?.automation?.create ?? false,
187
+ merge: config.triage?.automation?.merge ?? false,
188
+ review: config.triage?.automation?.review ?? false,
189
+ },
190
+ categories: resolveTriageCategories(config),
191
+ concurrency: {
192
+ runs: config.triage?.concurrency?.runs ?? 3,
193
+ },
194
+ output: config.triage?.output,
195
+ prompts: config.triage?.prompts ?? {},
196
+ safety: {
197
+ allowAuthors: config.triage?.safety?.allowAuthors ?? [],
198
+ allowMentionActors: config.triage?.safety?.allowMentionActors ?? [],
199
+ allowMentionRoles: config.triage?.safety?.allowMentionRoles ?? [
200
+ "AUTHOR",
201
+ "OWNER",
202
+ "MEMBER",
203
+ "COLLABORATOR",
204
+ ],
205
+ blockedLabels: config.triage?.safety?.blockedLabels ?? [],
206
+ requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
207
+ },
208
+ worktree: config.triage?.worktree,
209
+ },
130
210
  };
131
211
  }
@@ -9,6 +9,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 });
11
11
  const validateSchema = AJV.compile(schema);
12
+ const TRIAGE_CATEGORY_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
13
+ const RESERVED_TRIAGE_CATEGORY_IDS = new Set(["ASK", "none"]);
12
14
  const CONFIG_KEYS = new Set([
13
15
  "$schema",
14
16
  "agents",
@@ -18,6 +20,7 @@ const CONFIG_KEYS = new Set([
18
20
  "merge",
19
21
  "output",
20
22
  "review",
23
+ "triage",
21
24
  ]);
22
25
  const AGENTS_KEYS = new Set(["permissions"]);
23
26
  const REVIEWER_KEYS = new Set([
@@ -36,6 +39,21 @@ const EDITOR_KEYS = new Set([
36
39
  "permissions",
37
40
  "persona",
38
41
  ]);
42
+ const TRIAGE_AGENT_KEYS = new Set([
43
+ "id",
44
+ "model",
45
+ "options",
46
+ "permissions",
47
+ "persona",
48
+ ]);
49
+ const TRIAGE_CREATOR_KEYS = new Set([
50
+ "account",
51
+ "author",
52
+ "model",
53
+ "options",
54
+ "permissions",
55
+ "persona",
56
+ ]);
39
57
  const AUTHOR_KEYS = new Set(["email", "name"]);
40
58
  const GITHUB_KEYS = new Set(["apiRetryAttempts", "host", "owner", "repo"]);
41
59
  const REVIEW_KEYS = new Set([
@@ -56,6 +74,18 @@ const MERGE_KEYS = new Set([
56
74
  "maxThreadResolutionCycles",
57
75
  "prompts",
58
76
  ]);
77
+ const TRIAGE_KEYS = new Set([
78
+ "account",
79
+ "agents",
80
+ "automation",
81
+ "categories",
82
+ "concurrency",
83
+ "creator",
84
+ "output",
85
+ "prompts",
86
+ "safety",
87
+ "worktree",
88
+ ]);
59
89
  const REVIEW_MERGE_KEYS = new Set([
60
90
  "approvalPolicy",
61
91
  "auto",
@@ -69,6 +99,22 @@ const AUTOMATION_KEYS = new Set(["close", "merge"]);
69
99
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
70
100
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
71
101
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
102
+ const TRIAGE_AUTOMATION_KEYS = new Set([
103
+ "clear",
104
+ "close",
105
+ "create",
106
+ "merge",
107
+ "review",
108
+ ]);
109
+ const TRIAGE_CATEGORY_KEYS = new Set(["description", "id", "labels", "types"]);
110
+ const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
111
+ const TRIAGE_SAFETY_KEYS = new Set([
112
+ "allowAuthors",
113
+ "allowMentionActors",
114
+ "allowMentionRoles",
115
+ "blockedLabels",
116
+ "requiredLabels",
117
+ ]);
72
118
  const SAFETY_KEYS = new Set([
73
119
  "allowAuthors",
74
120
  "blockedPaths",
@@ -88,6 +134,19 @@ const MERGE_PROMPT_KEYS = new Set([
88
134
  "edit",
89
135
  "editGuidelines",
90
136
  ]);
137
+ const TRIAGE_PROMPT_KEYS = new Set([
138
+ "action",
139
+ "acceptance",
140
+ "category",
141
+ "comment",
142
+ "commentClassification",
143
+ "create",
144
+ "createGuidelines",
145
+ "duplicate",
146
+ "existingPr",
147
+ "question",
148
+ "reconsider",
149
+ ]);
91
150
  function githubHost(config) {
92
151
  return config.github?.host ?? "github.com";
93
152
  }
@@ -98,6 +157,56 @@ function ghHostOption(config) {
98
157
  function isPlainObject(value) {
99
158
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
100
159
  }
160
+ function expandAgentRefUse(value, path, refs, refsInvalid, errors) {
161
+ if (!isPlainObject(value) || !Object.hasOwn(value, "ref"))
162
+ return value;
163
+ const use = { ...value };
164
+ const ref = use.ref;
165
+ delete use.ref;
166
+ if (typeof ref !== "string") {
167
+ errors.push(`${path}.ref must be a string`);
168
+ return use;
169
+ }
170
+ if (refsInvalid) {
171
+ errors.push(`agents.refs must be an object to resolve ${path}.ref`);
172
+ return use;
173
+ }
174
+ const preset = refs?.[ref];
175
+ if (preset == null) {
176
+ errors.push(`${path}.ref references unknown agents.refs preset: ${ref}`);
177
+ return use;
178
+ }
179
+ if (!isPlainObject(preset)) {
180
+ errors.push(`agents.refs.${ref} must be an object when referenced by ${path}.ref`);
181
+ return use;
182
+ }
183
+ const presetFields = { ...preset };
184
+ delete presetFields.ref;
185
+ return { ...presetFields, ...use };
186
+ }
187
+ function expandAgentRefs(config, errors) {
188
+ if (!config || typeof config !== "object")
189
+ return;
190
+ const magiConfig = config;
191
+ const agents = magiConfig.agents;
192
+ const refsValue = isPlainObject(agents) ? agents.refs : undefined;
193
+ const refsInvalid = refsValue != null && !isPlainObject(refsValue);
194
+ 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));
197
+ }
198
+ if (isPlainObject(magiConfig.merge?.editor)) {
199
+ magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
200
+ }
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));
203
+ }
204
+ if (isPlainObject(magiConfig.triage?.creator)) {
205
+ magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
206
+ }
207
+ if (isPlainObject(magiConfig.agents))
208
+ delete magiConfig.agents.refs;
209
+ }
101
210
  function validateKnownKeys(value, path, keys, errors) {
102
211
  if (!isPlainObject(value))
103
212
  return;
@@ -227,6 +336,41 @@ function validateReviewerList(reviewers, path, errors, catalog) {
227
336
  }
228
337
  });
229
338
  }
339
+ function validateTriageAgentList(agents, path, errors, catalog) {
340
+ if (agents == null)
341
+ return;
342
+ if (!Array.isArray(agents)) {
343
+ errors.push(`${path} must be an array`);
344
+ return;
345
+ }
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) => {
351
+ if (!agent || typeof agent !== "object") {
352
+ errors.push(`${path}[${index}] must be an object`);
353
+ return;
354
+ }
355
+ validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
356
+ if (!agent.model)
357
+ errors.push(`${path}[${index}].model is required`);
358
+ validateString(agent.model, `${path}[${index}].model`, errors);
359
+ validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
360
+ validateString(agent.persona, `${path}[${index}].persona`, errors);
361
+ if (agent.options != null && !isPlainObject(agent.options))
362
+ errors.push(`${path}[${index}].options must be an object`);
363
+ validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
364
+ if (agent.id) {
365
+ if (!validateReviewerId(agent.id)) {
366
+ errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
367
+ }
368
+ if (RESERVED_REVIEWER_KEYS.has(agent.id)) {
369
+ errors.push(`${path}[${index}].id is reserved: ${agent.id}`);
370
+ }
371
+ }
372
+ });
373
+ }
230
374
  function validateResolvedReviewers(reviewers, path, errors) {
231
375
  const keys = new Set();
232
376
  const accounts = new Set();
@@ -239,6 +383,14 @@ function validateResolvedReviewers(reviewers, path, errors) {
239
383
  accounts.add(reviewer.account);
240
384
  }
241
385
  }
386
+ function validateResolvedAgentKeys(agents, path, errors) {
387
+ const keys = new Set();
388
+ for (const agent of agents) {
389
+ if (keys.has(agent.key))
390
+ errors.push(`${path} has duplicate agent key: ${agent.key}`);
391
+ keys.add(agent.key);
392
+ }
393
+ }
242
394
  function validateEditor(editor, path, errors, catalog) {
243
395
  if (!editor)
244
396
  return;
@@ -282,6 +434,40 @@ function validateEditor(editor, path, errors, catalog) {
282
434
  }
283
435
  }
284
436
  }
437
+ function validateTriageCreator(creator, path, errors, catalog) {
438
+ if (!creator)
439
+ return;
440
+ if (!isPlainObject(creator)) {
441
+ errors.push(`${path} must be an object`);
442
+ return;
443
+ }
444
+ validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
445
+ if (!creator.model)
446
+ errors.push(`${path}.model is required`);
447
+ validateString(creator.account, `${path}.account`, errors);
448
+ validateString(creator.model, `${path}.model`, errors);
449
+ validateString(creator.persona, `${path}.persona`, errors);
450
+ validateModel(creator.model, `${path}.model`, errors, catalog);
451
+ if (creator.options != null && !isPlainObject(creator.options)) {
452
+ errors.push(`${path}.options must be an object`);
453
+ }
454
+ validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
455
+ const author = creator.author;
456
+ if (!author || !isPlainObject(author)) {
457
+ if (author != null)
458
+ errors.push(`${path}.author must be an object`);
459
+ errors.push(`${path}.author.name is required`);
460
+ errors.push(`${path}.author.email is required`);
461
+ return;
462
+ }
463
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
464
+ if (!author.name)
465
+ errors.push(`${path}.author.name is required`);
466
+ validateString(author.name, `${path}.author.name`, errors);
467
+ if (!author.email)
468
+ errors.push(`${path}.author.email is required`);
469
+ validateString(author.email, `${path}.author.email`, errors);
470
+ }
285
471
  function validateMerge(config, errors, options) {
286
472
  const merge = config.merge;
287
473
  if (options.requireGithub ?? true) {
@@ -407,6 +593,45 @@ function validateStringArray(value, path, errors) {
407
593
  errors.push(`${path}[${index}] must be a string`);
408
594
  });
409
595
  }
596
+ function validateTriageCategories(categories, path, errors) {
597
+ if (categories == null)
598
+ return;
599
+ if (!Array.isArray(categories)) {
600
+ errors.push(`${path} must be an array`);
601
+ return;
602
+ }
603
+ const ids = new Set();
604
+ categories.forEach((item, index) => {
605
+ const itemPath = `${path}[${index}]`;
606
+ if (!isPlainObject(item)) {
607
+ errors.push(`${itemPath} must be an object`);
608
+ return;
609
+ }
610
+ const category = item;
611
+ validateKnownKeys(category, itemPath, TRIAGE_CATEGORY_KEYS, errors);
612
+ if (!category.id) {
613
+ errors.push(`${itemPath}.id is required`);
614
+ }
615
+ else if (typeof category.id !== "string") {
616
+ errors.push(`${itemPath}.id must be a string`);
617
+ }
618
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(category.id)) {
619
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
620
+ }
621
+ else if (RESERVED_TRIAGE_CATEGORY_IDS.has(category.id)) {
622
+ errors.push(`${itemPath}.id is reserved: ${category.id}`);
623
+ }
624
+ else if (ids.has(category.id)) {
625
+ errors.push(`${itemPath}.id must be unique`);
626
+ }
627
+ else {
628
+ ids.add(category.id);
629
+ }
630
+ validateStringArray(category.labels, `${itemPath}.labels`, errors);
631
+ validateStringArray(category.types, `${itemPath}.types`, errors);
632
+ validateString(category.description, `${itemPath}.description`, errors);
633
+ });
634
+ }
410
635
  function validateSafety(config, errors) {
411
636
  const safety = config.review?.safety;
412
637
  if (safety != null && !isPlainObject(safety)) {
@@ -436,12 +661,70 @@ function validatePromptObject(prompts, path, keys, errors) {
436
661
  errors.push(`${path}.${key} must be a string`);
437
662
  }
438
663
  }
664
+ function validateTriage(config, errors, options) {
665
+ const triage = config.triage;
666
+ if (!triage)
667
+ return;
668
+ if (!isPlainObject(triage)) {
669
+ errors.push("triage must be an object");
670
+ return;
671
+ }
672
+ validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
673
+ const automation = triage.automation;
674
+ const concurrency = triage.concurrency;
675
+ const safety = triage.safety;
676
+ if (!triage.account)
677
+ errors.push("triage.account is required");
678
+ validateString(triage.account, "triage.account", errors);
679
+ if (!triage.agents)
680
+ errors.push("triage.agents is required");
681
+ validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
682
+ if (Array.isArray(triage.agents)) {
683
+ validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
684
+ }
685
+ validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
686
+ if (automation?.create && !triage.creator)
687
+ errors.push("triage.creator is required when triage.automation.create is true");
688
+ if (automation != null && !isPlainObject(automation)) {
689
+ errors.push("triage.automation must be an object");
690
+ }
691
+ validateKnownKeys(automation, "triage.automation", TRIAGE_AUTOMATION_KEYS, errors);
692
+ validateBoolean(automation?.close, "triage.automation.close", errors);
693
+ validateBoolean(automation?.create, "triage.automation.create", errors);
694
+ validateBoolean(automation?.merge, "triage.automation.merge", errors);
695
+ validateBoolean(automation?.review, "triage.automation.review", errors);
696
+ validateStringArray(automation?.clear, "triage.automation.clear", errors);
697
+ if (automation?.review && !automation.create) {
698
+ errors.push("triage.automation.review requires triage.automation.create to be true");
699
+ }
700
+ if (automation?.merge && !automation.create) {
701
+ errors.push("triage.automation.merge requires triage.automation.create to be true");
702
+ }
703
+ validateKnownKeys(concurrency, "triage.concurrency", TRIAGE_CONCURRENCY_KEYS, errors);
704
+ if (concurrency?.runs != null &&
705
+ (typeof concurrency.runs !== "number" ||
706
+ !Number.isInteger(concurrency.runs) ||
707
+ concurrency.runs < 1)) {
708
+ errors.push("triage.concurrency.runs must be a positive integer");
709
+ }
710
+ validateTriageCategories(triage.categories, "triage.categories", errors);
711
+ validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
712
+ validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
713
+ validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
714
+ validateStringArray(safety?.allowMentionRoles, "triage.safety.allowMentionRoles", errors);
715
+ validateStringArray(safety?.blockedLabels, "triage.safety.blockedLabels", errors);
716
+ validateStringArray(safety?.requiredLabels, "triage.safety.requiredLabels", errors);
717
+ validateString(triage.output, "triage.output", errors);
718
+ validateString(triage.worktree, "triage.worktree", errors);
719
+ }
439
720
  async function validatePrompts(config, errors, directory) {
440
721
  validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
441
722
  validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
723
+ validatePromptObject(config.triage?.prompts, "triage.prompts", TRIAGE_PROMPT_KEYS, errors);
442
724
  const promptEntries = [
443
725
  ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
444
726
  ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
727
+ ...Object.entries(config.triage?.prompts ?? {}).map(([key, value]) => [`triage.prompts.${key}`, value]),
445
728
  ];
446
729
  await Promise.all(promptEntries.map(async ([path, value]) => {
447
730
  if (typeof value !== "string")
@@ -464,6 +747,10 @@ async function validateAuth(config, exec, errors) {
464
747
  accounts.add(reviewer.account);
465
748
  if (agents.editor)
466
749
  accounts.add(agents.editor.account);
750
+ if (config.triage?.account)
751
+ accounts.add(config.triage.account);
752
+ if (agents.triageCreator?.account)
753
+ accounts.add(agents.triageCreator.account);
467
754
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
468
755
  try {
469
756
  await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
@@ -475,9 +762,31 @@ async function validateAuth(config, exec, errors) {
475
762
  }
476
763
  async function fetchPermissions(config, exec, account) {
477
764
  const token = (await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`)).trim();
478
- const raw = await exec(`GH_TOKEN=${JSON.stringify(token)} gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`);
765
+ const raw = await exec(`gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`, { env: { GH_TOKEN: token } });
479
766
  return JSON.parse(raw);
480
767
  }
768
+ async function validateWorktreeConfig(config, exec, options, errors) {
769
+ const agents = resolveAgents(config);
770
+ const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
771
+ const checkTriageCreator = Boolean(config.triage?.automation?.create &&
772
+ agents.triageCreator &&
773
+ (options.requireTriage || options.requireWorktreeConfig));
774
+ if (!checkEditor && !checkTriageCreator)
775
+ return;
776
+ if (!exec)
777
+ return;
778
+ const error = "git config extensions.worktreeConfig must be true when editor or triage PR creator is configured";
779
+ try {
780
+ const value = (await exec("git config --bool --get extensions.worktreeConfig"))
781
+ .trim()
782
+ .toLowerCase();
783
+ if (value !== "true")
784
+ errors.push(error);
785
+ }
786
+ catch {
787
+ errors.push(error);
788
+ }
789
+ }
481
790
  async function validateRepositoryPermissions(config, exec, errors, warnings) {
482
791
  if (!config.github?.owner || !config.github.repo)
483
792
  return;
@@ -493,6 +802,29 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
493
802
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
494
803
  }
495
804
  }));
805
+ if (config.triage?.account) {
806
+ try {
807
+ const permissions = await fetchPermissions(config, exec, config.triage.account);
808
+ if (!permissions.pull) {
809
+ errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
810
+ }
811
+ }
812
+ catch (error) {
813
+ warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
814
+ }
815
+ }
816
+ if (agents.triageCreator?.account &&
817
+ agents.triageCreator.account !== config.triage?.account) {
818
+ try {
819
+ const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
820
+ if (!permissions.push) {
821
+ errors.push(`GitHub account cannot push to repository for triage PR creation: ${agents.triageCreator.account}`);
822
+ }
823
+ }
824
+ catch (error) {
825
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agents.triageCreator.account} (${error.message})`);
826
+ }
827
+ }
496
828
  if (!agents.editor)
497
829
  return;
498
830
  try {
@@ -510,6 +842,7 @@ export async function validateConfig(config, options = {}) {
510
842
  const warnings = [];
511
843
  if (!config || typeof config !== "object")
512
844
  errors.push("config must be an object");
845
+ expandAgentRefs(config, errors);
513
846
  if (config && typeof config === "object")
514
847
  validateJsonSchema(config, errors);
515
848
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
@@ -522,10 +855,10 @@ export async function validateConfig(config, options = {}) {
522
855
  validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
523
856
  validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
524
857
  }
525
- if (!config.review) {
858
+ if ((options.requireReview ?? true) && !config.review) {
526
859
  errors.push("review is required");
527
860
  }
528
- else {
861
+ else if (config.review) {
529
862
  if (!isPlainObject(config.review)) {
530
863
  errors.push("review must be an object");
531
864
  }
@@ -539,6 +872,9 @@ export async function validateConfig(config, options = {}) {
539
872
  validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
540
873
  }
541
874
  }
875
+ if (options.requireTriage && !config.triage) {
876
+ errors.push("triage is required");
877
+ }
542
878
  validateMerge(config, errors, options);
543
879
  validateReviewMerge(config, errors);
544
880
  validateAutomation(config, errors);
@@ -546,6 +882,7 @@ export async function validateConfig(config, options = {}) {
546
882
  validateChecks(config, errors);
547
883
  validateConcurrency(config, errors);
548
884
  validateSafety(config, errors);
885
+ validateTriage(config, errors, options);
549
886
  await validatePrompts(config, errors, options.directory);
550
887
  if (config.output != null && !isPlainObject(config.output)) {
551
888
  errors.push("output must be an object");
@@ -560,6 +897,7 @@ export async function validateConfig(config, options = {}) {
560
897
  }
561
898
  validateString(config.review?.output, "review.output", errors);
562
899
  validateString(config.review?.worktree, "review.worktree", errors);
900
+ await validateWorktreeConfig(config, options.exec, options, errors);
563
901
  if (options.checkAuth && !errors.length) {
564
902
  if (!options.exec) {
565
903
  errors.push("validateConfig requires exec when checkAuth is true");