opencode-magi 0.0.0-dev-20260525091701 → 0.0.0-dev-20260525101932

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.
@@ -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
  }
@@ -191,9 +216,9 @@ export function resolveRepository(config) {
191
216
  },
192
217
  triage: {
193
218
  automation: {
194
- clear: config.triage?.automation?.clear ?? ["triage"],
195
219
  close: config.triage?.automation?.close ?? false,
196
220
  create: config.triage?.automation?.create ?? false,
221
+ label: config.triage?.automation?.label ?? DEFAULT_TRIAGE_LABEL_RULES,
197
222
  merge: config.triage?.automation?.merge ?? false,
198
223
  review: config.triage?.automation?.review ?? false,
199
224
  },
@@ -216,6 +241,7 @@ export function resolveRepository(config) {
216
241
  blockedLabels: config.triage?.safety?.blockedLabels ?? [],
217
242
  requiredLabels: config.triage?.safety?.requiredLabels ?? ["triage"],
218
243
  },
244
+ signals: config.triage?.signals ?? [],
219
245
  worktree: config.triage?.worktree,
220
246
  },
221
247
  };
@@ -80,6 +80,7 @@ const TRIAGE_KEYS = new Set([
80
80
  "prompts",
81
81
  "reporter",
82
82
  "safety",
83
+ "signals",
83
84
  "voters",
84
85
  "worktree",
85
86
  ]);
@@ -98,13 +99,19 @@ const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
98
99
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
99
100
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
100
101
  const TRIAGE_AUTOMATION_KEYS = new Set([
101
- "clear",
102
102
  "close",
103
103
  "create",
104
+ "label",
104
105
  "merge",
105
106
  "review",
106
107
  ]);
107
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
+ ]);
108
115
  const TRIAGE_CONCURRENCY_KEYS = new Set(["runs"]);
