next-issue 0.20.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
@@ -1,20 +1,12 @@
1
1
  # next-issue
2
2
 
3
3
  A small harness that works through the open issues of a GitHub repository with
4
- the Claude Agent SDK, or with [pi](https://pi.dev). Run it inside a git
5
- repository.
4
+ the Claude Agent SDK. Run it inside a git repository.
6
5
 
7
6
  ```bash
8
7
  npx -y next-issue@latest
9
8
  ```
10
9
 
11
- Or install it once:
12
-
13
- ```bash
14
- npm install -g next-issue@latest
15
- next-issue
16
- ```
17
-
18
10
  ## What it does
19
11
 
20
12
  For each open issue, oldest first:
@@ -35,11 +27,16 @@ For each open issue, oldest first:
35
27
  writes a `base.behind` event. A new branch then starts from the remote base.
36
28
  4. Let the implementer agent do the work, then commit and push. When a git
37
29
  hook stops a commit, the agent that made the change gets the hook output,
38
- fixes it, and the harness commits again. The budget is `maxCommitFixes`.
39
- 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.
40
36
  6. Wait for the checks, once the pull request shows the pushed commit. A
41
37
  pending state waits again, up to `checkTimeoutMinutes`. A read that fails
42
- 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
43
40
  check keeps asking for `checkGraceSeconds`, because a new pull request often
44
41
  shows no check yet, and only then goes straight to the review. The later
45
42
  gates of the same issue ask once.
@@ -62,16 +59,16 @@ For each open issue, oldest first:
62
59
  10. No open finding means approval: mark the pull request ready for review and
63
60
  set `status:done`. The merge stays with you, unless you pass `--merge`:
64
61
  then the harness squash-merges the pull request, deletes the remote
65
- branch and waits for the checks that the merge commit starts on the base
66
- branch. A cancelled run there is no failure. When those checks fail, the
67
- fixer gets their logs on a branch reset to the base branch, from the check
68
- budget, and the harness reopens the issue and opens a new pull request that
69
- goes to step 6 again, with fresh review rounds. The next issue waits for
70
- this, and a second ctrl-c stops only after it. The worktree, the local
71
- branch and the state file go away. Without `--merge`, the remote branch
72
- stays. A problem that the
73
- reviewer leaves out of the findings thus gets an approval: only the findings
74
- 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.
75
72
  11. With an open finding, run the fixer agent and go to step 6 again. The
76
73
  budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
77
74
  changes nothing and gives its reason instead. The reason goes on the pull
@@ -103,6 +100,8 @@ when:
103
100
  - the reviewer gives no verdict, or the checks do not finish in time;
104
101
  - with `--merge`, the merge of an approved pull request fails, for example
105
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;
106
105
  - with `--merge`, the checks on the base branch fail after the merge and the
107
106
  fixer cannot fix them, or they do not finish in time. The base branch is
108
107
  then broken, so the run stops after this issue;
@@ -152,15 +151,14 @@ and a `status:needs-human` pull request shows work that waits for a person.
152
151
  The harness runs all git and `gh` commands itself. The commit holds all the
153
152
  changes in the worktree, so a setup command that writes a file outside
154
153
  `.gitignore` puts that file in the commit. The reviewer is the one agent whose
155
- changes never get that far: the harness discards them before it reads the
156
- 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.
157
156
 
158
157
  ## Safety
159
158
 
160
159
  Every agent can run a shell command. The implementer and the fixer run with the
161
160
  permission checks off; the reviewer runs with a smaller tool set, but its shell
162
- has no limit on the commands. pi has no permission checks, so with the `pi`
163
- agent the reviewer only runs without the `edit` and `write` tools. They all start in the worktree, but nothing holds
161
+ has no limit on the commands. They all start in the worktree, but nothing holds
164
162
  them there: they can run any command, with your rights, anywhere on the machine.
165
163
  The prompt holds the title, the body and the comments of the issue, which come
166
164
  from GitHub.
@@ -215,7 +213,6 @@ Three levels of console output:
215
213
  - `gh`, authenticated with write access to the repository
216
214
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
217
215
  Claude subscription login
218
- - With the `pi` agent: `pi` on the `PATH`, set up with a model instead
219
216
 
220
217
  ## Configuration
221
218
 
@@ -236,7 +233,6 @@ does not know all stop the run, so a typo cannot pass without a word.
236
233
 
237
234
  ```json
238
235
  {
239
- "agent": "claude",
240
236
  "remote": "origin",
241
237
  "issueLimit": 100,
242
238
  "maxCiFixes": 5,
@@ -270,7 +266,6 @@ does not know all stop the run, so a typo cannot pass without a word.
270
266
 
271
267
  | Field | Default | Effect |
272
268
  | --- | --- | --- |
273
- | `agent` | `claude` | The agent that does the work: `claude` or `pi` |
274
269
  | `remote` | `origin` | The git remote for the repository and the branches |
275
270
  | `issueLimit` | `100` | The maximum number of open issues to read |
276
271
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
@@ -299,22 +294,6 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
299
294
  A role set to `null`, or with no entry in `models`, uses the default model of
300
295
  the agent.
301
296
 
302
- ## The pi agent
303
-
304
- With `"agent": "pi"` or `--agent pi`, each agent call runs `pi --mode json` in
305
- the worktree with the prompt on standard input. pi loads your own settings,
306
- extensions, skills and context files, as it does when you run it yourself. The
307
- flag wins over the config file.
308
-
309
- - The implementer and the fixer use your default pi tools.
310
- - The reviewer runs with `--tools -edit,-write`, so it keeps `bash`.
311
- - A model in `models` goes to `pi --model` as it is, so use a name that pi
312
- knows.
313
- - pi has no structured output, so the reviewer is asked to end its reply with
314
- a JSON verdict. A reply with no valid verdict hands the issue to a person.
315
- - A run fails when its last reply ends with an error, when pi exits with an
316
- error, or when pi stops before the run settles.
317
-
318
297
  Without a `setupCommand`, the harness looks in the new worktree for a lock file
319
298
  and runs the install command that goes with it:
320
299
 
@@ -340,7 +319,8 @@ A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
340
319
  hands the issue to a person. The wait for the checks has its own limit, so
341
320
  `commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
342
321
  issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
343
- 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.
344
324
 
345
325
  ## Retry an issue that waits for a person
346
326
 
@@ -361,7 +341,6 @@ that a label still holds back stays as it was.
361
341
  | `--force` | Replace an existing config file, with `init` |
362
342
  | `--once` | Stop after the first handled issue |
363
343
  | `--max <n>` | Handle at most n issues |
364
- | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
365
344
  | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
366
345
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
367
346
  | `--json` | Print the machine summary instead of the text one |
@@ -396,6 +375,7 @@ The workflow then bumps the version and publishes the package to npm with a
396
375
  [trusted publish](https://docs.npmjs.com/trusted-publishers), so no token is
397
376
  stored. Only after the publish does it push the tag and make the GitHub release,
398
377
  so a failed publish leaves no half-release behind. A manual run of the workflow
399
- publishes the version in `package.json` and changes nothing else, which repairs
400
- a release that reached GitHub but not npm. The release job stays on a
401
- 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,12 +1,12 @@
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
- import { runPi } from "./pi.mjs";
6
7
  const REVIEWING = {
7
8
  tools: ["Read", "Grep", "Glob", "Bash"],
8
9
  permissionMode: "dontAsk",
9
- piTools: "-edit,-write",
10
10
  };
11
11
  const CODING = {
12
12
  tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
@@ -14,10 +14,9 @@ const CODING = {
14
14
  allowDangerouslySkipPermissions: true,
15
15
  };
16
16
  export { CODING, REVIEWING };
17
+ const STDERR_TAIL = 2000;
18
+ const STDERR_GRACE_MS = 500;
17
19
  export async function runAgent(recorder, request) {
18
- if (request.agent === "pi") {
19
- return runPi(recorder, request);
20
- }
21
20
  const log = recorder.scope({ role: request.name });
22
21
  const usage = emptyUsage();
23
22
  const toolNames = new Map();
@@ -28,6 +27,8 @@ export async function runAgent(recorder, request) {
28
27
  let model = request.model;
29
28
  let sessionId;
30
29
  let structured;
30
+ let stderr = "";
31
+ let stderrClosed = Promise.resolve();
31
32
  const started = Date.now();
32
33
  log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
33
34
  const controller = new AbortController();
@@ -47,7 +48,14 @@ export async function runAgent(recorder, request) {
47
48
  allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
48
49
  systemPrompt: { type: "preset", preset: "claude_code" },
49
50
  abortController: controller,
50
- 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
+ },
51
59
  outputFormat: request.outputSchema === undefined
52
60
  ? undefined
53
61
  : { type: "json_schema", schema: request.outputSchema },
@@ -106,7 +114,11 @@ export async function runAgent(recorder, request) {
106
114
  }
107
115
  }
108
116
  catch (error) {
109
- 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);
110
122
  }
111
123
  finally {
112
124
  clearTimeout(timer);
@@ -146,20 +158,29 @@ function main(models) {
146
158
  return name;
147
159
  }
148
160
  export function toolFields(name, input) {
149
- if ((name !== "Bash" && name !== "bash") || typeof input !== "object" || input === null) {
161
+ if (name !== "Bash" || typeof input !== "object" || input === null) {
150
162
  return { tool: name };
151
163
  }
152
164
  const { command } = input;
153
165
  return typeof command === "string" ? { tool: name, command } : { tool: name };
154
166
  }
155
- 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) {
156
175
  const child = spawn(options.command, options.args, {
157
176
  cwd: options.cwd,
158
177
  env: options.env,
159
178
  signal: options.signal,
160
179
  detached: true,
161
- stdio: ["pipe", "pipe", "ignore"],
180
+ stdio: ["pipe", "pipe", "pipe"],
162
181
  });
182
+ child.stderr.setEncoding("utf8");
183
+ child.stderr.on("data", onStderr);
163
184
  track(child);
164
185
  return child;
165
186
  }
package/dist/args.mjs CHANGED
@@ -1,11 +1,10 @@
1
- import { AGENT_NAMES, CONFIG_FILE } from "./config.mjs";
1
+ import { CONFIG_FILE } from "./config.mjs";
2
2
  export const USAGE = `next-issue [command] [options]
3
3
 
4
4
  init write ${CONFIG_FILE} with the defaults
5
5
 
6
6
  --once stop after the first handled issue
7
7
  --max <n> handle at most n issues
8
- --agent <a> run the agents with claude or pi
9
8
  --merge squash-merge the pull request after the approval
10
9
  --reset drop the saved budgets and findings first
11
10
  --json print the machine summary instead of the text one
@@ -57,13 +56,6 @@ export function parseArgs(argv) {
57
56
  else if (arg === "--quiet") {
58
57
  args.level = "quiet";
59
58
  }
60
- else if (arg === "--agent") {
61
- const value = argv[++index];
62
- if (value === undefined || !AGENT_NAMES.includes(value)) {
63
- throw new Error("--agent needs claude or pi");
64
- }
65
- args.agent = value;
66
- }
67
59
  else if (arg === "--max") {
68
60
  args.max = whole(argv[++index], arg);
69
61
  }
package/dist/cli.mjs CHANGED
@@ -29,7 +29,6 @@ async function main() {
29
29
  return init(root, args.force);
30
30
  }
31
31
  const config = await loadConfig(root);
32
- config.agent = args.agent ?? config.agent;
33
32
  setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
34
33
  const repo = await detectRepo(root, config.remote);
35
34
  const recorder = await Recorder.create(repo.root, args.level);
package/dist/config.mjs CHANGED
@@ -1,9 +1,7 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { message } from "./observe.mjs";
4
- export const AGENT_NAMES = ["claude", "pi"];
5
4
  const DEFAULTS = {
6
- agent: "claude",
7
5
  remote: "origin",
8
6
  issueLimit: 100,
9
7
  maxCiFixes: 5,
@@ -95,12 +93,6 @@ function parseConfig(value) {
95
93
  }
96
94
  }
97
95
  const config = defaults();
98
- if (file.agent !== undefined) {
99
- if (typeof file.agent !== "string" || !AGENT_NAMES.includes(file.agent)) {
100
- throw new Error(`${CONFIG_FILE}: agent must be claude or pi.`);
101
- }
102
- config.agent = file.agent;
103
- }
104
96
  if (file.remote !== undefined) {
105
97
  config.remote = text(file.remote, "remote", false);
106
98
  }
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 });
@@ -162,7 +169,6 @@ async function work(context, issue, log, state) {
162
169
  prompt: implementPrompt(issue, comments),
163
170
  profile: CODING,
164
171
  model: config.models.implementer,
165
- agent: config.agent,
166
172
  timeoutMs: config.agentTimeoutMinutes * 60_000,
167
173
  }));
168
174
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
@@ -205,7 +211,7 @@ async function openPr(job) {
205
211
  const pr = await log.step("pull-request", { branch }, async () => {
206
212
  const existing = await ports.findPr(repo, branch);
207
213
  return (existing ??
208
- 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));
209
215
  });
210
216
  state.pr = pr;
211
217
  await writeState(repo, state);
@@ -270,17 +276,17 @@ async function reviewRound(job) {
270
276
  diffChars: full.length,
271
277
  cut: full.length > config.diffMaxChars,
272
278
  };
279
+ const before = await ports.revision(worktree);
273
280
  const review = await log.step("review", fields, () => ports.runAgent(log, {
274
281
  name: "reviewer",
275
282
  cwd: worktree,
276
283
  prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
277
284
  profile: REVIEWING,
278
285
  model: config.models.reviewer,
279
- agent: config.agent,
280
286
  timeoutMs: config.agentTimeoutMinutes * 60_000,
281
287
  outputSchema: VERDICT_SCHEMA,
282
288
  }));
283
- if (await ports.discardChanges(worktree)) {
289
+ if (await ports.discardChanges(worktree, before)) {
284
290
  log.event("review.discard", { round: state.reviewRounds }, "quiet");
285
291
  }
286
292
  const verdict = readVerdict(review.structured);
@@ -353,7 +359,14 @@ async function reviewRound(job) {
353
359
  async function watchBase(job) {
354
360
  const { context, issue, log, state, worktree } = job;
355
361
  const { repo, config, ports } = context;
356
- 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
+ }
357
370
  const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
358
371
  intervalSeconds: config.checkIntervalSeconds,
359
372
  graceMs: config.checkGraceSeconds * 1000,
@@ -440,7 +453,6 @@ async function fixRound(job, reason, detail, earlier, kind) {
440
453
  prompt: fixPrompt(issue, reason, detail, earlier, kind),
441
454
  profile: CODING,
442
455
  model: context.config.models.fixer,
443
- agent: context.config.agent,
444
456
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
445
457
  }));
446
458
  const unrelated = parseUnrelated(result.text);
@@ -465,7 +477,11 @@ async function commitWork(job, before, subject, role) {
465
477
  if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
466
478
  log.event("commit", { subject });
467
479
  }
468
- return (await ports.revision(worktree)) !== before;
480
+ if ((await ports.revision(worktree)) === before) {
481
+ return false;
482
+ }
483
+ state.title = subject;
484
+ return true;
469
485
  }
470
486
  catch (error) {
471
487
  const used = state.commitFixes ?? 0;
@@ -476,15 +492,15 @@ async function commitWork(job, before, subject, role) {
476
492
  await writeState(repo, state);
477
493
  const output = message(error).slice(-config.logMaxChars);
478
494
  log.event("commit.fail", { role, output }, "quiet");
479
- await log.step("commit-fix", { role }, () => ports.runAgent(log, {
495
+ const fix = await log.step("commit-fix", { role }, () => ports.runAgent(log, {
480
496
  name: role,
481
497
  cwd: worktree,
482
498
  prompt: hookPrompt(issue, comments, role, output),
483
499
  profile: CODING,
484
500
  model: config.models[role],
485
- agent: config.agent,
486
501
  timeoutMs: config.agentTimeoutMinutes * 60_000,
487
502
  }));
503
+ subject = parseCommitSubject(fix.text, subject);
488
504
  }
489
505
  }
490
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": "0.20.0",
3
+ "version": "1.0.1",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",
package/dist/pi.mjs DELETED
@@ -1,159 +0,0 @@
1
- import { toolFields } from "./agents.mjs";
2
- import { run } from "./exec.mjs";
3
- import { emptyUsage } from "./observe.mjs";
4
- const NO_LIMIT = 2 ** 31 - 1;
5
- const OUTPUT_TAIL = 200_000;
6
- export async function runPi(recorder, request) {
7
- const log = recorder.scope({ role: request.name });
8
- const usage = emptyUsage();
9
- let text = "";
10
- let toolCalls = 0;
11
- let turns = 0;
12
- let costUsd = 0;
13
- let model = request.model;
14
- let sessionId;
15
- let settled = false;
16
- let last;
17
- const read = (line) => {
18
- let event;
19
- try {
20
- event = JSON.parse(line);
21
- }
22
- catch {
23
- return;
24
- }
25
- if (event.type === "session") {
26
- sessionId = event.id;
27
- }
28
- else if (event.type === "message_end" && event.message?.role === "assistant") {
29
- const message = event.message;
30
- last = message;
31
- turns += 1;
32
- model = message.model ?? model;
33
- const input = message.usage?.input ?? 0;
34
- const output = message.usage?.output ?? 0;
35
- const cacheRead = message.usage?.cacheRead ?? 0;
36
- const cacheWrite = message.usage?.cacheWrite ?? 0;
37
- usage.input += input;
38
- usage.output += output;
39
- usage.cacheRead += cacheRead;
40
- usage.cacheWrite += cacheWrite;
41
- usage.total += input + output + cacheRead + cacheWrite;
42
- costUsd += message.usage?.cost?.total ?? 0;
43
- const said = textOf(message.content);
44
- if (said.length > 0) {
45
- text = said;
46
- if (log.level === "verbose") {
47
- process.stderr.write(said);
48
- }
49
- }
50
- }
51
- else if (event.type === "tool_execution_start") {
52
- toolCalls += 1;
53
- log.event("tool", toolFields(event.toolName ?? "unknown", event.args));
54
- }
55
- else if (event.type === "tool_execution_end" && event.isError === true) {
56
- log.event("tool.error", { tool: event.toolName }, "quiet");
57
- }
58
- else if (event.type === "agent_settled") {
59
- settled = true;
60
- }
61
- };
62
- const started = Date.now();
63
- log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs, agent: "pi" }, "normal");
64
- let rest = "";
65
- const result = await run("pi", piArgs(request), {
66
- cwd: request.cwd,
67
- input: piPrompt(request),
68
- timeoutMs: request.timeoutMs ?? NO_LIMIT,
69
- tailChars: OUTPUT_TAIL,
70
- onStdout: (chunk) => {
71
- rest += chunk;
72
- let end = rest.indexOf("\n");
73
- while (end !== -1) {
74
- read(rest.slice(0, end).replace(/\r$/, ""));
75
- rest = rest.slice(end + 1);
76
- end = rest.indexOf("\n");
77
- }
78
- },
79
- });
80
- if (rest.length > 0) {
81
- read(rest);
82
- }
83
- const failed = (reason) => new Error(`The ${request.name} agent failed: ${reason}`);
84
- if (result.timedOut) {
85
- throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
86
- }
87
- if (last?.stopReason === "error" || last?.stopReason === "aborted") {
88
- throw failed(last.errorMessage ?? last.stopReason);
89
- }
90
- if (result.code !== 0) {
91
- throw failed(`pi exited with code ${result.code}: ${result.stderr.trim()}`);
92
- }
93
- if (!settled) {
94
- throw failed("pi stopped before the run settled");
95
- }
96
- if (last?.stopReason === "length") {
97
- log.event("agent.incomplete", { subtype: "length" }, "quiet");
98
- }
99
- const ms = Date.now() - started;
100
- recorder.usage(request.name, usage, ms, {
101
- turns,
102
- toolCalls,
103
- model,
104
- session: sessionId,
105
- costUsd,
106
- });
107
- return {
108
- text: text.trim(),
109
- usage,
110
- turns,
111
- toolCalls,
112
- ms,
113
- costUsd,
114
- model,
115
- sessionId,
116
- structured: request.outputSchema === undefined ? undefined : jsonOf(text),
117
- };
118
- }
119
- function piArgs(request) {
120
- return [
121
- "--mode",
122
- "json",
123
- ...(request.model === undefined ? [] : ["--model", request.model]),
124
- ...(request.profile.piTools === undefined ? [] : ["--tools", request.profile.piTools]),
125
- ];
126
- }
127
- function piPrompt(request) {
128
- if (request.outputSchema === undefined) {
129
- return request.prompt;
130
- }
131
- return [
132
- request.prompt,
133
- "",
134
- "End your reply with only a JSON object in a json code block. The object must match this JSON schema:",
135
- JSON.stringify(request.outputSchema),
136
- ].join("\n");
137
- }
138
- function textOf(content) {
139
- if (typeof content === "string") {
140
- return content;
141
- }
142
- if (!Array.isArray(content)) {
143
- return "";
144
- }
145
- return content
146
- .filter((block) => block?.type === "text" && typeof block.text === "string")
147
- .map((block) => block.text)
148
- .join("");
149
- }
150
- function jsonOf(text) {
151
- const fenced = [...text.matchAll(/```(?:json)?[ \t]*\n([\s\S]*?)```/g)].at(-1)?.[1];
152
- const candidate = fenced ?? text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
153
- try {
154
- return JSON.parse(candidate);
155
- }
156
- catch {
157
- return undefined;
158
- }
159
- }