next-issue 0.17.1 → 0.19.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
@@ -59,9 +59,15 @@ For each open issue, oldest first:
59
59
  finding.
60
60
  10. No open finding means approval: mark the pull request ready for review and
61
61
  set `status:done`. The merge stays with you, unless you pass `--merge`:
62
- then the harness squash-merges the pull request and deletes the remote
63
- branch. The worktree, the local branch and the state file go away. Without
64
- `--merge`, the remote branch stays. A problem that the
62
+ then the harness squash-merges the pull request, deletes the remote
63
+ branch and waits for the checks that the merge commit starts on the base
64
+ branch. A cancelled run there is no failure. When those checks fail, the
65
+ fixer gets their logs on a branch reset to the base branch, from the check
66
+ budget, and the harness reopens the issue and opens a new pull request that
67
+ goes to step 6 again, with fresh review rounds. The next issue waits for
68
+ this, and a second ctrl-c stops only after it. The worktree, the local
69
+ branch and the state file go away. Without `--merge`, the remote branch
70
+ stays. A problem that the
65
71
  reviewer leaves out of the findings thus gets an approval: only the findings
66
72
  count.
67
73
  11. With an open finding, run the fixer agent and go to step 6 again. The
@@ -95,6 +101,9 @@ when:
95
101
  - the reviewer gives no verdict, or the checks do not finish in time;
96
102
  - with `--merge`, the merge of an approved pull request fails, for example
97
103
  because branch protection asks for a human approval;
104
+ - with `--merge`, the checks on the base branch fail after the merge and the
105
+ fixer cannot fix them, or they do not finish in time. The base branch is
106
+ then broken, so the run stops after this issue;
98
107
  - an agent does not finish in `agentTimeoutMinutes`;
99
108
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
100
109
  - a step of the run fails with an error. A `gh` call that is safe to repeat,
@@ -113,17 +122,24 @@ not the author, tells them apart. The agents read the comments of the issue, not
113
122
  of the pull request, so a comment of an agent never returns to a prompt: the
114
123
  dispute of the fixer reaches the reviewer through the state file.
115
124
 
116
- ## Stop after the current issue
125
+ ## Stop a run
117
126
 
118
- Run `touch .next-issue/stop` in a second terminal. The harness reads the file
119
- only between two issues, finishes the issue that it holds, deletes the file and
120
- then writes the summary as usual. A file from an earlier run is dropped at the
121
- start, so it stops nothing.
127
+ Press `Ctrl-C` to stop the run step by step:
122
128
 
123
- `Ctrl-C` is different: the harness passes the signal on to the command that runs
124
- and to every child of it, so the run dies in the middle of a step. The saved
125
- state lets the next run continue the issue, but the issue keeps
126
- `status:in-progress` and the run writes no summary.
129
+ 1. The first press stops the run after the current issue. The harness finishes
130
+ the issue that it holds and then writes the summary as usual.
131
+ 2. The second press stops the run after the current round: the implementation,
132
+ one checks round (wait, then fix) or one review round (review, then fix). The
133
+ issue ends as `stopped`. Its state, worktree and labels stay, so the next run
134
+ carries on from that point.
135
+ 3. The third press kills the command that runs and every child of it, so the run
136
+ dies in the middle of a step. The saved state lets the next run continue the
137
+ issue, but the issue keeps `status:in-progress` and the run writes no
138
+ summary.
139
+
140
+ Each of the first two presses prints a line that says what happens next. The
141
+ agents and commands run in a process group of their own, so a press reaches only
142
+ the harness.
127
143
 
128
144
  A status label that the harness puts on the issue goes on the pull request too,
129
145
  but only if the pull request exists at that moment. The `status:in-progress`
@@ -342,7 +358,7 @@ that a label still holds back stays as it was.
342
358
  | `--once` | Stop after the first handled issue |
343
359
  | `--max <n>` | Handle at most n issues |
344
360
  | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
345
- | `--merge` | Squash-merge the pull request after the approval, and delete the remote branch |
361
+ | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
346
362
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
347
363
  | `--json` | Print the machine summary instead of the text one |
