next-issue 0.8.0 → 0.9.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
@@ -21,25 +21,31 @@ For each open issue, oldest first:
21
21
  1. Claim the issue only when it holds the `status:todo` label, holds no
22
22
  `status:in-progress` or `status:in-review` label, and has no assignee. A
23
23
  `status:done`, `status:needs-human` or `status:blocked` label always stops
24
- the claim. An issue with saved state under `.next-issue/` resumes instead,
25
- whatever its labels, unless another person is now the assignee.
24
+ the claim, a resume too. An issue with saved state under `.next-issue/`
25
+ resumes instead, whatever its other labels, unless another person is now the
26
+ assignee.
26
27
  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.
28
+ 3. Fetch the remote, fast-forward the local copy of the default branch when
29
+ that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
30
+ `issue-<n>` branch. A branch that exists on the remote is the start, and a
31
+ local branch that fell behind the remote is fast-forwarded.
29
32
  4. Let the implementer agent do the work, then commit and push.
30
33
  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.
34
+ 6. Wait for the checks, once the pull request shows the pushed commit. A
35
+ pending state waits again, up to `checkTimeoutMinutes`. A read that fails
36
+ is tried three times before the run stops. A repository that reports no
37
+ check keeps asking for `checkGraceSeconds`, because a new pull request often
38
+ shows no check yet, and only then goes straight to the review. The later
39
+ gates of the same issue ask once.
35
40
  7. On a red build, give the failed job logs to the fixer agent, then go to step
36
41
  6 again. The budget is `maxCiFixes`.
37
42
  8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
38
43
  reviewer returns a structured verdict that rates each finding `blocking` or
39
44
  `minor`. The harness puts the result on the pull request.
40
45
  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.
46
+ and set `status:done`. The merge stays with you. The worktree, the local
47
+ branch and the state file go away. The remote branch stays. A blocking
48
+ finding always stops the approval, even when the reviewer also says approve.
43
49
  10. With a blocking finding, run the fixer agent and go to step 6 again. The
44
50
  budget is `maxReviewRounds`.
45
51
 
@@ -106,18 +112,21 @@ Every run has an id, `<timestamp>`, and writes two files under
106
112
  one report per issue with the outcome, the reason, the pull request number
107
113
  and the budgets used.
108
114
 
115
+ At the start of a run, the harness deletes the files under `.next-issue/runs/`
116
+ that are older than `keepRunDays`. Set the field to `0` to keep them all.
117
+
109
118
  A short text summary of the same data goes to standard output. Use `--json` to
110
119
  get the full summary object there instead, so the harness fits in a pipe. All
111
120
  progress goes to standard error.
112
121
 
113
- Recorded for each step: `claim`, `comments`, `worktree`, `implement`, `push`,
114
- `pull-request`, `checks`, `review` and `fix`, each with a `.start`, `.end` or
115
- `.error` event and a duration in `ms`. Every `git` and `gh` command is recorded
116
- with its exit code and duration. A `tool` event holds the tool name, and for a
117
- `Bash` tool the command too. Each agent call adds a `usage` event with the
118
- tokens, the cost estimate in dollars, the turn count, the tool-call count, the
119
- model and the session id, so you can open the session again and read the full
120
- transcript.
122
+ Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
123
+ `push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
124
+ `.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
125
+ is recorded with its exit code and duration. A `tool` event holds the tool
126
+ name, and for a `Bash` tool the command too. Each agent call adds a `usage`
127
+ event with the tokens, the cost estimate in dollars, the turn count, the
128
+ tool-call count, the model and the session id, so you can open the session
129
+ again and read the full transcript.
121
130
 
