opencode-magi 0.2.0 → 0.3.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 (35) 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 +290 -3
  6. package/dist/config/worktree.js +8 -2
  7. package/dist/github/commands.js +343 -15
  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 +73 -0
  12. package/dist/orchestrator/majority.js +14 -0
  13. package/dist/orchestrator/merge.js +16 -3
  14. package/dist/orchestrator/report.js +15 -1
  15. package/dist/orchestrator/review-context.js +309 -0
  16. package/dist/orchestrator/review.js +49 -9
  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 +162 -1
  21. package/dist/prompts/contracts.js +119 -12
  22. package/dist/prompts/output.js +149 -14
  23. package/dist/prompts/templates/review/review.md +6 -0
  24. package/dist/prompts/templates/triage/acceptance.md +7 -0
  25. package/dist/prompts/templates/triage/action.md +5 -0
  26. package/dist/prompts/templates/triage/category.md +10 -0
  27. package/dist/prompts/templates/triage/comment-classification.md +7 -0
  28. package/dist/prompts/templates/triage/comment.md +5 -0
  29. package/dist/prompts/templates/triage/create.md +7 -0
  30. package/dist/prompts/templates/triage/duplicate.md +7 -0
  31. package/dist/prompts/templates/triage/existing-pr.md +7 -0
  32. package/dist/prompts/templates/triage/question.md +5 -0
  33. package/dist/prompts/templates/triage/reconsider.md +5 -0
  34. package/package.json +5 -2
  35. package/schema.json +127 -2
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
  }
@@ -227,6 +286,41 @@ function validateReviewerList(reviewers, path, errors, catalog) {
227
286
  }
228
287
  });
229
288
  }
289
+ function validateTriageAgentList(agents, path, errors, catalog) {
290
+ if (agents == null)
291
+ return;
292
+ if (!Array.isArray(agents)) {
293
+ errors.push(`${path} must be an array`);
294
+ return;
295
+ }
296
+ if (agents.length < 3)
297
+ errors.push(`${path} must contain at least 3 agents`);
298
+ if (agents.length % 2 === 0)
299
+ errors.push(`${path} must contain an odd number of agents`);
300
+ agents.forEach((agent, index) => {
301
+ if (!agent || typeof agent !== "object") {
302
+ errors.push(`${path}[${index}] must be an object`);
303
+ return;
304
+ }
305
+ validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
306
+ if (!agent.model)
307
+ errors.push(`${path}[${index}].model is required`);
308
+ validateString(agent.model, `${path}[${index}].model`, errors);
309
+ validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
310
+ validateString(agent.persona, `${path}[${index}].persona`, errors);
311
+ if (agent.options != null && !isPlainObject(agent.options))
312
+ errors.push(`${path}[${index}].options must be an object`);
313
+ validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
314
+ if (agent.id) {
315
+ if (!validateReviewerId(agent.id)) {
316
+ errors.push(`${path}[${index}].id may contain only letters, numbers, underscores, and hyphens`);
317
+ }
318
+ if (RESERVED_REVIEWER_KEYS.has(agent.id)) {
319
+ errors.push(`${path}[${index}].id is reserved: ${agent.id}`);
320
+ }
321
+ }
322
+ });
323
+ }
230
324
  function validateResolvedReviewers(reviewers, path, errors) {
231
325
  const keys = new Set();
232
326
  const accounts = new Set();
@@ -239,6 +333,14 @@ function validateResolvedReviewers(reviewers, path, errors) {
239
333
  accounts.add(reviewer.account);
240
334
  }
241
335
  }
336
+ function validateResolvedAgentKeys(agents, path, errors) {
337
+ const keys = new Set();
338
+ for (const agent of agents) {
339
+ if (keys.has(agent.key))
340
+ errors.push(`${path} has duplicate agent key: ${agent.key}`);
341
+ keys.add(agent.key);
342
+ }
343
+ }
242
344
  function validateEditor(editor, path, errors, catalog) {
243
345
  if (!editor)
244
346
  return;
@@ -282,6 +384,40 @@ function validateEditor(editor, path, errors, catalog) {
282
384
  }
283
385
  }
284
386
  }
