opencode-magi 0.0.0-dev-20260525030452 → 0.0.0-dev-20260525064434

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
@@ -154,17 +154,14 @@ Add the following content to the configuration file.
154
154
 
155
155
  Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
156
156
 
157
- `model` can be a single `provider/model` string or an ordered candidate array. Candidate arrays are resolved during validation against OpenCode's model catalog; the first available model is selected. Put provider-specific options on object candidates, not on the agent role.
157
+ `model` can be a single `provider/model` string, a single object with `id` and `options`, or an ordered candidate array. Candidate arrays are resolved during validation against OpenCode's model catalog; the first available model is selected. Put provider-specific options on model objects, not on the agent role.
158
158
 
159
159
  ```json
160
160
  {
161
- "model": [
162
- "anthropic/claude-sonnet-4-5",
163
- {
164
- "id": "openai/gpt-5.1",
165
- "options": { "reasoningEffort": "high" }
166
- }
167
- ]
161
+ "model": {
162
+ "id": "openai/gpt-5.1",
163
+ "options": { "reasoningEffort": "high" }
164
+ }
168
165
  }
169
166
  ```
170
167
 
@@ -138,6 +138,7 @@ export function resolveRepository(config) {
138
138
  agents: resolveAgents(config),
139
139
  automation: {
140
140
  close: config.merge?.automation?.close ?? false,
141
+ conflict: config.merge?.automation?.conflict ?? false,
141
142
  merge: config.merge?.automation?.merge ?? true,
142
143
  },
143
144
  checks: {
@@ -93,6 +93,7 @@ const REVIEW_MERGE_KEYS = new Set([
93
93
  const REVIEW_CHECKS_KEYS = new Set(["exclude", "retryFailedJobs", "wait"]);
94
94
  const MERGE_CHECKS_KEYS = new Set(["wait"]);
95
95
  const AUTOMATION_KEYS = new Set(["close", "merge"]);
96
+ const MERGE_AUTOMATION_KEYS = new Set(["close", "conflict", "merge"]);
96
97
  const CLEAR_KEYS = new Set(["branch", "output", "session", "worktree"]);
97
98
  const CONCURRENCY_KEYS = new Set(["reviewers", "runs"]);
98
99
  const OUTPUT_KEYS = new Set(["repairAttempts"]);
@@ -319,9 +320,21 @@ function validateAndNormalizeModel(target, path, errors, catalog) {
319
320
  validateModelId(model, path, errors, catalog);
320
321
  return;
321
322
  }
323
+ if (isPlainObject(model)) {
324
+ const candidate = readModelCandidate(model, path, errors);
325
+ if (candidate &&
326
+ validateModelId(candidate.id, `${path}.id`, errors, catalog)) {
327
+ target.model = candidate.id;
328
+ if (candidate.options)
329
+ target.options = candidate.options;
330
+ else
331
+ delete target.options;
332
+ }
333
+ return;
334
+ }
322
335
  if (!Array.isArray(model)) {
323
336
  if (model != null)
324
- errors.push(`${path} must be a string or an array`);
337
+ errors.push(`${path} must be a string, an object, or an array`);
325
338
  return;
326
339
  }
327
340
  if (!model.length) {
@@ -540,7 +553,7 @@ function validateMerge(config, errors, options) {
540
553
  errors.push("merge must be an object");
541
554
  }
542
555
  validateKnownKeys(merge, "merge", MERGE_KEYS, errors);
543
- validateBooleanObject(merge?.automation, "merge.automation", AUTOMATION_KEYS, errors);
556
+ validateBooleanObject(merge?.automation, "merge.automation", MERGE_AUTOMATION_KEYS, errors);
544
557
  const checks = merge?.checks;
545
558
  validateKnownKeys(checks, "merge.checks", MERGE_CHECKS_KEYS, errors);
546
559
  validateBoolean(checks?.wait, "merge.checks.wait", errors);
@@ -685,6 +685,29 @@ export async function waitForAutoMerge(exec, repository, pr, intervalMs = 30_000
685
685
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
686
686
  }
687
687
  }
688
+ export async function fetchBaseBranch(exec, repository, meta, worktreePath) {
689
+ await exec(`git fetch --no-tags ${shellQuote(repositoryGitUrl(repository, repository.github.owner, repository.github.repo))} ${shellQuote(`refs/heads/${meta.baseRefName}`)}`, { cwd: worktreePath });
690
+ }
691
+ export async function mergeBaseNoCommit(exec, baseSha, worktreePath) {
692
+ await exec(`git merge --no-commit --no-ff ${shellQuote(baseSha)}`, {
693
+ cwd: worktreePath,
694
+ }).catch(() => undefined);
695
+ }
696
+ export async function listUnmergedFiles(exec, worktreePath) {
697
+ const output = await exec("git diff --name-only --diff-filter=U", {
698
+ cwd: worktreePath,
699
+ });
700
+ return output
701
+ .split("\n")
702
+ .map((line) => line.trim())
703
+ .filter(Boolean);
704
+ }
705
+ export async function abortMerge(exec, worktreePath) {
706
+ await exec("git merge --abort", { cwd: worktreePath }).catch(() => undefined);
707
+ }
708
+ export async function currentHeadSha(exec, worktreePath) {
709
+ return (await exec("git rev-parse HEAD", { cwd: worktreePath })).trim();
710
+ }
688
711
  export async function closePullRequest(exec, repository, pr, account) {
689
712
  const token = await ghToken(exec, repository, account);
690
713
  return exec(`gh pr close ${pr} --repo ${shellQuote(repoSpecifier(repository))}`, ghTokenEnv(token));
@@ -1,8 +1,8 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { prRunOutputDir } from "../config/output";
4
- import { closePullRequest, configureGitIdentity, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, mergePullRequest, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, waitForMergeQueue, } from "../github/commands";
5
- import { composeEditPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
4
+ import { abortMerge, closePullRequest, configureGitIdentity, currentHeadSha, fetchBaseBranch, fetchMergeQueueRequirement, fetchPullRequest, fetchUnresolvedThreads, listUnmergedFiles, mergePullRequest, mergeBaseNoCommit, postApproval, postChangesRequested, postCloseComment, postReply, pushHead, removeWorktree, resolveThread, waitForAutoMerge, waitForMergeQueue, } from "../github/commands";
5
+ import { composeEditPrompt, composeMergeConflictPrompt, composeRereviewCloseReconsiderationPrompt, composeRereviewPrompt, } from "../prompts/compose";
6
6
  import { parseEditOutput, parseRereviewCloseReconsiderationOutput, parseRereviewOutput, } from "../prompts/output";
7
7
  import { throwIfAborted, withAbortSignal } from "./abort";
8
8
  import { waitForChecksWithClassification } from "./ci";
@@ -433,6 +433,224 @@ async function mergeWithQueue(input, exec, editorAccount) {
433
433
  }
434
434
  return waitForMergeQueue(exec, input.repository, input.pr);
435
435
  }
436
+ async function runConflictEditor(input) {
437
+ const editor = input.run.repository.agents.editor;
438
+ if (!editor)
439
+ throw new Error("merge.editor is required for magi_merge");
440
+ await configureGitIdentity(input.run.exec, input.worktreePath, {
441
+ email: editor.author?.email,
442
+ name: editor.author?.name,
443
+ });
444
+ const artifactDir = outputDir(input.run);
445
+ const prompt = await composeMergeConflictPrompt({
446
+ baseBranch: input.baseBranch,
447
+ baseSha: input.baseSha,
448
+ conflictedFiles: JSON.stringify(input.conflictedFiles, null, 2),
449
+ directory: input.run.directory,
450
+ headSha: input.headSha,
451
+ pr: input.run.pr,
452
+ repository: input.run.repository,
453
+ worktreePath: input.worktreePath,
454
+ });
455
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_started" });
456
+ const result = await withEditorFailureProgress({
457
+ cycle: input.cycle,
458
+ onProgress: input.run.onProgress,
459
+ run: () => runModelWithRepair({
460
+ client: input.run.client,
461
+ model: editor.model,
462
+ onProgress: async (progress) => {
463
+ if (progress.type === "session_created") {
464
+ await input.run.onProgress?.({
465
+ cycle: input.cycle,
466
+ options: progress.options,
467
+ sessionId: progress.sessionId,
468
+ type: "editor_session",
469
+ });
470
+ }
471
+ if (progress.type === "repair") {
472
+ await input.run.onProgress?.({
473
+ cycle: input.cycle,
474
+ type: "editor_repair",
475
+ });
476
+ }
477
+ if (progress.type === "response") {
478
+ await input.run.onProgress?.({
479
+ cycle: input.cycle,
480
+ sessionId: progress.sessionId,
481
+ type: "editor_response",
482
+ });
483
+ }
484
+ },
485
+ options: editor.options,
486
+ parentSessionId: input.run.parentSessionId,
487
+ parse: parseEditOutput,
488
+ permission: editor.permission,
489
+ prompt,
490
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
491
+ schemaName: "edit",
492
+ signal: input.run.signal,
493
+ title: `magi resolve conflict ${input.run.repository.alias}#${input.run.pr}`,
494
+ }),
495
+ });
496
+ await writeFile(join(artifactDir, "editor.conflict.prompt.txt"), prompt);
497
+ await writeFile(join(artifactDir, "editor.conflict.raw.txt"), result.raw);
498
+ await writeFile(join(artifactDir, "editor.conflict.json"), JSON.stringify(result.value, null, 2));
499
+ await input.run.onProgress?.({ cycle: input.cycle, type: "editor_completed" });
500
+ return result.value;
501
+ }
502
+ async function recoverMergeQueueConflict(input) {
503
+ await input.run.onProgress?.({
504
+ phase: "checking merge queue conflict",
505
+ type: "phase",
506
+ });
507
+ const meta = await fetchPullRequest(input.exec, input.run.repository, input.run.pr);
508
+ if (meta.state && meta.state.toUpperCase() !== "OPEN")
509
+ return undefined;
510
+ await fetchBaseBranch(input.exec, input.run.repository, meta, input.worktreePath);
511
+ await mergeBaseNoCommit(input.exec, meta.baseRefOid, input.worktreePath);
512
+ const conflictedFiles = await listUnmergedFiles(input.exec, input.worktreePath);
513
+ if (!conflictedFiles.length) {
514
+ await abortMerge(input.exec, input.worktreePath);
515
+ return undefined;
516
+ }
517
+ await input.run.onProgress?.({
518
+ phase: "resolving merge conflict",
519
+ type: "phase",
520
+ });
521
+ const editorOutput = await runConflictEditor({
522
+ baseBranch: meta.baseRefName,
523
+ baseSha: meta.baseRefOid,
524
+ conflictedFiles,
525
+ cycle: input.cycle,
526
+ headSha: input.previousHeadSha,
527
+ run: input.run,
528
+ worktreePath: input.worktreePath,
529
+ });
530
+ if (editorOutput.mode !== "EDITED") {
531
+ return {
532
+ ciReports: input.ciReports,
533
+ editorOutput,
534
+ headSha: input.previousHeadSha,
535
+ outputs: {},
536
+ posted: {},
537
+ status: "changes_unresolved",
538
+ };
539
+ }
540
+ const remainingConflicts = await listUnmergedFiles(input.exec, input.worktreePath);
541
+ const editedHeadSha = await currentHeadSha(input.exec, input.worktreePath);
542
+ if (remainingConflicts.length || editedHeadSha === input.previousHeadSha) {
543
+ return {
544
+ ciReports: input.ciReports,
545
+ editorOutput,
546
+ headSha: input.previousHeadSha,
547
+ outputs: {},
548
+ posted: {},
549
+ status: "changes_unresolved",
550
+ };
551
+ }
552
+ const editor = input.run.repository.agents.editor;
553
+ if (!editor)
554
+ throw new Error("merge.editor is required for magi_merge");
555
+ const headOwner = meta.headRepositoryOwner?.login;
556
+ const headRepo = meta.headRepository?.name;
557
+ if (!headOwner || !headRepo) {
558
+ throw new Error("Pull request head repository is missing");
559
+ }
560
+ await pushHead(input.exec, input.run.repository, input.worktreePath, editor.account, { owner: headOwner, ref: meta.headRefName, repo: headRepo });
561
+ const ciReports = [...input.ciReports];
562
+ let ciFailureContext = "";
563
+ await input.run.onProgress?.({
564
+ phase: "waiting for checks after conflict resolution",
565
+ type: "phase",
566
+ });
567
+ const checkResult = await waitForChecksWithClassification({
568
+ afterEdit: {
569
+ cycle: input.cycle,
570
+ headSha: editedHeadSha,
571
+ previousHeadSha: input.previousHeadSha,
572
+ worktreePath: input.worktreePath,
573
+ },
574
+ client: input.run.client,
575
+ directory: input.run.directory,
576
+ exec: input.exec,
577
+ headSha: editedHeadSha,
578
+ onProgress: (phase) => input.run.onProgress?.({ phase, type: "phase" }),
579
+ parentSessionId: input.run.parentSessionId,
580
+ pr: input.run.pr,
581
+ repairAttempts: input.run.config.output?.repairAttempts ?? 3,
582
+ repository: input.run.repository,
583
+ signal: input.run.signal,
584
+ wait: input.run.repository.checks.waitAfterEdit,
585
+ });
586
+ ciFailureContext = checkResult?.ciFailureContext ?? "";
587
+ if (checkResult &&
588
+ (checkResult.report.scopeOutsideRecovered.length ||
589
+ checkResult.report.scopeOutsideUnresolved.length ||
590
+ checkResult.report.scopeInside.length)) {
591
+ ciReports.push(checkResult.report);
592
+ await input.run.onProgress?.({
593
+ report: checkResult.report,
594
+ type: "ci_report",
595
+ });
596
+ }
597
+ await input.run.onProgress?.({
598
+ phase: "rereview after conflict resolution",
599
+ type: "phase",
600
+ });
601
+ const rereview = await runRereview(input.run, input.worktreePath, input.previousHeadSha, input.cycle, input.sessionIds, ciFailureContext);
602
+ if (rereview.verdict === "CLOSE") {
603
+ if (!input.run.repository.automation.close) {
604
+ return {
605
+ ciReports,
606
+ editorOutput,
607
+ headSha: editedHeadSha,
608
+ outputs: rereview.outputs,
609
+ posted: rereview.posted,
610
+ status: "close_requested",
611
+ };
612
+ }
613
+ await input.run.onProgress?.({ phase: "closing PR", type: "phase" });
614
+ await closePullRequest(input.exec, input.run.repository, input.run.pr, editor.account);
615
+ return {
616
+ ciReports,
617
+ editorOutput,
618
+ headSha: editedHeadSha,
619
+ outputs: rereview.outputs,
620
+ posted: rereview.posted,
621
+ status: "closed",
622
+ };
623
+ }
624
+ if (rereview.verdict === "MERGE") {
625
+ if (hasBlockingCiReports(ciReports)) {
626
+ return {
627
+ ciReports,
628
+ editorOutput,
629
+ headSha: editedHeadSha,
630
+ outputs: rereview.outputs,
631
+ posted: rereview.posted,
632
+ status: "ci_unresolved",
633
+ };
634
+ }
635
+ await input.run.onProgress?.({ phase: "re-enqueueing PR", type: "phase" });
636
+ const status = await mergeWithQueue(input.run, input.exec, editor.account);
637
+ return {
638
+ ciReports,
639
+ editorOutput,
640
+ headSha: editedHeadSha,
641
+ outputs: rereview.outputs,
642
+ posted: rereview.posted,
643
+ status,
644
+ };
645
+ }
646
+ return {
647
+ ciReports,
648
+ editorOutput,
649
+ headSha: editedHeadSha,
650
+ outputs: rereview.outputs,
651
+ posted: rereview.posted,
652
+ };
653
+ }
436
654
  export function hasBlockingCiReports(reports) {
437
655
  return reports.some((report) => report.scopeInside.length || report.scopeOutsideUnresolved.length);
438
656
  }
@@ -656,6 +874,28 @@ export async function runMerge(input) {
656
874
  outputs: reportOutputs,
657
875
  posted: reportPosted,
658
876
  });
877
+ let previousHeadSha = review.headSha;
878
+ const ciReports = [...review.ciReports];
879
+ const threadAttempts = {};
880
+ let dryRunThreads = input.dryRun
881
+ ? syntheticReviewThreads(reportOutputs)
882
+ : undefined;
883
+ let conflictRecoveryAttempted = false;
884
+ const applyConflictRecovery = (recovery) => {
885
+ editorOutputs.push({
886
+ ...recovery.editorOutput,
887
+ label: "Conflict recovery",
888
+ });
889
+ ciReports.length = 0;
890
+ ciReports.push(...recovery.ciReports);
891
+ reportCiReports = [...recovery.ciReports];
892
+ if (Object.keys(recovery.outputs).length)
893
+ reportOutputs = recovery.outputs;
894
+ if (Object.keys(recovery.posted).length)
895
+ reportPosted = recovery.posted;
896
+ previousHeadSha = recovery.headSha;
897
+ dryRunThreads = undefined;
898
+ };
659
899
  if (review.verdict === "SAFETY_BLOCKED") {
660
900
  await input.onProgress?.({
661
901
  status: "safety_blocked",
@@ -693,15 +933,45 @@ export async function runMerge(input) {
693
933
  }
694
934
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
695
935
  const status = await mergeWithQueue(input, exec, editor.account);
696
- await input.onProgress?.({ status, type: "merge_completed" });
697
- return complete({ cycles: 0, pr: input.pr, status });
936
+ if (status === "dequeued" &&
937
+ input.repository.automation.conflict &&
938
+ input.repository.merge.mergeQueue) {
939
+ if (!review.worktreePath)
940
+ throw new Error("Review worktree is missing");
941
+ conflictRecoveryAttempted = true;
942
+ const recovery = await recoverMergeQueueConflict({
943
+ ciReports,
944
+ cycle: 1,
945
+ exec,
946
+ previousHeadSha,
947
+ run: abortableInput,
948
+ sessionIds: review.sessionIds,
949
+ worktreePath: review.worktreePath,
950
+ });
951
+ if (recovery) {
952
+ applyConflictRecovery(recovery);
953
+ if (recovery.status) {
954
+ await input.onProgress?.({
955
+ status: recovery.status,
956
+ type: "merge_completed",
957
+ });
958
+ return complete({
959
+ cycles: 1,
960
+ pr: input.pr,
961
+ status: recovery.status,
962
+ });
963
+ }
964
+ }
965
+ else {
966
+ await input.onProgress?.({ status, type: "merge_completed" });
967
+ return complete({ cycles: 0, pr: input.pr, status });
968
+ }
969
+ }
970
+ else {
971
+ await input.onProgress?.({ status, type: "merge_completed" });
972
+ return complete({ cycles: 0, pr: input.pr, status });
973
+ }
698
974
  }
699
- let previousHeadSha = review.headSha;
700
- const ciReports = [...review.ciReports];
701
- const threadAttempts = {};
702
- let dryRunThreads = input.dryRun
703
- ? syntheticReviewThreads(reportOutputs)
704
- : undefined;
705
975
  for (let cycle = 1;; cycle += 1) {
706
976
  const unresolvedThreads = input.dryRun
707
977
  ? flattenSyntheticThreads(dryRunThreads ?? {})
@@ -886,6 +1156,36 @@ export async function runMerge(input) {
886
1156
  }
887
1157
  await input.onProgress?.({ phase: "merging PR", type: "phase" });
888
1158
  const status = await mergeWithQueue(input, exec, editor.account);
1159
+ if (status === "dequeued" &&
1160
+ input.repository.automation.conflict &&
1161
+ input.repository.merge.mergeQueue &&
1162
+ !conflictRecoveryAttempted) {
1163
+ conflictRecoveryAttempted = true;
1164
+ const recovery = await recoverMergeQueueConflict({
1165
+ ciReports,
1166
+ cycle: cycle + 1,
1167
+ exec,
1168
+ previousHeadSha,
1169
+ run: abortableInput,
1170
+ sessionIds: review.sessionIds,
1171
+ worktreePath: review.worktreePath,
1172
+ });
1173
+ if (recovery) {
1174
+ applyConflictRecovery(recovery);
1175
+ if (recovery.status) {
1176
+ await input.onProgress?.({
1177
+ status: recovery.status,
1178
+ type: "merge_completed",
1179
+ });
1180
+ return complete({
1181
+ cycles: cycle + 1,
1182
+ pr: input.pr,
1183
+ status: recovery.status,
1184
+ });
1185
+ }
1186
+ continue;
1187
+ }
1188
+ }
889
1189
  await input.onProgress?.({ status, type: "merge_completed" });
890
1190
  return complete({ cycles: cycle, pr: input.pr, status });
891
1191
  }
@@ -138,7 +138,7 @@ function editorLines(outputs) {
138
138
  return [
139
139
  "- **Editor**:",
140
140
  ...outputs.flatMap((output, index) => {
141
- const label = ` - Cycle ${index + 1}`;
141
+ const label = ` - ${output.label ?? `Cycle ${index + 1}`}`;
142
142
  if (output.mode === "REPLIED") {
143
143
  return [
144
144
  `${label}: replied without code changes`,
@@ -163,6 +163,90 @@ export async function inlineCommentTargetsForDiff(input) {
163
163
  cwd: input.worktreePath,
164
164
  }));
165
165
  }
166
+ function firstTargetLine(targets, path) {
167
+ const lines = targets.get(path);
168
+ if (!lines?.size)
169
+ return undefined;
170
+ return [...lines].sort((a, b) => a - b)[0];
171
+ }
172
+ function mergeInlineCommentTargets(left, right) {
173
+ const merged = new Map();
174
+ for (const [path, lines] of [...left, ...right]) {
175
+ const targetLines = merged.get(path) ?? new Set();
176
+ for (const line of lines)
177
+ targetLines.add(line);
178
+ merged.set(path, targetLines);
179
+ }
180
+ return merged;
181
+ }
182
+ function targetLineSummary(targets, path) {
183
+ const lines = targets.get(path);
184
+ if (!lines?.size)
185
+ return "(none)";
186
+ const sorted = [...lines].sort((a, b) => a - b);
187
+ const shown = sorted.slice(0, 12).join(", ");
188
+ return sorted.length > 12 ? `${shown}, ...` : shown;
189
+ }
190
+ function indentedExcerpt(lines) {
191
+ return lines
192
+ .slice(0, 24)
193
+ .map((line) => ` ${line}`)
194
+ .join("\n");
195
+ }
196
+ function parseMergeConflictSections(output) {
197
+ const conflictHeaders = new Set([
198
+ "added in both",
199
+ "changed in both",
200
+ "removed in local",
201
+ "removed in remote",
202
+ ]);
203
+ const sections = [];
204
+ let current;
205
+ for (const line of output.split("\n")) {
206
+ if (!line.trim())
207
+ continue;
208
+ if (!line.startsWith(" ") &&
209
+ !line.startsWith("+") &&
210
+ !line.startsWith("-") &&
211
+ !line.startsWith("@")) {
212
+ current = conflictHeaders.has(line)
213
+ ? { lines: [line], paths: new Set() }
214
+ : undefined;
215
+ if (current)
216
+ sections.push(current);
217
+ continue;
218
+ }
219
+ if (!current)
220
+ continue;
221
+ current.lines.push(line);
222
+ const path = /^ (?:base|our|their)\s+\d+\s+[0-9a-f]+\s+(.+)$/.exec(line)?.[1];
223
+ if (path)
224
+ current.paths.add(path);
225
+ }
226
+ return sections.flatMap((section) => [...section.paths].map((path) => ({
227
+ excerpt: indentedExcerpt(section.lines),
228
+ path,
229
+ })));
230
+ }
231
+ export async function mergeConflictContextForDiff(input) {
232
+ const mergeBase = (await input.exec(`git merge-base ${shellQuote(input.baseSha)} ${shellQuote(input.headSha)}`, { cwd: input.worktreePath })).trim();
233
+ const output = await input.exec(`git merge-tree ${shellQuote(mergeBase)} ${shellQuote(input.headSha)} ${shellQuote(input.baseSha)}`, { cwd: input.worktreePath });
234
+ const conflicts = parseMergeConflictSections(output);
235
+ if (!conflicts.length)
236
+ return "";
237
+ return [
238
+ "The PR currently has unresolved merge conflicts with the base branch.",
239
+ "Treat unresolved conflicts as review findings and request changes when they make the PR unsafe or impossible to merge.",
240
+ "Use suggestedLine when it is present; it is a valid right-side PR diff line for an inline finding.",
241
+ ...conflicts.map((conflict) => {
242
+ const suggestedLine = firstTargetLine(input.inlineCommentTargets, conflict.path);
243
+ const suggestedLineText = suggestedLine
244
+ ? `suggestedLine: ${suggestedLine}`
245
+ : "suggestedLine: (no right-side PR diff line found)";
246
+ return `<conflict_file>\npath: ${conflict.path}\n${suggestedLineText}\nrightSideDiffLines: ${targetLineSummary(input.inlineCommentTargets, conflict.path)}\nmergeTreeExcerpt:\n${conflict.excerpt}\n</conflict_file>`;
247
+ }),
248
+ ].join("\n");
249
+ }
166
250
  function parsePostedFindingLocation(location) {
167
251
  const range = /^(.*):(\d+)-(\d+)$/.exec(location);
168
252
  if (range) {
@@ -709,6 +793,13 @@ export async function runReview(input) {
709
793
  toSha: meta.headRefOid,
710
794
  worktreePath,
711
795
  });
796
+ const mergeConflictContext = await mergeConflictContextForDiff({
797
+ baseSha: meta.baseRefOid,
798
+ exec,
799
+ headSha: meta.headRefOid,
800
+ inlineCommentTargets: initialInlineCommentTargets,
801
+ worktreePath,
802
+ });
712
803
  for (const reviewer of input.repository.agents.reviewers) {
713
804
  const assignment = mode.assignments.get(reviewer.account);
714
805
  if (assignment?.type !== "skip")
@@ -740,6 +831,9 @@ export async function runReview(input) {
740
831
  toSha: meta.headRefOid,
741
832
  worktreePath,
742
833
  });
834
+ const rereviewInlineCommentTargets = mergeConflictContext
835
+ ? mergeInlineCommentTargets(inlineCommentTargets, initialInlineCommentTargets)
836
+ : inlineCommentTargets;
743
837
  const unresolved = unresolvedThreadsByAccount.get(reviewer.account) ??
744
838
  (await fetchUnresolvedThreads(exec, input.repository, input.pr, reviewer.account));
745
839
  const prompt = await composeRereviewPrompt({
@@ -747,6 +841,7 @@ export async function runReview(input) {
747
841
  ciFailureContext,
748
842
  directory: input.directory,
749
843
  headSha: meta.headRefOid,
844
+ mergeConflictContext,
750
845
  pr: input.pr,
751
846
  previousReview: previousReviewText(previous),
752
847
  previousHeadSha: previous.commit.oid,
@@ -787,7 +882,7 @@ export async function runReview(input) {
787
882
  },
788
883
  options: reviewer.options,
789
884
  parentSessionId: input.parentSessionId,
790
- parse: (text) => parseRereviewOutputWithInlineTargets(text, inlineCommentTargets),
885
+ parse: (text) => parseRereviewOutputWithInlineTargets(text, rereviewInlineCommentTargets),
791
886
  permission: reviewer.permission,
792
887
  prompt,
793
888
  repairAttempts: input.config.output?.repairAttempts ?? 3,
@@ -819,6 +914,7 @@ export async function runReview(input) {
819
914
  ciFailureContext,
820
915
  directory: input.directory,
821
916
  headSha: meta.headRefOid,
917
+ mergeConflictContext,
822
918
  pr: input.pr,
823
919
  repository: input.repository,
824
920
  reviewContext,
@@ -35,6 +35,7 @@ function repositoryValues(repository) {
35
35
  }
36
36
  function reviewValues(input) {
37
37
  const ciFailureContext = input.ciFailureContext?.trim() ?? "";
38
+ const mergeConflictContext = input.mergeConflictContext?.trim() ?? "";
38
39
  return {
39
40
  ...repositoryValues(input.repository),
40
41
  baseSha: input.baseSha,
@@ -44,6 +45,10 @@ function reviewValues(input) {
44
45
  : "",
45
46
  headSha: input.headSha,
46
47
  jsonEncodedWorktreePath: JSON.stringify(input.worktreePath),
48
+ mergeConflictContext,
49
+ mergeConflictContextBlock: mergeConflictContext
50
+ ? `<merge_conflict_context>\n${mergeConflictContext}\n</merge_conflict_context>`
51
+ : "",
47
52
  pr: String(input.pr),
48
53
  reviewContext: input.reviewContext ?? "",
49
54
  worktreePath: input.worktreePath,
@@ -67,6 +72,17 @@ function editValues(input) {
67
72
  worktreePath: input.worktreePath,
68
73
  };
69
74
  }
75
+ function mergeConflictValues(input) {
76
+ return {
77
+ ...repositoryValues(input.repository),
78
+ baseBranch: input.baseBranch,
79
+ baseSha: input.baseSha,
80
+ conflictedFiles: input.conflictedFiles,
81
+ headSha: input.headSha,
82
+ pr: String(input.pr),
83
+ worktreePath: input.worktreePath,
84
+ };
85
+ }
70
86
  function triageValues(input) {
71
87
  const categories = input.repository.triage?.categories ?? [];
72
88
  const categoryOptions = categories
@@ -97,6 +113,12 @@ function previousReviewBlock(previousReview) {
97
113
  function reviewContextBlock(reviewContext) {
98
114
  return reviewContext?.trim() ? reviewContext.trim() : "";
99
115
  }
116
+ function mergeConflictContextBlock(mergeConflictContext) {
117
+ const body = mergeConflictContext?.trim();
118
+ return body
119
+ ? `<merge_conflict_context>\n${body}\n</merge_conflict_context>`
120
+ : "";
121
+ }
100
122
  async function reviewGuidelinesBlock(input) {
101
123
  const body = (await readOptionalPrompt(input.directory, input.path, input.values)).trim();
102
124
  return body ? `<review_guidelines>\n${body}\n</review_guidelines>` : "";
@@ -136,6 +158,7 @@ export async function composeReviewPrompt(input) {
136
158
  return [
137
159
  task,
138
160
  reviewContextBlock(input.reviewContext),
161
+ mergeConflictContextBlock(input.mergeConflictContext),
139
162
  languageBlock(input.repository.language),
140
163
  personaBlock(input.reviewer.persona),
141
164
  await reviewGuidelinesBlock({
@@ -159,6 +182,7 @@ export async function composeRereviewPrompt(input) {
159
182
  return [
160
183
  task,
161
184
  reviewContextBlock(input.reviewContext),
185
+ mergeConflictContextBlock(input.mergeConflictContext),
162
186
  input.includeSessionContext === false
163
187
  ? ""
164
188
  : languageBlock(input.repository.language),
@@ -200,6 +224,28 @@ export async function composeEditPrompt(input) {
200
224
  .filter(Boolean)
201
225
  .join("\n\n");
202
226
  }
227
+ export async function composeMergeConflictPrompt(input) {
228
+ const values = mergeConflictValues(input);
229
+ const task = await taskBlock({
230
+ builtin: "merge/conflict",
231
+ directory: input.directory,
232
+ values,
233
+ });
234
+ const persona = input.repository.agents.editor?.persona;
235
+ return [
236
+ task,
237
+ languageBlock(input.repository.language),
238
+ personaBlock(persona),
239
+ await editGuidelinesBlock({
240
+ directory: input.directory,
241
+ path: input.repository.prompts.editGuidelines,
242
+ values,
243
+ }),
244
+ editOutputContract,
245
+ ]
246
+ .filter(Boolean)
247
+ .join("\n\n");
248
+ }
203
249
  export async function composeFindingValidationPrompt(input) {
204
250
  const values = { ...reviewValues(input), findings: input.findings };
205
251
  const task = await taskBlock({
@@ -0,0 +1,10 @@
1
+ Resolve merge conflicts for pull request #{pr} in {owner}/{repo}.
2
+ The PR worktree is {worktreePath}.
3
+
4
+ The latest base branch is {baseBranch} at {baseSha}.
5
+ The PR head before conflict recovery was {headSha}.
6
+
7
+ Conflicted files:
8
+ {conflictedFiles}
9
+
10
+ Resolve every merge conflict in the worktree. Preserve the intended PR behavior while incorporating the latest base branch changes. Stage all resolved files and create a commit. Do not push.
@@ -13,6 +13,8 @@ Every newFinding must target a valid right-side line in the PR diff.
13
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
14
  Do not omit line. Do not create file-level or body-only newFindings.
15
15
 
16
+ If `<merge_conflict_context>` is present, treat unresolved merge conflicts as review findings. Request changes when a conflict makes the PR unsafe or impossible to merge, and prefer the provided `suggestedLine` when it is present.
17
+
16
18
  {ciFailureContextBlock}
17
19
  Do not edit files or perform write operations.
18
20
 
@@ -14,4 +14,6 @@ Every finding must target a valid right-side line in the PR diff.
14
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
15
  Do not omit line. Do not create file-level or body-only findings.
16
16
 
17
+ If `<merge_conflict_context>` is present, treat unresolved merge conflicts as review findings. Request changes when a conflict makes the PR unsafe or impossible to merge, and prefer the provided `suggestedLine` when it is present.
18
+
17
19
  {ciFailureContextBlock}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260525030452",
3
+ "version": "0.0.0-dev-20260525064434",
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
@@ -149,6 +149,7 @@
149
149
  "modelConfig": {
150
150
  "oneOf": [
151
151
  { "type": "string", "minLength": 1 },
152
+ { "$ref": "#/$defs/modelCandidate" },
152
153
  {
153
154
  "type": "array",
154
155
  "minItems": 1,
@@ -178,6 +179,15 @@
178
179
  "close": { "type": "boolean" }
179
180
  }
180
181
  },
182
+ "mergeAutomation": {
183
+ "type": "object",
184
+ "additionalProperties": false,
185
+ "properties": {
186
+ "merge": { "type": "boolean", "default": true },
187
+ "close": { "type": "boolean", "default": false },
188
+ "conflict": { "type": "boolean", "default": false }
189
+ }
190
+ },
181
191
  "reviewChecks": {
182
192
  "type": "object",
183
193
  "additionalProperties": false,
@@ -338,7 +348,7 @@
338
348
  "editor": { "$ref": "#/$defs/editor" },
339
349
  "checks": { "$ref": "#/$defs/mergeChecks" },
340
350
  "prompts": { "$ref": "#/$defs/mergePrompts" },
341
- "automation": { "$ref": "#/$defs/automation" },
351
+ "automation": { "$ref": "#/$defs/mergeAutomation" },
342
352
  "maxThreadResolutionCycles": {
343
353
  "type": "integer",
344
354
  "minimum": 0,