opencode-magi 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -61,26 +61,33 @@ Add the following content to the configuration file.
61
61
  ```json
62
62
  {
63
63
  "$schema": "https://raw.githubusercontent.com/magi-ai/opencode-magi/main/schema.json",
64
- "review": {
65
- "agents": [
66
- {
67
- "account": "your-account-1",
68
- "model": "openai/gpt-5.5"
64
+ "agents": {
65
+ "refs": {
66
+ "account-1": {
67
+ "model": "openai/gpt-5.5",
68
+ "account": "account-1"
69
69
  },
70
- {
71
- "account": "your-account-2",
72
- "model": "anthropic/claude-opus-4-7"
70
+ "account-2": {
71
+ "model": "anthropic/claude-opus-4-7",
72
+ "account": "account-2"
73
73
  },
74
- {
75
- "account": "your-account-3",
76
- "model": "opencode/kimi-k2-6"
74
+ "account-3": {
75
+ "model": "opencode/kimi-k2-6",
76
+ "account": "account-3"
77
77
  }
78
+ }
79
+ },
80
+ "review": {
81
+ "agents": [
82
+ { "ref": "account-1" },
83
+ { "ref": "account-2" },
84
+ { "ref": "account-3" }
78
85
  ]
79
86
  }
80
87
  }
81
88
  ```
82
89
 
83
- `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
90
+ After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique.
84
91
 
85
92
  #### Set project config
86
93
 
@@ -101,53 +108,54 @@ Add the following content to the configuration file.
101
108
  "owner": "your-owner",
102
109
  "repo": "your-repo"
103
110
  },
104
- "review": {
105
- "agents": [
106
- {
107
- "account": "your-account-1",
108
- "model": "openai/gpt-5.5"
111
+ "agents": {
112
+ "refs": {
113
+ "account-1": {
114
+ "model": "openai/gpt-5.5",
115
+ "account": "account-1"
109
116
  },
110
- {
111
- "account": "your-account-2",
112
- "model": "anthropic/claude-opus-4-7"
117
+ "account-2": {
118
+ "model": "anthropic/claude-opus-4-7",
119
+ "account": "account-2"
113
120
  },
114
- {
115
- "account": "your-account-3",
116
- "model": "opencode/kimi-k2-6"
121
+ "account-3": {
122
+ "model": "opencode/kimi-k2-6",
123
+ "account": "account-3"
124
+ },
125
+ "account-4": {
126
+ "model": "openai/gpt-5.5",
127
+ "account": "account-4",
128
+ "author": {
129
+ "name": "account-4",
130
+ "email": "your-email@example.com"
131
+ }
117
132
  }
133
+ }
134
+ },
135
+ "review": {
136
+ "agents": [
137
+ { "ref": "account-1" },
138
+ { "ref": "account-2" },
139
+ { "ref": "account-3" }
118
140
  ]
119
141
  },
120
142
  "merge": {
121
- "editor": {
122
- "account": "your-editor-account",
123
- "model": "openai/gpt-5.5",
124
- "author": {
125
- "name": "your-account",
126
- "email": "your-email@example.com"
127
- }
128
- }
143
+ "editor": { "ref": "account-4" }
129
144
  },
130
145
  "triage": {
131
- "account": "your-triage-account",
146
+ "account": "account-5",
132
147
  "agents": [
133
- {
134
- "id": "general",
135
- "model": "openai/gpt-5.5"
136
- },
137
- {
138
- "id": "maintenance",
139
- "model": "anthropic/claude-opus-4-7"
140
- },
141
- {
142
- "id": "product",
143
- "model": "opencode/kimi-k2-6"
144
- }
148
+ { "ref": "account-1" },
149
+ { "ref": "account-2" },
150
+ { "ref": "account-3" }
145
151
  ]
146
152
  }
147
153
  }
148
154
  ```
149
155
 
150
- `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
156
+ Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
157
+
158
+ After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
151
159
 
152
160
  #### Validate config
153
161
 
@@ -246,6 +246,12 @@ function duplicateReferences(text) {
246
246
  refs.add(Number(match[1]));
247
247
  return [...refs];
248
248
  }
249
+ function issueTitleSearchQuery(title, fallback) {
250
+ return (title
251
+ .replaceAll(/[^\p{L}\p{N}_]+/gu, " ")
252
+ .replaceAll(/\s+/g, " ")
253
+ .trim() || fallback);
254
+ }
249
255
  async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
250
256
  const raw = await exec(`gh issue view ${number} --repo ${shellQuote(repoSpecifier(repository))} --json number,title,url,state,body,createdAt`).catch(() => undefined);
251
257
  if (!raw)
@@ -254,7 +260,7 @@ async function fetchIssueCandidate(exec, repository, number, whyCandidate) {
254
260
  return { ...data, whyCandidate };
255
261
  }
256
262
  export async function searchDuplicateIssues(exec, repository, issue, limit = 5) {
257
- const query = issue.title;
263
+ const query = issueTitleSearchQuery(issue.title, String(issue.number));
258
264
  const explicitCandidates = await Promise.all(duplicateReferences(issue.body)
259
265
  .filter((number) => number !== issue.number)
260
266
  .map((number) => fetchIssueCandidate(exec, repository, number, "Issue body explicitly references a duplicate target.")));
@@ -509,9 +515,6 @@ export async function postCloseComment(exec, repository, pr, account, body) {
509
515
  await rm(payloadPath, { force: true });
510
516
  }
511
517
  }
512
- function isInlineFinding(finding) {
513
- return finding.line != null;
514
- }
515
518
  function findingComment(finding) {
516
519
  const comment = {
517
520
  body: `**Issue:** ${finding.issue}\n\n**Fix:** ${finding.fix}`,
@@ -525,54 +528,18 @@ function findingComment(finding) {
525
528
  }
526
529
  return comment;
527
530
  }
528
- function requirementFindingSummary(finding) {
529
- return [
530
- `- Missing issue #${finding.issueNumber} requirement: ${finding.requirement}`,
531
- ` Evidence: ${finding.evidence}`,
532
- ` Fix: ${finding.fix}`,
533
- ].join("\n");
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");
531
+ function changesRequestedBody(findings) {
532
+ return findings.length === 1
533
+ ? "Changes requested: 1 inline comment."
534
+ : `Changes requested: ${findings.length} inline comments.`;
567
535
  }
