next-issue 0.12.5 → 0.14.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
@@ -18,13 +18,12 @@ next-issue
18
18
 
19
19
  For each open issue, oldest first:
20
20
 
21
- 1. Claim the issue only when it holds the `status:todo` label, holds no
22
- `status:in-progress` or `status:in-review` label, and has no assignee. A
23
- `status:done`, `status:needs-human` or `status:blocked` label always stops
24
- the claim, a resume too. An issue with saved state under `.next-issue/`
25
- resumes instead, whatever its other labels, unless another person is now the
26
- assignee.
27
- 2. Assign the issue to you and add `status:in-progress`.
21
+ 1. Claim the issue only when it holds the `status:todo` label and holds no
22
+ `status:in-progress` or `status:in-review` label. A `status:done`,
23
+ `status:needs-human` or `status:blocked` label always stops the claim, a
24
+ resume too. An issue with saved state under `.next-issue/` resumes instead,
25
+ whatever its other labels.
26
+ 2. Add `status:in-progress` to the issue.
28
27
  3. Fetch the remote, fast-forward the local copy of the default branch when
29
28
  that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
30
29
  `issue-<n>` branch. A branch that exists on the remote is the start, and a
@@ -61,7 +60,12 @@ For each open issue, oldest first:
61
60
  reviewer leaves out of the findings thus gets an approval: only the findings
62
61
  count.
63
62
  11. With an open finding, run the fixer agent and go to step 6 again. The
64
- budget is `maxReviewRounds`.
63
+ budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
64
+ changes nothing and gives its reason instead. The reason goes on the pull
65
+ request and into the next round, where it answers every finding of its own
66
+ round, so the reviewer withdraws them all or repeats them all. With only
67
+ some findings wrong, the fixer fixes the correct ones, and the next round
68
+ holds the rest.
65
69
 
66
70
  ## Stops for a loop
67
71
 
@@ -69,20 +73,35 @@ The harness hands the issue to a person, with the `status:needs-human` label,
69
73
  when:
70
74
 
71
75
  - the check budget or the review budget runs out;
72
- - a fixer round adds no commit, so there is no progress;
76
+ - a fixer round adds no commit and gives no reason, so there is no progress;
73
77
  - the fixer reports that the checks fail for a reason the change did not cause,
74
78
  such as a test already broken on the base branch or one that fails only under
75
79
  load. It changes nothing and the reason reaches the pull request, rather than
76
80
  the fixer spending its whole budget on a failure it cannot fix;
81
+ - a fixer disputes the findings and leaves a change behind, either in the
82
+ worktree or in a commit of its own. A dispute carries a reason, not a diff,
83
+ so the harness throws none of it away: an unclean worktree stays on the
84
+ disk, and a commit of the fixer stays on the local branch. Neither is
85
+ pushed, so the pull request shows no such change. It holds the reason of the
86
+ dispute, and the change is on the machine that ran the harness;
77
87
  - the reviewer repeats a finding set from an earlier round, which shows a
78
- ping-pong between the fixer and the reviewer;
88
+ ping-pong between the fixer and the reviewer, or a finding that the fixer
89
+ disputed and the reviewer holds to. The person then decides;
79
90
  - the reviewer gives no verdict, or the checks do not finish in time;
80
91
  - an agent does not finish in `agentTimeoutMinutes`;
81
92
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
82
93
  - a step of the run fails with an error.
83
94
 
84
- The fixer sees the findings of all earlier rounds, not only the last one. From
85
- round two, the reviewer judges only the earlier findings and any regression.
95
+ The fixer sees the findings of all earlier rounds, not only the last one, with
96
+ its own disputes next to them. From round two, the reviewer judges only the
97
+ earlier findings and any regression.
98
+
99
+ Each agent that writes to the pull request names itself in the first line of the
100
+ comment: `### Review: approved`, `### Review: changes requested` and
101
+ `### Fixer: no change`. The account is the same for all of them, so the heading,
102
+ not the author, tells them apart. The agents read the comments of the issue, not
103
+ of the pull request, so a comment of an agent never returns to a prompt: the
104
+ dispute of the fixer reaches the reviewer through the state file.
86
105
 
