opencode-magi 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -157,6 +157,56 @@ function ghHostOption(config) {
157
157
  function isPlainObject(value) {
158
158
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
159
159
  }
160
+ function expandAgentRefUse(value, path, refs, refsInvalid, errors) {
161
+ if (!isPlainObject(value) || !Object.hasOwn(value, "ref"))
162
+ return value;
163
+ const use = { ...value };
164
+ const ref = use.ref;
165
+ delete use.ref;
166
+ if (typeof ref !== "string") {
167
+ errors.push(`${path}.ref must be a string`);
168
+ return use;
169
+ }
170
+ if (refsInvalid) {
171
+ errors.push(`agents.refs must be an object to resolve ${path}.ref`);
172
+ return use;
173
+ }
174
+ const preset = refs?.[ref];
175
+ if (preset == null) {
176
+ errors.push(`${path}.ref references unknown agents.refs preset: ${ref}`);
177
+ return use;
178
+ }
179
+ if (!isPlainObject(preset)) {
180
+ errors.push(`agents.refs.${ref} must be an object when referenced by ${path}.ref`);
181
+ return use;
182
+ }
183
+ const presetFields = { ...preset };
184
+ delete presetFields.ref;
185
+ return { ...presetFields, ...use };
186
+ }
187
+ function expandAgentRefs(config, errors) {
188
+ if (!config || typeof config !== "object")
189
+ return;
190
+ const magiConfig = config;
191
+ const agents = magiConfig.agents;
192
+ const refsValue = isPlainObject(agents) ? agents.refs : undefined;
193
+ const refsInvalid = refsValue != null && !isPlainObject(refsValue);
194
+ const refs = isPlainObject(refsValue) ? refsValue : undefined;
195
+ if (Array.isArray(magiConfig.review?.agents)) {
196
+ magiConfig.review.agents = magiConfig.review.agents.map((agent, index) => expandAgentRefUse(agent, `review.agents[${index}]`, refs, refsInvalid, errors));
197
+ }
198
+ if (isPlainObject(magiConfig.merge?.editor)) {
199
+ magiConfig.merge.editor = expandAgentRefUse(magiConfig.merge.editor, "merge.editor", refs, refsInvalid, errors);
200
+ }
201
+ if (Array.isArray(magiConfig.triage?.agents)) {
202
+ magiConfig.triage.agents = magiConfig.triage.agents.map((agent, index) => expandAgentRefUse(agent, `triage.agents[${index}]`, refs, refsInvalid, errors));
203
+ }
204
+ if (isPlainObject(magiConfig.triage?.creator)) {
205
+ magiConfig.triage.creator = expandAgentRefUse(magiConfig.triage.creator, "triage.creator", refs, refsInvalid, errors);
206
+ }
207
+ if (isPlainObject(magiConfig.agents))
208
+ delete magiConfig.agents.refs;
209
+ }
160
210
  function validateKnownKeys(value, path, keys, errors) {
161
211
  if (!isPlainObject(value))
162
212
  return;
@@ -792,6 +842,7 @@ export async function validateConfig(config, options = {}) {
792
842
  const warnings = [];
793
843
  if (!config || typeof config !== "object")
794
844
  errors.push("config must be an object");
845
+ expandAgentRefs(config, errors);
795
846
  if (config && typeof config === "object")
796
847
  validateJsonSchema(config, errors);
797
848
  validateKnownKeys(config, "config", CONFIG_KEYS, errors);
@@ -509,6 +509,9 @@ export async function postCloseComment(exec, repository, pr, account, body) {
509
509
  await rm(payloadPath, { force: true });
510
510
  }
511
511
  }
512
+ function isInlineFinding(finding) {
513
+ return finding.line != null;
514
+ }
512
515
  function findingComment(finding) {
513
516
  const comment = {
514
517
  body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
@@ -529,16 +532,47 @@ function requirementFindingSummary(finding) {
529
532
  ` Fix: ${finding.fix}`,
530
533
  ].join("\n");
531
534
  }
535
+ function findingLocation(finding) {
536
+ if (finding.line == null)
537
+ return finding.path;
538
+ if (finding.startLine == null)
539
+ return `${finding.path}:${finding.line}`;
540
+ return `${finding.path}:${finding.startLine}-${finding.line}`;
541
+ }
542
+ function findingSummary(finding) {
543
+ return [
544
+ `- ${findingLocation(finding)}: ${finding.issue}`,
545
+ ` Fix: ${finding.fix}`,
546
+ ]
547
+ .filter(Boolean)
548
+ .join("\n");
549
+ }
550
+ function changesRequestedBody(findings, requirementFindings) {
551
+ const inlineFindings = findings.filter(isInlineFinding);
552
+ const fileLevelFindings = findings.filter((finding) => !isInlineFinding(finding));
553
+ const sections = [];
554
+ if (inlineFindings.length) {
555
+ sections.push(["Inline findings:", ...inlineFindings.map(findingSummary)].join("\n"));
556
+ }
557
+ if (fileLevelFindings.length) {
558
+ sections.push(["File-level findings:", ...fileLevelFindings.map(findingSummary)].join("\n"));
559
+ }
560
+ if (requirementFindings.length) {
561
+ sections.push([
562
+ "Requirement findings:",
563
+ ...requirementFindings.map(requirementFindingSummary),
564
+ ].join("\n"));
565
+ }
566
+ return sections.join("\n\n");
567
+ }
532
568
  export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
533
569
  const token = await ghToken(exec, repository, account);
534
570
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
535
- const body = findings
536
- .map((finding) => `- ${finding.issue.split("\n")[0]}`)
537
- .concat(requirementFindings.map(requirementFindingSummary))
538
- .join("\n");
571
+ const inlineFindings = findings.filter(isInlineFinding);
572
+ const body = changesRequestedBody(findings, requirementFindings);
539
573
  await writeFile(payloadPath, JSON.stringify({
540
574
  body,
541
- comments: findings.map(findingComment),
575
+ comments: inlineFindings.map(findingComment),
542
576
  event: "REQUEST_CHANGES",
543
577
  }));
544
578
  try {
@@ -52,6 +52,12 @@ function assertPositiveInteger(value, name) {
52
52
  export function validateInlineCommentTargets(findings, targets, label = "findings") {
53
53
  for (const [index, finding] of findings.entries()) {
54
54
  const name = `${label}[${index}]`;
55
+ if (finding.line == null) {
56
+ if (finding.startLine != null) {
57
+ throw new Error(`${name}.startLine requires line`);
58
+ }
59
+ continue;
60
+ }
55
61
  assertPositiveInteger(finding.line, `${name}.line`);
56
62
  if (finding.startLine != null) {
57
63
  assertPositiveInteger(finding.startLine, `${name}.startLine`);
@@ -50,7 +50,7 @@ async function withReviewerFailureProgress(input) {
50
50
  throw error;
51
51
  }
52
52
  }
53
- async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
53
+ async function runEditor(input, worktreePath, cycle, reviewFindings, unresolvedThreads) {
54
54
  const editor = input.repository.agents.editor;
55
55
  if (!editor)
56
56
  throw new Error("agents.editor is required for magi_merge");
@@ -64,6 +64,7 @@ async function runEditor(input, worktreePath, cycle, unresolvedThreads) {
64
64
  directory: input.directory,
65
65
  pr: input.pr,
66
66
  repository: input.repository,
67
+ reviewFindings: JSON.stringify(reviewFindings, null, 2),
67
68
  unresolvedThreads: JSON.stringify(unresolvedThreads, null, 2),
68
69
  worktreePath,
69
70
  });
@@ -145,14 +146,14 @@ async function postRereviewOutput(input, reviewerKey, output) {
145
146
  if (output.verdict === "CLOSE") {
146
147
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
147
148
  }
148
- if (output.newFindings.length) {
149
+ if (output.newFindings.length || output.requirementFindings.length) {
149
150
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
150
151
  fix: "Please address this before merging.",
151
152
  issue: finding.body,
152
- line: finding.line,
153
153
  path: finding.path,
154
+ ...(finding.line == null ? {} : { line: finding.line }),
154
155
  startLine: finding.startLine,
155
- })));
156
+ })), output.requirementFindings);
156
157
  }
157
158
  return replies[0] ?? "";
158
159
  }
