opencode-magi 0.3.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.
@@ -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")
@@ -135,16 +135,64 @@ 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 undefined;
151
+ }
152
+ function reviewFindingsFromBody(body) {
153
+ const findings = [];
154
+ const lines = (body ?? "").split(/\r?\n/);
155
+ let section;
156
+ for (let index = 0; index < lines.length; index += 1) {
157
+ const line = lines[index];
158
+ if (line === "Inline findings:" || line === "File-level findings:") {
159
+ section = "finding";
160
+ continue;
161
+ }
162
+ if (line === "Requirement findings:") {
163
+ section = undefined;
164
+ continue;
165
+ }
166
+ if (section === "finding") {
167
+ const match = /^- (.*): (.+)$/.exec(line ?? "");
168
+ const fix = /^\s+Fix: (.+)$/.exec(lines[index + 1] ?? "");
169
+ if (!match || !fix)
170
+ continue;
171
+ const location = parsePostedFindingLocation(match[1] ?? "");
172
+ if (!location)
173
+ continue;
174
+ findings.push({
175
+ ...location,
176
+ fix: fix[1] ?? "Please address this before merging.",
177
+ issue: match[2] ?? "Review finding.",
178
+ });
179
+ index += 1;
180
+ continue;
181
+ }
182
+ }
183
+ return { findings };
184
+ }
185
+ export function reviewOutputFromState(review) {
139
186
  const verdict = reviewStateToVerdict(review.state);
187
+ if (verdict === "CHANGES_REQUESTED")
188
+ return { ...reviewFindingsFromBody(review.body), verdict };
140
189
  return verdict === "CLOSE"
141
190
  ? {
142
191
  findings: [],
143
192
  reason: review.body || "Close requested.",
144
- requirementFindings: [],
145
193
  verdict,
146
194
  }
147
- : { findings: [], requirementFindings: [], verdict };
195
+ : { findings: [], verdict };
148
196
  }
149
197
  export function hasPendingThreadReply(threads, reviewerAccount) {
150
198
  return threads.some((thread) => {
@@ -168,15 +216,15 @@ async function postRereviewOutput(input, reviewerKey, output) {
168
216
  return postApproval(input.exec, input.repository, input.pr, reviewer.account);
169
217
  if (output.verdict === "CLOSE")
170
218
  return postCloseComment(input.exec, input.repository, input.pr, reviewer.account, output.reason ?? "Close requested.");
171
- if (!output.newFindings.length && !output.requirementFindings.length)
219
+ if (!output.newFindings.length)
172
220
  return "";
173
221
  return postChangesRequested(input.exec, input.repository, input.pr, reviewer.account, output.newFindings.map((finding) => ({
174
222
  fix: "Please address this before merging.",
175
223
  issue: finding.body,
176
- line: finding.line,
177
224
  path: finding.path,
225
+ line: finding.line,
178
226
  startLine: finding.startLine,
179
- })), output.requirementFindings);
227
+ })));
180
228
  }
181
229
  function isReviewOutput(output) {
182
230
  return "findings" in output;
@@ -761,7 +809,14 @@ export async function runReview(input) {
761
809
  sessionIds,
762
810
  worktreePath,
763
811
  });
764
- const outputs = validation.outputs;
812
+ const activeOutputs = validation.outputs;
813
+ const skippedOutputs = Object.fromEntries(input.repository.agents.reviewers.flatMap((reviewer) => {
814
+ const assignment = mode.assignments.get(reviewer.account);
815
+ return assignment?.type === "skip"
816
+ ? [[reviewer.key, reviewOutputFromState(assignment.review)]]
817
+ : [];
818
+ }));
819
+ const outputs = { ...skippedOutputs, ...activeOutputs };
765
820
  const remainingSkippedVerdicts = input.repository.agents.reviewers.flatMap((reviewer) => {
766
821
  const assignment = mode.assignments.get(reviewer.account);
767
822
  if (assignment?.type !== "skip" || closeTargets.includes(reviewer.key))
@@ -773,7 +828,7 @@ export async function runReview(input) {
773
828
  },
774
829
  ];
775
830
  });