122
131
  ```bash
123
132
  jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
@@ -134,7 +143,7 @@ Three levels of console output:
134
143
 
135
144
  ## Requirements
136
145
 
137
- - Node 24 or later, because the command runs the TypeScript source directly
146
+ - Node 24 or later
138
147
  - `gh`, authenticated with write access to the repository
139
148
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
140
149
  Claude subscription login
@@ -170,6 +179,7 @@ does not know all stop the run, so a typo cannot pass without a word.
170
179
  "commandTimeoutMinutes": 10,
171
180
  "logMaxChars": 20000,
172
181
  "diffMaxChars": 60000,
182
+ "keepRunDays": 7,
173
183
  "draftPullRequest": true,
174
184
  "models": {},
175
185
  "labels": {
@@ -195,8 +205,9 @@ does not know all stop the run, so a typo cannot pass without a word.
195
205
  | `agentTimeoutMinutes` | `30` | The limit for one agent call |
196
206
  | `setupTimeoutMinutes` | `15` | The limit for the setup command |
197
207
  | `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
198
- | `logMaxChars` | `20000` | The maximum length of the failed job logs |
208
+ | `logMaxChars` | `20000` | The maximum length of the failed job logs, shared between the failed jobs |
199
209
  | `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
210
+ | `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
200
211
  | `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
201
212
  | `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
202
213
  | `models` | `{}` | The model per agent role |
@@ -221,7 +232,10 @@ A hand-over leaves the state file with the used budgets in it, so the next run
221
232
  would hand the issue over again at once. To give the issue another try, take the
222
233
  `status:needs-human` label off and run with `--reset`. The flag puts the budgets
223
234
  and the findings back to zero and keeps the branch, the pull request and the
224
- step that the last run reached, so the work carries on where it stopped.
235
+ step that the last run reached, so the work carries on where it stopped. A
236
+ commit that you pushed to the pull request in the meantime is fetched first.
237
+ The flag touches only an issue that the run handles, so the state of an issue
238
+ that a label still holds back stays as it was.
225
239
 
226
240
  ## Commands and options
227
241
 
@@ -237,6 +251,9 @@ step that the last run reached, so the work carries on where it stopped.
237
251
  | `--quiet` | Show only the milestones and the summary |
238
252
  | `--help` | Show the option list |
239
253
 
254
+ The exit code is 1 when an issue needs a person or failed, and 0 when it does
255
+ not, so a cron job or a CI step can read the result of the run.
256
+
240
257
  ## Development
241
258
 