387
+ function validateTriageCreator(creator, path, errors, catalog) {
388
+ if (!creator)
389
+ return;
390
+ if (!isPlainObject(creator)) {
391
+ errors.push(`${path} must be an object`);
392
+ return;
393
+ }
394
+ validateKnownKeys(creator, path, TRIAGE_CREATOR_KEYS, errors);
395
+ if (!creator.model)
396
+ errors.push(`${path}.model is required`);
397
+ validateString(creator.account, `${path}.account`, errors);
398
+ validateString(creator.model, `${path}.model`, errors);
399
+ validateString(creator.persona, `${path}.persona`, errors);
400
+ validateModel(creator.model, `${path}.model`, errors, catalog);
401
+ if (creator.options != null && !isPlainObject(creator.options)) {
402
+ errors.push(`${path}.options must be an object`);
403
+ }
404
+ validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
405
+ const author = creator.author;
406
+ if (!author || !isPlainObject(author)) {
407
+ if (author != null)
408
+ errors.push(`${path}.author must be an object`);
409
+ errors.push(`${path}.author.name is required`);
410
+ errors.push(`${path}.author.email is required`);
411
+ return;
412
+ }
413
+ validateKnownKeys(author, `${path}.author`, AUTHOR_KEYS, errors);
414
+ if (!author.name)
415
+ errors.push(`${path}.author.name is required`);
416
+ validateString(author.name, `${path}.author.name`, errors);
417
+ if (!author.email)
418
+ errors.push(`${path}.author.email is required`);
419
+ validateString(author.email, `${path}.author.email`, errors);
420
+ }
285
421
  function validateMerge(config, errors, options) {
286
422
  const merge = config.merge;
287
423
  if (options.requireGithub ?? true) {
@@ -407,6 +543,45 @@ function validateStringArray(value, path, errors) {
407
543
  errors.push(`${path}[${index}] must be a string`);
408
544
  });
409
545
  }
