next-issue 0.7.0 → 0.8.1

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
@@ -24,22 +24,27 @@ For each open issue, oldest first:
24
24
  the claim. An issue with saved state under `.next-issue/` resumes instead,
25
25
  whatever its labels, unless another person is now the assignee.
26
26
  2. Assign the issue to you and add `status:in-progress`.
27
- 3. Fetch the default branch, fast-forward the local copy of it when that is
28
- safe, then add a git worktree on a new `issue-<n>` branch from it.
27
+ 3. Fetch the remote, fast-forward the local copy of the default branch when
28
+ that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
29
+ `issue-<n>` branch. A branch that exists on the remote is the start, and a
30
+ local branch that fell behind the remote is fast-forwarded.
29
31
  4. Let the implementer agent do the work, then commit and push.
30
32
  5. Open a draft pull request that closes the issue.
31
- 6. Wait for the checks. A pending state waits again, up to
32
- `checkTimeoutMinutes`. A repository that reports no check keeps asking for
33
- `checkGraceSeconds`, because a new pull request often shows no check yet,
34
- and only then goes straight to the review.
33
+ 6. Wait for the checks, once the pull request shows the pushed commit. A
34
+ pending state waits again, up to `checkTimeoutMinutes`. A read that fails
35
+ is tried three times before the run stops. A repository that reports no
36
+ check keeps asking for `checkGraceSeconds`, because a new pull request often
37
+ shows no check yet, and only then goes straight to the review. The later
38
+ gates of the same issue ask once.
35
39
  7. On a red build, give the failed job logs to the fixer agent, then go to step
36
40
  6 again. The budget is `maxCiFixes`.
37
41
  8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
38
42
  reviewer returns a structured verdict that rates each finding `blocking` or
39
43
  `minor`. The harness puts the result on the pull request.
40
44
  9. No blocking finding means approval: mark the pull request ready for review
41
- and set `status:done`. The merge stays with you. A blocking finding always
42
- stops the approval, even when the reviewer also says approve.
45
+ and set `status:done`. The merge stays with you. The worktree, the local
46
+ branch and the state file go away. The remote branch stays. A blocking
47
+ finding always stops the approval, even when the reviewer also says approve.
43
48
  10. With a blocking finding, run the fixer agent and go to step 6 again. The
44
49
  budget is `maxReviewRounds`.
45
50
 
@@ -50,6 +55,10 @@ when:
50
55
 
51
56
  - the check budget or the review budget runs out;
52
57
  - a fixer round adds no commit, so there is no progress;
58
+ - the fixer reports that the checks fail for a reason the change did not cause,
59
+ such as a test already broken on the base branch or one that fails only under
60
+ load. It changes nothing and the reason reaches the pull request, rather than
61
+ the fixer spending its whole budget on a failure it cannot fix;
53
62
  - the reviewer repeats a finding set from an earlier round, which shows a
54
63
  ping-pong between the fixer and the reviewer;
55
64
  - the reviewer gives no verdict, or the checks do not finish in time;
@@ -106,14 +115,14 @@ A short text summary of the same data goes to standard output. Use `--json` to
106
115
  get the full summary object there instead, so the harness fits in a pipe. All
107
116
  progress goes to standard error.
108
117
 
109
- Recorded for each step: `claim`, `comments`, `worktree`, `implement`, `push`,
110
- `pull-request`, `checks`, `review` and `fix`, each with a `.start`, `.end` or
111
- `.error` event and a duration in `ms`. Every `git` and `gh` command is recorded
112
- with its exit code and duration. A `tool` event holds the tool name, and for a
113
- `Bash` tool the command too. Each agent call adds a `usage` event with the
114
- tokens, the cost estimate in dollars, the turn count, the tool-call count, the
115
- model and the session id, so you can open the session again and read the full
116
- transcript.
118
+ Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
119
+ `push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
120
+ `.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
121
+ is recorded with its exit code and duration. A `tool` event holds the tool
122
+ name, and for a `Bash` tool the command too. Each agent call adds a `usage`
123
+ event with the tokens, the cost estimate in dollars, the turn count, the
124
+ tool-call count, the model and the session id, so you can open the session
125
+ again and read the full transcript.
117
126
 
