next-issue 1.0.0 → 1.0.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
@@ -7,13 +7,6 @@ the Claude Agent SDK. Run it inside a git repository.
7
7
  npx -y next-issue@latest
8
8
  ```
9
9
 
10
- Or install it once:
11
-
12
- ```bash
13
- npm install -g next-issue@latest
14
- next-issue
15
- ```
16
-
17
10
  ## What it does
18
11
 
19
12
  For each open issue, oldest first:
@@ -34,11 +27,16 @@ For each open issue, oldest first:
34
27
  writes a `base.behind` event. A new branch then starts from the remote base.
35
28
  4. Let the implementer agent do the work, then commit and push. When a git
36
29
  hook stops a commit, the agent that made the change gets the hook output,
37
- fixes it, and the harness commits again. The budget is `maxCommitFixes`.
38
- 5. Open a draft pull request that closes the issue.
30
+ fixes it, and the harness commits again. A `commit:` line in the reply of
31
+ the agent replaces the subject, so a hook that rejects the subject can pass.
32
+ The budget is `maxCommitFixes`.
33
+ 5. Open a draft pull request that closes the issue. Its title is the subject
34
+ of the commit that starts it, so a squash merge gives a conventional
35
+ commit.
39
36
  6. Wait for the checks, once the pull request shows the pushed commit. A
40
37
  pending state waits again, up to `checkTimeoutMinutes`. A read that fails
41
- is tried three times before the run stops. A repository that reports no
38
+ is tried three times before the run stops. A watch that exits with an error
39
+ while no check failed counts as such a read, not as a red build. A repository that reports no
42
40
  check keeps asking for `checkGraceSeconds`, because a new pull request often
43
41
  shows no check yet, and only then goes straight to the review. The later
44
42
  gates of the same issue ask once.
@@ -61,16 +59,16 @@ For each open issue, oldest first:
61
59
  10. No open finding means approval: mark the pull request ready for review and
62
60
  set `status:done`. The merge stays with you, unless you pass `--merge`:
63
61
  then the harness squash-merges the pull request, deletes the remote
64
- branch and waits for the checks that the merge commit starts on the base
65
- branch. A cancelled run there is no failure. When those checks fail, the
66
- fixer gets their logs on a branch reset to the base branch, from the check
67
- budget, and the harness reopens the issue and opens a new pull request that
68
- goes to step 6 again, with fresh review rounds. The next issue waits for
69
- this, and a second ctrl-c stops only after it. The worktree, the local
70
- branch and the state file go away. Without `--merge`, the remote branch
71
- stays. A problem that the
72
- reviewer leaves out of the findings thus gets an approval: only the findings
73
- count.
62
+ branch, waits until the pull request is merged, for example through a
63
+ merge queue, and then waits for the checks that the merge commit starts on
64
+ the base branch. A cancelled run there is no failure. When those checks
65
+ fail, the fixer gets their logs on a branch reset to the base branch, from
66
+ the check budget, and the harness reopens the issue and opens a new pull
67
+ request that goes to step 6 again, with fresh review rounds. The next
68
+ issue waits for this, and a second ctrl-c stops only after it. The
69
+ worktree, the local branch and the state file go away. Without `--merge`,
70
+ the remote branch stays. A problem that the reviewer leaves out of the
71
+ findings thus gets an approval: only the findings count.
74
72
  11. With an open finding, run the fixer agent and go to step 6 again. The
75
73
  budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
76
74
  changes nothing and gives its reason instead. The reason goes on the pull
@@ -102,6 +100,8 @@ when:
102
100
  - the reviewer gives no verdict, or the checks do not finish in time;
103
101
  - with `--merge`, the merge of an approved pull request fails, for example
104
102
  because branch protection asks for a human approval;
103
+ - with `--merge`, a queued merge does not finish in `checkTimeoutMinutes`, or
104
+ the pull request closes without a merge;
105
105
  - with `--merge`, the checks on the base branch fail after the merge and the
106
106
  fixer cannot fix them, or they do not finish in time. The base branch is
107
107
  then broken, so the run stops after this issue;
@@ -151,8 +151,8 @@ and a `status:needs-human` pull request shows work that waits for a person.
151
151
  The harness runs all git and `gh` commands itself. The commit holds all the
152
152
  changes in the worktree, so a setup command that writes a file outside
153
153
  `.gitignore` puts that file in the commit. The reviewer is the one agent whose
154
- changes never get that far: the harness discards them before it reads the
155
- verdict.
154
+ changes never get that far: the harness discards them, and undoes any commit
155
+ that the reviewer made, before it reads the verdict.
156
156
 
157
157
  ## Safety
158
158
 
@@ -319,7 +319,8 @@ A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
319
319
  hands the issue to a person. The wait for the checks has its own limit, so
320
320
  `commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