242
259
  ```bash
package/dist/cli.mjs CHANGED
@@ -6,9 +6,10 @@ 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 { message, Recorder } from "./observe.mjs";
9
+ import { exitCode, runIssues } from "./loop.mjs";
10
+ import { message, pruneRuns, Recorder } from "./observe.mjs";
10
11
  import { PORTS, processIssue } from "./pipeline.mjs";
11
- import { resetState, STATE_DIR, takeStop } from "./state.mjs";
12
+ import { STATE_DIR, takeStop } from "./state.mjs";
12
13
  import { formatSummary } from "./summary.mjs";
13
14
  function write(stream, text) {
14
15
  return new Promise((resolve, reject) => {
@@ -29,6 +30,10 @@ async function main() {
29
30
  setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
30
31
  const repo = await detectRepo(root, config.remote);
31
32
  const recorder = await Recorder.create(repo.root, args.level);
33
+ const pruned = await pruneRuns(repo.root, config.keepRunDays);
34
+ if (pruned > 0) {
35
+ recorder.event("runs.pruned", { files: pruned, keepDays: config.keepRunDays }, "verbose");
36
+ }
32
37
  setCommandObserver((record) => {
33
38
  recorder.event(record.code === 0 ? "command" : "command.fail", {
34
39
  cmd: `${record.command} ${record.args.join(" ")}`,
@@ -55,42 +60,30 @@ async function init(root, force) {
55
60
  }
56
61
  async function handle(args, config, repo, recorder) {
57
62
  const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
58
- const context = { repo, config, base, login, recorder, ports: PORTS };
63
+ const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
59
64
  recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
60
65
  const issues = await openIssues(repo, config.issueLimit);
61
66
  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
- }
67
+ const { reports, stoppedAt } = await runIssues(args, {
68
+ issues,
69
+ process: (issue) => processIssue(context, issue),
70
+ stop: () => takeStop(repo),
71
+ });
72
+ if (stoppedAt !== undefined) {
73
+ recorder.event("run.stop", { issue: stoppedAt }, "quiet");
81
74
  }
82
- const summary = { ...recorder.summary(), issues: reports };
83
75
  recorder.event("run.end", {
84
76
  done: count(reports, "done"),
85
77
  needsHuman: count(reports, "needs-human"),
86
78
  failed: count(reports, "error"),
87
79
  skipped: count(reports, "skipped"),
88
- tokens: summary.total.total,
80
+ tokens: recorder.summary().total.total,
89
81
  }, "quiet");
82
+ const summary = { ...recorder.summary(), issues: reports };
90
83
  const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
91
84
  await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
92
85
  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;
86
+ return exitCode(reports);
94
87
  }
95
88
  function count(reports, outcome) {
96
89
  return reports.filter((report) => report.outcome === outcome).length;
package/dist/config.mjs CHANGED
@@ -14,6 +14,7 @@ const DEFAULTS = {
14
14
  commandTimeoutMinutes: 10,
15
15
  logMaxChars: 20000,
16
16
  diffMaxChars: 60000,
17
+ keepRunDays: 7,
17
18
  draftPullRequest: true,
18
19
  models: {},
19
20
  labels: {
@@ -38,6 +39,7 @@ const WHOLE_FIELDS = [
38
39
  ["commandTimeoutMinutes", 1],
39
40
  ["logMaxChars", 1],
40
41
  ["diffMaxChars", 1],
42
+ ["keepRunDays", 0],
41
43
  ];
42
44
  const MODEL_ROLES = ["implementer", "reviewer", "fixer"];
43
45
  const LABEL_NAMES = ["ready", "inProgress", "inReview", "done", "needsHuman", "skip"];
package/dist/exec.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  let observer;
3
3
  let defaultTimeoutMs = 10 * 60_000;
4
+ const STDERR_TAIL = 200_000;
4
5
  export function setCommandObserver(next) {
5
6
  observer = next;
6
7
  }
@@ -15,7 +16,7 @@ export function run(command, args, options = {}) {
15
16
  cwd: options.cwd,
16
17
  stdio: [
17
18
  options.input === undefined ? "ignore" : "pipe",
18
- options.inherit === true ? "inherit" : "pipe",
19
+ options.show === true ? 2 : "pipe",
19
20
  "pipe",
20
21
  ],
21
22
  });
@@ -30,20 +31,19 @@ export function run(command, args, options = {}) {
30
31
  stdout = stdout.slice(-options.tailChars);
31
32
  }
32
33
  });
34
+ const stderrLimit = options.tailChars ?? STDERR_TAIL;
33
35
  child.stderr?.on("data", (chunk) => {
34
36
  stderr += chunk;
37
+ if (stderr.length > stderrLimit) {
38
+ stderr = stderr.slice(-stderrLimit);
39
+ }
35
40
  });
36
41
  const timer = setTimeout(() => {
37
42
  timedOut = true;
38
43
  child.kill("SIGKILL");
39
44
  }, limit);
40
45
  let done = false;
41
- child.on("error", (error) => {
42
- clearTimeout(timer);
43
- done = true;
44
- reject(error);
45
- });
46
- child.on("close", (code) => {
46
+ const settle = (code) => {
47
47
  clearTimeout(timer);
48
48
  if (done) {
49
49
  return;
@@ -60,7 +60,21 @@ export function run(command, args, options = {}) {
60
60
  timedOut,
61
61
  });
62
62
  resolve(result);
63
+ };
64
+ child.on("error", (error) => {
65
+ clearTimeout(timer);
66
+ done = true;
67
+ reject(error);
68
+ });
69
+ child.on("exit", (code) => {
70
+ if (!timedOut) {
71
+ return;
72
+ }
73
+ child.stdout?.destroy();
74
+ child.stderr?.destroy();
75
+ settle(code);
63
76
  });
77
+ child.on("close", settle);
64
78
  if (options.input !== undefined) {
65
79
  child.stdin?.on("error", () => undefined);
66
80
  child.stdin?.end(options.input);
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,21 @@ 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) {
68
- await run("gh", ["label", "create", label, "--repo", slug(repo)], { cwd: repo.root });
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
+ }
81
+ const result = await run("gh", ["label", "create", label, "--repo", slug(repo)], {
82
+ cwd: repo.root,
83
+ });
84
+ if (result.code === 0) {
85
+ known.add(label);
86
+ }
69
87
  }
70
88
  export async function findPr(repo, branch) {
71
89
  const result = await run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number", "--repo", slug(repo)], { cwd: repo.root });
@@ -76,7 +94,7 @@ export async function findPr(repo, branch) {
76
94
  return parsed[0]?.number;
77
95
  }
78
96
  export async function createPr(repo, branch, base, title, body, draft) {
79
- await gh(repo, [
97
+ const created = await gh(repo, [
80
98
  "pr",
81
99
  "create",
82
100
  "--head",
@@ -89,12 +107,19 @@ export async function createPr(repo, branch, base, title, body, draft) {
89
107
  body,
90
108
  ...(draft ? ["--draft"] : []),
91
109
  ]);
92
- const number = await findPr(repo, branch);
110
+ const number = prNumber(created) ?? (await findPr(repo, branch));
93
111
  if (number === undefined) {
94
112
  throw new Error(`The pull request for ${branch} was not found after creation.`);
95
113
  }
96
114
  return number;
97
115
  }
116
+ export function prNumber(output) {
117
+ const match = /\/pull\/(\d+)\b/.exec(output);
118
+ return match === null ? undefined : Number(match[1]);
119
+ }
120
+ async function prHead(repo, branch) {
121
+ return gh(repo, ["pr", "view", branch, "--json", "headRefOid", "--jq", ".headRefOid"]);
122
+ }
98
123
  async function readChecks(repo, branch) {
99
124
  const result = await run("gh", ["pr", "checks", branch, "--json", "name,bucket,link,description", "--repo", slug(repo)], { cwd: repo.root });
100
125
  const output = result.stdout.trim();
@@ -107,8 +132,8 @@ async function readChecks(repo, branch) {
107
132
  const checks = JSON.parse(output);
108
133
  return checks.length === 0 ? "none" : checks;
109
134
  }
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 });
135
+ async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
136
+ const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, show, timeoutMs });
112
137
  if (watch.timedOut) {
113
138
  return "timeout";
114
139
  }
@@ -125,31 +150,53 @@ const REAL_CLOCK = {
125
150
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
126
151
  };
127
152
  export async function pollChecks(probe, options, clock = REAL_CLOCK) {
128
- const started = clock.now();
129
- const deadline = started + options.timeoutMs;
153
+ const deadline = clock.now() + options.timeoutMs;
130
154
  const rest = () => Math.max(0, deadline - clock.now());
155
+ let shown;
156
+ let failures = 0;
131
157
  for (;;) {
132
158
  if (rest() === 0) {
133
159
  return "timeout";
134
160
  }
135
- if ((await probe.list()) === "none") {
136
- if (clock.now() - started >= options.graceMs) {
137
- return "none";
161
+ const state = await read(probe, options.head, rest(), () => (shown ??= clock.now()));
162
+ if (state instanceof Error) {
163
+ failures += 1;
164
+ if (failures >= READ_TRIES) {
165
+ throw state;
138
166
  }
139
167
  }
140
168
  else {
141
- const state = await probe.watch(rest());
142
- if (state !== "pending") {
169
+ failures = 0;
170
+ if (state === "none" && clock.now() - (shown ?? 0) >= options.graceMs) {
171
+ return "none";
172
+ }
173
+ if (state !== "none" && state !== "behind" && state !== "pending") {
143
174
  return state;
144
175
  }
145
176
  }
146
177
  await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
147
178
  }
148
179
  }
180
+ async function read(probe, head, timeoutMs, shown) {
181
+ try {
182
+ if (head !== undefined && (await probe.head()) !== head) {
183
+ return "behind";
184
+ }
185
+ shown();
186
+ if ((await probe.list()) === "none") {
187
+ return "none";
188
+ }
189
+ return await probe.watch(timeoutMs);
190
+ }
191
+ catch (error) {
192
+ return error instanceof Error ? error : new Error(String(error));
193
+ }
194
+ }
149
195
  export function waitForChecks(repo, branch, options) {
150
196
  return pollChecks({
197
+ head: () => prHead(repo, branch),
151
198
  list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
152
- watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.inherit),
199
+ watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.show),
153
200
  }, options);
154
201
  }
155
202
  export async function failedCheckLogs(repo, branch, maxChars) {
@@ -158,6 +205,7 @@ export async function failedCheckLogs(repo, branch, maxChars) {
158
205
  return "The checks failed, but gh reports no check for the branch.";
159
206
  }
160
207
  const failed = checks.filter((check) => check.bucket === "fail");
208
+ const share = Math.max(1, Math.floor(maxChars / Math.max(1, failed.length)));
161
209
  const parts = [];
162
210
  for (const check of failed) {
163
211
  parts.push(`## ${check.name}\n${check.description}`);