@@ -161,6 +162,51 @@ function parseRereviewOutputWithInlineTargets(text, targets) {
161
162
  validateInlineCommentTargets(output.newFindings, targets, "newFindings");
162
163
  return output;
163
164
  }
165
+ function newFindingToEditorFinding(reviewer, finding) {
166
+ return {
167
+ body: finding.body,
168
+ fix: "Please address this before merging.",
169
+ path: finding.path,
170
+ reviewer,
171
+ ...(finding.line == null ? {} : { line: finding.line }),
172
+ ...(finding.startLine == null ? {} : { startLine: finding.startLine }),
173
+ type: finding.line == null ? "file" : "inline",
174
+ };
175
+ }
176
+ export function blockingReviewFindings(outputs) {
177
+ return Object.entries(outputs).flatMap(([reviewer, output]) => {
178
+ if (output.verdict !== "CHANGES_REQUESTED")
179
+ return [];
180
+ const requirementFindings = output.requirementFindings.map((finding) => ({
181
+ evidence: finding.evidence,
182
+ fix: finding.fix,
183
+ issueNumber: finding.issueNumber,
184
+ requirement: finding.requirement,
185
+ reviewer,
186
+ type: "requirement",
187
+ }));
188
+ if ("findings" in output) {
189
+ return [
190
+ ...output.findings.map((finding) => ({
191
+ fix: finding.fix,
192
+ issue: finding.issue,
193
+ path: finding.path,
194
+ reviewer,
195
+ ...(finding.line == null ? {} : { line: finding.line }),
196
+ ...(finding.startLine == null
197
+ ? {}
198
+ : { startLine: finding.startLine }),
199
+ type: finding.line == null ? "file" : "inline",
200
+ })),
201
+ ...requirementFindings,
202
+ ];
203
+ }
204
+ return [
205
+ ...output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding)),
206
+ ...requirementFindings,
207
+ ];
208
+ });
209
+ }
164
210
  async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