321
321
  issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
322
- run stopped.
322
+ run stopped. A state file that is not valid JSON gives the issue an `error`
323
+ outcome that names the file, rather than a silent skip.
323
324
 
324
325
  ## Retry an issue that waits for a person
325
326
 
@@ -374,6 +375,7 @@ The workflow then bumps the version and publishes the package to npm with a
374
375
  [trusted publish](https://docs.npmjs.com/trusted-publishers), so no token is
375
376
  stored. Only after the publish does it push the tag and make the GitHub release,
376
377
  so a failed publish leaves no half-release behind. A manual run of the workflow
377
- publishes the version in `package.json` and changes nothing else, which repairs
378
- a release that reached GitHub but not npm. The release job stays on a
379
- GitHub runner, because npm takes provenance only from a GitHub runner.
378
+ checks out the last tag and publishes that commit under the tag version, and
379
+ changes nothing else. This repairs a release that reached GitHub but not npm.
380
+ The release job stays on a GitHub runner, because npm takes provenance only
381
+ from a GitHub runner.
package/dist/agents.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { setTimeout as delay } from "node:timers/promises";
3
5
  import { track } from "./exec.mjs";
4
6
  import { emptyUsage } from "./observe.mjs";
5
7
  const REVIEWING = {
@@ -12,6 +14,8 @@ const CODING = {
12
14
  allowDangerouslySkipPermissions: true,
13
15
  };
14
16
  export { CODING, REVIEWING };
17
+ const STDERR_TAIL = 2000;
18
+ const STDERR_GRACE_MS = 500;
15
19
  export async function runAgent(recorder, request) {
16
20
  const log = recorder.scope({ role: request.name });
17
21
  const usage = emptyUsage();
@@ -23,6 +27,8 @@ export async function runAgent(recorder, request) {
23
27
  let model = request.model;
24
28
  let sessionId;
25
29
  let structured;
30
+ let stderr = "";
31
+ let stderrClosed = Promise.resolve();
26
32
  const started = Date.now();
27
33
  log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
28
34
  const controller = new AbortController();
@@ -42,7 +48,14 @@ export async function runAgent(recorder, request) {
42
48
  allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
43
49
  systemPrompt: { type: "preset", preset: "claude_code" },
44
50
  abortController: controller,
45
- spawnClaudeCodeProcess: spawnClaude,
51
+ pathToClaudeCodeExecutable: request.executable,
52
+ spawnClaudeCodeProcess: (options) => {
53
+ const child = spawnClaude(options, (chunk) => {
54
+ stderr = (stderr + chunk).slice(-STDERR_TAIL);
55
+ });
56
+ stderrClosed = once(child.stderr, "close").catch(() => undefined);
57
+ return child;
58
+ },
46
59
  outputFormat: request.outputSchema === undefined
47
60
  ? undefined
48
61
  : { type: "json_schema", schema: request.outputSchema },
@@ -101,7 +114,11 @@ export async function runAgent(recorder, request) {
101
114
  }
102
115
  }
103
116
  catch (error) {
104
- throw controller.signal.aborted ? late() : error;
117
+ if (controller.signal.aborted) {
118
+ throw late();
119
+ }
120
+ await Promise.race([stderrClosed, delay(STDERR_GRACE_MS, undefined, { ref: false })]);
121
+ throw withStderr(error, stderr);
105
122
  }
106
123
  finally {
107
124
  clearTimeout(timer);
@@ -147,14 +164,23 @@ export function toolFields(name, input) {
147
164
  const { command } = input;
148
165
  return typeof command === "string" ? { tool: name, command } : { tool: name };
149
166
  }
150
- export function spawnClaude(options) {
167
+ export function withStderr(error, stderr) {
168
+ const tail = stderr.trim();
169
+ if (tail.length === 0 || !(error instanceof Error)) {
170
+ return error;
171
+ }
172
+ return new Error(`${error.message}\nClaude CLI stderr:\n${tail}`, { cause: error });
173
+ }
174
+ export function spawnClaude(options, onStderr = () => undefined) {
151
175
  const child = spawn(options.command, options.args, {
152
176
  cwd: options.cwd,
153
177
  env: options.env,
154
178
  signal: options.signal,
155
179
  detached: true,
156
- stdio: ["pipe", "pipe", "ignore"],
180
+ stdio: ["pipe", "pipe", "pipe"],
157
181
  });
182
+ child.stderr.setEncoding("utf8");
183
+ child.stderr.on("data", onStderr);
158
184
  track(child);
159
185
  return child;
160
186
  }
package/dist/git.mjs CHANGED
@@ -100,11 +100,11 @@ export async function commitAll(cwd, message) {
100
100
  await must("git", ["commit", "-m", message], { cwd });
101
101
  return true;
102
102
  }
103
- export async function discardChanges(cwd) {
104
- if (!(await isDirty(cwd))) {
103
+ export async function discardChanges(cwd, target) {
104
+ if (!(await isDirty(cwd)) && (await revision(cwd)) === target) {
105
105
  return false;
106
106
  }
107
- await must("git", ["reset", "--hard", "HEAD"], { cwd });
107
+ await must("git", ["reset", "--hard", target], { cwd });
108
108
  await must("git", ["clean", "-fd"], { cwd });
109
109
  return true;
110
110
  }
package/dist/github.mjs CHANGED
@@ -168,7 +168,11 @@ async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
168
168
  if (watch.code === CHECK_PENDING_CODE) {
169
169
  return "pending";
170
170
  }
171
- return "fail";
171
+ const checks = await readChecks(repo, branch);
172
+ if (checks !== "none" && checks.some((check) => check.bucket === "fail")) {
173
+ return "fail";
174
+ }
175
+ throw new Error(`gh pr checks ${branch} --watch failed with code ${watch.code} and no check failed: ${watch.stderr.trim()}`);
172
176
  }
173
177
  const REAL_CLOCK = {
174
178
  now: () => Date.now(),
@@ -293,8 +297,20 @@ export async function mergePr(repo, pr) {
293
297
  const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
294
298
  return result.code === 0;
295
299
  }
296
- export async function mergeCommit(repo, pr) {
297
- return gh(repo, ["pr", "view", String(pr), "--json", "mergeCommit", "--jq", ".mergeCommit.oid"]);
300
+ export async function mergeCommit(repo, pr, options, clock = REAL_CLOCK) {
301
+ const deadline = clock.now() + options.timeoutMs;
302
+ for (;;) {
303
+ const raw = await gh(repo, ["pr", "view", String(pr), "--json", "state,mergeCommit"]);
304
+ const view = JSON.parse(raw);
305
+ if (view.state === "MERGED" && view.mergeCommit?.oid) {
306
+ return view.mergeCommit.oid;
307
+ }
308
+ const rest = deadline - clock.now();
309
+ if (view.state === "CLOSED" || rest <= 0) {
310
+ return undefined;
311
+ }
312
+ await clock.sleep(Math.min(options.intervalSeconds * 1000, rest));
313
+ }
298
314
  }
299
315
  export async function reopenIssue(repo, issue) {
300
316
  await gh(repo, ["issue", "reopen", String(issue)]);
package/dist/pipeline.mjs CHANGED
@@ -58,7 +58,14 @@ export function skipReason(issue, config, resuming) {
58
58
  export async function processIssue(context, issue) {
59
59
  const started = Date.now();
60
60
  const log = context.recorder.scope({ issue: issue.number });
61
- const saved = await readState(context.repo, issue.number);
61
+ let saved;
62
+ try {
63
+ saved = await readState(context.repo, issue.number);
64
+ }
65
+ catch (error) {
66
+ log.event("issue.error", { error: message(error), ms: Date.now() - started }, "quiet");
67
+ return { ...skipped(issue), outcome: "error", reason: message(error), ms: Date.now() - started };
68
+ }
62
69
  const skip = skipReason(issue, context.config, saved !== undefined);
63
70
  if (skip !== undefined) {
64
71
  log.event("issue.skip", { title: issue.title, reason: skip });
@@ -204,7 +211,7 @@ async function openPr(job) {
204
211
  const pr = await log.step("pull-request", { branch }, async () => {
205
212
  const existing = await ports.findPr(repo, branch);
206
213
  return (existing ??
207
- ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
214
+ ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
208
215
  });
209
216
  state.pr = pr;
210
217
  await writeState(repo, state);
@@ -269,6 +276,7 @@ async function reviewRound(job) {
269
276
  diffChars: full.length,
270
277
  cut: full.length > config.diffMaxChars,
271
278
  };
279
+ const before = await ports.revision(worktree);
272
280
  const review = await log.step("review", fields, () => ports.runAgent(log, {
273
281
  name: "reviewer",
274
282
  cwd: worktree,
@@ -278,7 +286,7 @@ async function reviewRound(job) {
278
286
  timeoutMs: config.agentTimeoutMinutes * 60_000,
279
287
  outputSchema: VERDICT_SCHEMA,
280
288
  }));
281
- if (await ports.discardChanges(worktree)) {
289
+ if (await ports.discardChanges(worktree, before)) {
282
290
  log.event("review.discard", { round: state.reviewRounds }, "quiet");
283
291
  }
284
292
  const verdict = readVerdict(review.structured);
@@ -351,7 +359,14 @@ async function reviewRound(job) {
351
359
  async function watchBase(job) {
352
360
  const { context, issue, log, state, worktree } = job;
353
361
  const { repo, config, ports } = context;
354
- const sha = await ports.mergeCommit(repo, state.pr);
362
+ const sha = await ports.mergeCommit(repo, state.pr, {
363
+ intervalSeconds: config.checkIntervalSeconds,
364
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
365
+ });
366
+ if (sha === undefined) {
367
+ await escalate(job, "The pull request did not merge in time, or it closed without a merge.");
368
+ return { done: true, report: await finish(job, "needs-human", "not merged") };
369
+ }
355
370
  const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
356
371
  intervalSeconds: config.checkIntervalSeconds,
357
372
  graceMs: config.checkGraceSeconds * 1000,
@@ -462,7 +477,11 @@ async function commitWork(job, before, subject, role) {
462
477
  if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
463
478
  log.event("commit", { subject });
464
479
  }
465
- return (await ports.revision(worktree)) !== before;
480
+ if ((await ports.revision(worktree)) === before) {
481
+ return false;
482
+ }
483
+ state.title = subject;
484
+ return true;
466
485
  }
467
486
  catch (error) {
468
487
  const used = state.commitFixes ?? 0;
@@ -473,7 +492,7 @@ async function commitWork(job, before, subject, role) {
473
492
  await writeState(repo, state);
474
493
  const output = message(error).slice(-config.logMaxChars);
475
494
  log.event("commit.fail", { role, output }, "quiet");
476
- await log.step("commit-fix", { role }, () => ports.runAgent(log, {
495
+ const fix = await log.step("commit-fix", { role }, () => ports.runAgent(log, {
477
496
  name: role,
478
497
  cwd: worktree,
479
498
  prompt: hookPrompt(issue, comments, role, output),
@@ -481,6 +500,7 @@ async function commitWork(job, before, subject, role) {
481
500
  model: config.models[role],
482
501
  timeoutMs: config.agentTimeoutMinutes * 60_000,
483
502
  }));
503
+ subject = parseCommitSubject(fix.text, subject);
484
504
  }
485
505
  }
486
506
  }
package/dist/prompts.mjs CHANGED
@@ -95,12 +95,12 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
95
95
  .join("\n\n");
96
96
  }
97
97
  export function parseUnrelated(text) {
98
- const match = /^unrelated:\s*(.+)$/im.exec(text);
98
+ const match = [...text.matchAll(/^unrelated:\s*(.+)$/gim)].at(-1);
99
99
  const note = match?.[1]?.trim();
100
100
  return note !== undefined && note.length > 0 ? note : undefined;
101
101
  }
102
102
  export function parseCommitSubject(text, fallback) {
103
- const match = /^commit:\s*(.+)$/im.exec(text);
103
+ const match = [...text.matchAll(/^commit:\s*(.+)$/gim)].at(-1);
104
104
  const subject = match?.[1]
105
105
  ?.trim()
106
106
  .replace(/^`+|`+$/g, "")
package/dist/state.mjs CHANGED
@@ -8,11 +8,22 @@ function file(repo, issue) {
8
8
  return join(dir(repo), `${issue}.json`);
9
9
  }
10
10
  export async function readState(repo, issue) {
11
+ const path = file(repo, issue);
12
+ let text;
11
13
  try {
12
- return JSON.parse(await readFile(file(repo, issue), "utf8"));
14
+ text = await readFile(path, "utf8");
15
+ }
16
+ catch (error) {
17
+ if (error.code === "ENOENT") {
18
+ return undefined;
19
+ }
20
+ throw error;
21
+ }
22
+ try {
23
+ return JSON.parse(text);
13
24
  }
14
25
  catch {
15
- return undefined;
26
+ throw new Error(`the state file ${path} is not valid JSON`);
16
27
  }
17
28
  }
18
29
  export function resetState(state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",