568
- export async function postChangesRequested(exec, repository, pr, account, findings, requirementFindings = []) {
536
+ export async function postChangesRequested(exec, repository, pr, account, findings) {
569
537
  const token = await ghToken(exec, repository, account);
570
538
  const payloadPath = join(tmpdir(), `magi-review-${process.pid}-${Date.now()}.json`);
571
- const inlineFindings = findings.filter(isInlineFinding);
572
- const body = changesRequestedBody(findings, requirementFindings);
539
+ const body = changesRequestedBody(findings);
573
540
  await writeFile(payloadPath, JSON.stringify({
574
541
  body,
575
- comments: inlineFindings.map(findingComment),
542
+ comments: findings.map(findingComment),
576
543
  event: "REQUEST_CHANGES",
577
544
  }));
578
545
  try {
package/dist/index.js CHANGED
@@ -95,12 +95,17 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
95
95
  const tokens = value.split(/[\s,]+/).filter(Boolean);
96
96
  const configOverrides = {};
97
97
  const prTokens = [];
98
+ let sync = false;
98
99
  for (let index = 0; index < tokens.length; index++) {
99
100
  const token = tokens[index];
100
101
  if (token === "--dry-run") {
101
102
  dryRun = true;
102
103
  continue;
103
104
  }
105
+ if (token === "--sync") {
106
+ sync = true;
107
+ continue;
108
+ }
104
109
  switch (token) {
105
110
  case "--language":
106
111
  setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
@@ -146,18 +151,23 @@ export function parseRunArguments(value, dryRun = false, command = "review") {
146
151
  prTokens.push(token);
147
152
  }
148
153
  }
149
- return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")) };
154
+ return { configOverrides, dryRun, prs: parsePrs(prTokens.join(" ")), sync };
150
155
  }
151
156
  export function parseIssueRunArguments(value, dryRun = false) {
152
157
  const tokens = value.split(/[\s,]+/).filter(Boolean);
153
158
  const configOverrides = {};
154
159
  const issueTokens = [];
160
+ let sync = false;
155
161
  for (let index = 0; index < tokens.length; index++) {
156
162
  const token = tokens[index];
157
163
  if (token === "--dry-run") {
158
164
  dryRun = true;
159
165
  continue;
160
166
  }
167
+ if (token === "--sync") {
168
+ sync = true;
169
+ continue;
170
+ }
161
171
  switch (token) {
162
172
  case "--language":
163
173
  setConfigOverride(configOverrides, ["language"], nextFlagValue(tokens, ++index, token));
@@ -195,7 +205,12 @@ export function parseIssueRunArguments(value, dryRun = false) {
195
205
  issueTokens.push(token);
196
206
  }
197
207
  }
198
- return { configOverrides, dryRun, issues: parseIssues(issueTokens.join(" ")) };
208
+ return {
209
+ configOverrides,
210
+ dryRun,
211
+ issues: parseIssues(issueTokens.join(" ")),
212
+ sync,
213
+ };
199
214
  }
200
215
  function nextFlagValue(tokens, index, flag) {
201
216
  const value = tokens[index];
@@ -203,6 +218,15 @@ function nextFlagValue(tokens, index, flag) {
203
218
  throw new Error(`${flag} requires a value.`);
204
219
  return value;
205
220
  }
221
+ async function syncResult(runManager, states) {
222
+ const output = await runManager.formatStatesWithReports(states, {
223
+ verbose: true,
224
+ });
225
+ const failed = states.filter((state) => state.status !== "completed");
226
+ if (failed.length)
227
+ throw new Error(output);
228
+ return output;
229
+ }
206
230
  function parseIntegerFlag(value, flag, minimum) {
207
231
  const parsed = Number.parseInt(value, 10);
208
232
  if (!Number.isInteger(parsed) ||
@@ -432,6 +456,7 @@ export const MagiPlugin = async ({ client, directory }) => {
432
456
  args: {
433
457
  prs: tool.schema.string(),
434
458
  dryRun: tool.schema.boolean().optional(),
459
+ sync: tool.schema.boolean().optional(),
435
460
  },
436
461
  async execute(args, context) {
437
462
  const parsed = parseRunArguments(args.prs, args.dryRun ?? false, "merge");
@@ -448,6 +473,7 @@ export const MagiPlugin = async ({ client, directory }) => {
448
473
  if (!validation.ok)
449
474
  return JSON.stringify(validation, null, 2);
450
475
  const repository = resolveRepository(config);
476
+ const sync = parsed.sync || args.sync === true;
451
477
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
452
478
  config,
453
479
  dryRun: parsed.dryRun,
@@ -455,7 +481,10 @@ export const MagiPlugin = async ({ client, directory }) => {
455
481
  pr,
456
482
  parentSessionId: context.sessionID,
457
483
  signal: context.abort,
484
+ sync,
458
485
  }), { signal: context.abort });
486
+ if (sync)
487
+ return syncResult(runManager, states);
459
488
  return states
460
489
  .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
461
490
  .join("\n");
@@ -469,6 +498,7 @@ export const MagiPlugin = async ({ client, directory }) => {
469
498
  args: {
470
499
  prs: tool.schema.string(),
471
500
  dryRun: tool.schema.boolean().optional(),
501
+ sync: tool.schema.boolean().optional(),
472
502
  },
473
503
  async execute(args, context) {
474
504
  const parsed = parseRunArguments(args.prs, args.dryRun ?? false);
@@ -484,6 +514,7 @@ export const MagiPlugin = async ({ client, directory }) => {
484
514
  if (!validation.ok)
485
515
  return JSON.stringify(validation, null, 2);
486
516
  const repository = resolveRepository(config);
517
+ const sync = parsed.sync || args.sync === true;
487
518
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
488
519
  config,
489
520
  dryRun: parsed.dryRun,
@@ -491,7 +522,10 @@ export const MagiPlugin = async ({ client, directory }) => {
491
522
  pr,
492
523
  parentSessionId: context.sessionID,
493
524
  signal: context.abort,
525
+ sync,
494
526
  }), { signal: context.abort });
527
+ if (sync)
528
+ return syncResult(runManager, states);
495
529
  return states
496
530
  .map((state) => `Started reviewing ${prMarkdownLink(repository, state.pr)}.`)
497
531
  .join("\n");
@@ -502,6 +536,7 @@ export const MagiPlugin = async ({ client, directory }) => {
502
536
  args: {
503
537
  issues: tool.schema.string(),
504
538
  dryRun: tool.schema.boolean().optional(),
539
+ sync: tool.schema.boolean().optional(),
505
540
  },
506
541
  async execute(args, context) {
507
542
  const parsed = parseIssueRunArguments(args.issues, args.dryRun ?? false);
@@ -523,6 +558,7 @@ export const MagiPlugin = async ({ client, directory }) => {
523
558
  const repository = resolveRepository(config);
524
559
  if (!repository.triage)
525
560
  return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
561
+ const sync = parsed.sync || args.sync === true;
526
562
  const states = await mapPool(parsed.issues, repository.triage.concurrency.runs, (issue) => runManager.startTriage({
527
563
  config,
528
564
  dryRun: parsed.dryRun,
@@ -530,7 +566,10 @@ export const MagiPlugin = async ({ client, directory }) => {
530
566
  parentSessionId: context.sessionID,
531
567
  repository,
532
568
  signal: context.abort,
569
+ sync,
533
570
  }), { signal: context.abort });
571
+ if (sync)
572
+ return syncResult(runManager, states);
534
573
  return states
535
574
  .map((state) => `Started triaging ${issueMarkdownLink(repository, state.issue)}.`)
536
575
  .join("\n");
@@ -58,10 +58,9 @@ export function applyFindingValidation(input) {
58
58
  discarded.push(target);
59
59
  return false;
60
60
  });
61
- next[reviewer] =
62
- findings.length || output.requirementFindings.length
63
- ? { ...output, findings }
64
- : { findings: [], requirementFindings: [], verdict: "MERGE" };
61
+ next[reviewer] = findings.length
62
+ ? { ...output, findings }
63
+ : { findings: [], verdict: "MERGE" };
65
64
  }
66
65
  return { outputs: next, summary: { discarded, kept } };
67
66
  }
@@ -52,12 +52,6 @@ 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
- }
61
55
  assertPositiveInteger(finding.line, `${name}.line`);
62
56
  if (finding.startLine != null) {
63
57
  assertPositiveInteger(finding.startLine, `${name}.startLine`);
@@ -146,14 +146,14 @@ async function postRereviewOutput(input, reviewerKey, output) {
146
146
  if (output.verdict === "CLOSE") {
147
147
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
148
148
  }
149
- if (output.newFindings.length || output.requirementFindings.length) {
149
+ if (output.newFindings.length) {
150
150
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
151
151
  fix: "Please address this before merging.",
152
152
  issue: finding.body,
153
153
  path: finding.path,
154
- ...(finding.line == null ? {} : { line: finding.line }),
154
+ line: finding.line,
155
155
  startLine: finding.startLine,
156
- })), output.requirementFindings);
156
+ })));
157
157
  }
158
158
  return replies[0] ?? "";
159
159
  }
@@ -166,45 +166,29 @@ function newFindingToEditorFinding(reviewer, finding) {
166
166
  return {
167
167
  body: finding.body,
168
168
  fix: "Please address this before merging.",
169
+ line: finding.line,
169
170
  path: finding.path,
170
171
  reviewer,
171
- ...(finding.line == null ? {} : { line: finding.line }),
172
172
  ...(finding.startLine == null ? {} : { startLine: finding.startLine }),
173
- type: finding.line == null ? "file" : "inline",
173
+ type: "inline",
174
174
  };
175
175
  }
176
176
  export function blockingReviewFindings(outputs) {
177
177
  return Object.entries(outputs).flatMap(([reviewer, output]) => {
178
178
  if (output.verdict !== "CHANGES_REQUESTED")
179
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
180
  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
- ];
181
+ return output.findings.map((finding) => ({
182
+ fix: finding.fix,
183
+ issue: finding.issue,
184
+ line: finding.line,
185
+ path: finding.path,
186
+ reviewer,
187
+ ...(finding.startLine == null ? {} : { startLine: finding.startLine }),
188
+ type: "inline",
189
+ }));
203
190
  }
204
- return [
205
- ...output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding)),
206
- ...requirementFindings,
207
- ];
191
+ return output.newFindings.map((finding) => newFindingToEditorFinding(reviewer, finding));
208
192
  });