776
- const activeVerdicts = Object.entries(outputs).map(([reviewer, output]) => ({
831
+ const activeVerdicts = Object.entries(activeOutputs).map(([reviewer, output]) => ({
777
832
  reviewer,
778
833
  verdict: output.verdict,
779
834
  }));
@@ -786,7 +841,7 @@ export async function runReview(input) {
786
841
  ? [[reviewer.key, "skipped: already reviewed current head"]]
787
842
  : [];
788
843
  })),
789
- ...Object.fromEntries(await Promise.all(Object.entries(outputs).map(async ([key, output]) => [
844
+ ...Object.fromEntries(await Promise.all(Object.entries(activeOutputs).map(async ([key, output]) => [
790
845
  key,
791
846
  input.dryRun
792
847
  ? dryRunReviewPost(key, 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);
@@ -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
  };
@@ -15,25 +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 and startLine must refer to lines inside the PR diff hunk.
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.
35
28
  - Omit startLine for single-line findings.
36
- - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
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.
37
32
  </output_contract>`.trim();
38
33
  export const rereviewOutputContract = `
39
34
  <output_contract>
@@ -45,17 +40,19 @@ The object must match this shape:
45
40
  "resolve": [{ "commentId": 123, "threadId": "..." }],
46
41
  "followUps": [{ "commentId": 123, "body": "..." }],
47
42
  "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
48
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }],
49
43
  "reason": "Required only for CLOSE."
50
44
  }
51
45
 
52
46
  Rules:
53
- - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
54
- - CHANGES_REQUESTED requires at least one followUp, newFinding, or requirementFinding.
55
- - CLOSE requires a reason and empty followUps, newFindings, and requirementFindings arrays.
56
- - line and startLine must refer to lines inside the latest PR diff hunk.
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.
57
52
  - Omit startLine for single-line findings.
58
- - Use requirementFindings for missing closing-issue requirements that do not map cleanly to a diff line.
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.
59
56
  </output_contract>`.trim();
60
57
  export const findingValidationOutputContract = `
61
58
  <output_contract>
@@ -94,15 +91,16 @@ The object must match this shape:
94
91
  "issue": "What is wrong.",
95
92
  "fix": "How to fix it."
96
93
  }
97
- ],
98
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
94
+ ]
99
95
  }
100
96
 
101
97
  Rules:
102
- - MERGE requires empty findings and requirementFindings arrays.
103
- - CHANGES_REQUESTED requires at least one finding or requirementFinding.
98
+ - MERGE requires an empty findings array.
99
+ - CHANGES_REQUESTED requires at least one finding.
104
100
  - CLOSE is not allowed in this reconsideration step.
105
- - Omit startLine for single-line 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.
106
104
  </output_contract>`.trim();
107
105
  export const rereviewCloseReconsiderationOutputContract = `
108
106
  <output_contract>
@@ -113,15 +111,16 @@ The object must match this shape:
113
111
  "verdict": "MERGE" | "CHANGES_REQUESTED",
114
112
  "resolve": [{ "commentId": 123, "threadId": "..." }],
115
113
  "followUps": [{ "commentId": 123, "body": "..." }],
116
- "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }],
117
- "requirementFindings": [{ "issueNumber": 47, "requirement": "Missing requirement.", "evidence": "Why it is missing.", "fix": "How to fix it." }]
114
+ "newFindings": [{ "path": "relative/path.ext", "line": 123, "startLine": 120, "body": "..." }]
118
115
  }
119
116
 
120
117
  Rules:
121
- - MERGE requires empty followUps, newFindings, and requirementFindings arrays.
122
- - 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.
123
120
  - CLOSE is not allowed in this reconsideration step.
124
- - Omit startLine for single-line 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.
125
124
  </output_contract>`.trim();
126
125
  export const editOutputContract = `
127
126
  <output_contract>
@@ -71,16 +71,15 @@ function requireNumber(value, path) {
71
71
  throw new Error(`${path} must be an integer`);
72
72
  return value;
73
73
  }
