opencode-magi 0.0.0-dev-20260520165753 → 0.0.0-dev-20260520173258

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.
@@ -3,33 +3,27 @@ import { dirname, join } from "node:path";
3
3
  import { issueRunOutputDir } from "../config/output";
4
4
  import { worktreeBaseDir } from "../config/worktree";
5
5
  import { assignIssue, closeIssue, closePullRequest, configureGitIdentity, createPullRequest, fetchIssue, fetchIssueComments, fetchRelatedPullRequests, postIssueComment, pushHead, removeIssueLabels, removeWorktree, searchDuplicateIssues, shellQuote, updateIssueComment, } from "../github/commands";
6
- import { composeTriageBugPrompt, composeTriageActionPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageFeaturePrompt, composeTriageKindPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
- import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, parseTriageFinalOutput, parseTriageKindOutput, } from "../prompts/output";
6
+ import { composeTriageAcceptancePrompt, composeTriageActionPrompt, composeTriageCategoryPrompt, composeTriageCommentClassificationPrompt, composeTriageCommentPrompt, composeTriageCreatePrPrompt, composeTriageDuplicatePrompt, composeTriageExistingPrPrompt, composeTriageQuestionPrompt, composeTriageReconsiderPrompt, } from "../prompts/compose";
7
+ import { parseTriageActionOutput, parseTriageBinaryOutput, parseTriageCategoryOutput, parseTriageCommentClassificationOutput, parseTriageCreatePrOutput, parseTriageDuplicateOutput, parseTriageExistingPrOutput, } from "../prompts/output";
8
8
  import { aggregateStringMajority, majorityThreshold } from "./majority";
9
9
  import { runModelText, runModelWithRepair } from "./model";
10
10
  const MARKER_PREFIX = "opencode-magi:triage";
11
- const KIND_VOTES = ["ASK", "BUG", "FEATURE"];
12
11
  const BINARY_VOTES = ["ASK", "NO", "YES"];
13
12
  const DUPLICATE_VOTES = ["DUPLICATE", "NOT_DUPLICATE"];
14
13
  const EXISTING_PR_VOTES = [
15
14
  "RELATED_PR_DOES_NOT_HANDLE_ISSUE",
16
15
  "RELATED_PR_HANDLES_ISSUE",
17
16
  ];
18
- const FINAL_VOTES = [
19
- "ASK",
20
- "BUG_ACCEPTED",
21
- "BUG_REJECTED",
22
- "DUPLICATE",
23
- "FEATURE_ACCEPTED",
24
- "FEATURE_REJECTED",
25
- ];
26
17
  const RECONSIDERATION_CLASSES = new Set([
27
18
  "CLARIFICATION",
28
19
  "NEW_EVIDENCE",
29
20
  "OBJECTION",
30
21
  ]);