165
211
  throwIfAborted(input.signal);
166
212
  const meta = await fetchPullRequest(input.exec, input.repository, input.pr);
@@ -460,15 +506,42 @@ function syntheticReviewThreads(outputs) {
460
506
  const threads = {};
461
507
  for (const [reviewer, output] of Object.entries(outputs)) {
462
508
  if ("findings" in output) {
463
- threads[reviewer] = output.findings.map((finding) => {
509
+ threads[reviewer] = output.findings.flatMap((finding) => {
510
+ if (finding.line == null)
511
+ return [];
464
512
  const commentId = nextCommentId--;
465
- return {
466
- body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
513
+ return [
514
+ {
515
+ body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
516
+ commentId,
517
+ comments: [
518
+ {
519
+ author: reviewer,
520
+ body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
521
+ commentId,
522
+ createdAt: new Date(0).toISOString(),
523
+ },
524
+ ],
525
+ line: finding.line,
526
+ path: finding.path,
527
+ threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
528
+ },
529
+ ];
530
+ });
531
+ continue;
532
+ }
533
+ threads[reviewer] = output.newFindings.flatMap((finding) => {
534
+ if (finding.line == null)
535
+ return [];
536
+ const commentId = nextCommentId--;
537
+ return [
538
+ {
539
+ body: finding.body,
467
540
  commentId,
468
541
  comments: [
469
542
  {
470
543
  author: reviewer,
471
- body: `Issue: ${finding.issue}\n\nFix: ${finding.fix}`,
544
+ body: finding.body,
472
545
  commentId,
473
546
  createdAt: new Date(0).toISOString(),
474
547
  },
@@ -476,27 +549,8 @@ function syntheticReviewThreads(outputs) {
476
549
  line: finding.line,
477
550
  path: finding.path,
478
551
  threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
479
- };
480
- });
481
- continue;
482
- }
483
- threads[reviewer] = output.newFindings.map((finding) => {
484
- const commentId = nextCommentId--;
485
- return {
486
- body: finding.body,
487
- commentId,
488
- comments: [
489
- {
490
- author: reviewer,
491
- body: finding.body,
492
- commentId,
493
- createdAt: new Date(0).toISOString(),
494
- },
495
- ],
496
- line: finding.line,
497
- path: finding.path,
498
- threadId: `dry-run:${reviewer}:${Math.abs(commentId)}`,
499
- };
552
+ },
553
+ ];
500
554
  });
501
555
  }
502
556
  return threads;
@@ -666,7 +720,14 @@ export async function runMerge(input) {
666
720
  maxThreadResolutionCycles: input.repository.merge.maxThreadResolutionCycles,
667
721
  threads: unresolvedThreads,
668
722
  });
669
- if (!editableThreads.length) {
723
+ const editorFindings = blockingReviewFindings(reportOutputs);
724
+ const editableFindings = editableThreads.length
725
+ ? editorFindings
726
+ : editorFindings.filter((finding) => finding.type !== "inline");
727
+ const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
728
+ cycle > input.repository.merge.maxThreadResolutionCycles;
729
+ if (!editableThreads.length &&
730
+ (!editableFindings.length || findingAttemptsExhausted)) {
670
731
  await input.onProgress?.({
671
732
  status: "changes_unresolved",
672
733
  type: "merge_completed",
@@ -693,7 +754,7 @@ export async function runMerge(input) {
693
754
  });
694
755
  if (!review.worktreePath)
695
756
  throw new Error("Review worktree is missing");
696
- const editorOutput = await runEditor(abortableInput, review.worktreePath, cycle, editableThreads);
757
+ const editorOutput = await runEditor(abortableInput, review.worktreePath, cycle, editableFindings, editableThreads);
697
758
  editorOutputs.push(editorOutput);
698
759
  dryRunThreads = input.dryRun
699
760
  ? appendDryRunEditorResponses({
@@ -16,15 +16,19 @@ function pullRequestLine(input) {
16
16
  return `- **Pull Request**: [#${input.pr}](${url})`;
17
17
  }
18
18
  function formatFinding(finding) {
19
- const line = finding.startLine == null
20
- ? `${finding.path}:${finding.line}`
21
- : `${finding.path}:${finding.startLine}-${finding.line}`;
19
+ const line = finding.line == null
20
+ ? finding.path
21
+ : finding.startLine == null
22
+ ? `${finding.path}:${finding.line}`
23
+ : `${finding.path}:${finding.startLine}-${finding.line}`;
22
24
  return `\`${line}\`: ${finding.issue}`;
23
25
  }
24
26
  function formatRereviewFinding(finding) {
25
- const line = finding.startLine == null
26
- ? `${finding.path}:${finding.line}`
27
- : `${finding.path}:${finding.startLine}-${finding.line}`;
27
+ const line = finding.line == null
28
+ ? finding.path
29
+ : finding.startLine == null
30
+ ? `${finding.path}:${finding.line}`
31
+ : `${finding.path}:${finding.startLine}-${finding.line}`;
28
32
  return `\`${line}\`: ${finding.body}`;
29
33
  }
30
34
  function formatRequirementFinding(finding) {
@@ -135,8 +135,69 @@ function parseRereviewOutputWithInlineTargets(text, targets) {
135
135
  validateInlineCommentTargets(output.newFindings, targets, "newFindings");
136
136
  return output;
137
137
  }
138
- function reviewOutputFromState(review) {
138
+ function parsePostedFindingLocation(location) {
139
+ const range = /^(.*):(\d+)-(\d+)$/.exec(location);
140
+ if (range) {
141
+ return {
142
+ line: Number(range[3]),
143
+ path: range[1] ?? location,
144
+ startLine: Number(range[2]),
145
+ };
146
+ }
147
+ const line = /^(.*):(\d+)$/.exec(location);
148
+ if (line)
149
+ return { line: Number(line[2]), path: line[1] ?? location };
150
+ return { path: location };
151
+ }
152
+ function reviewFindingsFromBody(body) {
153
+ const findings = [];
154
+ const requirementFindings = [];
155
+ const lines = (body ?? "").split(/\r?\n/);
156
+ let section;
157
+ for (let index = 0; index < lines.length; index += 1) {
158
+ const line = lines[index];
159
+ if (line === "Inline findings:" || line === "File-level findings:") {
160
+ section = "finding";
161
+ continue;
162
+ }
163
+ if (line === "Requirement findings:") {
164
+ section = "requirement";
165
+ continue;
166
+ }
167
+ if (section === "finding") {
168
+ const match = /^- (.*): (.+)$/.exec(line ?? "");
169
+ const fix = /^\s+Fix: (.+)$/.exec(lines[index + 1] ?? "");
170
+ if (!match || !fix)
171
+ continue;
172
+ findings.push({
173
+ ...parsePostedFindingLocation(match[1] ?? ""),
174
+ fix: fix[1] ?? "Please address this before merging.",
175
+ issue: match[2] ?? "Review finding.",
176
+ });
177
+ index += 1;
178
+ continue;
179
+ }
180
+ if (section !== "requirement")
181
+ continue;
182
+ const match = /^- Missing issue #(\d+) requirement: (.+)$/.exec(line ?? "");
183
+ const evidence = /^\s+Evidence: (.+)$/.exec(lines[index + 1] ?? "");
184
+ const fix = /^\s+Fix: (.+)$/.exec(lines[index + 2] ?? "");
185
+ if (!match || !evidence || !fix)
186
+ continue;
187
+ requirementFindings.push({
188
+ evidence: evidence[1] ?? "See review body.",
189
+ fix: fix[1] ?? "Please address this before merging.",
190
+ issueNumber: Number(match[1]),
191
+ requirement: match[2] ?? "Review requirement.",
192
+ });
193
+ index += 2;
194
+ }
195
+ return { findings, requirementFindings };
196
+ }
197
+ export function reviewOutputFromState(review) {
139
198
  const verdict = reviewStateToVerdict(review.state);
199
+ if (verdict === "CHANGES_REQUESTED")
200
+ return { ...reviewFindingsFromBody(review.body), verdict };
140
201
  return verdict === "CLOSE"
141
202
  ? {
142
203
  findings: [],
@@ -173,8 +234,8 @@ async function postRereviewOutput(input, reviewerKey, output) {
173
234
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
174
235
  fix: "Please address this before merging.",
175
236
  issue: finding.body,
176
- line: finding.line,
177
237
  path: finding.path,
238
+ ...(finding.line == null ? {} : { line: finding.line }),
178
239
  startLine: finding.startLine,
179
240
  })), output.requirementFindings);
180
241
  }
@@ -761,7 +822,14 @@ export async function runReview(input) {
761
822
  sessionIds,
762
823
  worktreePath,
763
824
  });
764
- const outputs = validation.outputs;
825
+ const activeOutputs = validation.outputs;
826
+ const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
827
+ const assignment = mode.assignments.get(reviewer.account);
828
+ return assignment?.type === "skip"
829
+ ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
830
+ : [];
831
+ }));
832
+ const outputs = { ...skippedOutputs, ...activeOutputs };
765
833
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
766
834
  const assignment = mode.assignments.get(reviewer.account);
767
835
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
@@ -773,7 +841,7 @@ export async function runReview(input) {
773
841
  },
774
842
  ];
775
843
  });
776
- const activeVerdicts = Object.entries(outputs).map(([reviewer, output]) => ({
844
+ const activeVerdicts = Object.entries(activeOutputs).map(([reviewer, output]) => ({
777
845
  reviewer,
778
846
  verdict: output.verdict,
779
847
  }));
@@ -786,7 +854,7 @@ export async function runReview(input) {
786
854
  ? [[reviewer.key, "skipped: already reviewed current head"]]
787
855
  : [];
788
856
  })),
789
- ...Object.fromEntries(await Promise.all(Object.entries(outputs).map(async ([key, output]) => [
857
+ ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
790
858
  key,
791
859
  input.dryRun
792
860
  ? dryRunReviewPost(key, output)
@@ -62,6 +62,7 @@ function editValues(input) {
62
62
  return {
63
63
  ...repositoryValues(input.repository),
64
64
  pr: String(input.pr),
65
+ reviewFindings: input.reviewFindings,
65
66
  unresolvedThreads: input.unresolvedThreads,
66
67
  worktreePath: input.worktreePath,
67
68
  };
@@ -31,9 +31,10 @@ Rules:
31
31
  - CHANGES_REQUESTED requires at least one finding or requirementFinding.
32
32
  - CLOSE requires a reason and empty findings and requirementFindings arrays.
33
33
  - path must be repository-relative.
34
- - line and startLine must refer to lines inside the PR diff hunk.
35
- - Omit startLine for single-line findings.
36
- - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
34
+ - line is optional. Include line only when the finding targets a valid line inside the PR diff hunk.
35
+ - startLine is allowed only when line is present and must also refer to a line inside the PR diff hunk.
36
+ - Omit startLine for single-line findings and omit line for file-level or body-only findings.
37
+ - Use requirementFindings only for missing closing-issue requirements; use findings for ordinary file-level issues that do not map cleanly to a diff line.
37
38
  </output_contract>`.trim();
38
39
  export const rereviewOutputContract = `
39
40
  <output_contract>
@@ -53,9 +54,10 @@ Rules:
53
54
  - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
54
55
  - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
55
56
  - CLOSE requires a reason and empty followUps, newFindings, and requirementFindings arrays.
56
- - line and startLine must refer to lines inside the latest PR diff hunk.
57
- - Omit startLine for single-line findings.
58
- - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
57
+ - line is optional. Include line only when the newFinding targets a valid line inside the latest PR diff hunk.
58
+ - startLine is allowed only when line is present and must also refer to a line inside the latest PR diff hunk.
59
+ - Omit startLine for single-line findings and omit line for file-level or body-only findings.
60
+ - Use requirementFindings only for missing closing-issue requirements; use newFindings for ordinary file-level issues that do not map cleanly to a diff line.
59
61
  </output_contract>`.trim();
60
62
  export const findingValidationOutputContract = `
61
63
  <output_contract>
@@ -102,7 +104,9 @@ Rules:
102
104
  - MERGE requires empty findings and requirementFindings arrays.
103
105
  - CHANGES_REQUESTED requires at least one finding or requirementFinding.
104
106
  - CLOSE is not allowed in this reconsideration step.
105
- - Omit startLine for single-line findings.
107
+ - line is optional. Include line only when the finding targets a valid line inside the PR diff hunk.
108
+ - startLine is allowed only when line is present.
109
+ - Omit startLine for single-line findings and omit line for file-level or body-only findings.
106
110
  </output_contract>`.trim();
107
111
  export const rereviewCloseReconsiderationOutputContract = `
108
112
  <output_contract>
@@ -121,7 +125,9 @@ Rules:
121
125
  - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
122
126
  - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
123
127
  - CLOSE is not allowed in this reconsideration step.
124
- - Omit startLine for single-line findings.
128
+ - line is optional. Include line only when the newFinding targets a valid line inside the latest PR diff hunk.
129
+ - startLine is allowed only when line is present.
130
+ - Omit startLine for single-line findings and omit line for file-level or body-only findings.
125
131
  </output_contract>`.trim();
126
132
  export const editOutputContract = `
127
133
  <output_contract>
@@ -71,6 +71,16 @@ function requireNumber(value, path) {
71
71
  throw new Error(`${path} must be an integer`);
72
72
  return value;
73
73
  }
74
+ function optionalLine(value, path) {
75
+ return value == null ? undefined : requireNumber(value, path);
76
+ }
77
+ function optionalStartLine(input) {
78
+ if (input.value == null)
79
+ return undefined;
80
+ if (input.line == null)
81
+ throw new Error(`${input.path} requires line`);
82
+ return requireNumber(input.value, input.path);
83
+ }
74
84
  function parseRequirementFindings(value) {
75
85
  return (value == null ? [] : requireArray(value, "requirementFindings")).map((finding, index) => {
76
86
  const item = finding;
@@ -173,17 +183,20 @@ export function parseReviewOutput(text) {
173
183
  throw new Error("verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
174
184
  const findings = requireArray(data.findings, "findings").map((finding, index) => {
175
185
  const item = finding;
186
+ const line = optionalLine(item.line, `findings[${index}].line`);
176
187
  return {
177
188
  fix: requireString(item.fix, `findings[${index}].fix`),
178
189
  issue: requireString(item.issue, `findings[${index}].issue`),
179
- line: requireNumber(item.line, `findings[${index}].line`),
190
+ line,
180
191
  path: requireString(item.path, `findings[${index}].path`),
181
192
  perspective: item.perspective == null
182
193
  ? undefined
183
194
  : requireString(item.perspective, `findings[${index}].perspective`),
184
- startLine: item.startLine == null
185
- ? undefined
186
- : requireNumber(item.startLine, `findings[${index}].startLine`),
195
+ startLine: optionalStartLine({
196
+ line,
197
+ path: `findings[${index}].startLine`,
198
+ value: item.startLine,
199
+ }),
187
200
  };
188
201
  });
189
202
  const requirementFindings = parseRequirementFindings(data.requirementFindings);
@@ -229,13 +242,16 @@ export function parseRereviewOutput(text) {
229
242
  });
230
243
  const newFindings = requireArray(data.newFindings, "newFindings").map((item, index) => {
231
244
  const value = item;
245
+ const line = optionalLine(value.line, `newFindings[${index}].line`);
232
246
  return {
233
247
  body: requireString(value.body, `newFindings[${index}].body`),
234
- line: requireNumber(value.line, `newFindings[${index}].line`),
248
+ line,
235
249
  path: requireString(value.path, `newFindings[${index}].path`),
236
- startLine: value.startLine == null
237
- ? undefined
238
- : requireNumber(value.startLine, `newFindings[${index}].startLine`),
250
+ startLine: optionalStartLine({
251
+ line,
252
+ path: `newFindings[${index}].startLine`,
253
+ value: value.startLine,
254
+ }),
239
255
  };
240
256
  });
241
257
  const requirementFindings = parseRequirementFindings(data.requirementFindings);
@@ -349,7 +365,7 @@ function parseEditOutputWithOptions(text, options) {
349
365
  commentId: requireNumber(value.commentId, `responses[${index}].commentId`),
350
366
  };
351
367
  });
352
- if (options.requireResponses && !responses.length)
368
+ if (options.requireResponses && data.mode === "REPLIED" && !responses.length)
353
369
  throw new Error("responses must not be empty");
354
370
  if (data.mode === "EDITED") {
355
371
  if (!filesTouched.length)
@@ -1,9 +1,16 @@
1
1
  Fix pull request #{pr} for {owner}/{repo}.
2
2
  The PR worktree is {worktreePath}.
3
- Act as the PR author and resolve every unresolved review thread listed below.
3
+
4
+ Act as the PR author and address every blocking review finding listed below.
5
+ Review findings are the complete set of requested changes. Inline findings target a PR diff line; file-level findings may not have a GitHub thread; requirement findings describe missing closing-issue requirements.
6
+ {reviewFindings}
7
+
8
+ Unresolved GitHub review threads are conversations that may need replies or resolution.
4
9
  {unresolvedThreads}
5
- For each thread, decide whether you agree with the reviewer.
6
- If you understand and agree with the requested change, edit the code, stage changes, commit, and reply with action FIXED.
7
- If the requested change is incorrect or unnecessary and you have a clear reason, do not edit for that thread; reply with action DISAGREE and explain why.
8
- If you cannot determine whether the request is correct or what change is expected, do not blindly edit; reply with action ASK and ask a concrete question.
10
+
11
+ For each review finding and thread, decide whether you agree with the reviewer.
12
+ If you understand and agree with the requested change, edit the code, stage changes, commit, and reply with action FIXED for each related thread.
13
+ If a requested change in a thread is incorrect or unnecessary and you have a clear reason, do not edit for that thread; reply with action DISAGREE and explain why.
14
+ If you cannot determine whether a threaded request is correct or what change is expected, do not blindly edit; reply with action ASK and ask a concrete question.
15
+ File-level and requirement findings may not have a thread to reply to, but they are still blocking and must be addressed.
9
16
  Do not make changes just because a reviewer requested them. Do not push.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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
@@ -9,7 +9,11 @@
9
9
  "type": "object",
10
10
  "additionalProperties": false,
11
11
  "properties": {
12
- "permissions": { "$ref": "#/$defs/permissions" }
12
+ "permissions": { "$ref": "#/$defs/permissions" },
13
+ "refs": {
14
+ "type": "object",
15
+ "additionalProperties": { "$ref": "#/$defs/agentRef" }
16
+ }
13
17
  }
14
18
  },
15
19
  "clear": {
@@ -51,11 +55,33 @@
51
55
  "triage": { "$ref": "#/$defs/triage" }
52
56
  },
53
57
  "$defs": {
58
+ "agentRef": {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "properties": {
62
+ "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
63
+ "model": { "type": "string", "minLength": 1 },
64
+ "options": { "type": "object", "additionalProperties": true },
65
+ "account": { "type": "string", "minLength": 1 },
66
+ "author": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "properties": {
70
+ "name": { "type": "string", "minLength": 1 },
71
+ "email": { "type": "string", "minLength": 1 }
72
+ }
73
+ },
74
+ "permissions": { "$ref": "#/$defs/permissions" },
75
+ "persona": { "type": "string" }
76
+ }
77
+ },
54
78
  "reviewer": {
55
79
  "type": "object",
56
- "required": ["model", "account"],
80
+ "if": { "not": { "required": ["ref"] } },
81
+ "then": { "required": ["model", "account"] },
57
82
  "additionalProperties": false,
58
83
  "properties": {
84
+ "ref": { "type": "string", "minLength": 1 },
59
85
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
60
86
  "model": { "type": "string", "minLength": 1 },
61
87
  "options": { "type": "object", "additionalProperties": true },
@@ -66,9 +92,11 @@
66
92
  },
67
93
  "editor": {
68
94
  "type": "object",
69
- "required": ["model", "account", "author"],
95
+ "if": { "not": { "required": ["ref"] } },
96
+ "then": { "required": ["model", "account", "author"] },
70
97
  "additionalProperties": false,
71
98
  "properties": {
99
+ "ref": { "type": "string", "minLength": 1 },
72
100
  "model": { "type": "string", "minLength": 1 },
73
101
  "options": { "type": "object", "additionalProperties": true },
74
102
  "account": { "type": "string", "minLength": 1 },
@@ -87,9 +115,11 @@
87
115
  },
88
116
  "triageAgent": {
89
117
  "type": "object",
90
- "required": ["model"],
118
+ "if": { "not": { "required": ["ref"] } },
119
+ "then": { "required": ["model"] },
91
120
  "additionalProperties": false,
92
121
  "properties": {
122
+ "ref": { "type": "string", "minLength": 1 },
93
123
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
94
124
  "model": { "type": "string", "minLength": 1 },
95
125
  "options": { "type": "object", "additionalProperties": true },
@@ -99,9 +129,11 @@
99
129
  },
100
130
  "triageCreator": {
101
131
  "type": "object",
102
- "required": ["model", "author"],
132
+ "if": { "not": { "required": ["ref"] } },
133
+ "then": { "required": ["model", "author"] },
103
134
  "additionalProperties": false,
104
135
  "properties": {
136
+ "ref": { "type": "string", "minLength": 1 },
105
137
  "account": { "type": "string", "minLength": 1 },
106
138
  "model": { "type": "string", "minLength": 1 },
107
139
  "options": { "type": "object", "additionalProperties": true },