74
- function parseRequirementFindings(value) {
75
- return (value == null ? [] : requireArray(value, "requirementFindings")).map((finding, index) => {
76
- const item = finding;
77
- return {
78
- evidence: requireString(item.evidence, `requirementFindings[${index}].evidence`),
79
- fix: requireString(item.fix, `requirementFindings[${index}].fix`),
80
- issueNumber: requireNumber(item.issueNumber, `requirementFindings[${index}].issueNumber`),
81
- requirement: requireString(item.requirement, `requirementFindings[${index}].requirement`),
82
- };
83
- });
74
+ function requireLine(value, path) {
75
+ if (value == null)
76
+ throw new Error(`${path} is required`);
77
+ return requireNumber(value, path);
78
+ }
79
+ function optionalStartLine(input) {
80
+ if (input.value == null)
81
+ return undefined;
82
+ return requireNumber(input.value, input.path);
84
83
  }
85
84
  function requireOneOf(value, path, values) {
86
85
  const text = requireString(value, path);
@@ -169,34 +168,34 @@ export function parseReviewOutput(text) {
169
168
  const data = extractJson(text);
170
169
  if (!data || typeof data !== "object")
171
170
  throw new Error("review output must be an object");
171
+ if (data.requirementFindings != null)
172
+ throw new Error("requirementFindings is not accepted");
172
173
  if (!isVerdict(data.verdict))
173
174
  throw new Error("verdict must be MERGE, CHANGES_REQUESTED, or CLOSE");
174
175
  const findings = requireArray(data.findings, "findings").map((finding, index) => {
175
176
  const item = finding;
177
+ const line = requireLine(item.line, `findings[${index}].line`);
176
178
  return {
177
179
  fix: requireString(item.fix, `findings[${index}].fix`),
178
180
  issue: requireString(item.issue, `findings[${index}].issue`),
179
- line: requireNumber(item.line, `findings[${index}].line`),
181
+ line,
180
182
  path: requireString(item.path, `findings[${index}].path`),
181
183
  perspective: item.perspective == null
182
184
  ? undefined
183
185
  : requireString(item.perspective, `findings[${index}].perspective`),
184
- startLine: item.startLine == null
185
- ? undefined
186
- : requireNumber(item.startLine, `findings[${index}].startLine`),
186
+ startLine: optionalStartLine({
187
+ line,
188
+ path: `findings[${index}].startLine`,
189
+ value: item.startLine,
190
+ }),
187
191
  };
188
192
  });
189
- const requirementFindings = parseRequirementFindings(data.requirementFindings);
190
- if (data.verdict === "MERGE" &&
191
- (findings.length || requirementFindings.length))
192
- throw new Error("MERGE requires no findings or requirementFindings");
193
- if (data.verdict === "CHANGES_REQUESTED" &&
194
- !findings.length &&
195
- !requirementFindings.length)
196
- throw new Error("CHANGES_REQUESTED requires findings or requirementFindings");
197
- if (data.verdict === "CLOSE" &&
198
- (findings.length || requirementFindings.length))
199
- 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");
200
199
  const reason = typeof data.reason === "string" && data.reason.trim()
201
200
  ? data.reason
202
201
  : undefined;
@@ -205,7 +204,6 @@ export function parseReviewOutput(text) {
205
204
  return {
206
205
  findings,
207
206
  reason,
208
- requirementFindings,
209
207
  verdict: data.verdict,
210
208
  };
211
209
  }
@@ -213,6 +211,8 @@ export function parseRereviewOutput(text) {
213
211
  const data = extractJson(text);
214
212
  if (!isVerdict(data.verdict))
215
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");
216
216
  const resolve = requireArray(data.resolve, "resolve").map((item, index) => {
217
217
  const value = item;
218
218
  return {
@@ -229,38 +229,36 @@ export function parseRereviewOutput(text) {
229
229
  });
230
230
  const newFindings = requireArray(data.newFindings, "newFindings").map((item, index) => {
231
231
  const value = item;
232
+ const line = requireLine(value.line, `newFindings[${index}].line`);
232
233
  return {
233
234
  body: requireString(value.body, `newFindings[${index}].body`),
234
- line: requireNumber(value.line, `newFindings[${index}].line`),
235
+ line,
235
236
  path: requireString(value.path, `newFindings[${index}].path`),
236
- startLine: value.startLine == null
237
- ? undefined
238
- : requireNumber(value.startLine, `newFindings[${index}].startLine`),
237
+ startLine: optionalStartLine({
238
+ line,
239
+ path: `newFindings[${index}].startLine`,
240
+ value: value.startLine,
241
+ }),
239
242
  };
240
243
  });
241
- const requirementFindings = parseRequirementFindings(data.requirementFindings);
242
- if (data.verdict === "MERGE" &&
243
- (followUps.length || newFindings.length || requirementFindings.length)) {
244
- 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");
245
246
  }
246
- if (data.verdict === "CLOSE" &&
247
- (followUps.length || newFindings.length || requirementFindings.length)) {
248
- 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");
249
249
  }
250
250
  if (data.verdict === "CLOSE" && !data.reason) {
251
251
  throw new Error("CLOSE requires reason");
252
252
  }
253
253
  if (data.verdict === "CHANGES_REQUESTED" &&
254
254
  !followUps.length &&
255
- !newFindings.length &&
256
- !requirementFindings.length) {
257
- throw new Error("CHANGES_REQUESTED requires followUps, newFindings, or requirementFindings");
255
+ !newFindings.length) {
256
+ throw new Error("CHANGES_REQUESTED requires followUps or newFindings");
258
257
  }
259
258
  return {
260
259
  followUps,
261
260
  newFindings,
262
261
  reason: data.reason == null ? undefined : requireString(data.reason, "reason"),
263
- requirementFindings,
264
262
  resolve,
265
263
  verdict: data.verdict,
266
264
  };
@@ -349,7 +347,7 @@ function parseEditOutputWithOptions(text, options) {
349
347
  commentId: requireNumber(value.commentId, `responses[${index}].commentId`),
350
348
  };
351
349
  });
352
- if (options.requireResponses && !responses.length)
350
+ if (options.requireResponses && data.mode === "REPLIED" && !responses.length)
353
351
  throw new Error("responses must not be empty");
354
352
  if (data.mode === "EDITED") {
355
353
  if (!filesTouched.length)
@@ -1,9 +1,15 @@
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. Each finding targets a PR diff line and should have a corresponding GitHub review thread unless it comes from legacy read-side state.
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.
9
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.