@@ -167,11 +215,11 @@ export async function failedCheckLogs(repo, branch, maxChars) {
167
215
  }
168
216
  const logs = await run("gh", ["run", "view", runId, "--log-failed", "--repo", slug(repo)], {
169
217
  cwd: repo.root,
170
- tailChars: maxChars,
218
+ tailChars: share,
171
219
  });
172
- parts.push(logs.stdout.slice(-maxChars));
220
+ parts.push(logs.stdout.slice(-share));
173
221
  }
174
- return parts.join("\n\n").slice(-maxChars);
222
+ return parts.join("\n\n");
175
223
  }
176
224
  export async function markPrReady(repo, pr) {
177
225
  const result = await run("gh", ["pr", "ready", String(pr), "--repo", slug(repo)], {
package/dist/loop.mjs ADDED
@@ -0,0 +1,24 @@
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
+ const report = await loop.process(issue);
9
+ reports.push(report);
10
+ if (await loop.stop()) {
11
+ return { reports, stoppedAt: issue.number };
12
+ }
13
+ if (args.once && report.outcome !== "skipped") {
14
+ break;
15
+ }
16
+ }
17
+ return { reports };
18
+ }
19
+ export function exitCode(reports) {
20
+ return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
21
+ }
22
+ function handled(reports) {
23
+ return reports.filter((report) => report.outcome !== "skipped").length;
24
+ }
package/dist/observe.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createWriteStream } from "node:fs";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { STATE_DIR } from "./state.mjs";
5
5
  const VISIBILITY = { quiet: 0, normal: 1, verbose: 2 };