546
+ function validateTriageCategories(categories, path, errors) {
547
+ if (categories == null)
548
+ return;
549
+ if (!Array.isArray(categories)) {
550
+ errors.push(`${path} must be an array`);
551
+ return;
552
+ }
553
+ const ids = new Set();
554
+ categories.forEach((item, index) => {
555
+ const itemPath = `${path}[${index}]`;
556
+ if (!isPlainObject(item)) {
557
+ errors.push(`${itemPath} must be an object`);
558
+ return;
559
+ }
560
+ const category = item;
561
+ validateKnownKeys(category, itemPath, TRIAGE_CATEGORY_KEYS, errors);
562
+ if (!category.id) {
563
+ errors.push(`${itemPath}.id is required`);
564
+ }
565
+ else if (typeof category.id !== "string") {
566
+ errors.push(`${itemPath}.id must be a string`);
567
+ }
568
+ else if (!TRIAGE_CATEGORY_ID_PATTERN.test(category.id)) {
569
+ errors.push(`${itemPath}.id must match /^[A-Za-z0-9_-]+$/`);
570
+ }
571
+ else if (RESERVED_TRIAGE_CATEGORY_IDS.has(category.id)) {
572
+ errors.push(`${itemPath}.id is reserved: ${category.id}`);
573
+ }
574
+ else if (ids.has(category.id)) {
575
+ errors.push(`${itemPath}.id must be unique`);
576
+ }
577
+ else {
578
+ ids.add(category.id);
579
+ }
580
+ validateStringArray(category.labels, `${itemPath}.labels`, errors);
581
+ validateStringArray(category.types, `${itemPath}.types`, errors);
582
+ validateString(category.description, `${itemPath}.description`, errors);
583
+ });
584
+ }
410
585
  function validateSafety(config, errors) {
411
586
  const safety = config.review?.safety;
412
587
  if (safety != null && !isPlainObject(safety)) {
@@ -436,12 +611,70 @@ function validatePromptObject(prompts, path, keys, errors) {
436
611
  errors.push(`${path}.${key} must be a string`);
437
612
  }
438
613
  }
614
+ function validateTriage(config, errors, options) {
615
+ const triage = config.triage;
616
+ if (!triage)
617
+ return;
618
+ if (!isPlainObject(triage)) {
619
+ errors.push("triage must be an object");
620
+ return;
621
+ }
622
+ validateKnownKeys(triage, "triage", TRIAGE_KEYS, errors);
623
+ const automation = triage.automation;
624
+ const concurrency = triage.concurrency;
625
+ const safety = triage.safety;
626
+ if (!triage.account)
627
+ errors.push("triage.account is required");
628
+ validateString(triage.account, "triage.account", errors);
629
+ if (!triage.agents)
630
+ errors.push("triage.agents is required");
631
+ validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
632
+ if (Array.isArray(triage.agents)) {
633
+ validateResolvedAgentKeys(resolveAgents(config).triage ?? [], "triage.resolvedAgents", errors);
634
+ }
635
+ validateTriageCreator(triage.creator, "triage.creator", errors, options.modelCatalog);
636
+ if (automation?.create && !triage.creator)
637
+ errors.push("triage.creator is required when triage.automation.create is true");
638
+ if (automation != null && !isPlainObject(automation)) {
639
+ errors.push("triage.automation must be an object");
640
+ }
641
+ validateKnownKeys(automation, "triage.automation", TRIAGE_AUTOMATION_KEYS, errors);
642
+ validateBoolean(automation?.close, "triage.automation.close", errors);
643
+ validateBoolean(automation?.create, "triage.automation.create", errors);
644
+ validateBoolean(automation?.merge, "triage.automation.merge", errors);
645
+ validateBoolean(automation?.review, "triage.automation.review", errors);
646
+ validateStringArray(automation?.clear, "triage.automation.clear", errors);
647
+ if (automation?.review && !automation.create) {
648
+ errors.push("triage.automation.review requires triage.automation.create to be true");
649
+ }
650
+ if (automation?.merge && !automation.create) {
651
+ errors.push("triage.automation.merge requires triage.automation.create to be true");
652
+ }
653
+ validateKnownKeys(concurrency, "triage.concurrency", TRIAGE_CONCURRENCY_KEYS, errors);
654
+ if (concurrency?.runs != null &&
655
+ (typeof concurrency.runs !== "number" ||
656
+ !Number.isInteger(concurrency.runs) ||
657
+ concurrency.runs < 1)) {
658
+ errors.push("triage.concurrency.runs must be a positive integer");
659
+ }
660
+ validateTriageCategories(triage.categories, "triage.categories", errors);
661
+ validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
662
+ validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
663
+ validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
664
+ validateStringArray(safety?.allowMentionRoles, "triage.safety.allowMentionRoles", errors);
665
+ validateStringArray(safety?.blockedLabels, "triage.safety.blockedLabels", errors);
666
+ validateStringArray(safety?.requiredLabels, "triage.safety.requiredLabels", errors);
667
+ validateString(triage.output, "triage.output", errors);
668
+ validateString(triage.worktree, "triage.worktree", errors);
669
+ }
439
670
  async function validatePrompts(config, errors, directory) {
440
671
  validatePromptObject(config.review?.prompts, "review.prompts", REVIEW_PROMPT_KEYS, errors);
441
672
  validatePromptObject(config.merge?.prompts, "merge.prompts", MERGE_PROMPT_KEYS, errors);
673
+ validatePromptObject(config.triage?.prompts, "triage.prompts", TRIAGE_PROMPT_KEYS, errors);
442
674
  const promptEntries = [
443
675
  ...Object.entries(config.review?.prompts ?? {}).map(([key, value]) => [`review.prompts.${key}`, value]),
444
676
  ...Object.entries(config.merge?.prompts ?? {}).map(([key, value]) => [`merge.prompts.${key}`, value]),
677
+ ...Object.entries(config.triage?.prompts ?? {}).map(([key, value]) => [`triage.prompts.${key}`, value]),
445
678
  ];
446
679
  await Promise.all(promptEntries.map(async ([path, value]) => {
447
680
  if (typeof value !== "string")
@@ -464,6 +697,10 @@ async function validateAuth(config, exec, errors) {
464
697
  accounts.add(reviewer.account);
465
698
  if (agents.editor)
466
699
  accounts.add(agents.editor.account);
700
+ if (config.triage?.account)
701
+ accounts.add(config.triage.account);
702
+ if (agents.triageCreator?.account)
703
+ accounts.add(agents.triageCreator.account);
467
704
  await Promise.all([...accounts].filter(Boolean).map(async (account) => {
468
705
  try {
469
706
  await exec(`gh auth token${ghHostOption(config)} --user ${JSON.stringify(account)}`);
@@ -475,9 +712,31 @@ async function validateAuth(config, exec, errors) {
475
712
  }
476
713
  async function fetchPermissions(config, exec, account) {
477
714
  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`);
715
+ const raw = await exec(`gh api${ghHostOption(config)} repos/${config.github?.owner}/${config.github?.repo} --jq .permissions`, { env: { GH_TOKEN: token } });
479
716
  return JSON.parse(raw);
480
717
  }
718
+ async function validateWorktreeConfig(config, exec, options, errors) {
719
+ const agents = resolveAgents(config);
720
+ const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
721
+ const checkTriageCreator = Boolean(config.triage?.automation?.create &&
722
+ agents.triageCreator &&
723
+ (options.requireTriage || options.requireWorktreeConfig));
724
+ if (!checkEditor && !checkTriageCreator)
725
+ return;
726
+ if (!exec)
727
+ return;
728
+ const error = "git config extensions.worktreeConfig must be true when editor or triage PR creator is configured";
729
+ try {
730
+ const value = (await exec("git config --bool --get extensions.worktreeConfig"))
731
+ .trim()
732
+ .toLowerCase();
733
+ if (value !== "true")
734
+ errors.push(error);
735
+ }
736
+ catch {
737
+ errors.push(error);
738
+ }
739
+ }
481
740
  async function validateRepositoryPermissions(config, exec, errors, warnings) {
482
741
  if (!config.github?.owner || !config.github.repo)
483
742
  return;
@@ -493,6 +752,29 @@ async function validateRepositoryPermissions(config, exec, errors, warnings) {
493
752
  warnings.push(`Could not validate repository permissions for GitHub account: ${reviewer.account} (${error.message})`);
494
753
  }
495
754
  }));
755
+ if (config.triage?.account) {
756
+ try {
757
+ const permissions = await fetchPermissions(config, exec, config.triage.account);
758
+ if (!permissions.pull) {
759
+ errors.push(`GitHub account cannot read repository for issue triage: ${config.triage.account}`);
760
+ }
761
+ }
762
+ catch (error) {
763
+ warnings.push(`Could not validate repository permissions for GitHub account: ${config.triage.account} (${error.message})`);
764
+ }
765
+ }
766
+ if (agents.triageCreator?.account &&
767
+ agents.triageCreator.account !== config.triage?.account) {
768
+ try {
769
+ const permissions = await fetchPermissions(config, exec, agents.triageCreator.account);
770
+ if (!permissions.push) {
771
+ errors.push(`GitHub account cannot push to repository for triage PR creation: ${agents.triageCreator.account}`);
772
+ }
773
+ }
774
+ catch (error) {
775
+ warnings.push(`Could not validate repository permissions for GitHub account: ${agents.triageCreator.account} (${error.message})`);
776
+ }
777
+ }
496
778
  if (!agents.editor)
497
779
  return;
498
780
  try {
@@ -522,10 +804,10 @@ export async function validateConfig(config, options = {}) {
522
804
  validateKnownKeys(config.agents, "agents", AGENTS_KEYS, errors);
523
805
  validatePermissionConfig(config.agents?.permissions, "agents.permissions", errors);
524
806
  }
525
- if (!config.review) {
807
+ if ((options.requireReview ?? true) && !config.review) {
526
808
  errors.push("review is required");
527
809
  }
528
- else {
810
+ else if (config.review) {
529
811
  if (!isPlainObject(config.review)) {
530
812
  errors.push("review must be an object");
531
813
  }
@@ -539,6 +821,9 @@ export async function validateConfig(config, options = {}) {
539
821
  validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
540
822
  }
541
823
  }
824
+ if (options.requireTriage && !config.triage) {
825
+ errors.push("triage is required");
826
+ }
542
827
  validateMerge(config, errors, options);
543
828
  validateReviewMerge(config, errors);
544
829
  validateAutomation(config, errors);
@@ -546,6 +831,7 @@ export async function validateConfig(config, options = {}) {
546
831
  validateChecks(config, errors);
547
832
  validateConcurrency(config, errors);
548
833
  validateSafety(config, errors);
834
+ validateTriage(config, errors, options);
549
835
  await validatePrompts(config, errors, options.directory);
550
836
  if (config.output != null && !isPlainObject(config.output)) {
551
837
  errors.push("output must be an object");
@@ -560,6 +846,7 @@ export async function validateConfig(config, options = {}) {
560
846
  }
561
847
  validateString(config.review?.output, "review.output", errors);
562
848
  validateString(config.review?.worktree, "review.worktree", errors);
849
+ await validateWorktreeConfig(config, options.exec, options, errors);
563
850
  if (options.checkAuth && !errors.length) {
564
851
  if (!options.exec) {
565
852
  errors.push("validateConfig requires exec when checkAuth is true");
@@ -1,13 +1,19 @@
1
1
  import { isAbsolute, join } from "node:path";
2
2
  const DEFAULT_WORKTREE_DIRS = {
3
+ issue: ".magi/worktrees/issue",
3
4
  pr: ".magi/worktrees/pr",
4
5
  };
5
6
  function resolvePath(directory, path) {
6
7
  return isAbsolute(path) ? path : join(directory, path);
7
8
  }
8
9
  export function worktreeBaseDir(directory, config, kind) {
9
- return resolvePath(directory, config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]);
10
+ return resolvePath(directory, kind === "issue"
11
+ ? (config.triage?.worktree ?? DEFAULT_WORKTREE_DIRS[kind])
12
+ : (config.review?.worktree ?? DEFAULT_WORKTREE_DIRS[kind]));
10
13
  }
11
14
  export function worktreeBaseDirs(directory, config = {}) {
12
- return [worktreeBaseDir(directory, config, "pr")];
15
+ return [
16
+ worktreeBaseDir(directory, config, "pr"),
17
+ worktreeBaseDir(directory, config, "issue"),
18
+ ];
13
19
  }