348
364
  | `--verbose` | Show every command and all agent output |
package/dist/agents.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import { spawn } from "node:child_process";
3
+ import { track } from "./exec.mjs";
2
4
  import { emptyUsage } from "./observe.mjs";
3
5
  import { runPi } from "./pi.mjs";
4
6
  const REVIEWING = {
@@ -45,6 +47,7 @@ export async function runAgent(recorder, request) {
45
47
  allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
46
48
  systemPrompt: { type: "preset", preset: "claude_code" },
47
49
  abortController: controller,
50
+ spawnClaudeCodeProcess: spawnClaude,
48
51
  outputFormat: request.outputSchema === undefined
49
52
  ? undefined
50
53
  : { type: "json_schema", schema: request.outputSchema },
@@ -149,3 +152,14 @@ export function toolFields(name, input) {
149
152
  const { command } = input;
150
153
  return typeof command === "string" ? { tool: name, command } : { tool: name };
151
154
  }
155
+ export function spawnClaude(options) {
156
+ const child = spawn(options.command, options.args, {
157
+ cwd: options.cwd,
158
+ env: options.env,
159
+ signal: options.signal,
160
+ detached: true,
161
+ stdio: ["pipe", "pipe", "ignore"],
162
+ });
163
+ track(child);
164
+ return child;
165
+ }
package/dist/cli.mjs CHANGED
@@ -6,10 +6,11 @@ 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 { defaultBranch, openIssues } from "./github.mjs";
9
+ import { listenForInterrupts } from "./interrupt.mjs";
9
10
  import { exitCode, runIssues } from "./loop.mjs";
10
11
  import { message, pruneRuns, Recorder } from "./observe.mjs";
11
12
  import { PORTS, processIssue } from "./pipeline.mjs";
12
- import { STATE_DIR, takeStop } from "./state.mjs";
13
+ import { STATE_DIR } from "./state.mjs";
13
14
  import { formatSummary } from "./summary.mjs";
14
15
  import { packageVersion } from "./version.mjs";
15
16
  function write(stream, text) {
@@ -61,8 +62,18 @@ async function init(root, force) {
61
62
  return 0;
62
63
  }
63
64
  async function handle(args, config, repo, recorder) {
65
+ const interrupts = listenForInterrupts((text) => void write(process.stderr, `${text}\n`));
64
66
  const base = await defaultBranch(repo);
65
- const context = { repo, config, base, reset: args.reset, merge: args.merge, recorder, ports: PORTS };
67
+ const context = {
68
+ repo,
69
+ config,
70
+ base,
71
+ reset: args.reset,
72
+ merge: args.merge,
73
+ stopAfterRound: interrupts.afterRound,
74
+ recorder,
75
+ ports: PORTS,
76
+ };
66
77
  recorder.event("run.start", {
67
78
  version: await packageVersion(),
68
79
  repo: `${repo.owner}/${repo.name}`,
@@ -74,7 +85,7 @@ async function handle(args, config, repo, recorder) {
74
85
  const { reports, stoppedAt } = await runIssues(args, {
75
86
  issues,
76
87
  process: (issue) => processIssue(context, issue),
77
- stop: () => takeStop(repo),
88
+ stop: interrupts.afterIssue,
78
89
  });
79
90
  if (stoppedAt !== undefined) {
80
91
  recorder.event("run.stop", { issue: stoppedAt }, "quiet");
package/dist/exec.mjs CHANGED
@@ -3,7 +3,7 @@ let observer;
3
3
  let defaultTimeoutMs = 10 * 60_000;
4
4
  const STDERR_TAIL = 200_000;
5
5
  const groups = new Set();
6
- function forward(signal) {
6
+ export function stopChildren(signal) {
7
7
  for (const pid of groups) {
8
8
  try {
9
9
  process.kill(-pid, signal);
@@ -11,18 +11,15 @@ function forward(signal) {
11
11
  catch { }
12
12
  }
13
13
  groups.clear();
14
- process.off("SIGINT", onInterrupt);
15
14
  process.off("SIGTERM", onTerminate);
16
15
  process.kill(process.pid, signal);
17
16
  }
18
- const onInterrupt = () => forward("SIGINT");
19
- const onTerminate = () => forward("SIGTERM");
17
+ const onTerminate = () => stopChildren("SIGTERM");
20
18
  function watch(pid) {
21
19
  if (pid === undefined) {
22
20
  return;
23
21
  }
24
22
  if (groups.size === 0) {
25
- process.on("SIGINT", onInterrupt);
26
23
  process.on("SIGTERM", onTerminate);
27
24
  }
28
25
  groups.add(pid);
@@ -33,10 +30,14 @@ function unwatch(pid) {
33
30
  }
34
31
  groups.delete(pid);
35
32
  if (groups.size === 0) {
36
- process.off("SIGINT", onInterrupt);
37
33
  process.off("SIGTERM", onTerminate);
38
34
  }
39
35
  }
36
+ export function track(child) {
37
+ watch(child.pid);
38
+ child.on("close", () => unwatch(child.pid));
39
+ child.on("error", () => unwatch(child.pid));
40
+ }
40
41
  export function setCommandObserver(next) {
41
42
  observer = next;
42
43
  }
package/dist/git.mjs CHANGED
@@ -111,6 +111,10 @@ export async function discardChanges(cwd) {
111
111
  export async function push(cwd, remote, branch) {
112
112
  await must("git", ["push", "-u", remote, branch], { cwd });
113
113
  }
114
+ export async function resetToBase(cwd, base, remote) {
115
+ await must("git", ["fetch", remote, base], { cwd });
116
+ await must("git", ["reset", "--hard", `${remote}/${base}`], { cwd });
117
+ }
114
118
  export async function revision(cwd) {
115
119
  return must("git", ["rev-parse", "HEAD"], { cwd });
116
120
  }
package/dist/github.mjs CHANGED
@@ -5,6 +5,7 @@ const NETWORK_TRIES = 3;
5
5
  const NETWORK_FAULT = /unexpected EOF|connection reset|connection refused|i\/o timeout|TLS handshake timeout|HTTP 50[234]/i;
6
6
  const KNOWN_LABELS = new Map();
7
7
  const NO_CHECKS = /no checks reported/i;
8
+ const FAILED_CONCLUSIONS = new Set(["failure", "timed_out", "startup_failure"]);
8
9
  function slug(repo) {
9
10
  return `${repo.owner}/${repo.name}`;
10
11
  }
@@ -228,12 +229,42 @@ export async function failedCheckLogs(repo, branch, maxChars) {
228
229
  if (checks === "none") {
229
230
  return "The checks failed, but gh reports no check for the branch.";
230
231
  }
231
- const failed = checks.filter((check) => check.bucket === "fail");
232
+ const failed = checks
233
+ .filter((check) => check.bucket === "fail")
234
+ .map((check) => ({
235
+ heading: `## ${check.name}\n${check.description}`,
236
+ runId: /\/actions\/runs\/(\d+)/.exec(check.link)?.[1],
237
+ }));
238
+ return runLogs(repo, failed, maxChars);
239
+ }
240
+ async function baseRuns(repo, sha) {
241
+ const raw = await gh(repo, ["run", "list", "--commit", sha, "--json", "databaseId,name,status,conclusion"]);
242
+ return JSON.parse(raw);
243
+ }
244
+ function baseState(runs) {
245
+ if (runs.some((entry) => entry.status === "completed" && FAILED_CONCLUSIONS.has(entry.conclusion))) {
246
+ return "fail";
247
+ }
248
+ return runs.every((entry) => entry.status === "completed") ? "pass" : "pending";
249
+ }
250
+ export function waitForBaseChecks(repo, sha, options) {
251
+ return pollChecks({
252
+ head: async () => sha,
253
+ list: async () => ((await baseRuns(repo, sha)).length === 0 ? "none" : "some"),
254
+ watch: async () => baseState(await baseRuns(repo, sha)),
255
+ }, options);
256
+ }
257
+ export async function failedBaseLogs(repo, sha, maxChars) {
258
+ const failed = (await baseRuns(repo, sha))
259
+ .filter((entry) => entry.status === "completed" && FAILED_CONCLUSIONS.has(entry.conclusion))
260
+ .map((entry) => ({ heading: `## ${entry.name}`, runId: String(entry.databaseId) }));
261
+ return runLogs(repo, failed, maxChars);
262
+ }
263
+ async function runLogs(repo, failed, maxChars) {
232
264
  const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
233
265
  const parts = [];
234
- for (const check of failed) {
235
- parts.push(`## ${check.name}\n${check.description}`);
236
- const runId = /\/actions\/runs\/(\d+)/.exec(check.link)?.[1];
266
+ for (const { heading, runId } of failed) {
267
+ parts.push(heading);
237
268
  if (runId === undefined) {
238
269
  continue;
239
270
  }
@@ -262,6 +293,12 @@ export async function mergePr(repo, pr) {
262
293
  const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
263
294
  return result.code === 0;
264
295
  }
296
+ export async function mergeCommit(repo, pr) {
297
+ return gh(repo, ["pr", "view", String(pr), "--json", "mergeCommit", "--jq", ".mergeCommit.oid"]);
298
+ }
299
+ export async function reopenIssue(repo, issue) {
300
+ await gh(repo, ["issue", "reopen", String(issue)]);
301
+ }
265
302
  export async function commentOnPr(repo, pr, body) {
266
303
  await ghOnce(repo, ["pr", "comment", String(pr), "--body-file", "-"], body);
267
304
  }
@@ -0,0 +1,22 @@
1
+ import { stopChildren } from "./exec.mjs";
2
+ export function listenForInterrupts(say) {
3
+ let presses = 0;
4
+ const press = () => {
5
+ presses += 1;
6
+ if (presses === 1) {
7
+ say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
8
+ }
9
+ else if (presses === 2) {
10
+ say("Stopping after this round. Press Ctrl-C again to stop now.");
11
+ }
12
+ else {
13
+ process.off("SIGINT", press);
14
+ stopChildren("SIGINT");
15
+ }
16
+ };
17
+ process.on("SIGINT", press);
18
+ return {
19
+ afterIssue: () => presses >= 1,
20
+ afterRound: () => presses >= 2,
21
+ };
22
+ }
package/dist/loop.mjs CHANGED
@@ -1,5 +1,4 @@
1
1
  export async function runIssues(args, loop) {
2
- await loop.stop();
3
2
  const reports = [];
4
3
  for (const issue of loop.issues) {
5
4
  if (args.max !== undefined && handled(reports) >= args.max) {
@@ -7,7 +6,7 @@ export async function runIssues(args, loop) {
7
6
  }
8
7
  const report = await loop.process(issue);
9
8
  reports.push(report);
10
- if (await loop.stop()) {
9
+ if (loop.stop() || report.halt === true) {
11
10
  return { reports, stoppedAt: issue.number };
12
11
  }
13
12
  if (args.once && report.outcome !== "skipped") {
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, isDirty, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
- import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, mergePr, openBlockers, setLabel, waitForChecks, } from "./github.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, isDirty, push, removeWorktree, resetToBase, revision, worktreePath, } from "./git.mjs";
5
+ import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, reopenIssue, setLabel, waitForBaseChecks, 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";
@@ -17,20 +17,25 @@ export const PORTS = {
17
17
  detectSetupCommand,
18
18
  diff,
19
19
  discardChanges,
20
+ failedBaseLogs,
20
21
  failedCheckLogs,
21
22
  findPr,
22
23
  hasWorktree,
23
24
  isDirty,
24
25
  issueComments,
25
26
  markPrReady,
27
+ mergeCommit,
26
28
  mergePr,
27
29
  openBlockers,
28
30
  push,
29
31
  removeWorktree,
32
+ reopenIssue,
33
+ resetToBase,
30
34
  revision,
31
35
  runAgent,
32
36
  runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
33
37
  setLabel,
38
+ waitForBaseChecks,
34
39
  waitForChecks,
35
40
  };
36
41
  export function skipReason(issue, config, resuming) {
@@ -135,6 +140,7 @@ async function work(context, issue, log, state) {
135
140
  comments,
136
141
  started,
137
142
  noChecks: false,
143
+ mainBroken: false,
138
144
  };
139
145
  const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
140
146
  if (setupCommand !== undefined) {
@@ -167,17 +173,16 @@ async function work(context, issue, log, state) {
167
173
  state.phase = "implemented";
168
174
  await writeState(repo, state);
169
175
  }
176
+ if (context.stopAfterRound()) {
177
+ return stop(job);
178
+ }
170
179
  await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
171
- const pr = await log.step("pull-request", { branch }, async () => {
172
- const existing = await ports.findPr(repo, branch);
173
- return (existing ??
174
- ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
175
- });
176
- state.pr = pr;
177
180
  state.phase = "review";
178
- await writeState(repo, state);
179
- log.event("pull-request.ready", { pr }, "quiet");
181
+ await openPr(job);
180
182
  for (;;) {
183
+ if (context.stopAfterRound()) {
184
+ return stop(job);
185
+ }
181
186
  const gate = await checkGate(job);
182
187
  if (gate.done) {
183
188
  return gate.report;
@@ -185,12 +190,27 @@ async function work(context, issue, log, state) {
185
190
  if (gate.fixed) {
186
191
  continue;
187
192
  }
193
+ if (context.stopAfterRound()) {
194
+ return stop(job);
195
+ }
188
196
  const round = await reviewRound(job);
189
197
  if (round.done) {
190
198
  return round.report;
191
199
  }
192
200
  }
193
201
  }
202
+ async function openPr(job) {
203
+ const { context, issue, log, state, branch } = job;
204
+ const { repo, config, ports } = context;
205
+ const pr = await log.step("pull-request", { branch }, async () => {
206
+ const existing = await ports.findPr(repo, branch);
207
+ return (existing ??
208
+ ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
209
+ });
210
+ state.pr = pr;
211
+ await writeState(repo, state);
212
+ log.event("pull-request.ready", { pr }, "quiet");
213
+ }
194
214
  async function checkGate(job) {
195
215
  const { context, log, state, branch, worktree } = job;
196
216
  const { repo, config, ports } = context;
@@ -289,9 +309,18 @@ async function reviewRound(job) {
289
309
  if (config.draftPullRequest && !(await ports.markPrReady(repo, pr))) {
290
310
  log.event("pull-request.undraft.fail", { pr }, "quiet");
291
311
  }
292
- if (context.merge && !(await ports.mergePr(repo, pr))) {
293
- await escalate(job, "The merge of the pull request failed.");
294
- return { done: true, report: await finish(job, "needs-human", "merge failed") };
312
+ if (context.merge) {
313
+ if (!(await ports.mergePr(repo, pr))) {
314
+ await escalate(job, "The merge of the pull request failed.");
315
+ return { done: true, report: await finish(job, "needs-human", "merge failed") };
316
+ }
317
+ const watched = await watchBase(job);
318
+ if (watched.done) {
319
+ return { done: true, report: watched.report };
320
+ }
321
+ if (watched.fixed) {
322
+ return { done: false };
323
+ }
295
324
  }
296
325
  await setStatus(context, issue.number, pr, config.labels.done);
297
326
  return { done: true, report: await finish(job, "done") };
@@ -321,9 +350,65 @@ async function reviewRound(job) {
321
350
  }
322
351
  return { done: false };
323
352
  }
353
+ async function watchBase(job) {
354
+ const { context, issue, log, state, worktree } = job;
355
+ const { repo, config, ports } = context;
356
+ const sha = await ports.mergeCommit(repo, state.pr);
357
+ const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
358
+ intervalSeconds: config.checkIntervalSeconds,
359
+ graceMs: config.checkGraceSeconds * 1000,
360
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
361
+ }));
362
+ log.event("main-checks.state", { state: checks });
363
+ if (checks === "pass" || checks === "none") {
364
+ return { done: false, fixed: false };
365
+ }
366
+ job.mainBroken = true;
367
+ if (checks !== "fail") {
368
+ await escalate(job, "The checks on main did not finish in time.");
369
+ return { done: true, report: await finish(job, "needs-human", "main checks timeout") };
370
+ }
371
+ if (state.ciFixes >= config.maxCiFixes) {
372
+ await escalate(job, `The checks on main failed and the limit of ${config.maxCiFixes} check fixes was reached.`);
373
+ return { done: true, report: await finish(job, "needs-human", "check budget") };
374
+ }
375
+ state.ciFixes += 1;
376
+ await writeState(repo, state);
377
+ await ports.resetToBase(worktree, context.base, config.remote);
378
+ const logs = await ports.failedBaseLogs(repo, sha, config.logMaxChars);
379
+ log.event("main-checks.logs", { chars: logs.length });
380
+ const fix = await fixRound(job, "The checks on main failed after the merge.", logs, [], "checks");
381
+ if (!fix.committed) {
382
+ const note = fix.unrelated;
383
+ await escalate(job, note === undefined
384
+ ? "The fixer added no commit for the failed checks on main."
385
+ : `The checks on main fail for a reason this change did not cause: ${note}`);
386
+ return {
387
+ done: true,
388
+ report: await finish(job, "needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
389
+ };
390
+ }
391
+ await ports.reopenIssue(repo, issue.number);
392
+ state.reviewRounds = 0;
393
+ state.reviewLog = [];
394
+ await openPr(job);
395
+ return { done: false, fixed: true };
396
+ }
324
397
  function escalate(job, reason) {
325
398
  return handOver(job.context, job.log, job.issue.number, job.state, reason);
326
399
  }
400
+ function stop(job) {
401
+ const { issue, log, state } = job;
402
+ log.event("issue.stop", { phase: state.phase }, "quiet");
403
+ return {
404
+ issue: issue.number,
405
+ outcome: "stopped",
406
+ pr: state.pr,
407
+ ciFixes: state.ciFixes,
408
+ reviewRounds: state.reviewRounds,
409
+ ms: Date.now() - job.started,
410
+ };
411
+ }
327
412
  async function finish(job, outcome, reason) {
328
413
  const { context, issue, log, state, branch } = job;
329
414
  const { repo, ports } = context;
@@ -342,6 +427,7 @@ async function finish(job, outcome, reason) {
342
427
  ciFixes: state.ciFixes,
343
428
  reviewRounds: state.reviewRounds,
344
429
  ms: Date.now() - job.started,
430
+ ...(job.mainBroken && outcome !== "done" ? { halt: true } : {}),
345
431
  };
346
432
  }
347
433
  async function fixRound(job, reason, detail, earlier, kind) {
package/dist/state.mjs CHANGED
@@ -22,15 +22,6 @@ export async function writeState(repo, state) {
22
22
  await mkdir(dir(repo), { recursive: true });
23
23
  await writeFile(file(repo, state.issue), `${JSON.stringify(state, null, 2)}\n`);
24
24
  }
25
- export async function takeStop(repo) {
26
- try {
27
- await rm(join(dir(repo), "stop"));
28
- return true;
29
- }
30
- catch {
31
- return false;
32
- }
33
- }
34
25
  export async function dropState(repo, issue) {
35
26
  await rm(file(repo, issue), { force: true });
36
27
  }
package/dist/summary.mjs CHANGED
@@ -1,4 +1,4 @@
1
- const OUTCOMES = ["done", "needs-human", "error", "skipped"];
1
+ const OUTCOMES = ["done", "needs-human", "error", "skipped", "stopped"];
2
2
  export function formatSummary(summary) {
3
3
  const tally = OUTCOMES.map((outcome) => `${summary.issues.filter((report) => report.outcome === outcome).length} ${outcome}`).join(", ");
4
4
  const lines = [`run ${summary.runId}`, `issues ${summary.issues.length}: ${tally}`];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",