209
193
  }
210
194
  async function runRereview(input, worktreePath, previousHeadSha, cycle, sessionIds, ciFailureContext, options = {}) {
@@ -507,8 +491,6 @@ function syntheticReviewThreads(outputs) {
507
491
  for (const [reviewer, output] of Object.entries(outputs)) {
508
492
  if ("findings" in output) {
509
493
  threads[reviewer] = output.findings.flatMap((finding) => {
510
- if (finding.line == null)
511
- return [];
512
494
  const commentId = nextCommentId--;
513
495
  return [
514
496
  {
@@ -531,8 +513,6 @@ function syntheticReviewThreads(outputs) {
531
513
  continue;
532
514
  }
533
515
  threads[reviewer] = output.newFindings.flatMap((finding) => {
534
- if (finding.line == null)
535
- return [];
536
516
  const commentId = nextCommentId--;
537
517
  return [
538
518
  {
@@ -721,9 +701,7 @@ export async function runMerge(input) {
721
701
  threads: unresolvedThreads,
722
702
  });
723
703
  const editorFindings = blockingReviewFindings(reportOutputs);
724
- const editableFindings = editableThreads.length
725
- ? editorFindings
726
- : editorFindings.filter((finding) => finding.type !== "inline");
704
+ const editableFindings = editableThreads.length ? editorFindings : [];
727
705
  const findingAttemptsExhausted = input.repository.merge.maxThreadResolutionCycles !== 0 &&
728
706
  cycle > input.repository.merge.maxThreadResolutionCycles;
729
707
  if (!editableThreads.length &&
@@ -16,24 +16,17 @@ function pullRequestLine(input) {
16
16
  return `- **Pull Request**: [#${input.pr}](${url})`;
17
17
  }
18
18
  function formatFinding(finding) {
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}`;
19
+ const line = finding.startLine == null
20
+ ? `${finding.path}:${finding.line}`
21
+ : `${finding.path}:${finding.startLine}-${finding.line}`;
24
22
  return `\`${line}\`: ${finding.issue}`;
25
23
  }
26
24
  function formatRereviewFinding(finding) {
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}`;
25
+ const line = finding.startLine == null
26
+ ? `${finding.path}:${finding.line}`
27
+ : `${finding.path}:${finding.startLine}-${finding.line}`;
32
28
  return `\`${line}\`: ${finding.body}`;
33
29
  }
34
- function formatRequirementFinding(finding) {
35
- return `Issue #${finding.issueNumber}: ${finding.requirement}`;
36
- }
37
30
  function isReviewOutput(output) {
38
31
  return "findings" in output;
39
32
  }
@@ -85,10 +78,7 @@ function reviewerDetailLines(output) {
85
78
  return output.reason ? [output.reason] : [];
86
79
  if (output.verdict !== "CHANGES_REQUESTED")
87
80
  return [];
88
- return [
89
- ...output.findings.map(formatFinding),
90
- ...output.requirementFindings.map(formatRequirementFinding),
91
- ];
81
+ return output.findings.map(formatFinding);
92
82
  }
93
83
  if (output.verdict === "CLOSE")
94
84
  return output.reason ? [output.reason] : [];
@@ -96,7 +86,6 @@ function reviewerDetailLines(output) {
96
86
  return [];
97
87
  return [
98
88
  ...output.newFindings.map(formatRereviewFinding),
99
- ...output.requirementFindings.map(formatRequirementFinding),
100
89
  ...output.followUps.map((item) => `Comment #${item.commentId}: ${item.body}`),
101
90
  ];
102
91
  }
@@ -37,6 +37,23 @@ function quoteEvidence(value) {
37
37
  const compact = value.replaceAll(/\s+/g, " ").trim();
38
38
  return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact;
39
39
  }
40
+ function errorText(error) {
41
+ if (!error || typeof error !== "object")
42
+ return String(error);
43
+ const value = error;
44
+ return [value.message, value.stderr, value.stdout]
45
+ .filter((item) => typeof item === "string")
46
+ .join("\n");
47
+ }
48
+ function isIssueLookupFailure(error) {
49
+ const text = errorText(error);
50
+ return (/could not resolve to an issue/i.test(text) ||
51
+ /could not fetch issue #\d+/i.test(text) ||
52
+ /not an issue/i.test(text));
53
+ }
54
+ function isIssueUrl(url) {
55
+ return /\/issues\/\d+(?:$|[/?#])/i.test(url);
56
+ }
40
57
  function issueReferencePattern(repository) {
41
58
  const host = escapeRegExp(repository.github.host || "github.com");
42
59
  const owner = escapeRegExp(repository.github.owner);
@@ -120,6 +137,9 @@ export function collectIssueRelationships(input) {
120
137
  async function contextIssue(input) {
121
138
  const issue = input.issue ??
122
139
  (await fetchIssue(input.exec, input.repository, input.relationship.number));
140
+ if (!isIssueUrl(issue.url)) {
141
+ throw new Error(`Reference #${issue.number} resolved to ${issue.url}, not an Issue`);
142
+ }
123
143
  const commentPage = await fetchIssueCommentPage(input.exec, input.repository, issue.number, input.limit);
124
144
  return {
125
145
  author: issue.author,
@@ -138,6 +158,19 @@ async function contextIssue(input) {
138
158
  url: issue.url,
139
159
  };
140
160
  }
161
+ async function contextIssueIfIssue(input) {
162
+ try {
163
+ return await contextIssue(input);
164
+ }
165
+ catch (error) {
166
+ if (isIssueLookupFailure(error))
167
+ return undefined;
168
+ throw error;
169
+ }
170
+ }
171
+ function presentIssue(issue) {
172
+ return Boolean(issue);
173
+ }
141
174
  function orderReviewThreads(threads) {
142
175
  return [...threads]
143
176
  .sort((a, b) => {
@@ -183,13 +216,13 @@ export async function buildReviewContextSnapshot(input) {
183
216
  const closingRelationships = relationships.filter((relationship) => relationship.relationship === "closing");
184
217
  const referencedRelationships = relationships.filter((relationship) => relationship.relationship === "referenced");
185
218
  return {
186
- closingIssues: await Promise.all(closingRelationships.map((relationship) => contextIssue({
219
+ closingIssues: (await Promise.all(closingRelationships.map((relationship) => contextIssueIfIssue({
187
220
  exec: input.exec,
188
221
  issue: closingIssueMap.get(relationship.number),
189
222
  limit: LIMITS.closingIssueComments,
190
223
  relationship,
191
224
  repository: input.repository,
192
- }))),
225
+ })))).filter(presentIssue),
193
226
  pullRequest: {
194
227
  author: input.pr.author?.login ?? safetyMeta.author,
195
228
  baseRef: input.pr.baseRefName,
@@ -207,12 +240,12 @@ export async function buildReviewContextSnapshot(input) {
207
240
  title: input.pr.title,
208
241
  url: input.pr.url,
209
242
  },
210
- referencedIssues: await Promise.all(referencedRelationships.map((relationship) => contextIssue({
243
+ referencedIssues: (await Promise.all(referencedRelationships.map((relationship) => contextIssueIfIssue({
211
244
  exec: input.exec,
212
245
  limit: LIMITS.referencedIssueComments,
213
246
  relationship,
214
247
  repository: input.repository,
215
- }))),
248
+ })))).filter(presentIssue),
216
249
  reviewDiscussion: {
217
250
  prComments: boundedComments(prComments, LIMITS.prComments),
218
251
  prCommentsOmitted,
@@ -39,7 +39,7 @@ async function postReviewOutput(input, reviewerKey, output) {
39
39
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
40
40
  if (output.verdict === "CLOSE")
41
41
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
42
- return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings, output.requirementFindings);
42
+ return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.findings);
43
43
  }
44
44
  function dryRunReviewPost(key, output) {
45
45
  if (output.verdict === "MERGE")
@@ -147,11 +147,10 @@ function parsePostedFindingLocation(location) {
147
147
  const line = /^(.*):(\d+)$/.exec(location);
148
148
  if (line)
149
149
  return { line: Number(line[2]), path: line[1] ?? location };
150
- return { path: location };
150
+ return undefined;
151
151
  }
152
152
  function reviewFindingsFromBody(body) {
153
153
  const findings = [];
154
- const requirementFindings = [];
155
154
  const lines = (body ?? "").split(/\r?\n/);
156
155
  let section;
157
156
  for (let index = 0; index < lines.length; index += 1) {
@@ -161,7 +160,7 @@ function reviewFindingsFromBody(body) {
161
160
  continue;
162
161
  }
163
162
  if (line === "Requirement findings:") {
164
- section = "requirement";
163
+ section = undefined;
165
164
  continue;
166
165
  }
167
166
  if (section === "finding") {
@@ -169,30 +168,19 @@ function reviewFindingsFromBody(body) {
169
168
  const fix = /^\s+Fix: (.+)$/.exec(lines[index + 1] ?? "");
170
169
  if (!match || !fix)
171
170
  continue;
171
+ const location = parsePostedFindingLocation(match[1] ?? "");
172
+ if (!location)
173
+ continue;
172
174
  findings.push({
173
- ...parsePostedFindingLocation(match[1] ?? ""),
175
+ ...location,
174
176
  fix: fix[1] ?? "Please address this before merging.",
175
177
  issue: match[2] ?? "Review finding.",
176
178
  });
177
179
  index += 1;
178
180
  continue;
179
181
  }
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
182
  }
195
- return { findings, requirementFindings };
183
+ return { findings };
196
184
  }
197
185
  export function reviewOutputFromState(review) {
198
186
  const verdict = reviewStateToVerdict(review.state);
@@ -202,10 +190,9 @@ export function reviewOutputFromState(review) {
202
190
  ? {
203
191
  findings: [],
204
192
  reason: review.body || "Close requested.",
205
- requirementFindings: [],
206
193
  verdict,
207
194
  }
208
- : { findings: [], requirementFindings: [], verdict };
195
+ : { findings: [], verdict };
209
196
  }
210
197
  export function hasPendingThreadReply(threads, reviewerAccount) {
211
198
  return threads.some((thread) => {
@@ -229,15 +216,15 @@ async function postRereviewOutput(input, reviewerKey, output) {
229
216
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
230
217
  if (output.verdict === "CLOSE")
231
218
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
232
- if (!output.newFindings.length && !output.requirementFindings.length)
219
+ if (!output.newFindings.length)
233
220
  return "";
234
221
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
235
222
  fix: "Please address this before merging.",
236
223
  issue: finding.body,
237
224
  path: finding.path,
238
- ...(finding.line == null ? {} : { line: finding.line }),
225
+ line: finding.line,
239
226
  startLine: finding.startLine,
240
- })), output.requirementFindings);
227
+ })));
241
228
  }
242
229
  function isReviewOutput(output) {
243
230
  return "findings" in output;
@@ -15,6 +15,7 @@ const DEFAULT_CLEAR_OPTIONS = {
15
15
  session: true,
16
16
  worktree: true,
17
17
  };
18
+ const SYNC_RUN_TIMEOUT_MS = 600_000;
18
19
  function createRunId() {
19
20
  return `run-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
20
21
  }
@@ -453,11 +454,14 @@ export class MagiRunManager {
453
454
  await this.notify(state, `Started Magi review for ${prMarkdownLink(state)}.`);
454
455
  const controller = new AbortController();
455
456
  this.controllers.set(runId, controller);
456
- void this.executeReview({
457
+ const execute = () => this.executeReview({
457
458
  ...input,
458
459
  runId,
459
460
  signal: controller.signal,
460
- }).catch(async (error) => {
461
+ });
462
+ if (input.sync)
463
+ return this.executeSync(state, controller, execute);
464
+ void execute().catch(async (error) => {
461
465
  await this.failRun(runId, error);
462
466
  });
463
467
  return state;
@@ -511,11 +515,14 @@ export class MagiRunManager {
511
515
  await this.notify(state, `Started Magi merge for ${prMarkdownLink(state)}.`);
512
516
  const controller = new AbortController();
513
517
  this.controllers.set(runId, controller);
514
- void this.executeMerge({
518
+ const execute = () => this.executeMerge({
515
519
  ...input,
516
520
  runId,
517
521
  signal: controller.signal,
518
- }).catch(async (error) => {
522
+ });
523
+ if (input.sync)
524
+ return this.executeSync(state, controller, execute);
525
+ void execute().catch(async (error) => {
519
526
  await this.failRun(runId, error);
520
527
  });
521
528
  return state;
@@ -568,11 +575,14 @@ export class MagiRunManager {
568
575
  await this.notify(state, `Started Magi triage for ${issueMarkdownLink(state)}.`);
569
576
  const controller = new AbortController();
570
577
  this.controllers.set(runId, controller);
571
- void this.executeTriage({
578
+ const execute = () => this.executeTriage({
572
579
  ...input,
573
580
  runId,
574
581
  signal: controller.signal,
575
- }).catch(async (error) => {
582
+ });
583
+ if (input.sync)
584
+ return this.executeSync(state, controller, execute);
585
+ void execute().catch(async (error) => {
576
586
  await this.failRun(runId, error);
577
587
  });
578
588
  return state;
@@ -1210,6 +1220,36 @@ export class MagiRunManager {
1210
1220
  hasBlockedAgents(state) {
1211
1221
  return this.agentEntries(state).some(([, agent]) => agent.status === "blocked");
1212
1222
  }
1223
+ async executeSync(state, controller, execute) {
1224
+ let timeout;
1225
+ const timeoutPromise = new Promise((resolve) => {
1226
+ timeout = setTimeout(() => resolve("timeout"), SYNC_RUN_TIMEOUT_MS);
1227
+ });
1228
+ try {
1229
+ const result = await Promise.race([
1230
+ execute().then(() => "completed"),
1231
+ timeoutPromise,
1232
+ ]);
1233
+ if (result === "timeout") {
1234
+ controller.abort();
1235
+ await this.failRun(state.runId, new Error("Magi sync run timed out after 600 seconds."));
1236
+ }
1237
+ }
1238
+ catch (error) {
1239
+ controller.abort();
1240
+ await this.failRun(state.runId, error);
1241
+ }
1242
+ finally {
1243
+ if (timeout)
1244
+ clearTimeout(timeout);
1245
+ }
1246
+ return (await this.readStateByRunId(state.runId)) ?? state;
1247
+ }
1248
+ assertSuccessfulSyncFollowUp(state) {
1249
+ if (state.status === "completed")
1250
+ return;
1251
+ throw new Error(`Synchronous follow-up ${state.command} run ${state.runId} finished with status ${state.status}.`);
1252
+ }
1213
1253
  async executeReview(input) {
1214
1254
  const result = await runReview({
1215
1255
  approvalPolicy: input.repository.merge.approvalPolicy,
@@ -1347,24 +1387,30 @@ export class MagiRunManager {
1347
1387
  : undefined;
1348
1388
  const triageAutomation = input.repository.triage?.automation;
1349
1389
  if (followUpPr != null && triageAutomation?.merge) {
1350
- await this.startMerge({
1390
+ const followUp = await this.startMerge({
1351
1391
  config: input.config,
1352
1392
  dryRun: input.dryRun,
1353
1393
  parentSessionId: input.parentSessionId,
1354
1394
  pr: followUpPr,
1355
1395
  repository: input.repository,
1356
1396
  signal: input.signal,
1397
+ sync: input.sync,
1357
1398
  });
1399
+ if (input.sync)
1400
+ this.assertSuccessfulSyncFollowUp(followUp);
1358
1401
  }
1359
1402
  else if (followUpPr != null && triageAutomation?.review) {
1360
- await this.startReview({
1403
+ const followUp = await this.startReview({
1361
1404
  config: input.config,
1362
1405
  dryRun: input.dryRun,
1363
1406
  parentSessionId: input.parentSessionId,
1364
1407
  pr: followUpPr,
1365
1408
  repository: input.repository,
1366
1409
  signal: input.signal,
1410
+ sync: input.sync,
1367
1411
  });
1412
+ if (input.sync)
1413
+ this.assertSuccessfulSyncFollowUp(followUp);
1368
1414
  }
1369
1415
  this.active.delete(input.runId);
1370
1416
  this.controllers.delete(input.runId);
@@ -15,26 +15,20 @@ 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
- ],
26
18
  "reason": "Required only for CLOSE."
27
19
  }
28
20
 
29
21
  Rules:
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.
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.
33
25
  - path must be repository-relative.
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.
26
+ - line is required and must target a valid right-side line inside the PR diff hunk.
27
+ - startLine is optional and must also target a valid right-side line inside the same PR diff hunk range.
28
+ - Omit startLine for single-line findings.
29
+ - Do not omit line. Do not create file-level or body-only findings.
30
+ - Missing closing-issue requirements must be normal findings anchored to the nearest responsible changed line.
31
+ - If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
38
32
  </output_contract>`.trim();
39
33
  export const rereviewOutputContract = `
40
34
  <output_contract>
@@ -46,18 +40,19 @@ The object must match this shape:
46
40
  "resolve": [{ "commentId": 123, "threadId": "..." }],
47
41
  "followUps": [{ "commentId": 123, "body": "..." }],
48
42
  "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
49
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }],
50
43
  "reason": "Required only for CLOSE."
51
44
  }
52
45
 
53
46
  Rules:
54
- - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
55
- - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
56
- - CLOSE requires a reason and empty followUps, newFindings, and requirementFindings arrays.
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.
47
+ - MERGE requires empty followUps and newFindings arrays.
48
+ - CHANGES_REQUESTED requires at least one followUp or newFinding.
49
+ - CLOSE requires a reason and empty followUps and newFindings arrays.
50
+ - line is required and must target a valid right-side line inside the latest PR diff hunk.
51
+ - startLine is optional and must also target a valid right-side line inside the same latest PR diff hunk range.
52
+ - Omit startLine for single-line findings.
53
+ - Do not omit line. Do not create file-level or body-only findings.
54
+ - Missing closing-issue requirements must be normal newFindings anchored to the nearest responsible changed line.
55
+ - If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
61
56
  </output_contract>`.trim();
62
57
  export const findingValidationOutputContract = `
63
58
  <output_contract>
@@ -96,17 +91,16 @@ The object must match this shape:
96
91
  "issue": "What is wrong.",
97
92
  "fix": "How to fix it."
98
93
  }
99
- ],
100
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
94
+ ]
101
95
  }
102
96
 
103
97
  Rules:
104
- - MERGE requires empty findings and requirementFindings arrays.
105
- - CHANGES_REQUESTED requires at least one finding or requirementFinding.
98
+ - MERGE requires an empty findings array.
99
+ - CHANGES_REQUESTED requires at least one finding.
106
100
  - CLOSE is not allowed in this reconsideration step.
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.
101
+ - line is required and must target a valid right-side line inside the PR diff hunk.
102
+ - startLine is optional and must also target a valid right-side line inside the same PR diff hunk range.
103
+ - Do not omit line. Do not create file-level or body-only findings.
110
104
  </output_contract>`.trim();
111
105
  export const rereviewCloseReconsiderationOutputContract = `
112
106
  <output_contract>
@@ -117,17 +111,16 @@ The object must match this shape:
117
111
  "verdict": "MERGE" | "CHANGES_REQUESTED",
118
112
  "resolve": [{ "commentId": 123, "threadId": "..." }],
119
113
  "followUps": [{ "commentId": 123, "body": "..." }],
120
- "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
121
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
114
+ "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
122
115
  }
123
116
 
124
117
  Rules:
125
- - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
126
- - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
118
+ - MERGE requires empty followUps and newFindings arrays.
119
+ - CHANGES_REQUESTED requires at least one followUp or newFinding.
127
120
  - CLOSE is not allowed in this reconsideration step.
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.
121
+ - line is required and must target a valid right-side line inside the latest PR diff hunk.
122
+ - startLine is optional and must also target a valid right-side line inside the same latest PR diff hunk range.
123
+ - Do not omit line. Do not create file-level or body-only findings.
131
124
  </output_contract>`.trim();
132
125
  export const editOutputContract = `
133
126
  <output_contract>
@@ -71,27 +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);
74
+ function requireLine(value, path) {
75
+ if (value == null)
76
+ throw new Error(`${path} is required`);
77
+ return requireNumber(value, path);
76
78
  }
77
79
  function optionalStartLine(input) {
78
80
  if (input.value == null)
79
81
  return undefined;
80
- if (input.line == null)
81
- throw new Error(`${input.path} requires line`);
82
82
  return requireNumber(input.value, input.path);
83
83
  }
84
- function parseRequirementFindings(value) {
85
- return (value == null ? [] : requireArray(value, "requirementFindings")).map((finding, index) => {
86
- const item = finding;
87
- return {
88
- evidence: requireString(item.evidence, `requirementFindings[${index}].evidence`),
89
- fix: requireString(item.fix, `requirementFindings[${index}].fix`),
90
- issueNumber: requireNumber(item.issueNumber, `requirementFindings[${index}].issueNumber`),
91
- requirement: requireString(item.requirement, `requirementFindings[${index}].requirement`),
92
- };
93
- });
94
- }
95
84
  function requireOneOf(value, path, values) {
96
85
  const text = requireString(value, path);
97
86
  if (!values.includes(text)) {
@@ -179,11 +168,13 @@ export function parseReviewOutput(text) {
179
168
  const data = extractJson(text);
180
169
  if (!data || typeof data !== "object")
181
170
  throw new Error("review output must be an object");
171
+ if (data.requirementFindings != null)
172
+ throw new Error("requirementFindings is not accepted");
182
173
  if (!isVerdict(data.verdict))
183
174
  throw new Error("verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
184
175
  const findings = requireArray(data.findings, "findings").map((finding, index) => {
185
176
  const item = finding;
186
- const line = optionalLine(item.line, `findings[${index}].line`);
177
+ const line = requireLine(item.line, `findings[${index}].line`);
187
178
  return {
188
179
  fix: requireString(item.fix, `findings[${index}].fix`),
189
180
  issue: requireString(item.issue, `findings[${index}].issue`),
@@ -199,17 +190,12 @@ export function parseReviewOutput(text) {
199
190
  }),
200
191
  };
201
192
  });
202
- const requirementFindings = parseRequirementFindings(data.requirementFindings);
203
- if (data.verdict === "MERGE" &&
204
- (findings.length || requirementFindings.length))
205
- throw new Error("MERGE requires no findings or requirementFindings");
206
- if (data.verdict === "CHANGES_REQUESTED" &&
207
- !findings.length &&
208
- !requirementFindings.length)
209
- throw new Error("CHANGES_REQUESTED requires findings or requirementFindings");
210
- if (data.verdict === "CLOSE" &&
211
- (findings.length || requirementFindings.length))
212
- throw new Error("CLOSE requires no findings or requirementFindings");
193
+ if (data.verdict === "MERGE" && findings.length)
194
+ throw new Error("MERGE requires no findings");
195
+ if (data.verdict === "CHANGES_REQUESTED" && !findings.length)
196
+ throw new Error("CHANGES_REQUESTED requires findings");
197
+ if (data.verdict === "CLOSE" && findings.length)
198
+ throw new Error("CLOSE requires no findings");
213
199
  const reason = typeof data.reason === "string" && data.reason.trim()
214
200
  ? data.reason
215
201
  : undefined;
@@ -218,7 +204,6 @@ export function parseReviewOutput(text) {
218
204
  return {
219
205
  findings,
220
206
  reason,
221
- requirementFindings,
222
207
  verdict: data.verdict,
223
208
  };
224
209
  }
@@ -226,6 +211,8 @@ export function parseRereviewOutput(text) {
226
211
  const data = extractJson(text);
227
212
  if (!isVerdict(data.verdict))
228
213
  throw new Error("rereview verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
214
+ if (data.requirementFindings != null)
215
+ throw new Error("requirementFindings is not accepted");
229
216
  const resolve = requireArray(data.resolve, "resolve").map((item, index) => {
230
217
  const value = item;
231
218
  return {
@@ -242,7 +229,7 @@ export function parseRereviewOutput(text) {
242
229
  });
243
230
  const newFindings = requireArray(data.newFindings, "newFindings").map((item, index) => {
244
231
  const value = item;
245
- const line = optionalLine(value.line, `newFindings[${index}].line`);
232
+ const line = requireLine(value.line, `newFindings[${index}].line`);
246
233
  return {
247
234
  body: requireString(value.body, `newFindings[${index}].body`),
248
235
  line,
@@ -254,29 +241,24 @@ export function parseRereviewOutput(text) {
254
241
  }),
255
242
  };
256
243
  });
257
- const requirementFindings = parseRequirementFindings(data.requirementFindings);
258
- if (data.verdict === "MERGE" &&
259
- (followUps.length || newFindings.length || requirementFindings.length)) {
260
- throw new Error("MERGE requires no followUps, newFindings, or requirementFindings");
244
+ if (data.verdict === "MERGE" && (followUps.length || newFindings.length)) {
245
+ throw new Error("MERGE requires no followUps or newFindings");
261
246
  }
262
- if (data.verdict === "CLOSE" &&
263
- (followUps.length || newFindings.length || requirementFindings.length)) {
264
- throw new Error("CLOSE requires no followUps, newFindings, or requirementFindings");
247
+ if (data.verdict === "CLOSE" && (followUps.length || newFindings.length)) {
248
+ throw new Error("CLOSE requires no followUps or newFindings");
265
249
  }
266
250
  if (data.verdict === "CLOSE" && !data.reason) {
267
251
  throw new Error("CLOSE requires reason");
268
252
  }
269
253
  if (data.verdict === "CHANGES_REQUESTED" &&
270
254
  !followUps.length &&
271
- !newFindings.length &&
272
- !requirementFindings.length) {
273
- throw new Error("CHANGES_REQUESTED requires followUps, newFindings, or requirementFindings");
255
+ !newFindings.length) {
256
+ throw new Error("CHANGES_REQUESTED requires followUps or newFindings");
274
257
  }
275
258
  return {
276
259
  followUps,
277
260
  newFindings,
278
261
  reason: data.reason == null ? undefined : requireString(data.reason, "reason"),
279
- requirementFindings,
280
262
  resolve,
281
263
  verdict: data.verdict,
282
264
  };
@@ -2,7 +2,7 @@ Fix pull request #{pr} for {owner}/{repo}.
2
2
  The PR worktree is {worktreePath}.
3
3
 
4
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.
5
+ Review findings are the complete set of requested changes. Each finding targets a PR diff line and should have a corresponding GitHub review thread unless it comes from legacy read-side state.
6
6
  {reviewFindings}
7
7
 
8
8
  Unresolved GitHub review threads are conversations that may need replies or resolution.
@@ -12,5 +12,4 @@ For each review finding and thread, decide whether you agree with the reviewer.
12
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
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
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.
16
15
  Do not make changes just because a reviewer requested them. Do not push.
@@ -2,4 +2,5 @@ You requested CLOSE for pull request #{pr} in {owner}/{repo}, but the other revi
2
2
  Reconsider your decision using the existing session context and choose MERGE or CHANGES_REQUESTED instead.
3
3
  Original close reason:
4
4
  {closeReason}
5
+ Every finding must target a valid right-side line in the PR diff. If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior.
5
6
  Do not edit files or perform write operations.
@@ -9,6 +9,9 @@ If there is no new commit, still reconsider the thread when a user replied after
9
9
  If you agree with the user's explanation or the code is fixed, resolve the thread.
10
10
  If you do not agree, reply in the same thread with a followUp explaining why the issue still needs changes and keep CHANGES_REQUESTED.
11
11
  Do not duplicate an existing unresolved thread as a newFinding. Use newFindings only for separate new issues.
12
+ Every newFinding must target a valid right-side line in the PR diff.
13
+ If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
14
+ Do not omit line. Do not create file-level or body-only newFindings.
12
15
 
13
16
  {ciFailureContextBlock}
14
17
  Do not edit files or perform write operations.
@@ -10,4 +10,8 @@ Request changes if a closing issue requirement is missing, only documented, only
10
10
  Do not approve solely because the PR improves the codebase if it claims to close an issue that remains incomplete.
11
11
  For referenced non-closing issues, use them as context only unless the PR body explicitly claims to complete them.
12
12
 
13
+ Every finding must target a valid right-side line in the PR diff.
14
+ If the problem itself does not have an exact changed line, choose the nearest changed line that represents the cause, responsibility, missing implementation, or affected behavior. This includes but is not limited to missing validation, missing wiring, missing requirements, missing tests, missing documentation, affected configuration, or relevant call sites.
15
+ Do not omit line. Do not create file-level or body-only findings.
16
+
13
17
  {ciFailureContextBlock}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.4.0",
3
+ "version": "0.5.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>",