87
106
  ## Stop after the current issue
88
107
 
@@ -96,10 +115,11 @@ and to every child of it, so the run dies in the middle of a step. The saved
96
115
  state lets the next run continue the issue, but the issue keeps
97
116
  `status:in-progress` and the run writes no summary.
98
117
 
99
- Every status label that the harness puts on the issue goes on the pull request
100
- too, from the first review round. Thus a draft pull request shows work that the
101
- harness has not finished, and a `status:needs-human` pull request shows work
102
- that waits for a person.
118
+ A status label that the harness puts on the issue goes on the pull request too,
119
+ but only if the pull request exists at that moment. The `status:in-progress`
120
+ label of a fresh issue comes before the pull request, and stays on the issue
121
+ alone. Thus a draft pull request shows work that the harness has not finished,
122
+ and a `status:needs-human` pull request shows work that waits for a person.
103
123
 
104
124
  The harness runs all git and `gh` commands itself. The commit holds all the
105
125
  changes in the worktree, so a setup command that writes a file outside
@@ -188,8 +208,8 @@ does not know all stop the run, so a typo cannot pass without a word.
188
208
  {
189
209
  "remote": "origin",
190
210
  "issueLimit": 100,
191
- "maxCiFixes": 3,
192
- "maxReviewRounds": 3,
211
+ "maxCiFixes": 5,
212
+ "maxReviewRounds": 5,
193
213
  "checkIntervalSeconds": 15,
194
214
  "checkGraceSeconds": 60,
195
215
  "checkTimeoutMinutes": 60,
@@ -220,8 +240,8 @@ does not know all stop the run, so a typo cannot pass without a word.
220
240
  | --- | --- | --- |
221
241
  | `remote` | `origin` | The git remote for the repository and the branches |
222
242
  | `issueLimit` | `100` | The maximum number of open issues to read |
223
- | `maxCiFixes` | `3` | The budget for check fixes per issue |
224
- | `maxReviewRounds` | `3` | The budget for review rounds per issue |
243
+ | `maxCiFixes` | `5` | The budget for check fixes per issue |
244
+ | `maxReviewRounds` | `5` | The budget for review rounds per issue |
225
245
  | `checkIntervalSeconds` | `15` | The time between two check states |
226
246
  | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
227
247
  | `checkTimeoutMinutes` | `60` | The limit for one check wait |
@@ -237,10 +257,10 @@ does not know all stop the run, so a typo cannot pass without a word.
237
257
  | `labels` | see above | The names of the labels that the harness reads and sets |
238
258
 
239
259
  An empty `labels.ready` turns the ready requirement off. The harness then
240
- claims every open issue that no other label and no assignee holds back.
260
+ claims every open issue that no other label holds back.
241
261
 
242
- The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
243
- `in-flight` or `assigned`.
262
+ The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
263
+ `in-flight`.
244
264
 
245
265
  A role set to `null`, or with no entry in `models`, uses the default model of
246
266
  the SDK.
package/dist/cli.mjs CHANGED
@@ -5,7 +5,7 @@ import { parseArgs, USAGE } from "./args.mjs";
5
5
  import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
6
  import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
7
  import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
8
- import { currentLogin, defaultBranch, openIssues } from "./github.mjs";
8
+ import { defaultBranch, openIssues } from "./github.mjs";
9
9
  import { exitCode, runIssues } from "./loop.mjs";
10
10
  import { message, pruneRuns, Recorder } from "./observe.mjs";
11
11
  import { PORTS, processIssue } from "./pipeline.mjs";
@@ -60,13 +60,12 @@ async function init(root, force) {
60
60
  return 0;
61
61
  }
62
62
  async function handle(args, config, repo, recorder) {
63
- const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
64
- const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
63
+ const base = await defaultBranch(repo);
64
+ const context = { repo, config, base, reset: args.reset, recorder, ports: PORTS };
65
65
  recorder.event("run.start", {
66
66
  version: await packageVersion(),
67
67
  repo: `${repo.owner}/${repo.name}`,
68
68
  base,
69
- login,
70
69
  log: recorder.logFile,
71
70
  }, "quiet");
72
71
  const issues = await openIssues(repo, config.issueLimit);
package/dist/config.mjs CHANGED
@@ -4,8 +4,8 @@ import { message } from "./observe.mjs";
4
4
  const DEFAULTS = {
5
5
  remote: "origin",
6
6
  issueLimit: 100,
7
- maxCiFixes: 3,
8
- maxReviewRounds: 3,
7
+ maxCiFixes: 5,
8
+ maxReviewRounds: 5,
9
9
  checkIntervalSeconds: 15,
10
10
  checkGraceSeconds: 60,
11
11
  checkTimeoutMinutes: 60,
@@ -83,7 +83,7 @@ export async function loadConfig(root) {
83
83
  }
84
84
  return parseConfig(parsed);
85
85
  }
86
- export function parseConfig(value) {
86
+ function parseConfig(value) {
87
87
  const file = record(value, "the file");
88
88
  for (const key of Object.keys(file)) {
89
89
  if (!FIELDS.includes(key)) {
package/dist/github.mjs CHANGED
@@ -15,9 +15,6 @@ export async function defaultBranch(repo) {
15
15
  });
16
16
  return JSON.parse(raw).defaultBranchRef.name;
17
17
  }
18
- export async function currentLogin(repo) {
19
- return must("gh", ["api", "user", "--jq", ".login"], { cwd: repo.root });
20
- }
21
18
  export async function openIssues(repo, limit) {
22
19
  const raw = await gh(repo, [
23
20
  "issue",
@@ -29,7 +26,7 @@ export async function openIssues(repo, limit) {
29
26
  "--limit",
30
27
  String(limit),
31
28
  "--json",
32
- "number,title,body,createdAt,labels,assignees",
29
+ "number,title,body,createdAt,labels",
33
30
  ]);
34
31
  const parsed = JSON.parse(raw);
35
32
  return parsed
@@ -39,7 +36,6 @@ export async function openIssues(repo, limit) {
39
36
  body: item.body ?? "",
40
37
  createdAt: item.createdAt,
41
38
  labels: item.labels.map((label) => label.name),
42
- assignees: item.assignees.map((assignee) => assignee.login),
43
39
  }))
44
40
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
45
41
  }
@@ -48,10 +44,7 @@ export async function issueComments(repo, issue) {
48
44
  const parsed = JSON.parse(raw);
49
45
  return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
50
46
  }
51
- export async function assignIssue(repo, issue, login) {
52
- await gh(repo, ["issue", "edit", String(issue), "--add-assignee", login]);
53
- }
54
- export async function labelsOf(repo, kind, number) {
47
+ async function labelsOf(repo, kind, number) {
55
48
  const raw = await gh(repo, [kind, "view", String(number), "--json", "labels"]);
56
49
  const parsed = JSON.parse(raw);
57
50
  return parsed.labels.map((label) => label.name);
@@ -68,7 +61,7 @@ export async function setLabel(repo, kind, number, add, remove) {
68
61
  const current = await labelsOf(repo, kind, number);
69
62
  await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
70
63
  }
71
- export async function ensureLabel(repo, label) {
64
+ async function ensureLabel(repo, label) {
72
65
  let known = KNOWN_LABELS.get(slug(repo));
73
66
  if (known === undefined) {
74
67
  const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
package/dist/pipeline.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { CODING, REVIEWING, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
- import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
- import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, isDirty, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
+ import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
@@ -10,7 +10,6 @@ import { dropState, readState, resetState, writeState, } from "./state.mjs";
10
10
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
11
11
  export const PORTS = {
12
12
  addWorktree,
13
- assignIssue,
14
13
  commentOnPr,
15
14
  commitAll,
16
15
  createPr,
@@ -21,6 +20,7 @@ export const PORTS = {
21
20
  failedCheckLogs,
22
21
  findPr,
23
22
  hasWorktree,
23
+ isDirty,
24
24
  issueComments,
25
25
  markPrReady,
26
26
  push,
@@ -31,14 +31,14 @@ export const PORTS = {
31
31
  setLabel,
32
32
  waitForChecks,
33
33
  };
34
- export function skipReason(issue, config, login, resuming) {
34
+ export function skipReason(issue, config, resuming) {
35
35
  const labels = new Set(issue.labels);
36
36
  const names = config.labels;
37
37
  if (labels.has(names.done) || labels.has(names.needsHuman) || labels.has(names.skip)) {
38
38
  return "stop-label";
39
39
  }
40
40
  if (resuming) {
41
- return issue.assignees.every((name) => name === login) ? undefined : "assigned";
41
+ return undefined;
42
42
  }
43
43
  if (names.ready.length > 0 && !labels.has(names.ready)) {
44
44
  return "not-ready";
@@ -46,13 +46,13 @@ export function skipReason(issue, config, login, resuming) {
46
46
  if (labels.has(names.inProgress) || labels.has(names.inReview)) {
47
47
  return "in-flight";
48
48
  }
49
- return issue.assignees.length === 0 ? undefined : "assigned";
49
+ return undefined;
50
50
  }
51
51
  export async function processIssue(context, issue) {
52
52
  const started = Date.now();
53
53
  const log = context.recorder.scope({ issue: issue.number });
54
54
  const saved = await readState(context.repo, issue.number);
55
- const skip = skipReason(issue, context.config, context.login, saved !== undefined);
55
+ const skip = skipReason(issue, context.config, saved !== undefined);
56
56
  if (skip !== undefined) {
57
57
  log.event("issue.skip", { title: issue.title, reason: skip });
58
58
  return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
@@ -97,7 +97,6 @@ async function work(context, issue, log, state) {
97
97
  const { repo, config, ports } = context;
98
98
  const branch = state.branch;
99
99
  await log.step("claim", { label: config.labels.inProgress }, async () => {
100
- await ports.assignIssue(repo, issue.number, context.login);
101
100
  await setStatus(context, issue.number, state.pr, config.labels.inProgress);
102
101
  await writeState(repo, state);
103
102
  });
@@ -107,25 +106,16 @@ async function work(context, issue, log, state) {
107
106
  if (!added.baseUpdated) {
108
107
  log.event("base.behind", { base: context.base }, "quiet");
109
108
  }
110
- const job = { context, issue, log, state, branch, worktree, comments };
111
- const escalate = (reason) => handOver(context, log, issue.number, state, reason);
112
- const finish = async (outcome, reason) => {
113
- await cleanUp(context, log, issue.number);
114
- if (outcome === "done") {
115
- if (!(await ports.deleteBranch(repo, branch))) {
116
- log.event("branch.kept", { branch }, "quiet");
117
- }
118
- await dropState(repo, issue.number);
119
- }
120
- return {
121
- issue: issue.number,
122
- outcome,
123
- reason,
124
- pr: state.pr,
125
- ciFixes: state.ciFixes,
126
- reviewRounds: state.reviewRounds,
127
- ms: Date.now() - started,
128
- };
109
+ const job = {
110
+ context,
111
+ issue,
112
+ log,
113
+ state,
114
+ branch,
115
+ worktree,
116
+ comments,
117
+ started,
118
+ noChecks: false,
129
119
  };
130
120
  const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
131
121
  if (setupCommand !== undefined) {
@@ -133,10 +123,10 @@ async function work(context, issue, log, state) {
133
123
  if (setup.code !== 0) {
134
124
  const late = setup.timedOut === true;
135
125
  log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
136
- await escalate(late
126
+ await escalate(job, late
137
127
  ? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
138
128
  : "The setup command failed in the worktree.");
139
- return finish("needs-human", late ? "setup timeout" : "setup failed");
129
+ return finish(job, "needs-human", late ? "setup timeout" : "setup failed");
140
130
  }
141
131
  }
142
132
  if (state.phase === "claimed") {
@@ -151,138 +141,183 @@ async function work(context, issue, log, state) {
151
141
  }));
152
142
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
153
143
  if (!(await commitWork(job, before, subject))) {
154
- await escalate("The implementer produced no change.");
155
- return finish("needs-human", "no implementation commit");
144
+ await escalate(job, "The implementer produced no change.");
145
+ return finish(job, "needs-human", "no implementation commit");
156
146
  }
157
147
  state.phase = "implemented";
158
148
  await writeState(repo, state);
159
149
  }
160
150
  await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
161
- state.pr = await log.step("pull-request", { branch }, async () => {
151
+ const pr = await log.step("pull-request", { branch }, async () => {
162
152
  const existing = await ports.findPr(repo, branch);
163
153
  return (existing ??
164
154
  ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
165
155
  });
156
+ state.pr = pr;
166
157
  state.phase = "review";
167
158
  await writeState(repo, state);
168
- log.event("pull-request.ready", { pr: state.pr }, "quiet");
169
- let noChecks = false;
170
- const gate = async () => {
171
- const head = await ports.revision(worktree);
172
- const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
173
- head,
174
- intervalSeconds: config.checkIntervalSeconds,
175
- graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
176
- timeoutMs: config.checkTimeoutMinutes * 60_000,
177
- show: log.level === "verbose",
178
- }));
179
- log.event("checks.state", { state: checks });
180
- noChecks ||= checks === "none";
181
- if (checks === "timeout") {
182
- await escalate("The checks did not finish in time.");
183
- return { done: true, report: await finish("needs-human", "checks timeout") };
184
- }
185
- if (checks !== "fail") {
186
- return { done: false, fixed: false };
187
- }
188
- if (state.ciFixes >= config.maxCiFixes) {
189
- await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
190
- return { done: true, report: await finish("needs-human", "check budget") };
191
- }
192
- state.ciFixes += 1;
193
- await writeState(repo, state);
194
- const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
195
- log.event("checks.logs", { chars: logs.length });
196
- const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
197
- if (!fix.committed) {
198
- const note = fix.unrelated;
199
- await escalate(note === undefined
200
- ? "The fixer added no commit for the failed checks."
201
- : `The checks fail for a reason this change did not cause: ${note}`);
202
- return {
203
- done: true,
204
- report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
205
- };
206
- }
207
- return { done: false, fixed: true };
208
- };
159
+ log.event("pull-request.ready", { pr }, "quiet");
209
160
  for (;;) {
210
- const before = await gate();
211
- if (before.done) {
212
- return before.report;
161
+ const gate = await checkGate(job);
162
+ if (gate.done) {
163
+ return gate.report;
213
164
  }
214
- if (before.fixed) {
165
+ if (gate.fixed) {
215
166
  continue;
216
167
  }
217
- if (state.reviewRounds >= config.maxReviewRounds) {
218
- await escalate(`The limit of ${config.maxReviewRounds} review rounds was reached.`);
219
- return finish("needs-human", "review budget");
168
+ const round = await reviewRound(job);
169
+ if (round.done) {
170
+ return round.report;
220
171
  }
221
- state.reviewRounds += 1;
222
- await writeState(repo, state);
223
- await setStatus(context, issue.number, state.pr, config.labels.inReview);
224
- const full = await ports.diff(worktree, context.base, config.remote);
225
- const patch = capDiff(full, config.diffMaxChars);
226
- const fields = {
227
- round: state.reviewRounds,
228
- diffChars: full.length,
229
- cut: full.length > config.diffMaxChars,
172
+ }
173
+ }
174
+ async function checkGate(job) {
175
+ const { context, log, state, branch, worktree } = job;
176
+ const { repo, config, ports } = context;
177
+ const head = await ports.revision(worktree);
178
+ const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
179
+ head,
180
+ intervalSeconds: config.checkIntervalSeconds,
181
+ graceMs: job.noChecks ? 0 : config.checkGraceSeconds * 1000,
182
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
183
+ show: log.level === "verbose",
184
+ }));
185
+ log.event("checks.state", { state: checks });
186
+ job.noChecks ||= checks === "none";
187
+ if (checks === "timeout") {
188
+ await escalate(job, "The checks did not finish in time.");
189
+ return { done: true, report: await finish(job, "needs-human", "checks timeout") };
190
+ }
191
+ if (checks !== "fail") {
192
+ return { done: false, fixed: false };
193
+ }
194
+ if (state.ciFixes >= config.maxCiFixes) {
195
+ await escalate(job, `The limit of ${config.maxCiFixes} check fixes was reached.`);
196
+ return { done: true, report: await finish(job, "needs-human", "check budget") };
197
+ }
198
+ state.ciFixes += 1;
199
+ await writeState(repo, state);
200
+ const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
201
+ log.event("checks.logs", { chars: logs.length });
202
+ const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
203
+ if (!fix.committed) {
204
+ const note = fix.unrelated;
205
+ await escalate(job, note === undefined
206
+ ? "The fixer added no commit for the failed checks."
207
+ : `The checks fail for a reason this change did not cause: ${note}`);
208
+ return {
209
+ done: true,
210
+ report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
230
211
  };
231
- const review = await log.step("review", fields, () => ports.runAgent(log, {
232
- name: "reviewer",
233
- cwd: worktree,
234
- prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
235
- profile: REVIEWING,
236
- model: config.models.reviewer,
237
- timeoutMs: config.agentTimeoutMinutes * 60_000,
238
- outputSchema: VERDICT_SCHEMA,
239
- }));
240
- if (await ports.discardChanges(worktree)) {
241
- log.event("review.discard", { round: state.reviewRounds }, "quiet");
212
+ }
213
+ return { done: false, fixed: true };
214
+ }
215
+ async function reviewRound(job) {
216
+ const { context, issue, log, state, worktree, comments } = job;
217
+ const { repo, config, ports } = context;
218
+ const pr = state.pr;
219
+ if (state.reviewRounds >= config.maxReviewRounds) {
220
+ await escalate(job, `The limit of ${config.maxReviewRounds} review rounds was reached.`);
221
+ return { done: true, report: await finish(job, "needs-human", "review budget") };
222
+ }
223
+ state.reviewRounds += 1;
224
+ await writeState(repo, state);
225
+ await setStatus(context, issue.number, pr, config.labels.inReview);
226
+ const full = await ports.diff(worktree, context.base, config.remote);
227
+ const patch = capDiff(full, config.diffMaxChars);
228
+ const fields = {
229
+ round: state.reviewRounds,
230
+ diffChars: full.length,
231
+ cut: full.length > config.diffMaxChars,
232
+ };
233
+ const review = await log.step("review", fields, () => ports.runAgent(log, {
234
+ name: "reviewer",
235
+ cwd: worktree,
236
+ prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
237
+ profile: REVIEWING,
238
+ model: config.models.reviewer,
239
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
240
+ outputSchema: VERDICT_SCHEMA,
241
+ }));
242
+ if (await ports.discardChanges(worktree)) {
243
+ log.event("review.discard", { round: state.reviewRounds }, "quiet");
244
+ }
245
+ const verdict = readVerdict(review.structured);
246
+ if (verdict === undefined) {
247
+ await escalate(job, "The reviewer gave no verdict.");
248
+ return { done: true, report: await finish(job, "needs-human", "no verdict") };
249
+ }
250
+ const blocking = blockingFindings(verdict);
251
+ const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
252
+ log.event("verdict", {
253
+ round: state.reviewRounds,
254
+ blocking: blocking.length,
255
+ minor: verdict.findings.length - blocking.length,
256
+ open: open.length,
257
+ summary: verdict.summary,
258
+ }, "quiet");
259
+ await ports.commentOnPr(repo, pr, formatVerdict(verdict, open));
260
+ if (open.length === 0) {
261
+ const gate = await checkGate(job);
262
+ if (gate.done) {
263
+ return { done: true, report: gate.report };
264
+ }
265
+ if (gate.fixed) {
266
+ return { done: false };
242
267
  }
243
- const verdict = readVerdict(review.structured);
244
- if (verdict === undefined) {
245
- await escalate("The reviewer gave no verdict.");
246
- return finish("needs-human", "no verdict");
268
+ if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
269
+ log.event("pull-request.undraft.fail", { pr }, "quiet");
247
270
  }
248
- const blocking = blockingFindings(verdict);
249
- const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
250
- log.event("verdict", {
251
- round: state.reviewRounds,
252
- blocking: blocking.length,
253
- minor: verdict.findings.length - blocking.length,
254
- open: open.length,
255
- summary: verdict.summary,
256
- }, "quiet");
257
- await ports.commentOnPr(repo, state.pr, formatVerdict(verdict, open));
258
- if (open.length === 0) {
259
- const after = await gate();
260
- if (after.done) {
261
- return after.report;
262
- }
263
- if (after.fixed) {
264
- continue;
265
- }
266
- if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
267
- log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
268
- }
269
- await setStatus(context, issue.number, state.pr, config.labels.done);
270
- return finish("done");
271
+ await setStatus(context, issue.number, pr, config.labels.done);
272
+ return { done: true, report: await finish(job, "done") };
273
+ }
274
+ const mark = fingerprint(open);
275
+ if (state.reviewLog.some((round) => round.fingerprint === mark)) {
276
+ await escalate(job, "The reviewer repeated findings that an earlier round did not fix.");
277
+ return { done: true, report: await finish(job, "needs-human", "repeated findings") };
278
+ }
279
+ const earlier = history(state.reviewLog);
280
+ state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
281
+ await writeState(repo, state);
282
+ const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
283
+ if (!fix.committed) {
284
+ const note = fix.unrelated;
285
+ if (note === undefined) {
286
+ await escalate(job, "The fixer added no commit for the review findings.");
287
+ return { done: true, report: await finish(job, "needs-human", "no fix commit") };
271
288
  }
272
- const mark = fingerprint(open);
273
- if (state.reviewLog.some((round) => round.fingerprint === mark)) {
274
- await escalate("The reviewer repeated findings that an earlier round did not fix.");
275
- return finish("needs-human", "repeated findings");
289
+ if (fix.stray === true) {
290
+ await escalate(job, `The fixer disputed the findings and left a change behind: ${note}`);
291
+ return { done: true, report: await finish(job, "needs-human", "dispute with changes") };
276
292
  }
277
- const earlier = history(state.reviewLog);
278
- state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
293
+ state.reviewLog.at(-1).dispute = note;
279
294
  await writeState(repo, state);
280
- const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
281
- if (!fix.committed) {
282
- await escalate("The fixer added no commit for the review findings.");
283
- return finish("needs-human", "no fix commit");
295
+ await ports.commentOnPr(repo, pr, `### Fixer: no change\n\n${note}`);
296
+ }
297
+ return { done: false };
298
+ }
299
+ function escalate(job, reason) {
300
+ return handOver(job.context, job.log, job.issue.number, job.state, reason);
301
+ }
302
+ async function finish(job, outcome, reason) {
303
+ const { context, issue, log, state, branch } = job;
304
+ const { repo, ports } = context;
305
+ await cleanUp(context, log, issue.number);
306
+ if (outcome === "done") {
307
+ if (!(await ports.deleteBranch(repo, branch))) {
308
+ log.event("branch.kept", { branch }, "quiet");
284
309
  }
310
+ await dropState(repo, issue.number);
285
311
  }
312
+ return {
313
+ issue: issue.number,
314
+ outcome,
315
+ reason,
316
+ pr: state.pr,
317
+ ciFixes: state.ciFixes,
318
+ reviewRounds: state.reviewRounds,
319
+ ms: Date.now() - job.started,
320
+ };
286
321
  }
287
322
  async function fixRound(job, reason, detail, earlier, kind) {
288
323
  const { context, issue, log, worktree, branch } = job;
@@ -298,8 +333,9 @@ async function fixRound(job, reason, detail, earlier, kind) {
298
333
  }));
299
334
  const unrelated = parseUnrelated(result.text);
300
335
  if (unrelated !== undefined) {
301
- log.event("fix.unrelated", { reason, note: unrelated }, "quiet");
302
- return { committed: false, unrelated };
336
+ const stray = (await ports.revision(worktree)) !== before || (await ports.isDirty(worktree));
337
+ log.event("fix.unrelated", { reason, note: unrelated, stray }, "quiet");
338
+ return { committed: false, unrelated, stray };
303
339
  }
304
340
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
305
341
  if (!(await commitWork(job, before, subject))) {
@@ -346,5 +382,10 @@ async function setStatus(context, issue, pr, label) {
346
382
  }
347
383
  }
348
384
  function history(rounds) {
349
- return rounds.map((round) => `Round ${round.round}:\n${round.findings}`);
385
+ return rounds.map((round) => {
386
+ const head = `Round ${round.round}:\n${round.findings}`;
387
+ return round.dispute === undefined
388
+ ? head
389
+ : `${head}\nThe fixer disputed every finding of this round: ${round.dispute}`;
390
+ });
350
391
  }
package/dist/prompts.mjs CHANGED
@@ -33,6 +33,8 @@ export function reviewPrompt(issue, comments, diff, earlier) {
33
33
  "This is a later round. Judge two things only:",
34
34
  "whether the earlier findings below are now fixed, and whether the new commits broke something.",
35
35
  "Do not raise a new point of taste. Do not repeat a finding that is fixed.",
36
+ "A `The fixer disputed every finding of this round` line answers all of the findings of that round, not one of them.",
37
+ "Withdraw every finding of that round if the dispute is correct. Repeat them all if the dispute is wrong.",
36
38
  `## Earlier findings\n${earlier.join("\n")}`,
37
39
  ].join("\n");
38
40
  return [
@@ -56,6 +58,12 @@ const UNRELATED_RULE = [
56
58
  "Establish that before you fix anything. If the failure is not yours, change nothing and end your final message with one line in the form `unrelated: <one sentence on what fails and why it is not this change>`.",
57
59
  "A person then reads it. Do not weaken, skip or re-record a test to make the checks pass.",
58
60
  ].join("\n");
61
+ const DISPUTE_RULE = [
62
+ "A finding can be wrong: it can miss a convention of this repository, ask for work that the issue does not want, or describe behaviour that the code does not have.",
63
+ "Establish that before you change anything. If every finding is wrong, change nothing and end your final message with one line in the form `unrelated: <one sentence on why the findings are wrong>`.",
64
+ "If only some of the findings are wrong, fix the correct ones and change nothing for the wrong ones. Write no `unrelated` line in that round. The next round holds only the findings that are left, and you can dispute them there.",
65
+ "The reviewer reads that line in the next round. Do not make a change that you hold to be wrong only to end the round.",
66
+ ].join("\n");
59
67
  export function fixPrompt(issue, reason, detail, earlier, kind) {
60
68
  return [
61
69
  `You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
@@ -70,7 +78,7 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
70
78
  : "",
71
79
  "## Rules",
72
80
  "Fix the cause, not the symptom. Keep the change minimal.",
73
- kind === "checks" ? UNRELATED_RULE : "",
81
+ kind === "checks" ? UNRELATED_RULE : DISPUTE_RULE,
74
82
  COMMIT_RULES,
75
83
  ]
76
84
  .filter((part) => part.length > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.12.5",
3
+ "version": "0.14.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",