118
127
  ```bash
119
128
  jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
@@ -130,7 +139,7 @@ Three levels of console output:
130
139
 
131
140
  ## Requirements
132
141
 
133
- - Node 24 or later, because the command runs the TypeScript source directly
142
+ - Node 24 or later
134
143
  - `gh`, authenticated with write access to the repository
135
144
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
136
145
  Claude subscription login
@@ -217,7 +226,8 @@ A hand-over leaves the state file with the used budgets in it, so the next run
217
226
  would hand the issue over again at once. To give the issue another try, take the
218
227
  `status:needs-human` label off and run with `--reset`. The flag puts the budgets
219
228
  and the findings back to zero and keeps the branch, the pull request and the
220
- step that the last run reached, so the work carries on where it stopped.
229
+ step that the last run reached, so the work carries on where it stopped. A
230
+ commit that you pushed to the pull request in the meantime is fetched first.
221
231
 
222
232
  ## Commands and options
223
233
 
package/dist/cli.mjs CHANGED
@@ -6,6 +6,7 @@ import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
6
  import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
7
7
  import { detectRepo, ensureIgnored, repoRoot } from "./git.mjs";
8
8
  import { currentLogin, defaultBranch, openIssues } from "./github.mjs";
9
+ import { exitCode, runIssues } from "./loop.mjs";
9
10
  import { message, Recorder } from "./observe.mjs";
10
11
  import { PORTS, processIssue } from "./pipeline.mjs";
11
12
  import { resetState, STATE_DIR, takeStop } from "./state.mjs";
@@ -59,25 +60,14 @@ async function handle(args, config, repo, recorder) {
59
60
  recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
60
61
  const issues = await openIssues(repo, config.issueLimit);
61
62
  recorder.event("issues", { open: issues.length }, "quiet");
62
- await takeStop(repo);
63
- const reports = [];
64
- for (const issue of issues) {
65
- const handled = reports.filter((report) => report.outcome !== "skipped").length;
66
- if (args.max !== undefined && handled >= args.max) {
67
- break;
68
- }
69
- if (args.reset) {
70
- await resetState(repo, issue.number);
71
- }
72
- const report = await processIssue(context, issue);
73
- reports.push(report);
74
- if (await takeStop(repo)) {
75
- recorder.event("run.stop", { issue: issue.number }, "quiet");
76
- break;
77
- }
78
- if (args.once && report.outcome !== "skipped") {
79
- break;
80
- }
63
+ const { reports, stoppedAt } = await runIssues(args, {
64
+ issues,
65
+ process: (issue) => processIssue(context, issue),
66
+ reset: (issue) => resetState(repo, issue),
67
+ stop: () => takeStop(repo),
68
+ });
69
+ if (stoppedAt !== undefined) {
70
+ recorder.event("run.stop", { issue: stoppedAt }, "quiet");
81
71
  }
82
72
  const summary = { ...recorder.summary(), issues: reports };
83
73
  recorder.event("run.end", {
@@ -90,7 +80,7 @@ async function handle(args, config, repo, recorder) {
90
80
  const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
91
81
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
92
82
  await write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
93
- return count(reports, "needs-human") + count(reports, "error") > 0 ? 1 : 0;
83
+ return exitCode(reports);
94
84
  }
95
85
  function count(reports, outcome) {
96
86
  return reports.filter((report) => report.outcome === outcome).length;
package/dist/exec.mjs CHANGED
@@ -15,7 +15,7 @@ export function run(command, args, options = {}) {
15
15
  cwd: options.cwd,
16
16
  stdio: [
17
17
  options.input === undefined ? "ignore" : "pipe",
18
- options.inherit === true ? "inherit" : "pipe",
18
+ options.show === true ? 2 : "pipe",
19
19
  "pipe",
20
20
  ],
21
21
  });
package/dist/git.mjs CHANGED
@@ -30,21 +30,26 @@ export function worktreePath(repo, issue) {
30
30
  export async function addWorktree(repo, issue, base, remote) {
31
31
  const path = worktreePath(repo, issue);
32
32
  const branch = branchName(issue);
33
- await must("git", ["fetch", remote, base], { cwd: repo.root });
33
+ const upstream = `${remote}/${branch}`;
34
+ await must("git", ["fetch", remote], { cwd: repo.root });
34
35
  await updateBase(repo, base, remote);
35
36
  await must("git", ["worktree", "prune"], { cwd: repo.root });
36
- if (await hasWorktree(repo, path)) {
37
- return path;
37
+ const pushed = await hasBranch(repo, upstream, "--remotes");
38
+ if (!(await hasWorktree(repo, path))) {
39
+ const args = (await hasBranch(repo, branch))
40
+ ? ["worktree", "add", path, branch]
41
+ : ["worktree", "add", "-b", branch, path, pushed ? upstream : `${remote}/${base}`];
42
+ await must("git", args, { cwd: repo.root });
43
+ }
44
+ if (pushed) {
45
+ await run("git", ["merge", "--ff-only", upstream], { cwd: path });
38
46
  }
39
- const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
40
- cwd: repo.root,
41
- });
42
- const args = existing.code === 0
43
- ? ["worktree", "add", path, branch]
44
- : ["worktree", "add", "-b", branch, path, `${remote}/${base}`];
45
- await must("git", args, { cwd: repo.root });
46
47
  return path;
47
48
  }
49
+ async function hasBranch(repo, name, ...flags) {
50
+ const list = await must("git", ["branch", "--list", ...flags, name], { cwd: repo.root });
51
+ return list.length > 0;
52
+ }
48
53
  export async function updateBase(repo, base, remote) {
49
54
  const head = await run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repo.root });
50
55
  const args = head.code === 0 && head.stdout.trim() === base
@@ -98,3 +103,10 @@ export async function commitAll(cwd, message) {
98
103
  export async function push(cwd, remote, branch) {
99
104
  await must("git", ["push", "-u", remote, branch], { cwd });
100
105
  }
106
+ export async function revision(cwd) {
107
+ return must("git", ["rev-parse", "HEAD"], { cwd });
108
+ }
109
+ export async function deleteBranch(repo, branch) {
110
+ const result = await run("git", ["branch", "-D", branch], { cwd: repo.root });
111
+ return result.code === 0;
112
+ }
package/dist/github.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { must, run } from "./exec.mjs";
2
2
  const CHECK_PENDING_CODE = 8;
3
+ const READ_TRIES = 3;
4
+ const KNOWN_LABELS = new Map();
3
5
  const NO_CHECKS = /no checks reported/i;
4
6
  function slug(repo) {
5
7
  return `${repo.owner}/${repo.name}`;
@@ -22,6 +24,8 @@ export async function openIssues(repo, limit) {
22
24
  "list",
23
25
  "--state",
24
26
  "open",
27
+ "--search",
28
+ "sort:created-asc",
25
29
  "--limit",
26
30
  String(limit),
27
31
  "--json",
@@ -65,7 +69,17 @@ export async function setLabel(repo, kind, number, add, remove) {
65
69
  await gh(repo, [kind, "edit", String(number), ...labelArgs(add, remove, current)]);
66
70
  }
67
71
  export async function ensureLabel(repo, label) {
72
+ let known = KNOWN_LABELS.get(slug(repo));
73
+ if (known === undefined) {
74
+ const raw = await gh(repo, ["label", "list", "--limit", "1000", "--json", "name"]);
75
+ known = new Set(JSON.parse(raw).map((item) => item.name));
76
+ KNOWN_LABELS.set(slug(repo), known);
77
+ }
78
+ if (known.has(label)) {
79
+ return;
80
+ }
68
81
  await run("gh", ["label", "create", label, "--repo", slug(repo)], { cwd: repo.root });
82
+ known.add(label);
69
83
  }
70
84
  export async function findPr(repo, branch) {
71
85
  const result = await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--repo", slug(repo)], { cwd: repo.root });
@@ -95,6 +109,9 @@ export async function createPr(repo, branch, base, title, body, draft) {
95
109
  }
96
110
  return number;
97
111
  }
112
+ async function prHead(repo, branch) {
113
+ return gh(repo, ["pr", "view", branch, "--json", "headRefOid", "--jq", ".headRefOid"]);
114
+ }
98
115
  async function readChecks(repo, branch) {
99
116
  const result = await run("gh", ["pr", "checks", branch, "--json", "name,bucket,link,description", "--repo", slug(repo)], { cwd: repo.root });
100
117
  const output = result.stdout.trim();
@@ -107,8 +124,8 @@ async function readChecks(repo, branch) {
107
124
  const checks = JSON.parse(output);
108
125
  return checks.length === 0 ? "none" : checks;
109
126
  }
110
- async function watchOnce(repo, branch, intervalSeconds, timeoutMs, inherit) {
111
- const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, inherit, timeoutMs });
127
+ async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
128
+ const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, show, timeoutMs });
112
129
  if (watch.timedOut) {
113
130
  return "timeout";
114
131
  }
@@ -125,31 +142,53 @@ const REAL_CLOCK = {
125
142
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
126
143
  };
127
144
  export async function pollChecks(probe, options, clock = REAL_CLOCK) {
128
- const started = clock.now();
129
- const deadline = started + options.timeoutMs;
145
+ const deadline = clock.now() + options.timeoutMs;
130
146
  const rest = () => Math.max(0, deadline - clock.now());
147
+ let shown;
148
+ let failures = 0;
131
149
  for (;;) {
132
150
  if (rest() === 0) {
133
151
  return "timeout";
134
152
  }
135
- if ((await probe.list()) === "none") {
136
- if (clock.now() - started >= options.graceMs) {
137
- return "none";
153
+ const state = await read(probe, options.head, rest(), () => (shown ??= clock.now()));
154
+ if (state instanceof Error) {
155
+ failures += 1;
156
+ if (failures >= READ_TRIES) {
157
+ throw state;
138
158
  }
139
159
  }
140
160
  else {
141
- const state = await probe.watch(rest());
142
- if (state !== "pending") {
161
+ failures = 0;
162
+ if (state === "none" && clock.now() - (shown ?? 0) >= options.graceMs) {
163
+ return "none";
164
+ }
165
+ if (state !== "none" && state !== "behind" && state !== "pending") {
143
166
  return state;
144
167
  }
145
168
  }
146
169
  await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
147
170
  }
148
171
  }
172
+ async function read(probe, head, timeoutMs, shown) {
173
+ try {
174
+ if (head !== undefined && (await probe.head()) !== head) {
175
+ return "behind";
176
+ }
177
+ shown();
178
+ if ((await probe.list()) === "none") {
179
+ return "none";
180
+ }
181
+ }
182
+ catch (error) {
183
+ return error instanceof Error ? error : new Error(String(error));
184
+ }
185
+ return probe.watch(timeoutMs);
186
+ }
149
187
  export function waitForChecks(repo, branch, options) {
150
188
  return pollChecks({
189
+ head: () => prHead(repo, branch),
151
190
  list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
152
- watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.inherit),
191
+ watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.show),
153
192
  }, options);
154
193
  }
155
194
  export async function failedCheckLogs(repo, branch, maxChars) {
package/dist/loop.mjs ADDED
@@ -0,0 +1,27 @@
1
+ export async function runIssues(args, loop) {
2
+ await loop.stop();
3
+ const reports = [];
4
+ for (const issue of loop.issues) {
5
+ if (args.max !== undefined && handled(reports) >= args.max) {
6
+ break;
7
+ }
8
+ if (args.reset) {
9
+ await loop.reset(issue.number);
10
+ }
11
+ const report = await loop.process(issue);
12
+ reports.push(report);
13
+ if (await loop.stop()) {
14
+ return { reports, stoppedAt: issue.number };
15
+ }
16
+ if (args.once && report.outcome !== "skipped") {
17
+ break;
18
+ }
19
+ }
20
+ return { reports };
21
+ }
22
+ export function exitCode(reports) {
23
+ return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
24
+ }
25
+ function handled(reports) {
26
+ return reports.filter((report) => report.outcome !== "skipped").length;
27
+ }
package/dist/pipeline.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { CODING, READ_ONLY, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
- import { addWorktree, branchName, commitAll, diff, hasWorktree, push, removeWorktree, worktreePath, } from "./git.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
5
  import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
- import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
8
- import { readState, writeState } from "./state.mjs";
7
+ import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
+ import { dropState, readState, writeState } from "./state.mjs";
9
9
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
10
10
  export const PORTS = {
11
11
  addWorktree,
@@ -13,6 +13,7 @@ export const PORTS = {
13
13
  commentOnPr,
14
14
  commitAll,
15
15
  createPr,
16
+ deleteBranch,
16
17
  diff,
17
18
  failedCheckLogs,
18
19
  findPr,
@@ -21,6 +22,7 @@ export const PORTS = {
21
22
  markPrReady,
22
23
  push,
23
24
  removeWorktree,
25
+ revision,
24
26
  runAgent,
25
27
  runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
26
28
  setLabel,
@@ -101,6 +103,12 @@ async function work(context, issue, log, state) {
101
103
  const escalate = (reason) => handOver(context, log, issue.number, state, reason);
102
104
  const finish = async (outcome, reason) => {
103
105
  await cleanUp(context, log, issue.number);
106
+ if (outcome === "done") {
107
+ if (!(await ports.deleteBranch(repo, branch))) {
108
+ log.event("branch.kept", { branch }, "quiet");
109
+ }
110
+ await dropState(repo, issue.number);
111
+ }
104
112
  return {
105
113
  issue: issue.number,
106
114
  outcome,
@@ -123,6 +131,7 @@ async function work(context, issue, log, state) {
123
131
  }
124
132
  }
125
133
  if (state.phase === "claimed") {
134
+ const before = await ports.revision(worktree);
126
135
  const result = await log.step("implement", {}, () => ports.runAgent(log, {
127
136
  name: "implementer",
128
137
  cwd: worktree,
@@ -132,11 +141,10 @@ async function work(context, issue, log, state) {
132
141
  timeoutMs: config.agentTimeoutMinutes * 60_000,
133
142
  }));
134
143
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
135
- if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
144
+ if (!(await commitWork(job, before, subject))) {
136
145
  await escalate("The implementer produced no change.");
137
146
  return finish("needs-human", "no implementation commit");
138
147
  }
139
- log.event("commit", { subject });
140
148
  state.phase = "implemented";
141
149
  await writeState(repo, state);
142
150
  }
@@ -149,14 +157,18 @@ async function work(context, issue, log, state) {
149
157
  state.phase = "review";
150
158
  await writeState(repo, state);
151
159
  log.event("pull-request.ready", { pr: state.pr }, "quiet");
160
+ let noChecks = false;
152
161
  const gate = async () => {
153
- const checks = await log.step("checks", { pr: state.pr }, () => ports.waitForChecks(repo, branch, {
162
+ const head = await ports.revision(worktree);
163
+ const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
164
+ head,
154
165
  intervalSeconds: config.checkIntervalSeconds,
155
- graceMs: config.checkGraceSeconds * 1000,
166
+ graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
156
167
  timeoutMs: config.checkTimeoutMinutes * 60_000,
157
- inherit: log.level === "verbose",
168
+ show: log.level === "verbose",
158
169
  }));
159
170
  log.event("checks.state", { state: checks });
171
+ noChecks ||= checks === "none";
160
172
  if (checks === "timeout") {
161
173
  await escalate("The checks did not finish in time.");
162
174
  return { done: true, report: await finish("needs-human", "checks timeout") };
@@ -172,10 +184,16 @@ async function work(context, issue, log, state) {
172
184
  await writeState(repo, state);
173
185
  const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
174
186
  log.event("checks.logs", { chars: logs.length });
175
- const committed = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog));
176
- if (!committed) {
177
- await escalate("The fixer added no commit for the failed checks.");
178
- return { done: true, report: await finish("needs-human", "no fix commit") };
187
+ const fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
188
+ if (!fix.committed) {
189
+ const note = fix.unrelated;
190
+ await escalate(note === undefined
191
+ ? "The fixer added no commit for the failed checks."
192
+ : `The checks fail for a reason this change did not cause: ${note}`);
193
+ return {
194
+ done: true,
195
+ report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
196
+ };
179
197
  }
180
198
  return { done: false, fixed: true };
181
199
  };
@@ -204,7 +222,7 @@ async function work(context, issue, log, state) {
204
222
  const review = await log.step("review", fields, () => ports.runAgent(log, {
205
223
  name: "reviewer",
206
224
  cwd: worktree,
207
- prompt: reviewPrompt(issue, comments, patch, state.reviewRounds, history(state.reviewLog)),
225
+ prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
208
226
  profile: READ_ONLY,
209
227
  model: config.models.reviewer,
210
228
  timeoutMs: config.agentTimeoutMinutes * 60_000,
@@ -236,8 +254,6 @@ async function work(context, issue, log, state) {
236
254
  log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
237
255
  }
238
256
  await setStatus(context, issue.number, state.pr, config.labels.done);
239
- state.phase = "done";
240
- await writeState(repo, state);
241
257
  return finish("done");
242
258
  }
243
259
  const mark = fingerprint(blocking);
@@ -248,32 +264,44 @@ async function work(context, issue, log, state) {
248
264
  const earlier = history(state.reviewLog);
249
265
  state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(blocking) });
250
266
  await writeState(repo, state);
251
- const committed = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier);
252
- if (!committed) {
267
+ const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier, "review");
268
+ if (!fix.committed) {
253
269
  await escalate("The fixer added no commit for the review findings.");
254
270
  return finish("needs-human", "no fix commit");
255
271
  }
256
272
  }
257
273
  }
258
- async function fixRound(job, reason, detail, earlier) {
274
+ async function fixRound(job, reason, detail, earlier, kind) {
259
275
  const { context, issue, log, worktree, branch } = job;
260
276
  const { ports } = context;
277
+ const before = await ports.revision(worktree);
261
278
  const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
262
279
  name: "fixer",
263
280
  cwd: worktree,
264
- prompt: fixPrompt(issue, reason, detail, earlier),
281
+ prompt: fixPrompt(issue, reason, detail, earlier, kind),
265
282
  profile: CODING,
266
283
  model: context.config.models.fixer,
267
284
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
268
285
  }));
286
+ const unrelated = parseUnrelated(result.text);
287
+ if (unrelated !== undefined) {
288
+ log.event("fix.unrelated", { reason, note: unrelated }, "quiet");
289
+ return { committed: false, unrelated };
290
+ }
269
291
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
270
- if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
292
+ if (!(await commitWork(job, before, subject))) {
271
293
  log.event("commit.empty", { reason }, "quiet");
272
- return false;
294
+ return { committed: false };
273
295
  }
274
- log.event("commit", { subject });
275
296
  await ports.push(worktree, context.config.remote, branch);
276
- return true;
297
+ return { committed: true };
298
+ }
299
+ async function commitWork(job, before, subject) {
300
+ const { context, issue, log, worktree } = job;
301
+ if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
302
+ log.event("commit", { subject });
303
+ }
304
+ return (await context.ports.revision(worktree)) !== before;
277
305
  }
278
306
  async function cleanUp(context, log, issue) {
279
307
  const path = worktreePath(context.repo, issue);
package/dist/prompts.mjs CHANGED
@@ -26,8 +26,8 @@ export function capDiff(diff, maxChars) {
26
26
  ? diff
27
27
  : `${diff.slice(0, maxChars)}\n... The diff is cut here, after ${maxChars} characters.`;
28
28
  }
29
- export function reviewPrompt(issue, comments, diff, round, earlier) {
30
- const scope = round === 1
29
+ export function reviewPrompt(issue, comments, diff, earlier) {
30
+ const scope = earlier.length === 0
31
31
  ? "Check correctness, scope, repository conventions and test cover."
32
32
  : [
33
33
  "This is a later round. Judge two things only:",
@@ -49,7 +49,12 @@ export function reviewPrompt(issue, comments, diff, round, earlier) {
49
49
  "Give the result as the structured output: the verdict, a short summary and one entry per finding.",
50
50
  ].join("\n\n");
51
51
  }
52
- export function fixPrompt(issue, reason, detail, earlier) {
52
+ const UNRELATED_RULE = [
53
+ "The checks can fail for a reason this change did not cause: a test that is already broken on the base branch, one that exercises code the diff does not touch, or one that fails only under load.",
54
+ "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>`.",
55
+ "A person then reads it. Do not weaken, skip or re-record a test to make the checks pass.",
56
+ ].join("\n");
57
+ export function fixPrompt(issue, reason, detail, earlier, kind) {
53
58
  return [
54
59
  `You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
55
60
  `## Reason\n${reason}`,
@@ -63,11 +68,17 @@ export function fixPrompt(issue, reason, detail, earlier) {
63
68
  : "",
64
69
  "## Rules",
65
70
  "Fix the cause, not the symptom. Keep the change minimal.",
71
+ kind === "checks" ? UNRELATED_RULE : "",
66
72
  COMMIT_RULES,
67
73
  ]
68
74
  .filter((part) => part.length > 0)
69
75
  .join("\n\n");
70
76
  }
77
+ export function parseUnrelated(text) {
78
+ const match = /^unrelated:\s*(.+)$/im.exec(text);
79
+ const note = match?.[1]?.trim();
80
+ return note !== undefined && note.length > 0 ? note : undefined;
81
+ }
71
82
  export function parseCommitSubject(text, fallback) {
72
83
  const match = /^commit:\s*(.+)$/im.exec(text);
73
84
  const subject = match?.[1]?.trim();
package/dist/state.mjs CHANGED
@@ -38,3 +38,6 @@ export async function takeStop(repo) {
38
38
  return false;
39
39
  }
40
40
  }
41
+ export async function dropState(repo, issue) {
42
+ await rm(file(repo, issue), { force: true });
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",