109
116
  const TRIAGE_SAFETY_KEYS = new Set([
110
117
  "allowAuthors",
@@ -113,6 +120,18 @@ const TRIAGE_SAFETY_KEYS = new Set([
113
120
  "blockedLabels",
114
121
  "requiredLabels",
115
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
+ ]);
116
135
  const SAFETY_KEYS = new Set([
117
136
  "allowAuthors",
118
137
  "blockedPaths",
@@ -692,6 +711,79 @@ function validateTriageCategories(categories, path, errors) {
692
711
  validateString(category.description, `${itemPath}.description`, errors);
693
712
  });
694
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
+ }
695
787
  function validateSafety(config, errors) {
696
788
  const safety = config.review?.safety;
697
789
  if (safety != null && !isPlainObject(safety)) {
@@ -765,7 +857,7 @@ function validateTriage(config, errors, options) {
765
857
  validateBoolean(automation?.create, "triage.automation.create", errors);
766
858
  validateBoolean(automation?.merge, "triage.automation.merge", errors);
767
859
  validateBoolean(automation?.review, "triage.automation.review", errors);
768
- validateStringArray(automation?.clear, "triage.automation.clear", errors);
860
+ validateTriageLabelRules(automation?.label, "triage.automation.label", errors);
769
861
  if (automation?.review && !automation.create) {
770
862
  errors.push("triage.automation.review requires triage.automation.create to be true");
771
863
  }
@@ -780,6 +872,7 @@ function validateTriage(config, errors, options) {
780
872
  errors.push("triage.concurrency.runs must be a positive integer");
781
873
  }
782
874
  validateTriageCategories(triage.categories, "triage.categories", errors);
875
+ validateTriageSignals(triage.signals, "triage.signals", errors);
783
876
  validateKnownKeys(safety, "triage.safety", TRIAGE_SAFETY_KEYS, errors);
784
877
  validateStringArray(safety?.allowAuthors, "triage.safety.allowAuthors", errors);
785
878
  validateStringArray(safety?.allowMentionActors, "triage.safety.allowMentionActors", errors);
@@ -404,6 +404,15 @@ export async function assignIssue(exec, repository, issue, account) {
404
404
  const token = await ghToken(exec, repository, account);
405
405
  return exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-assignee ${shellQuote(account)}`, ghTokenEnv(token));
406
406
  }
407
+ export async function addIssueLabels(exec, repository, issue, labels, account) {
408
+ const token = await ghToken(exec, repository, account);
409
+ const added = [];
410
+ for (const label of labels) {
411
+ await exec(`gh issue edit ${issue} --repo ${shellQuote(repoSpecifier(repository))} --add-label ${shellQuote(label)}`, ghTokenEnv(token));
412
+ added.push(label);
413
+ }
414
+ return added;
415
+ }
407
416
  export async function removeIssueLabels(exec, repository, issue, labels, account) {
408
417
  const token = await ghToken(exec, repository, account);
409
418
  const removed = [];
@@ -2,13 +2,14 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { issueRunOutputDir } from "../config/output";
4
4
  import { issueRunWorktreeDir } from "../config/worktree";
5
- import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
- import { composeTriageAcceptancePrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
- import { parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
5
+ import { addIssueLabels, assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
+ import { composeTriageAcceptancePrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageReconsiderPrompt, composeTriageSignalPrompt, } from "../prompts/compose";
7
+ import { parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageSignalOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
9
  import { runModelWithRepair, } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
11
11
  const BINARY_VOTES = ["ASK", "NO", "YES"];
12
+ const ACCEPTANCE_VOTES = ["ASK", "INVALID", "NO", "YES"];
12
13
  const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
13
14
  const EXISTING_PR_VOTES = [
14
15
  "RELATED_PR_DOES_NOT_HANDLE_ISSUE",
@@ -20,10 +21,29 @@ const RECONSIDERATION_CLASSES = new Set([
20
21
  "OBJECTION",
21
22
  ]);
22
23
  function marker(input) {
23
- const askReason = input.decision.askReason
24
- ? ` askReason=${input.decision.askReason}`
25
- : "";
26
- return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition}${askReason} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
24
+ return `<!-- ${MARKER_PREFIX} v=2 issue=${input.issue} category=${input.decision.category ?? "none"} disposition=${input.decision.disposition} signals=${input.decision.signals.join(",")} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
25
+ }
26
+ function markerDisposition(input) {
27
+ switch (input.disposition) {
28
+ case "accepted":
29
+ case "rejected":
30
+ case "invalid":
31
+ case "duplicate":
32
+ case "already_handled":
33
+ case "needs_category":
34
+ case "needs_acceptance":
35
+ case "blocked":
36
+ case "failed":
37
+ return input.disposition;
38
+ case "ask":
39
+ return input.askReason === "category_unclear"
40
+ ? "needs_category"
41
+ : "needs_acceptance";
42
+ case "clear_only":
43
+ return "already_handled";
44
+ default:
45
+ return undefined;
46
+ }
27
47
  }
28
48
  export function parseTriageMarker(body) {
29
49
  const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
@@ -41,30 +61,28 @@ export function parseTriageMarker(body) {
41
61
  const version = Number(entries.v);
42
62
  if (version !== 1 && version !== 2)
43
63
  return undefined;
64
+ const askReason = entries.askReason === "acceptance_unclear" ||
65
+ entries.askReason === "category_unclear"
66
+ ? entries.askReason
67
+ : undefined;
44
68
  return {
45
69
  action: entries.action,
46
- askReason: entries.askReason === "acceptance_unclear" ||
47
- entries.askReason === "category_unclear"
48
- ? entries.askReason
49
- : undefined,
70
+ askReason,
50
71
  category: entries.category === "none" ? null : entries.category || undefined,
51
72
  checkpoint: entries.checkpoint && Number.isFinite(Number(entries.checkpoint))
52
73
  ? Number(entries.checkpoint)
53
74
  : undefined,
54
- disposition: entries.disposition === "accepted" ||
55
- entries.disposition === "rejected" ||
56
- entries.disposition === "ask" ||
57
- entries.disposition === "duplicate" ||
58
- entries.disposition === "clear_only" ||
59
- entries.disposition === "failed"
60
- ? entries.disposition
61
- : undefined,
75
+ disposition: markerDisposition({
76
+ askReason,
77
+ disposition: entries.disposition,
78
+ }),
62
79
  issue: entries.issue ? Number(entries.issue) : undefined,
63
80
  pr: entries.pr,
64
81
  processed: entries.processed
65
82
  ? entries.processed.split(",").filter(Boolean).map(Number)
66
83
  : [],
67
84
  result: entries.result,
85
+ signals: entries.signals ? entries.signals.split(",").filter(Boolean) : [],
68
86
  v: version,
69
87
  };
70
88
  }
@@ -72,9 +90,46 @@ function labelsContain(labels, targets) {
72
90
  const set = new Set(labels.map((label) => label.toLowerCase()));
73
91
  return targets.some((target) => set.has(target.toLowerCase()));
74
92
  }
75
- function existingClearLabels(issue, labels) {
76
- const existing = new Set(issue.labels.map((label) => label.toLowerCase()));
77
- return labels.filter((label) => existing.has(label.toLowerCase()));
93
+ function addLabel(labels, label) {
94
+ const key = label.toLowerCase();
95
+ if (!labels.has(key))
96
+ labels.set(key, label);
97
+ }
98
+ function labelRuleMatches(rule, result) {
99
+ const when = rule.when;
100
+ if (when.disposition && when.disposition !== result.disposition)
101
+ return false;
102
+ if (when.category && when.category !== result.category)
103
+ return false;
104
+ if (when.signals?.length) {
105
+ const signals = new Set(result.signals);
106
+ if (when.signals.some((signal) => !signals.has(signal)))
107
+ return false;
108
+ }
109
+ return true;
110
+ }
111
+ export function triageLabelChanges(input) {
112
+ const add = new Map();
113
+ const remove = new Map();
114
+ for (const rule of input.rules) {
115
+ if (!labelRuleMatches(rule, input.result))
116
+ continue;
117
+ for (const label of rule.add ?? [])
118
+ addLabel(add, label);
119
+ for (const label of rule.remove ?? [])
120
+ addLabel(remove, label);
121
+ }
122
+ for (const key of add.keys())
123
+ remove.delete(key);
124
+ const existing = new Set(input.issueLabels.map((label) => label.toLowerCase()));
125
+ return {
126
+ add: [...add]
127
+ .filter(([key]) => !existing.has(key))
128
+ .map(([, label]) => label),
129
+ remove: [...remove]
130
+ .filter(([key]) => existing.has(key))
131
+ .map(([, label]) => label),
132
+ };
78
133
  }
79
134
  export function resolveIssueCategory(issue, repository) {
80
135
  const triage = repository.triage;
@@ -305,6 +360,86 @@ async function runPhaseVote(input) {
305
360
  vote: majority.vote,
306
361
  };
307
362
  }
363
+ async function runSignalVote(input) {
364
+ const agents = input.input.repository.agents.triage;
365
+ const signals = input.input.repository.triage?.signals ?? [];
366
+ if (!agents?.length)
367
+ throw new Error("triage.voters is required");
368
+ if (!signals.length)
369
+ return [];
370
+ await emitProgress(input.input, { phase: "signal", type: "phase" });
371
+ const signalIds = signals.map((signal) => signal.id);
372
+ const promptTexts = await Promise.all(agents.map((agent) => composeTriageSignalPrompt({
373
+ context: input.context,
374
+ directory: input.input.directory,
375
+ issue: input.input.issue,
376
+ repository: input.input.repository,
377
+ voter: agent,
378
+ })));
379
+ const outputs = await Promise.all(agents.map(async (agent, index) => {
380
+ const prompt = promptTexts[index];
381
+ await emitProgress(input.input, {
382
+ phase: "signal",
383
+ type: "triage_agent_started",
384
+ voter: agent.key,
385
+ });
386
+ const result = await runModelWithRepair({
387
+ client: input.input.client,
388
+ model: agent.model,
389
+ onProgress: (progress) => emitTriageModelProgress({
390
+ phase: "signal",
391
+ progress,
392
+ run: input.input,
393
+ voter: agent.key,
394
+ }),
395
+ options: agent.options,
396
+ parentSessionId: input.input.parentSessionId,
397
+ parse: (text) => parseTriageSignalOutput(text, signalIds),
398
+ permission: agent.permission,
399
+ prompt,
400
+ repairAttempts: input.input.config.output?.repairAttempts ?? 3,
401
+ schemaName: "triage signal",
402
+ signal: input.input.signal,
403
+ title: `Magi triage signal #${input.input.issue} (${agent.key})`,
404
+ });
405
+ await emitProgress(input.input, {
406
+ phase: "signal",
407
+ sessionId: result.sessionId,
408
+ type: "triage_agent_completed",
409
+ voter: agent.key,
410
+ vote: result.value.signals.map((signal) => signal.id).join(",") || "none",
411
+ });
412
+ return {
413
+ ...result.value,
414
+ promptText: prompt,
415
+ raw: result.raw,
416
+ sessionId: result.sessionId,
417
+ voter: agent.key,
418
+ };
419
+ }));
420
+ const threshold = majorityThreshold(outputs.length);
421
+ const counts = new Map();
422
+ for (const output of outputs) {
423
+ for (const id of new Set(output.signals.map((signal) => signal.id))) {
424
+ counts.set(id, (counts.get(id) ?? 0) + 1);
425
+ }
426
+ }
427
+ const selected = signalIds.filter((id) => (counts.get(id) ?? 0) >= threshold);
428
+ await Promise.all(outputs.map((output) => {
429
+ const base = join(input.outputDir, `${output.voter}.signal`);
430
+ return Promise.all([
431
+ writeFile(`${base}.prompt.txt`, `${output.promptText}\n`),
432
+ writeFile(`${base}.raw.txt`, `${output.raw}\n`),
433
+ writeJson(`${base}.json`, { signals: output.signals }),
434
+ ]);
435
+ }));
436
+ await writeJson(join(input.outputDir, "signal-majority.json"), {
437
+ counts: Object.fromEntries(counts),
438
+ selected,
439
+ threshold,
440
+ });
441
+ return selected;
442
+ }
308
443
  async function relationshipScan(input, issue) {
309
444
  const [comments, relatedPullRequests, duplicateCandidates] = await Promise.all([
310
445
  fetchIssueComments(input.exec, input.repository, input.issue),
@@ -384,40 +519,40 @@ export function eligibleMentionReplies(input) {
384
519
  function finalResultFromMarker(marker) {
385
520
  if (marker.disposition) {
386
521
  return {
387
- askReason: marker.askReason,
388
522
  category: marker.category ?? null,
389
523
  disposition: marker.disposition,
524
+ signals: marker.signals,
390
525
  };
391
526
  }
392
527
  switch (marker.result) {
393
528
  case "BUG_ACCEPTED":
394
529
  case "RESOLVED_BY_MERGED_PR":
395
- return { category: "bug", disposition: "accepted" };
530
+ return { category: "bug", disposition: "accepted", signals: [] };
396
531
  case "BUG_REJECTED":
397
- return { category: "bug", disposition: "rejected" };
532
+ return { category: "bug", disposition: "rejected", signals: [] };
398
533
  case "FEATURE_ACCEPTED":
399
- return { category: "feature", disposition: "accepted" };
534
+ return { category: "feature", disposition: "accepted", signals: [] };
400
535
  case "FEATURE_REJECTED":
401
- return { category: "feature", disposition: "rejected" };
536
+ return { category: "feature", disposition: "rejected", signals: [] };
402
537
  case "ASK":
403
538
  return {
404
- askReason: "acceptance_unclear",
405
539
  category: null,
406
- disposition: "ask",
540
+ disposition: "needs_acceptance",
541
+ signals: [],
407
542
  };
408
543
  case "CLEAR_ONLY":
409
- return { category: null, disposition: "clear_only" };
544
+ return { category: null, disposition: "already_handled", signals: [] };
410
545
  case "DUPLICATE":
411
- return { category: null, disposition: "duplicate" };
546
+ return { category: null, disposition: "duplicate", signals: [] };
412
547
  default:
413
- return { category: null, disposition: "failed" };
548
+ return { category: null, disposition: "failed", signals: [] };
414
549
  }
415
550
  }
416
551
  function decisionText(decision) {
417
552
  return JSON.stringify(decision);
418
553
  }
419
554
  function actionPlan(input) {
420
- if (input.result.disposition === "clear_only") {
555
+ if (input.result.disposition === "already_handled") {
421
556
  return {
422
557
  action: "CLEAR_ONLY",
423
558
  allowedActions: ["CLEAR_ONLY"],
@@ -427,11 +562,12 @@ function actionPlan(input) {
427
562
  postComment: false,
428
563
  };
429
564
  }
430
- if (input.result.disposition === "ask") {
565
+ if (input.result.disposition === "needs_category" ||
566
+ input.result.disposition === "needs_acceptance") {
431
567
  return {
432
568
  action: "ASK",
433
569
  allowedActions: ["ASK"],
434
- clearLabels: false,
570
+ clearLabels: true,
435
571
  closeIssue: false,
436
572
  createPr: false,
437
573
  postComment: true,
@@ -439,6 +575,7 @@ function actionPlan(input) {
439
575
  }
440
576
  const closeIssue = input.triage.automation.close &&
441
577
  (input.result.disposition === "rejected" ||
578
+ input.result.disposition === "invalid" ||
442
579
  input.result.disposition === "duplicate");
443
580
  const createPr = input.triage.automation.create && input.result.disposition === "accepted";
444
581
  return {
@@ -452,8 +589,13 @@ function actionPlan(input) {
452
589
  }
453
590
  function previousAutomationPlan(input) {
454
591
  const base = actionPlan({ result: input.result, triage: input.triage });
592
+ const labelChanges = triageLabelChanges({
593
+ issueLabels: input.issue.labels,
594
+ result: input.result,
595
+ rules: input.triage.automation.label,
596
+ });
455
597
  const clearLabels = base.clearLabels &&
456
- existingClearLabels(input.issue, input.triage.automation.clear).length > 0;
598
+ (labelChanges.add.length > 0 || labelChanges.remove.length > 0);
457
599
  const closeIssue = input.marker.action === "CLOSE" &&
458
600
  base.closeIssue &&
459
601
  input.issue.state === "OPEN";
@@ -558,6 +700,9 @@ function decisionCommentFallback(input) {
558
700
  if (input.result.disposition === "duplicate") {
559
701
  return "Magi marked this issue as a duplicate.";
560
702
  }
703
+ if (input.result.disposition === "invalid") {
704
+ return "Magi marked this issue as invalid or not actionable.";
705
+ }
561
706
  return "Magi completed triage for this issue.";
562
707
  }
563
708
  function agentForKey(repository, key) {
@@ -675,7 +820,9 @@ async function finishWithResult(input) {
675
820
  });
676
821
  let prUrl;
677
822
  const reporter = triageReporter(input.input.repository, input.issue.number);
678
- const comment = plan.postComment && input.result.disposition !== "ask"
823
+ const comment = plan.postComment &&
824
+ input.result.disposition !== "needs_category" &&
825
+ input.result.disposition !== "needs_acceptance"
679
826
  ? `${decisionCommentBody({
680
827
  action: plan.action,
681
828
  reason: input.commentReason,
@@ -691,7 +838,19 @@ async function finishWithResult(input) {
691
838
  if (comment) {
692
839
  await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
693
840
  }
694
- if (input.result.disposition === "ask" && input.askOutputs) {
841
+ const labelChanges = plan.clearLabels
842
+ ? triageLabelChanges({
843
+ issueLabels: input.issue.labels,
844
+ result: input.result,
845
+ rules: triage.automation.label,
846
+ })
847
+ : { add: [], remove: [] };
848
+ if (plan.clearLabels) {
849
+ await writeJson(join(input.outputDir, "label-changes.json"), labelChanges);
850
+ }
851
+ if ((input.result.disposition === "needs_category" ||
852
+ input.result.disposition === "needs_acceptance") &&
853
+ input.askOutputs) {
695
854
  await postAskComments({
696
855
  action: plan.action,
697
856
  dryRun: input.input.dryRun,
@@ -723,9 +882,11 @@ async function finishWithResult(input) {
723
882
  });
724
883
  }
725
884
  if (plan.clearLabels) {
726
- const clearLabels = existingClearLabels(input.issue, triage.automation.clear);
727
- if (clearLabels.length) {
728
- await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, clearLabels, reporter.account);
885
+ if (labelChanges.remove.length) {
886
+ await removeIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.remove, reporter.account);
887
+ }
888
+ if (labelChanges.add.length) {
889
+ await addIssueLabels(input.input.exec, input.input.repository, input.issue.number, labelChanges.add, reporter.account);
729
890
  }
730
891
  }
731
892
  if (plan.closeIssue) {
@@ -934,7 +1095,7 @@ export async function runTriage(input) {
934
1095
  issue: input.issue,
935
1096
  outputDir,
936
1097
  report,
937
- result: { category: null, disposition: "failed" },
1098
+ result: { category: null, disposition: "blocked", signals: [] },
938
1099
  };
939
1100
  }
940
1101
  let context = issueContext({ issue, relationship });
@@ -1016,8 +1177,7 @@ export async function runTriage(input) {
1016
1177
  });
1017
1178
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
1018
1179
  const previous = finalResultFromMarker(relationship.previousMarker);
1019
- if (previous.disposition !== "ask" ||
1020
- previous.askReason !== "acceptance_unclear") {
1180
+ if (previous.disposition !== "needs_acceptance") {
1021
1181
  const reconsideration = await runReconsiderationVote({
1022
1182
  context,
1023
1183
  input,
@@ -1026,15 +1186,23 @@ export async function runTriage(input) {
1026
1186
  commentReason = reconsideration.reason;
1027
1187
  result =
1028
1188
  reconsideration.vote === "YES"
1029
- ? { category: previous.category, disposition: "accepted" }
1189
+ ? {
1190
+ category: previous.category,
1191
+ disposition: "accepted",
1192
+ signals: [],
1193
+ }
1030
1194
  : reconsideration.vote === "NO"
1031
- ? { category: previous.category, disposition: "rejected" }
1195
+ ? {
1196
+ category: previous.category,
1197
+ disposition: "rejected",
1198
+ signals: [],
1199
+ }
1032
1200
  : {
1033
- askReason: "acceptance_unclear",
1034
1201
  category: previous.category,
1035
- disposition: "ask",
1202
+ disposition: "needs_acceptance",
1203
+ signals: [],
1036
1204
  };
1037
- if (result.disposition === "ask") {
1205
+ if (result.disposition === "needs_acceptance") {
1038
1206
  askCommentOutputs = askOutputs(reconsideration.outputs);
1039
1207
  markAskComments = true;
1040
1208
  }
@@ -1057,6 +1225,7 @@ export async function runTriage(input) {
1057
1225
  const relatedPrDecision = {
1058
1226
  category: resolveIssueCategory(issue, input.repository) ?? null,
1059
1227
  disposition: "accepted",
1228
+ signals: [],
1060
1229
  };
1061
1230
  const plan = {
1062
1231
  action: "CLOSE",
@@ -1086,7 +1255,7 @@ export async function runTriage(input) {
1086
1255
  outputDir,
1087
1256
  processed,
1088
1257
  relationship,
1089
- result: { category: null, disposition: "clear_only" },
1258
+ result: { category: null, disposition: "already_handled", signals: [] },
1090
1259
  runId,
1091
1260
  });
1092
1261
  }
@@ -1101,7 +1270,7 @@ export async function runTriage(input) {
1101
1270
  if (duplicate) {
1102
1271
  context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
1103
1272
  commentReason = duplicate.reason;
1104
- result = { category: null, disposition: "duplicate" };
1273
+ result = { category: null, disposition: "duplicate", signals: [] };
1105
1274
  }
1106
1275
  }
1107
1276
  if (!result) {
@@ -1125,9 +1294,9 @@ export async function runTriage(input) {
1125
1294
  const category = resolvedCategory ?? categoryVote?.vote ?? "ASK";
1126
1295
  if (category === "ASK") {
1127
1296
  result = {
1128
- askReason: "category_unclear",
1129
1297
  category: null,
1130
- disposition: "ask",
1298
+ disposition: "needs_category",
1299
+ signals: [],
1131
1300
  };
1132
1301
  askCommentOutputs = askOutputs(categoryVote?.outputs);
1133
1302
  markAskComments = false;
@@ -1146,25 +1315,41 @@ export async function runTriage(input) {
1146
1315
  phase: "acceptance",
1147
1316
  prompt: composeTriageAcceptancePrompt,
1148
1317
  schemaName: "triage acceptance",
1149
- votes: BINARY_VOTES,
1318
+ votes: ACCEPTANCE_VOTES,
1150
1319
  });
1151
1320
  commentReason = acceptance.reason;
1152
1321
  result =
1153
1322
  acceptance.vote === "YES"
1154
- ? { category, disposition: "accepted" }
1323
+ ? { category, disposition: "accepted", signals: [] }
1155
1324
  : acceptance.vote === "NO"
1156
- ? { category, disposition: "rejected" }
1157
- : {
1158
- askReason: "acceptance_unclear",
1159
- category,
1160
- disposition: "ask",
1161
- };
1162
- if (result.disposition === "ask") {
1325
+ ? { category, disposition: "rejected", signals: [] }
1326
+ : acceptance.vote === "INVALID"
1327
+ ? { category, disposition: "invalid", signals: [] }
1328
+ : {
1329
+ category,
1330
+ disposition: "needs_acceptance",
1331
+ signals: [],
1332
+ };
1333
+ if (result.disposition === "needs_acceptance") {
1163
1334
  askCommentOutputs = askOutputs(acceptance.outputs);
1164
1335
  markAskComments = true;
1165
1336
  }
1166
1337
  }
1167
1338
  }
1339
+ if (result &&
1340
+ result.disposition !== "blocked" &&
1341
+ result.disposition !== "failed" &&
1342
+ triage.signals.length) {
1343
+ const signalContext = `${context}\n\nFinal triage result: ${JSON.stringify(result)}`;
1344
+ result = {
1345
+ ...result,
1346
+ signals: await runSignalVote({
1347
+ context: signalContext,
1348
+ input,
1349
+ outputDir,
1350
+ }),
1351
+ };
1352
+ }
1168
1353
  return finishWithResult({
1169
1354
  askOutputs: askCommentOutputs,
1170
1355
  commentReason,
@@ -1176,9 +1361,9 @@ export async function runTriage(input) {
1176
1361
  processed,
1177
1362
  relationship,
1178
1363
  result: result ?? {
1179
- askReason: "acceptance_unclear",
1180
1364
  category: null,
1181
- disposition: "ask",
1365
+ disposition: "needs_acceptance",
1366
+ signals: [],
1182
1367
  },
1183
1368
  runId,
1184
1369
  });
@@ -1,7 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
- import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageCreatePrOutputContract, triageDuplicateOutputContract, triageVoteOutputContract, } from "./contracts";
4
+ import { ciClassificationAfterEditOutputContract, ciClassificationOutputContract, closeReconsiderationOutputContract, editOutputContract, findingValidationOutputContract, rereviewCloseReconsiderationOutputContract, rereviewOutputContract, reviewOutputContract, triageCommentClassificationOutputContract, triageCreatePrOutputContract, triageDuplicateOutputContract, triageSignalOutputContract, triageVoteOutputContract, } from "./contracts";
5
5
  async function readOptionalPrompt(directory, path, values = {}) {
6
6
  if (!path)
7
7
  return "";
@@ -90,12 +90,16 @@ function triageValues(input) {
90
90
  ? `- ${category.id}: ${category.description}`
91
91
  : `- ${category.id}`)
92
92
  .join("\n");
93
+ const signalOptions = (input.repository.triage?.signals ?? [])
94
+ .map((signal) => `- ${signal.id}: ${signal.description}`)
95
+ .join("\n");
93
96
  return {
94
97
  ...repositoryValues(input.repository),
95
98
  author: input.author ?? "",
96
99
  categoryOptions,
97
100
  context: input.context,
98
101
  issue: String(input.issue),
102
+ signalOptions,
99
103
  worktreePath: input.worktreePath ?? "",
100
104
  };
101
105
  }
@@ -441,7 +445,14 @@ export async function composeTriageAcceptancePrompt(input) {
441
445
  ...input,
442
446
  builtin: "acceptance",
443
447
  customPath: input.repository.triage?.prompts.acceptance,
444
- outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
448
+ outputContract: triageVoteOutputContract('"YES" | "NO" | "INVALID" | "ASK"'),
449
+ });
450
+ }
451
+ export async function composeTriageSignalPrompt(input) {
452
+ return composeTriageVotePrompt({
453
+ ...input,
454
+ builtin: "signal",
455
+ outputContract: triageSignalOutputContract,
445
456
  });
446
457
  }
447
458
  export async function composeTriageCommentClassificationPrompt(input) {
@@ -256,6 +256,24 @@ The object must match this shape:
256
256
  ]
257
257
  }
258
258
  </output_contract>`.trim();
259
+ export const triageSignalOutputContract = `
260
+ <output_contract>
261
+ Return exactly one JSON object and nothing else. Do not wrap it in markdown.
262
+
263
+ The object must match this shape:
264
+ {
265
+ "signals": [
266
+ {
267
+ "id": "configured_signal_id",
268
+ "reason": "Short rationale."
269
+ }
270
+ ]
271
+ }
272
+
273
+ Rules:
274
+ - Return only configured signal IDs that apply.
275
+ - Return an empty signals array when none apply.
276
+ </output_contract>`.trim();
259
277
  const outputContractsBySchemaName = {
260
278
  "CI classification": ciClassificationOutputContract,
261
279
  "close reconsideration": closeReconsiderationOutputContract,
@@ -264,13 +282,14 @@ const outputContractsBySchemaName = {
264
282
  rereview: rereviewOutputContract,
265
283
  "rereview close reconsideration": rereviewCloseReconsiderationOutputContract,
266
284
  review: reviewOutputContract,
267
- "triage acceptance": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
285
+ "triage acceptance": triageVoteOutputContract('"YES" | "NO" | "INVALID" | "ASK"'),
268
286
  "triage category": triageVoteOutputContract('"ASK" or one of the configured category IDs'),
269
287
  "triage create PR": triageCreatePrOutputContract,
270
288
  "triage comment classification": triageCommentClassificationOutputContract,
271
289
  "triage duplicate": triageDuplicateOutputContract,
272
290
  "triage existing PR": triageVoteOutputContract('"RELATED_PR_HANDLES_ISSUE" | "RELATED_PR_DOES_NOT_HANDLE_ISSUE"'),
273
291
  "triage reconsider": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
292
+ "triage signal": triageSignalOutputContract,
274
293
  };
275
294
  export function repairPrompt(schemaName) {
276
295
  const outputContract = outputContractsBySchemaName[schemaName];
@@ -112,7 +112,25 @@ export function parseTriageCategoryOutput(text, categories) {
112
112
  return parseTriageVote(text, ["ASK", ...categories]);
113
113
  }
114
114
  export function parseTriageBinaryOutput(text) {
115
- return parseTriageVote(text, ["ASK", "NO", "YES"]);
115
+ return parseTriageVote(text, ["ASK", "INVALID", "NO", "YES"]);
116
+ }
117
+ export function parseTriageSignalOutput(text, signalIds) {
118
+ const data = extractJson(text);
119
+ if (!data || typeof data !== "object")
120
+ throw new Error("triage signal output must be an object");
121
+ const ids = new Set(signalIds);
122
+ return {
123
+ signals: requireArray(data.signals, "signals").map((item, index) => {
124
+ const value = item;
125
+ const id = requireString(value.id, `signals[${index}].id`);
126
+ if (!ids.has(id))
127
+ throw new Error(`signals[${index}].id is not configured`);
128
+ return {
129
+ id,
130
+ reason: requireString(value.reason, `signals[${index}].reason`),
131
+ };
132
+ }),
133
+ };
116
134
  }
117
135
  export function parseTriageDuplicateOutput(text) {
118
136
  const data = extractJson(text);
@@ -1,6 +1,6 @@
1
1
  Evaluate issue #{issue} in {owner}/{repo} for the selected category.
2
2
 
3
- Choose YES when the issue should be accepted for the project. Choose NO when it should be rejected, is not actionable, or is not appropriate for this project. Choose ASK when specific missing information is required before deciding.
3
+ Choose YES when the issue should be accepted for the project. Choose NO when it should not be worked on for normal wontfix reasons. Choose INVALID when the report is unreproducible, contradictory, based on an impossible premise, or otherwise not actionable as an issue. Choose ASK when specific missing information is required before deciding.
4
4
 
5
5
  <context>
6
6
  {context}
@@ -0,0 +1,10 @@
1
+ Evaluate optional triage signals for issue #{issue} in {owner}/{repo}.
2
+
3
+ Configured signals:
4
+ {signalOptions}
5
+
6
+ Return every configured signal that applies to the final triage result and issue context. Do not invent signal IDs.
7
+
8
+ <context>
9
+ {context}
10
+ </context>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260525091701",
3
+ "version": "0.0.0-dev-20260525101932",
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
@@ -279,6 +279,47 @@
279
279
  "description": { "type": "string" }
280
280
  }
281
281
  },
282
+ "triageSignal": {
283
+ "type": "object",
284
+ "additionalProperties": false,
285
+ "required": ["id", "description"],
286
+ "properties": {
287
+ "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
288
+ "description": { "type": "string", "minLength": 1 }
289
+ }
290
+ },
291
+ "triageLabelRuleCondition": {
292
+ "type": "object",
293
+ "additionalProperties": false,
294
+ "minProperties": 1,
295
+ "properties": {
296
+ "disposition": {
297
+ "enum": [
298
+ "accepted",
299
+ "rejected",
300
+ "invalid",
301
+ "duplicate",
302
+ "already_handled",
303
+ "needs_category",
304
+ "needs_acceptance",
305
+ "blocked",
306
+ "failed"
307
+ ]
308
+ },
309
+ "category": { "type": "string" },
310
+ "signals": { "type": "array", "items": { "type": "string" } }
311
+ }
312
+ },
313
+ "triageLabelRule": {
314
+ "type": "object",
315
+ "additionalProperties": false,
316
+ "required": ["when"],
317
+ "properties": {
318
+ "when": { "$ref": "#/$defs/triageLabelRuleCondition" },
319
+ "add": { "type": "array", "items": { "type": "string" } },
320
+ "remove": { "type": "array", "items": { "type": "string" } }
321
+ }
322
+ },
282
323
  "triageAutomation": {
283
324
  "type": "object",
284
325
  "additionalProperties": false,
@@ -287,10 +328,9 @@
287
328
  "create": { "type": "boolean", "default": false },
288
329
  "review": { "type": "boolean", "default": false },
289
330
  "merge": { "type": "boolean", "default": false },
290
- "clear": {
331
+ "label": {
291
332
  "type": "array",
292
- "items": { "type": "string" },
293
- "default": ["triage"]
333
+ "items": { "$ref": "#/$defs/triageLabelRule" }
294
334
  }
295
335
  }
296
336
  },
@@ -371,6 +411,10 @@
371
411
  "type": "array",
372
412
  "items": { "$ref": "#/$defs/triageCategory" }
373
413
  },
414
+ "signals": {
415
+ "type": "array",
416
+ "items": { "$ref": "#/$defs/triageSignal" }
417
+ },
374
418
  "automation": { "$ref": "#/$defs/triageAutomation" },
375
419
  "safety": { "$ref": "#/$defs/triageSafety" },
376
420
  "concurrency": { "$ref": "#/$defs/triageConcurrency" },