@@ -117,6 +117,35 @@ export class Recorder {
117
117
  await this.sink.close();
118
118
  }
119
119
  }
120
+ export async function pruneRuns(root, keepDays) {
121
+ if (keepDays <= 0) {
122
+ return 0;
123
+ }
124
+ const dir = join(root, STATE_DIR, "runs");
125
+ let names;
126
+ try {
127
+ names = await readdir(dir);
128
+ }
129
+ catch {
130
+ return 0;
131
+ }
132
+ const cutoff = Date.now() - keepDays * 24 * 60 * 60_000;
133
+ let dropped = 0;
134
+ for (const name of names) {
135
+ const path = join(dir, name);
136
+ try {
137
+ const info = await stat(path);
138
+ if (info.isFile() && info.mtimeMs < cutoff) {
139
+ await rm(path, { force: true });
140
+ dropped += 1;
141
+ }
142
+ }
143
+ catch {
144
+ continue;
145
+ }
146
+ }
147
+ return dropped;
148
+ }
120
149
  export function message(error) {
121
150
  return error instanceof Error ? error.message : String(error);
122
151
  }
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
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
- import { readState, writeState } from "./state.mjs";
8
+ import { dropState, readState, resetState, 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,
@@ -53,7 +55,7 @@ export async function processIssue(context, issue) {
53
55
  return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
54
56
  }
55
57
  log.event("issue.start", { title: issue.title, createdAt: issue.createdAt }, "quiet");
56
- const state = saved ?? {
58
+ const fresh = {
57
59
  issue: issue.number,
58
60
  phase: "claimed",
59
61
  branch: branchName(issue.number),
@@ -61,6 +63,7 @@ export async function processIssue(context, issue) {
61
63
  reviewRounds: 0,
62
64
  reviewLog: [],
63
65
  };
66
+ const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
64
67
  try {
65
68
  const report = await work(context, issue, log, state);
66
69
  log.event("issue.end", { outcome: report.outcome, reason: report.reason, ms: report.ms }, "quiet");
@@ -101,6 +104,12 @@ async function work(context, issue, log, state) {
101
104
  const escalate = (reason) => handOver(context, log, issue.number, state, reason);
102
105
  const finish = async (outcome, reason) => {
103
106
  await cleanUp(context, log, issue.number);
107
+ if (outcome === "done") {
108
+ if (!(await ports.deleteBranch(repo, branch))) {
109
+ log.event("branch.kept", { branch }, "quiet");
110
+ }
111
+ await dropState(repo, issue.number);
112
+ }
104
113
  return {
105
114
  issue: issue.number,
106
115
  outcome,
@@ -123,6 +132,7 @@ async function work(context, issue, log, state) {
123
132
  }
124
133
  }
125
134
  if (state.phase === "claimed") {
135
+ const before = await ports.revision(worktree);
126
136
  const result = await log.step("implement", {}, () => ports.runAgent(log, {
127
137
  name: "implementer",
128
138
  cwd: worktree,
@@ -132,11 +142,10 @@ async function work(context, issue, log, state) {
132
142
  timeoutMs: config.agentTimeoutMinutes * 60_000,
133
143
  }));
134
144
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
135
- if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
145
+ if (!(await commitWork(job, before, subject))) {
136
146
  await escalate("The implementer produced no change.");
137
147
  return finish("needs-human", "no implementation commit");
138
148
  }
139
- log.event("commit", { subject });
140
149
  state.phase = "implemented";
141
150
  await writeState(repo, state);
142
151
  }
@@ -149,14 +158,18 @@ async function work(context, issue, log, state) {
149
158
  state.phase = "review";
150
159
  await writeState(repo, state);
151
160
  log.event("pull-request.ready", { pr: state.pr }, "quiet");
161
+ let noChecks = false;
152
162
  const gate = async () => {
153
- const checks = await log.step("checks", { pr: state.pr }, () => ports.waitForChecks(repo, branch, {
163
+ const head = await ports.revision(worktree);
164
+ const checks = await log.step("checks", { pr: state.pr, head }, () => ports.waitForChecks(repo, branch, {
165
+ head,
154
166
  intervalSeconds: config.checkIntervalSeconds,
155
- graceMs: config.checkGraceSeconds * 1000,
167
+ graceMs: noChecks ? 0 : config.checkGraceSeconds * 1000,
156
168
  timeoutMs: config.checkTimeoutMinutes * 60_000,
157
- inherit: log.level === "verbose",
169
+ show: log.level === "verbose",
158
170
  }));
159
171
  log.event("checks.state", { state: checks });
172
+ noChecks ||= checks === "none";
160
173
  if (checks === "timeout") {
161
174
  await escalate("The checks did not finish in time.");
162
175
  return { done: true, report: await finish("needs-human", "checks timeout") };
@@ -210,7 +223,7 @@ async function work(context, issue, log, state) {
210
223
  const review = await log.step("review", fields, () => ports.runAgent(log, {
211
224
  name: "reviewer",
212
225
  cwd: worktree,
213
- prompt: reviewPrompt(issue, comments, patch, state.reviewRounds, history(state.reviewLog)),
226
+ prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
214
227
  profile: READ_ONLY,
215
228
  model: config.models.reviewer,
216
229
  timeoutMs: config.agentTimeoutMinutes * 60_000,
@@ -242,8 +255,6 @@ async function work(context, issue, log, state) {
242
255
  log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
243
256
  }
244
257
  await setStatus(context, issue.number, state.pr, config.labels.done);
245
- state.phase = "done";
246
- await writeState(repo, state);
247
258
  return finish("done");
248
259
  }
249
260
  const mark = fingerprint(blocking);
@@ -264,6 +275,7 @@ async function work(context, issue, log, state) {
264
275
  async function fixRound(job, reason, detail, earlier, kind) {
265
276
  const { context, issue, log, worktree, branch } = job;
266
277
  const { ports } = context;
278
+ const before = await ports.revision(worktree);
267
279
  const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
268
280
  name: "fixer",
269
281
  cwd: worktree,
@@ -278,14 +290,20 @@ async function fixRound(job, reason, detail, earlier, kind) {
278
290
  return { committed: false, unrelated };
279
291
  }
280
292
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
281
- if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
293
+ if (!(await commitWork(job, before, subject))) {
282
294
  log.event("commit.empty", { reason }, "quiet");
283
295
  return { committed: false };
284
296
  }
285
- log.event("commit", { subject });
286
297
  await ports.push(worktree, context.config.remote, branch);
287
298
  return { committed: true };
288
299
  }
300
+ async function commitWork(job, before, subject) {
301
+ const { context, issue, log, worktree } = job;
302
+ if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
303
+ log.event("commit", { subject });
304
+ }
305
+ return (await context.ports.revision(worktree)) !== before;
306
+ }
289
307
  async function cleanUp(context, log, issue) {
290
308
  const path = worktreePath(context.repo, issue);
291
309
  try {
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:",
package/dist/state.mjs CHANGED
@@ -15,15 +15,10 @@ export async function readState(repo, issue) {
15
15
  return undefined;
16
16
  }
17
17
  }
18
- export async function resetState(repo, issue) {
19
- const state = await readState(repo, issue);
20
- if (state === undefined) {
21
- return false;
22
- }
18
+ export function resetState(state) {
23
19
  const next = { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
24
20
  delete next.handedOver;
25
- await writeState(repo, next);
26
- return true;
21
+ return next;
27
22
  }
28
23
  export async function writeState(repo, state) {
29
24
  await mkdir(dir(repo), { recursive: true });
@@ -38,3 +33,6 @@ export async function takeStop(repo) {
38
33
  return false;
39
34
  }
40
35
  }
36
+ export async function dropState(repo, issue) {
37
+ await rm(file(repo, issue), { force: true });
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",
@@ -37,10 +37,10 @@
37
37
  "prepublishOnly": "npm run typecheck && npm test"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "^26.2.0",
40
+ "@types/node": "^26.4.1",
41
41
  "typescript": "^7.0.2"
42
42
  },
43
43
  "dependencies": {
44
- "@anthropic-ai/claude-agent-sdk": "^0.3.241"
44
+ "@anthropic-ai/claude-agent-sdk": "^0.3.261"
45
45
  }
46
46
  }