31
22
  function marker(input) {
32
- return `<!-- ${MARKER_PREFIX} v=1 issue=${input.issue} result=${input.result} action=${input.action} checkpoint=${input.checkpoint ?? "pending"} pr=${input.pr ?? "none"} processed=${(input.processed ?? []).join(",")} -->`;
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(",")} -->`;
33
27
  }
34
28
  export function parseTriageMarker(body) {
35
29
  const match = body.match(/<!--\s*opencode-magi:triage\s+([^>]+?)\s*-->/);
@@ -45,13 +39,26 @@ export function parseTriageMarker(body) {
45
39
  : [part.slice(0, index), part.slice(index + 1)];
46
40
  }));
47
41
  const version = Number(entries.v);
48
- if (version !== 1)
42
+ if (version !== 1 && version !== 2)
49
43
  return undefined;
50
44
  return {
51
45
  action: entries.action,
46
+ askReason: entries.askReason === "acceptance_unclear" ||
47
+ entries.askReason === "category_unclear"
48
+ ? entries.askReason
49
+ : undefined,
50
+ category: entries.category === "none" ? null : entries.category || undefined,
52
51
  checkpoint: entries.checkpoint && Number.isFinite(Number(entries.checkpoint))
53
52
  ? Number(entries.checkpoint)
54
53
  : 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,
55
62
  issue: entries.issue ? Number(entries.issue) : undefined,
56
63
  pr: entries.pr,
57
64
  processed: entries.processed
@@ -69,17 +76,15 @@ function existingClearLabels(issue, labels) {
69
76
  const existing = new Set(issue.labels.map((label) => label.toLowerCase()));
70
77
  return labels.filter((label) => existing.has(label.toLowerCase()));
71
78
  }
72
- export function resolveIssueKind(issue, repository) {
79
+ export function resolveIssueCategory(issue, repository) {
73
80
  const triage = repository.triage;
74
81
  if (!triage)
75
82
  throw new Error("triage configuration is required");
76
- const bug = labelsContain(issue.labels, triage.kind.bug.label) ||
77
- (issue.type != null && triage.kind.bug.type.includes(issue.type));
78
- const feature = labelsContain(issue.labels, triage.kind.feature.label) ||
79
- (issue.type != null && triage.kind.feature.type.includes(issue.type));
80
- if (bug === feature)
83
+ const matches = triage.categories.filter((category) => labelsContain(issue.labels, category.labels) ||
84
+ (issue.type != null && category.types.includes(issue.type)));
85
+ if (matches.length !== 1)
81
86
  return undefined;
82
- return bug ? "BUG" : "FEATURE";
87
+ return matches[0].id;
83
88
  }
84
89
  function issueContext(input) {
85
90
  return JSON.stringify({
@@ -288,22 +293,42 @@ export function eligibleMentionReplies(input) {
288
293
  });
289
294
  }
290
295
  function finalResultFromMarker(marker) {
291
- if (marker.result === "RESOLVED_BY_MERGED_PR")
292
- return "BUG_ACCEPTED";
293
- return isFinalResult(marker.result) ? marker.result : "FAILED";
294
- }
295
- function isFinalResult(value) {
296
- return (value === "ASK" ||
297
- value === "BUG_ACCEPTED" ||
298
- value === "BUG_REJECTED" ||
299
- value === "CLEAR_ONLY" ||
300
- value === "DUPLICATE" ||
301
- value === "FEATURE_ACCEPTED" ||
302
- value === "FEATURE_REJECTED" ||
303
- value === "FAILED");
296
+ if (marker.disposition) {
297
+ return {
298
+ askReason: marker.askReason,
299
+ category: marker.category ?? null,
300
+ disposition: marker.disposition,
301
+ };
302
+ }
303
+ switch (marker.result) {
304
+ case "BUG_ACCEPTED":
305
+ case "RESOLVED_BY_MERGED_PR":
306
+ return { category: "bug", disposition: "accepted" };
307
+ case "BUG_REJECTED":
308
+ return { category: "bug", disposition: "rejected" };
309
+ case "FEATURE_ACCEPTED":
310
+ return { category: "feature", disposition: "accepted" };
311
+ case "FEATURE_REJECTED":
312
+ return { category: "feature", disposition: "rejected" };
313
+ case "ASK":
314
+ return {
315
+ askReason: "acceptance_unclear",
316
+ category: null,
317
+ disposition: "ask",
318
+ };
319
+ case "CLEAR_ONLY":
320
+ return { category: null, disposition: "clear_only" };
321
+ case "DUPLICATE":
322
+ return { category: null, disposition: "duplicate" };
323
+ default:
324
+ return { category: null, disposition: "failed" };
325
+ }
326
+ }
327
+ function decisionText(decision) {
328
+ return JSON.stringify(decision);
304
329
  }
305
330
  function actionPlan(input) {
306
- if (input.result === "CLEAR_ONLY") {
331
+ if (input.result.disposition === "clear_only") {
307
332
  return {
308
333
  action: "CLEAR_ONLY",
309
334
  allowedActions: ["CLEAR_ONLY"],
@@ -313,7 +338,7 @@ function actionPlan(input) {
313
338
  postComment: false,
314
339
  };
315
340
  }
316
- if (input.result === "ASK") {
341
+ if (input.result.disposition === "ask") {
317
342
  return {
318
343
  action: "ASK",
319
344
  allowedActions: ["ASK"],
@@ -324,11 +349,9 @@ function actionPlan(input) {
324
349
  };
325
350
  }
326
351
  const closeIssue = input.triage.automation.close &&
327
- (input.result === "BUG_REJECTED" ||
328
- input.result === "DUPLICATE" ||
329
- input.result === "FEATURE_REJECTED");
330
- const createPr = input.triage.automation.pr &&
331
- (input.result === "BUG_ACCEPTED" || input.result === "FEATURE_ACCEPTED");
352
+ (input.result.disposition === "rejected" ||
353
+ input.result.disposition === "duplicate");
354
+ const createPr = input.triage.automation.pr && input.result.disposition === "accepted";
332
355
  return {
333
356
  action: closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT",
334
357
  allowedActions: [closeIssue ? "CLOSE" : createPr ? "PR" : "COMMENT"],
@@ -428,18 +451,34 @@ async function runReconsiderationVote(input) {
428
451
  context: input.context,
429
452
  input: input.input,
430
453
  outputDir: input.outputDir,
431
- parse: parseTriageFinalOutput,
454
+ parse: parseTriageBinaryOutput,
432
455
  phase: "reconsider",
433
456
  prompt: composeTriageReconsiderPrompt,
434
457
  schemaName: "triage reconsider",
435
- votes: FINAL_VOTES,
458
+ votes: BINARY_VOTES,
436
459
  });
437
460
  }
438
461
  async function composeResultComment(input) {
439
462
  const agents = input.input.repository.agents.triage;
440
463
  if (!agents?.length)
441
464
  throw new Error("triage.agents is required");
442
- const prompt = await (input.result === "ASK"
465
+ if (input.result.disposition === "ask" &&
466
+ input.result.askReason === "category_unclear") {
467
+ const language = input.input.repository.language?.toLowerCase() ?? "";
468
+ const body = language.includes("ja") || language.includes("japanese")
469
+ ? `@${input.issue.author} 現在の説明だけでは、何をすべきか判断できません。\n\n期待する動作、実際の動作、必要な理由、関連する例・ログ・スクリーンショットなどを追記してください。`
470
+ : `@${input.issue.author} I can't determine what should be done from the current description.\n\nPlease add more detail, such as the expected behavior, the actual behavior, the reason this is needed, or any relevant examples, logs, or screenshots.`;
471
+ const comment = `${body}\n\n${marker({
472
+ action: input.action,
473
+ checkpoint: "pending",
474
+ decision: input.result,
475
+ issue: input.issue.number,
476
+ processed: input.processed,
477
+ })}`;
478
+ await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
479
+ return comment;
480
+ }
481
+ const prompt = await (input.result.disposition === "ask"
443
482
  ? composeTriageQuestionPrompt
444
483
  : composeTriageCommentPrompt)({
445
484
  author: input.issue.author,
@@ -461,9 +500,9 @@ async function composeResultComment(input) {
461
500
  `\n\n${marker({
462
501
  action: input.action,
463
502
  checkpoint: "pending",
503
+ decision: input.result,
464
504
  issue: input.issue.number,
465
505
  processed: input.processed,
466
- result: input.result,
467
506
  })}`;
468
507
  await writeFile(join(input.outputDir, "comment.md"), `${comment}\n`);
469
508
  return comment;
@@ -486,10 +525,10 @@ async function persistProcessedMarker(input) {
486
525
  const updatedMarker = marker({
487
526
  action: input.marker.action ?? input.marker.result ?? "ASK",
488
527
  checkpoint: markerCheckpoint(input.marker),
528
+ decision: finalResultFromMarker(input.marker),
489
529
  issue: input.issue.number,
490
530
  pr: input.pr ?? markerPr(input.marker),
491
531
  processed: input.processed,
492
- result: input.marker.result ?? "ASK",
493
532
  });
494
533
  const body = previousComment.body.replace(/<!--\s*opencode-magi:triage\s+[^>]+?\s*-->/, updatedMarker);
495
534
  if (body === previousComment.body)
@@ -516,7 +555,7 @@ async function finishWithResult(input) {
516
555
  const comment = plan.postComment
517
556
  ? await composeResultComment({
518
557
  action: plan.action,
519
- context: `Result: ${input.result}\nAction: ${plan.action}\n\n${input.context}`,
558
+ context: `Result: ${decisionText(input.result)}\nAction: ${plan.action}\n\n${input.context}`,
520
559
  input: input.input,
521
560
  issue: input.issue,
522
561
  outputDir: input.outputDir,
@@ -576,7 +615,7 @@ async function finishWithResult(input) {
576
615
  }
577
616
  }
578
617
  const report = [
579
- `Magi triage result for #${input.issue.number}: ${input.result}`,
618
+ `Magi triage result for #${input.issue.number}: ${decisionText(input.result)}`,
580
619
  prUrl ? `Created PR: ${prUrl}` : undefined,
581
620
  input.input.dryRun
582
621
  ? "Dry run: no GitHub mutations were performed."
@@ -690,7 +729,12 @@ export async function runTriage(input) {
690
729
  if (block) {
691
730
  const report = `Magi triage blocked for #${input.issue}: ${block}`;
692
731
  await writeFile(join(outputDir, "report.md"), `${report}\n`);
693
- return { issue: input.issue, outputDir, report, result: "FAILED" };
732
+ return {
733
+ issue: input.issue,
734
+ outputDir,
735
+ report,
736
+ result: { category: null, disposition: "failed" },
737
+ };
694
738
  }
695
739
  let context = issueContext({ issue, relationship });
696
740
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
@@ -765,8 +809,18 @@ export async function runTriage(input) {
765
809
  },
766
810
  });
767
811
  await writeFile(join(outputDir, "context.md"), `${context}\n`);
812
+ const vote = await runReconsiderationVote({ context, input, outputDir });
813
+ const previous = finalResultFromMarker(relationship.previousMarker);
768
814
  result =
769
- (await runReconsiderationVote({ context, input, outputDir })) ?? "ASK";
815
+ vote === "YES"
816
+ ? { category: previous.category, disposition: "accepted" }
817
+ : vote === "NO"
818
+ ? { category: previous.category, disposition: "rejected" }
819
+ : {
820
+ askReason: "acceptance_unclear",
821
+ category: previous.category,
822
+ disposition: "ask",
823
+ };
770
824
  }
771
825
  if (!result && relationship.relatedPullRequests.length) {
772
826
  const vote = await runPhaseVote({
@@ -782,6 +836,10 @@ export async function runTriage(input) {
782
836
  if (vote === "RELATED_PR_HANDLES_ISSUE") {
783
837
  const merged = relationship.relatedPullRequests.some((pr) => pr.state === "MERGED");
784
838
  if (merged && triage.automation.close) {
839
+ const relatedPrDecision = {
840
+ category: resolveIssueCategory(issue, input.repository) ?? null,
841
+ disposition: "accepted",
842
+ };
785
843
  const plan = {
786
844
  action: "CLOSE",
787
845
  allowedActions: ["CLOSE"],
@@ -795,16 +853,16 @@ export async function runTriage(input) {
795
853
  input,
796
854
  outputDir,
797
855
  plan,
798
- result: "BUG_ACCEPTED",
856
+ result: relatedPrDecision,
799
857
  });
800
858
  const body = await composeResultComment({
801
859
  action: "CLOSE",
802
- context: `Result: BUG_ACCEPTED\nAction: CLOSE\n\n${context}`,
860
+ context: `Result: ${decisionText(relatedPrDecision)}\nAction: CLOSE\n\n${context}`,
803
861
  input,
804
862
  issue,
805
863
  outputDir,
806
864
  processed,
807
- result: "BUG_ACCEPTED",
865
+ result: relatedPrDecision,
808
866
  });
809
867
  if (!input.dryRun) {
810
868
  await postMarkedIssueComment({
@@ -834,7 +892,7 @@ export async function runTriage(input) {
834
892
  issue: issue.number,
835
893
  outputDir,
836
894
  report,
837
- result: "BUG_ACCEPTED",
895
+ result: relatedPrDecision,
838
896
  };
839
897
  }
840
898
  return finishWithResult({
@@ -844,7 +902,7 @@ export async function runTriage(input) {
844
902
  outputDir,
845
903
  processed,
846
904
  relationship,
847
- result: "CLEAR_ONLY",
905
+ result: { category: null, disposition: "clear_only" },
848
906
  });
849
907
  }
850
908
  }
@@ -857,59 +915,60 @@ export async function runTriage(input) {
857
915
  });
858
916
  if (duplicate) {
859
917
  context = `${context}\n\nDuplicate decision: ${JSON.stringify(duplicate)}`;
860
- result = "DUPLICATE";
918
+ result = { category: null, disposition: "duplicate" };
861
919
  }
862
920
  }
863
921
  if (!result) {
864
- const resolvedKind = resolveIssueKind(issue, input.repository);
865
- await writeJson(join(outputDir, "kind-resolution.json"), {
866
- kind: resolvedKind,
867
- source: resolvedKind ? "config" : "vote",
922
+ const resolvedCategory = resolveIssueCategory(issue, input.repository);
923
+ await writeJson(join(outputDir, "category-resolution.json"), {
924
+ category: resolvedCategory,
925
+ source: resolvedCategory ? "config" : "vote",
868
926
  });
869
- const kind = resolvedKind ??
927
+ const category = resolvedCategory ??
870
928
  (await runPhaseVote({
871
929
  context,
872
930
  input,
873
931
  outputDir,
874
- parse: parseTriageKindOutput,
875
- phase: "kind",
876
- prompt: composeTriageKindPrompt,
877
- schemaName: "triage kind",
878
- votes: KIND_VOTES,
932
+ parse: (text) => parseTriageCategoryOutput(text, triage.categories.map((item) => item.id)),
933
+ phase: "category",
934
+ prompt: composeTriageCategoryPrompt,
935
+ schemaName: "triage category",
936
+ votes: ["ASK", ...triage.categories.map((item) => item.id)],
879
937
  })) ??
880
938
  "ASK";
881
- result = "ASK";
882
- if (kind === "BUG") {
883
- const vote = await runPhaseVote({
884
- context,
885
- input,
886
- outputDir,
887
- parse: parseTriageBinaryOutput,
888
- phase: "bug",
889
- prompt: composeTriageBugPrompt,
890
- schemaName: "triage bug",
891
- votes: BINARY_VOTES,
892
- });
893
- result =
894
- vote === "YES" ? "BUG_ACCEPTED" : vote === "NO" ? "BUG_REJECTED" : "ASK";
939
+ if (category === "ASK") {
940
+ result = {
941
+ askReason: "category_unclear",
942
+ category: null,
943
+ disposition: "ask",
944
+ };
895
945
  }
896
- if (kind === "FEATURE") {
946
+ else {
947
+ const categoryConfig = triage.categories.find((item) => item.id === category);
948
+ const voteContext = JSON.stringify({
949
+ category: categoryConfig,
950
+ triageContext: context,
951
+ }, null, 2);
897
952
  const vote = await runPhaseVote({
898
- context,
953
+ context: voteContext,
899
954
  input,
900
955
  outputDir,
901
956
  parse: parseTriageBinaryOutput,
902
- phase: "feature",
903
- prompt: composeTriageFeaturePrompt,
904
- schemaName: "triage feature",
957
+ phase: "acceptance",
958
+ prompt: composeTriageAcceptancePrompt,
959
+ schemaName: "triage acceptance",
905
960
  votes: BINARY_VOTES,
906
961
  });
907
962
  result =
908
963
  vote === "YES"
909
- ? "FEATURE_ACCEPTED"
964
+ ? { category, disposition: "accepted" }
910
965
  : vote === "NO"
911
- ? "FEATURE_REJECTED"
912
- : "ASK";
966
+ ? { category, disposition: "rejected" }
967
+ : {
968
+ askReason: "acceptance_unclear",
969
+ category,
970
+ disposition: "ask",
971
+ };
913
972
  }
914
973
  }
915
974
  return finishWithResult({
@@ -919,6 +978,10 @@ export async function runTriage(input) {
919
978
  outputDir,
920
979
  processed,
921
980
  relationship,
922
- result: result ?? "ASK",
981
+ result: result ?? {
982
+ askReason: "acceptance_unclear",
983
+ category: null,
984
+ disposition: "ask",
985
+ },
923
986
  });
924
987
  }
@@ -45,6 +45,7 @@ function reviewValues(input) {
45
45
  headSha: input.headSha,
46
46
  jsonEncodedWorktreePath: JSON.stringify(input.worktreePath),
47
47
  pr: String(input.pr),
48
+ reviewContext: input.reviewContext ?? "",
48
49
  worktreePath: input.worktreePath,
49
50
  };
50
51
  }
@@ -66,9 +67,16 @@ function editValues(input) {
66
67
  };
67
68
  }
68
69
  function triageValues(input) {
70
+ const categories = input.repository.triage?.categories ?? [];
71
+ const categoryOptions = categories
72
+ .map((category) => category.description
73
+ ? `- ${category.id}: ${category.description}`
74
+ : `- ${category.id}`)
75
+ .join("\n");
69
76
  return {
70
77
  ...repositoryValues(input.repository),
71
78
  author: input.author ?? "",
79
+ categoryOptions,
72
80
  context: input.context,
73
81
  issue: String(input.issue),
74
82
  worktreePath: input.worktreePath ?? "",
@@ -85,6 +93,9 @@ function previousReviewBlock(previousReview) {
85
93
  ? `<previous_review>\n${previousReview.trim()}\n</previous_review>`
86
94
  : "";
87
95
  }
96
+ function reviewContextBlock(reviewContext) {
97
+ return reviewContext?.trim() ? reviewContext.trim() : "";
98
+ }
88
99
  async function reviewGuidelinesBlock(input) {
89
100
  const body = (await readOptionalPrompt(input.directory, input.path, input.values)).trim();
90
101
  return body ? `<review_guidelines>\n${body}\n</review_guidelines>` : "";
@@ -95,6 +106,9 @@ async function editGuidelinesBlock(input) {
95
106
  }
96
107
  async function sessionContextBlocks(input) {
97
108
  return [
109
+ input.includeSessionContext
110
+ ? reviewContextBlock(input.values.reviewContext)
111
+ : "",
98
112
  input.includeSessionContext ? languageBlock(input.repository.language) : "",
99
113
  input.includeSessionContext ? personaBlock(input.reviewer.persona) : "",
100
114
  input.includeReviewGuidelines
@@ -116,6 +130,7 @@ export async function composeReviewPrompt(input) {
116
130
  });
117
131
  return [
118
132
  task,
133
+ reviewContextBlock(input.reviewContext),
119
134
  languageBlock(input.repository.language),
120
135
  personaBlock(input.reviewer.persona),
121
136
  await reviewGuidelinesBlock({
@@ -138,6 +153,7 @@ export async function composeRereviewPrompt(input) {
138
153
  });
139
154
  return [
140
155
  task,
156
+ reviewContextBlock(input.reviewContext),
141
157
  input.includeSessionContext === false
142
158
  ? ""
143
159
  : languageBlock(input.repository.language),
@@ -392,27 +408,23 @@ export async function composeTriageDuplicatePrompt(input) {
392
408
  outputContract: triageDuplicateOutputContract,
393
409
  });
394
410
  }
395
- export async function composeTriageKindPrompt(input) {
396
- return composeTriageVotePrompt({
397
- ...input,
398
- builtin: "kind",
399
- customPath: input.repository.triage?.prompts.kind,
400
- outputContract: triageVoteOutputContract('"BUG" | "FEATURE" | "ASK"'),
401
- });
402
- }
403
- export async function composeTriageBugPrompt(input) {
411
+ export async function composeTriageCategoryPrompt(input) {
412
+ const categories = input.repository.triage?.categories ?? [];
413
+ const votes = ["ASK", ...categories.map((category) => category.id)]
414
+ .map((vote) => JSON.stringify(vote))
415
+ .join(" | ");
404
416
  return composeTriageVotePrompt({
405
417
  ...input,
406
- builtin: "bug",
407
- customPath: input.repository.triage?.prompts.bug,
408
- outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
418
+ builtin: "category",
419
+ customPath: input.repository.triage?.prompts.category,
420
+ outputContract: triageVoteOutputContract(votes),
409
421
  });
410
422
  }
411
- export async function composeTriageFeaturePrompt(input) {
423
+ export async function composeTriageAcceptancePrompt(input) {
412
424
  return composeTriageVotePrompt({
413
425
  ...input,
414
- builtin: "feature",
415
- customPath: input.repository.triage?.prompts.feature,
426
+ builtin: "acceptance",
427
+ customPath: input.repository.triage?.prompts.acceptance,
416
428
  outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
417
429
  });
418
430
  }
@@ -429,6 +441,6 @@ export async function composeTriageReconsiderPrompt(input) {
429
441
  ...input,
430
442
  builtin: "reconsider",
431
443
  customPath: input.repository.triage?.prompts.reconsider,
432
- outputContract: triageVoteOutputContract('"ASK" | "BUG_ACCEPTED" | "BUG_REJECTED" | "DUPLICATE" | "FEATURE_ACCEPTED" | "FEATURE_REJECTED"'),
444
+ outputContract: triageVoteOutputContract('"YES" | "NO" | "ASK"'),
433
445
  });
434
446
  }
@@ -15,16 +15,25 @@ The object must match this shape:
15
15
  "perspective": "Optional review perspective."
16
16
  }
17
17
  ],
18
+ "requirementFindings": [
19
+ {
20
+ "issueNumber": 47,
21
+ "requirement": "Required closing-issue behavior that is missing.",
22
+ "evidence": "Why the PR does not satisfy the requirement.",
23
+ "fix": "How to satisfy the requirement."
24
+ }
25
+ ],
18
26
  "reason": "Required only for CLOSE."
19
27
  }
20
28
 
21
29
  Rules:
22
- - MERGE requires an empty findings array.
23
- - CHANGES_REQUESTED requires at least one finding.
24
- - CLOSE requires a reason and an empty findings array.
30
+ - MERGE requires empty findings and requirementFindings arrays.
31
+ - CHANGES_REQUESTED requires at least one finding or requirementFinding.
32
+ - CLOSE requires a reason and empty findings and requirementFindings arrays.
25
33
  - path must be repository-relative.
26
34
  - line and startLine must refer to lines inside the PR diff hunk.
27
35
  - Omit startLine for single-line findings.
36
+ - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
28
37
  </output_contract>`.trim();
29
38
  export const rereviewOutputContract = `
30
39
  <output_contract>
@@ -36,15 +45,17 @@ The object must match this shape:
36
45
  "resolve": [{ "commentId": 123, "threadId": "..." }],
37
46
  "followUps": [{ "commentId": 123, "body": "..." }],
38
47
  "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
48
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }],
39
49
  "reason": "Required only for CLOSE."
40
50
  }
41
51
 
42
52
  Rules:
43
- - MERGE requires empty followUps and newFindings arrays.
44
- - CHANGES_REQUESTED requires at least one followUp or newFinding.
45
- - CLOSE requires a reason and empty followUps and newFindings arrays.
53
+ - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
54
+ - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
55
+ - CLOSE requires a reason and empty followUps, newFindings, and requirementFindings arrays.
46
56
  - line and startLine must refer to lines inside the latest PR diff hunk.
47
57
  - Omit startLine for single-line findings.
58
+ - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
48
59
  </output_contract>`.trim();
49
60
  export const findingValidationOutputContract = `
50
61
  <output_contract>
@@ -83,12 +94,13 @@ The object must match this shape:
83
94
  "issue": "What is wrong.",
84
95
  "fix": "How to fix it."
85
96
  }
86
- ]
97
+ ],
98
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
87
99
  }
88
100
 
89
101
  Rules:
90
- - MERGE requires an empty findings array.
91
- - CHANGES_REQUESTED requires at least one finding.
102
+ - MERGE requires empty findings and requirementFindings arrays.
103
+ - CHANGES_REQUESTED requires at least one finding or requirementFinding.
92
104
  - CLOSE is not allowed in this reconsideration step.
93
105
  - Omit startLine for single-line findings.
94
106
  </output_contract>`.trim();
@@ -101,12 +113,13 @@ The object must match this shape:
101
113
  "verdict": "MERGE" | "CHANGES_REQUESTED",
102
114
  "resolve": [{ "commentId": 123, "threadId": "..." }],
103
115
  "followUps": [{ "commentId": 123, "body": "..." }],
104
- "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
116
+ "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
117
+ "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
105
118
  }
106
119
 
107
120
  Rules:
108
- - MERGE requires empty followUps and newFindings arrays.
109
- - CHANGES_REQUESTED requires at least one followUp or newFinding.
121
+ - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
122
+ - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
110
123
  - CLOSE is not allowed in this reconsideration step.
111
124
  - Omit startLine for single-line findings.
112
125
  </output_contract>`.trim();
@@ -240,13 +253,12 @@ const outputContractsBySchemaName = {
240
253
  "rereview close reconsideration": rereviewCloseReconsiderationOutputContract,
241
254
  review: reviewOutputContract,
242
255
  "triage action": triageActionOutputContract,
243
- "triage bug": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
256
+ "triage acceptance": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
257
+ "triage category": triageVoteOutputContract('"ASK" or one of the configured category IDs'),
244
258
  "triage comment classification": triageCommentClassificationOutputContract,
245
259
  "triage duplicate": triageDuplicateOutputContract,
246
260
  "triage existing PR": triageVoteOutputContract('"RELATED_PR_HANDLES_ISSUE" | "RELATED_PR_DOES_NOT_HANDLE_ISSUE"'),
247
- "triage feature": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
248
- "triage kind": triageVoteOutputContract('"BUG" | "FEATURE" | "ASK"'),
249
- "triage reconsider": triageVoteOutputContract('"ASK" | "BUG_ACCEPTED" | "BUG_REJECTED" | "DUPLICATE" | "FEATURE_ACCEPTED" | "FEATURE_REJECTED"'),
261
+ "triage reconsider": triageVoteOutputContract('"YES" | "NO" | "ASK"'),
250
262
  };
251
263
  export function repairPrompt(schemaName) {
252
264
  const outputContract = outputContractsBySchemaName[schemaName];