next-issue 0.6.0 → 0.7.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
@@ -29,15 +29,17 @@ For each open issue, oldest first:
29
29
  4. Let the implementer agent do the work, then commit and push.
30
30
  5. Open a draft pull request that closes the issue.
31
31
  6. Wait for the checks. A pending state waits again, up to
32
- `checkTimeoutMinutes`. A repository without checks goes straight to the
33
- review.
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
35
  7. On a red build, give the failed job logs to the fixer agent, then go to step
35
36
  6 again. The budget is `maxCiFixes`.
36
- 8. Let the reviewer agent judge the diff. The reviewer returns a structured
37
- verdict that rates each finding `blocking` or `minor`. The harness puts the
38
- result on the pull request.
37
+ 8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
38
+ reviewer returns a structured verdict that rates each finding `blocking` or
39
+ `minor`. The harness puts the result on the pull request.
39
40
  9. No blocking finding means approval: mark the pull request ready for review
40
- and set `status:done`. The merge stays with you.
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.
41
43
  10. With a blocking finding, run the fixer agent and go to step 6 again. The
42
44
  budget is `maxReviewRounds`.
43
45
 
@@ -51,6 +53,8 @@ when:
51
53
  - the reviewer repeats a finding set from an earlier round, which shows a
52
54
  ping-pong between the fixer and the reviewer;
53
55
  - the reviewer gives no verdict, or the checks do not finish in time;
56
+ - an agent does not finish in `agentTimeoutMinutes`;
57
+ - the setup command fails, or does not finish in `setupTimeoutMinutes`;
54
58
  - a step of the run fails with an error.
55
59
 
56
60
  The fixer sees the findings of all earlier rounds, not only the last one. From
@@ -78,8 +82,9 @@ command that writes a file outside `.gitignore` puts that file in the commit.
78
82
 
79
83
  ## Safety
80
84
 
81
- The implementer and the fixer run with the permission checks off. They can run
82
- any command in the worktree. The prompt holds the title, the body and the
85
+ The implementer and the fixer run with the permission checks off. They start in
86
+ the worktree, but nothing holds them there: they can run any command, with your
87
+ rights, anywhere on the machine. The prompt holds the title, the body and the
83
88
  comments of the issue, which come from GitHub.
84
89
 
85
90
  Thus a person who can write an issue or a comment can try to give an
@@ -144,7 +149,8 @@ already; `--force` replaces it. The file holds no `setupCommand`, because that
144
149
  field has no default.
145
150
 
146
151
  A field that is not in the file keeps its default. A file that is not valid
147
- JSON stops the run.
152
+ JSON, a field with a value of the wrong type and a field name that the harness
153
+ does not know all stop the run, so a typo cannot pass without a word.
148
154
 
149
155
  ```json
150
156
  {
@@ -153,15 +159,15 @@ JSON stops the run.
153
159
  "maxCiFixes": 3,
154
160
  "maxReviewRounds": 3,
155
161
  "checkIntervalSeconds": 15,
162
+ "checkGraceSeconds": 60,
156
163
  "checkTimeoutMinutes": 60,
164
+ "agentTimeoutMinutes": 30,
165
+ "setupTimeoutMinutes": 15,
166
+ "commandTimeoutMinutes": 10,
157
167
  "logMaxChars": 20000,
168
+ "diffMaxChars": 60000,
158
169
  "draftPullRequest": true,
159
- "setupCommand": "npm ci",
160
- "models": {
161
- "implementer": "claude-opus-5",
162
- "reviewer": "claude-opus-5",
163
- "fixer": "claude-opus-5"
164
- },
170
+ "models": {},
165
171
  "labels": {
166
172
  "ready": "status:todo",
167
173
  "inProgress": "status:in-progress",
@@ -180,8 +186,13 @@ JSON stops the run.
180
186
  | `maxCiFixes` | `3` | The budget for check fixes per issue |
181
187
  | `maxReviewRounds` | `3` | The budget for review rounds per issue |
182
188
  | `checkIntervalSeconds` | `15` | The time between two check states |
189
+ | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
183
190
  | `checkTimeoutMinutes` | `60` | The limit for one check wait |
191
+ | `agentTimeoutMinutes` | `30` | The limit for one agent call |
192
+ | `setupTimeoutMinutes` | `15` | The limit for the setup command |
193
+ | `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
184
194
  | `logMaxChars` | `20000` | The maximum length of the failed job logs |
195
+ | `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
185
196
  | `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
186
197
  | `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
187
198
  | `models` | `{}` | The model per agent role |
@@ -191,12 +202,22 @@ An empty `labels.ready` turns the ready requirement off. The harness then
191
202
  claims every open issue that no other label and no assignee holds back.
192
203
 
193
204
  The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
194
- `in-flight` or `assigned`. `--issue` obeys the same rules; it only makes the
195
- harness look at one issue.
205
+ `in-flight` or `assigned`.
196
206
 
197
207
  A role without an entry in `models` uses the default model of the SDK. A
198
- `setupCommand` that fails hands the issue to a person. State per issue goes to
199
- `.next-issue/<issue>.json`, so a new run continues where the last run stopped.
208
+ `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
209
+ hands the issue to a person. The wait for the checks has its own limit, so
210
+ `commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
211
+ issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
212
+ run stopped.
213
+
214
+ ## Retry an issue that waits for a person
215
+
216
+ A hand-over leaves the state file with the used budgets in it, so the next run
217
+ would hand the issue over again at once. To give the issue another try, take the
218
+ `status:needs-human` label off and run with `--reset`. The flag puts the budgets
219
+ and the findings back to zero and keeps the branch, the pull request and the
220
+ step that the last run reached, so the work carries on where it stopped.
200
221
 
201
222
  ## Commands and options
202
223
 
@@ -204,10 +225,9 @@ A role without an entry in `models` uses the default model of the SDK. A
204
225
  | --- | --- |
205
226
  | `init` | Write the config file with the defaults |
206
227
  | `--force` | Replace an existing config file, with `init` |
207
- | `--issue <n>` | Handle one issue only |
208
228
  | `--once` | Stop after the first handled issue |
209
229
  | `--max <n>` | Handle at most n issues |
210
- | `--reset` | Drop the saved budgets and findings first |
230
+ | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
211
231
  | `--json` | Print the machine summary instead of the text one |
212
232
  | `--verbose` | Show every command and all agent output |
213
233
  | `--quiet` | Show only the milestones and the summary |
package/dist/agents.mjs CHANGED
@@ -22,73 +22,90 @@ export async function runAgent(recorder, request) {
22
22
  let sessionId;
23
23
  let structured;
24
24
  const started = Date.now();
25
- log.event("agent.start", { model: request.model }, "normal");
26
- const stream = query({
27
- prompt: request.prompt,
28
- options: {
29
- cwd: request.cwd,
30
- model: request.model,
31
- tools: request.profile.tools,
32
- allowedTools: request.profile.tools,
33
- permissionMode: request.profile.permissionMode,
34
- allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
35
- systemPrompt: { type: "preset", preset: "claude_code" },
36
- outputFormat: request.outputSchema === undefined
37
- ? undefined
38
- : { type: "json_schema", schema: request.outputSchema },
39
- },
40
- });
41
- for await (const message of stream) {
42
- if (message.type === "assistant") {
43
- for (const block of message.message.content) {
44
- if (block.type === "tool_use") {
45
- toolCalls += 1;
46
- toolNames.set(block.id, block.name);
47
- log.event("tool", toolFields(block.name, block.input));
48
- }
49
- if (block.type === "text") {
50
- text += block.text;
51
- if (log.level === "verbose") {
52
- process.stderr.write(block.text);
25
+ log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
26
+ const controller = new AbortController();
27
+ const timer = request.timeoutMs === undefined
28
+ ? undefined
29
+ : setTimeout(() => controller.abort(), request.timeoutMs);
30
+ const late = () => new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
31
+ try {
32
+ const stream = query({
33
+ prompt: request.prompt,
34
+ options: {
35
+ cwd: request.cwd,
36
+ model: request.model,
37
+ tools: request.profile.tools,
38
+ allowedTools: request.profile.tools,
39
+ permissionMode: request.profile.permissionMode,
40
+ allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
41
+ systemPrompt: { type: "preset", preset: "claude_code" },
42
+ abortController: controller,
43
+ outputFormat: request.outputSchema === undefined
44
+ ? undefined
45
+ : { type: "json_schema", schema: request.outputSchema },
46
+ },
47
+ });
48
+ for await (const message of stream) {
49
+ if (message.type === "assistant") {
50
+ for (const block of message.message.content) {
51
+ if (block.type === "tool_use") {
52
+ toolCalls += 1;
53
+ toolNames.set(block.id, block.name);
54
+ log.event("tool", toolFields(block.name, block.input));
55
+ }
56
+ if (block.type === "text") {
57
+ text += block.text;
58
+ if (log.level === "verbose") {
59
+ process.stderr.write(block.text);
60
+ }
53
61
  }
54
62
  }
55
63
  }
56
- }
57
- if (message.type === "user" && Array.isArray(message.message.content)) {
58
- for (const block of message.message.content) {
59
- if (block.type === "tool_result" && block.is_error === true) {
60
- log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
64
+ if (message.type === "user" && Array.isArray(message.message.content)) {
65
+ for (const block of message.message.content) {
66
+ if (block.type === "tool_result" && block.is_error === true) {
67
+ log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
68
+ }
61
69
  }
62
70
  }
63
- }
64
- if (message.type === "result") {
65
- sessionId = message.session_id;
66
- turns = message.num_turns;
67
- costUsd = message.total_cost_usd;
68
- model = main(message.modelUsage) ?? model;
69
- for (const entry of Object.values(message.modelUsage)) {
70
- usage.input += entry.inputTokens;
71
- usage.output += entry.outputTokens;
72
- usage.cacheRead += entry.cacheReadInputTokens;
73
- usage.cacheWrite += entry.cacheCreationInputTokens;
74
- usage.total +=
75
- entry.inputTokens +
76
- entry.outputTokens +
77
- entry.cacheReadInputTokens +
78
- entry.cacheCreationInputTokens;
79
- }
80
- if (message.subtype === "error_during_execution") {
81
- throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
82
- }
83
- if (message.subtype === "success") {
84
- text = message.result.length > 0 ? message.result : text;
85
- structured = message.structured_output;
86
- }
87
- else {
88
- log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
71
+ if (message.type === "result") {
72
+ sessionId = message.session_id;
73
+ turns = message.num_turns;
74
+ costUsd = message.total_cost_usd;
75
+ model = main(message.modelUsage) ?? model;
76
+ for (const entry of Object.values(message.modelUsage)) {
77
+ usage.input += entry.inputTokens;
78
+ usage.output += entry.outputTokens;
79
+ usage.cacheRead += entry.cacheReadInputTokens;
80
+ usage.cacheWrite += entry.cacheCreationInputTokens;
81
+ usage.total +=
82
+ entry.inputTokens +
83
+ entry.outputTokens +
84
+ entry.cacheReadInputTokens +
85
+ entry.cacheCreationInputTokens;
86
+ }
87
+ if (message.subtype === "error_during_execution") {
88
+ throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
89
+ }
90
+ if (message.subtype === "success") {
91
+ text = message.result.length > 0 ? message.result : text;
92
+ structured = message.structured_output;
93
+ }
94
+ else {
95
+ log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
96
+ }
89
97
  }
90
98
  }
91
99
  }
100
+ catch (error) {
101
+ throw controller.signal.aborted ? late() : error;
102
+ }
103
+ finally {
104
+ clearTimeout(timer);
105
+ }
106
+ if (controller.signal.aborted) {
107
+ throw late();
108
+ }
92
109
  const ms = Date.now() - started;
93
110
  recorder.usage(request.name, usage, ms, {
94
111
  turns,
package/dist/args.mjs CHANGED
@@ -3,7 +3,6 @@ export const USAGE = `next-issue [command] [options]
3
3
 
4
4
  init write ${CONFIG_FILE} with the defaults
5
5
 
6
- --issue <n> handle one issue only
7
6
  --once stop after the first handled issue
8
7
  --max <n> handle at most n issues
9
8
  --reset drop the saved budgets and findings first
@@ -52,9 +51,6 @@ export function parseArgs(argv) {
52
51
  else if (arg === "--quiet") {
53
52
  args.level = "quiet";
54
53
  }
55
- else if (arg === "--issue") {
56
- args.issue = whole(argv[++index], arg);
57
- }
58
54
  else if (arg === "--max") {
59
55
  args.max = whole(argv[++index], arg);
60
56
  }
package/dist/cli.mjs CHANGED
@@ -3,12 +3,12 @@ import { writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { parseArgs, USAGE } from "./args.mjs";
5
5
  import { loadConfig, writeDefaultConfig } from "./config.mjs";
6
- import { setCommandObserver } from "./exec.mjs";
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
9
  import { message, Recorder } from "./observe.mjs";
10
- import { processIssue } from "./pipeline.mjs";
11
- import { clearState, STATE_DIR, takeStop } from "./state.mjs";
10
+ import { PORTS, processIssue } from "./pipeline.mjs";
11
+ import { resetState, STATE_DIR, takeStop } from "./state.mjs";
12
12
  import { formatSummary } from "./summary.mjs";
13
13
  function write(stream, text) {
14
14
  return new Promise((resolve, reject) => {
@@ -26,6 +26,7 @@ async function main() {
26
26
  return init(root, args.force);
27
27
  }
28
28
  const config = await loadConfig(root);
29
+ setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
29
30
  const repo = await detectRepo(root, config.remote);
30
31
  const recorder = await Recorder.create(repo.root, args.level);
31
32
  setCommandObserver((record) => {
@@ -34,6 +35,7 @@ async function main() {
34
35
  code: record.code,
35
36
  ms: record.ms,
36
37
  stderr: record.stderr.length > 0 ? record.stderr : undefined,
38
+ timedOut: record.timedOut ? true : undefined,
37
39
  }, record.code === 0 ? "verbose" : "quiet");
38
40
  });
39
41
  try {
@@ -53,9 +55,9 @@ async function init(root, force) {
53
55
  }
54
56
  async function handle(args, config, repo, recorder) {
55
57
  const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
56
- const context = { repo, config, base, login, recorder };
58
+ const context = { repo, config, base, login, recorder, ports: PORTS };
57
59
  recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
58
- const issues = (await openIssues(repo, config.issueLimit)).filter((issue) => args.issue === undefined || issue.number === args.issue);
60
+ const issues = await openIssues(repo, config.issueLimit);
59
61
  recorder.event("issues", { open: issues.length }, "quiet");
60
62
  await takeStop(repo);
61
63
  const reports = [];
@@ -65,7 +67,7 @@ async function handle(args, config, repo, recorder) {
65
67
  break;
66
68
  }
67
69
  if (args.reset) {
68
- await clearState(repo, issue.number);
70
+ await resetState(repo, issue.number);
69
71
  }
70
72
  const report = await processIssue(context, issue);
71
73
  reports.push(report);
package/dist/config.mjs CHANGED
@@ -7,8 +7,13 @@ const DEFAULTS = {
7
7
  maxCiFixes: 3,
8
8
  maxReviewRounds: 3,
9
9
  checkIntervalSeconds: 15,
10
+ checkGraceSeconds: 60,
10
11
  checkTimeoutMinutes: 60,
12
+ agentTimeoutMinutes: 30,
13
+ setupTimeoutMinutes: 15,
14
+ commandTimeoutMinutes: 10,
11
15
  logMaxChars: 20000,
16
+ diffMaxChars: 60000,
12
17
  draftPullRequest: true,
13
18
  models: {},
14
19
  labels: {
@@ -20,6 +25,22 @@ const DEFAULTS = {
20
25
  skip: "status:blocked",
21
26
  },
22
27
  };
28
+ const FIELDS = [...Object.keys(DEFAULTS), "setupCommand"];
29
+ const WHOLE_FIELDS = [
30
+ ["issueLimit", 1],
31
+ ["maxCiFixes", 0],
32
+ ["maxReviewRounds", 1],
33
+ ["checkIntervalSeconds", 1],
34
+ ["checkGraceSeconds", 0],
35
+ ["checkTimeoutMinutes", 1],
36
+ ["agentTimeoutMinutes", 1],
37
+ ["setupTimeoutMinutes", 1],
38
+ ["commandTimeoutMinutes", 1],
39
+ ["logMaxChars", 1],
40
+ ["diffMaxChars", 1],
41
+ ];
42
+ const MODEL_ROLES = ["implementer", "reviewer", "fixer"];
43
+ const LABEL_NAMES = ["ready", "inProgress", "inReview", "done", "needsHuman", "skip"];
23
44
  export const CONFIG_FILE = "next-issue.config.json";
24
45
  export async function writeDefaultConfig(root, force) {
25
46
  const path = join(root, CONFIG_FILE);
@@ -49,12 +70,82 @@ export async function loadConfig(root) {
49
70
  catch (error) {
50
71
  throw new Error(`${CONFIG_FILE} is not valid JSON: ${message(error)}`);
51
72
  }
52
- return {
73
+ return parseConfig(parsed);
74
+ }
75
+ export function parseConfig(value) {
76
+ const file = record(value, "the file");
77
+ for (const key of Object.keys(file)) {
78
+ if (!FIELDS.includes(key)) {
79
+ throw new Error(`${CONFIG_FILE} holds an unknown field: ${key}`);
80
+ }
81
+ }
82
+ const config = {
53
83
  ...DEFAULTS,
54
- ...parsed,
55
- models: { ...DEFAULTS.models, ...parsed.models },
56
- labels: { ...DEFAULTS.labels, ...parsed.labels },
84
+ models: { ...DEFAULTS.models },
85
+ labels: { ...DEFAULTS.labels },
57
86
  };
87
+ if (file.remote !== undefined) {
88
+ config.remote = text(file.remote, "remote", false);
89
+ }
90
+ for (const [field, least] of WHOLE_FIELDS) {
91
+ if (file[field] !== undefined) {
92
+ config[field] = whole(file[field], field, least);
93
+ }
94
+ }
95
+ if (file.draftPullRequest !== undefined) {
96
+ if (typeof file.draftPullRequest !== "boolean") {
97
+ throw new Error(`${CONFIG_FILE}: draftPullRequest must be true or false.`);
98
+ }
99
+ config.draftPullRequest = file.draftPullRequest;
100
+ }
101
+ if (file.setupCommand !== undefined) {
102
+ config.setupCommand = text(file.setupCommand, "setupCommand", false);
103
+ }
104
+ if (file.models !== undefined) {
105
+ const models = record(file.models, "models");
106
+ for (const key of Object.keys(models)) {
107
+ if (!MODEL_ROLES.includes(key)) {
108
+ throw new Error(`${CONFIG_FILE} holds an unknown model role: ${key}`);
109
+ }
110
+ }
111
+ for (const role of MODEL_ROLES) {
112
+ if (models[role] !== undefined) {
113
+ config.models[role] = text(models[role], `models.${role}`, false);
114
+ }
115
+ }
116
+ }
117
+ if (file.labels !== undefined) {
118
+ const labels = record(file.labels, "labels");
119
+ for (const key of Object.keys(labels)) {
120
+ if (!LABEL_NAMES.includes(key)) {
121
+ throw new Error(`${CONFIG_FILE} holds an unknown label name: ${key}`);
122
+ }
123
+ }
124
+ for (const name of LABEL_NAMES) {
125
+ if (labels[name] !== undefined) {
126
+ config.labels[name] = text(labels[name], `labels.${name}`, name === "ready");
127
+ }
128
+ }
129
+ }
130
+ return config;
131
+ }
132
+ function record(value, what) {
133
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
134
+ throw new Error(`${CONFIG_FILE}: ${what} must be an object.`);
135
+ }
136
+ return value;
137
+ }
138
+ function whole(value, field, least) {
139
+ if (typeof value !== "number" || !Number.isInteger(value) || value < least) {
140
+ throw new Error(`${CONFIG_FILE}: ${field} must be a whole number of ${least} or more.`);
141
+ }
142
+ return value;
143
+ }
144
+ function text(value, field, allowEmpty) {
145
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
146
+ throw new Error(`${CONFIG_FILE}: ${field} must be ${allowEmpty ? "text" : "text that is not empty"}.`);
147
+ }
148
+ return value;
58
149
  }
59
150
  export function managedLabels(config) {
60
151
  return [
package/dist/exec.mjs CHANGED
@@ -1,10 +1,15 @@
1
1
  import { spawn } from "node:child_process";
2
2
  let observer;
3
+ let defaultTimeoutMs = 10 * 60_000;
3
4
  export function setCommandObserver(next) {
4
5
  observer = next;
5
6
  }
7
+ export function setDefaultTimeout(ms) {
8
+ defaultTimeoutMs = ms;
9
+ }
6
10
  export function run(command, args, options = {}) {
7
11
  const started = Date.now();
12
+ const limit = options.timeoutMs ?? defaultTimeoutMs;
8
13
  return new Promise((resolve, reject) => {
9
14
  const child = spawn(command, args, {
10
15
  cwd: options.cwd,
@@ -21,22 +26,29 @@ export function run(command, args, options = {}) {
21
26
  child.stderr?.setEncoding("utf8");
22
27
  child.stdout?.on("data", (chunk) => {
23
28
  stdout += chunk;
29
+ if (options.tailChars !== undefined && stdout.length > options.tailChars) {
30
+ stdout = stdout.slice(-options.tailChars);
31
+ }
24
32
  });
25
33
  child.stderr?.on("data", (chunk) => {
26
34
  stderr += chunk;
27
35
  });
28
- const timer = options.timeoutMs === undefined
29
- ? undefined
30
- : setTimeout(() => {
31
- timedOut = true;
32
- child.kill("SIGKILL");
33
- }, options.timeoutMs);
36
+ const timer = setTimeout(() => {
37
+ timedOut = true;
38
+ child.kill("SIGKILL");
39
+ }, limit);
40
+ let done = false;
34
41
  child.on("error", (error) => {
35
42
  clearTimeout(timer);
43
+ done = true;
36
44
  reject(error);
37
45
  });
38
46
  child.on("close", (code) => {
39
47
  clearTimeout(timer);
48
+ if (done) {
49
+ return;
50
+ }
51
+ done = true;
40
52
  const result = { code: code ?? 1, stdout, stderr, timedOut };
41
53
  observer?.({
42
54
  command,
@@ -45,6 +57,7 @@ export function run(command, args, options = {}) {
45
57
  code: result.code,
46
58
  ms: Date.now() - started,
47
59
  stderr: result.code === 0 ? "" : stderr.trim(),
60
+ timedOut,
48
61
  });
49
62
  resolve(result);
50
63
  });
@@ -56,6 +69,9 @@ export function run(command, args, options = {}) {
56
69
  }
57
70
  export async function must(command, args, options = {}) {
58
71
  const result = await run(command, args, options);
72
+ if (result.timedOut) {
73
+ throw new Error(`${command} ${args.join(" ")} did not finish in time.`);
74
+ }
59
75
  if (result.code !== 0) {
60
76
  throw new Error(`${command} ${args.join(" ")} failed with code ${result.code}: ${result.stderr.trim()}`);
61
77
  }
package/dist/git.mjs CHANGED
@@ -36,7 +36,9 @@ export async function addWorktree(repo, issue, base, remote) {
36
36
  if (await hasWorktree(repo, path)) {
37
37
  return path;
38
38
  }
39
- const existing = await run("git", ["rev-parse", "--verify", branch], { cwd: repo.root });
39
+ const existing = await run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], {
40
+ cwd: repo.root,
41
+ });
40
42
  const args = existing.code === 0
41
43
  ? ["worktree", "add", path, branch]
42
44
  : ["worktree", "add", "-b", branch, path, `${remote}/${base}`];
@@ -45,12 +47,11 @@ export async function addWorktree(repo, issue, base, remote) {
45
47
  }
46
48
  export async function updateBase(repo, base, remote) {
47
49
  const head = await run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repo.root });
48
- if (head.code === 0 && head.stdout.trim() === base) {
49
- const merge = await run("git", ["merge", "--ff-only", `${remote}/${base}`], { cwd: repo.root });
50
- return merge.code === 0;
51
- }
52
- const fetch = await run("git", ["fetch", remote, `${base}:${base}`], { cwd: repo.root });
53
- return fetch.code === 0;
50
+ const args = head.code === 0 && head.stdout.trim() === base
51
+ ? ["merge", "--ff-only", `${remote}/${base}`]
52
+ : ["fetch", ".", `${remote}/${base}:${base}`];
53
+ const result = await run("git", args, { cwd: repo.root });
54
+ return result.code === 0;
54
55
  }
55
56
  export async function hasWorktree(repo, path) {
56
57
  const list = await must("git", ["worktree", "list", "--porcelain"], { cwd: repo.root });
package/dist/github.mjs CHANGED
@@ -107,8 +107,8 @@ async function readChecks(repo, branch) {
107
107
  const checks = JSON.parse(output);
108
108
  return checks.length === 0 ? "none" : checks;
109
109
  }
110
- async function watchOnce(repo, branch, intervalSeconds, timeoutMs) {
111
- const watch = await run("gh", ["pr", "checks", branch, "--watch", "--fail-fast", "--interval", String(intervalSeconds), "--repo", slug(repo)], { cwd: repo.root, inherit: true, timeoutMs });
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 });
112
112
  if (watch.timedOut) {
113
113
  return "timeout";
114
114
  }
@@ -120,22 +120,37 @@ async function watchOnce(repo, branch, intervalSeconds, timeoutMs) {
120
120
  }
121
121
  return "fail";
122
122
  }
123
- export async function waitForChecks(repo, branch, intervalSeconds, timeoutMs) {
124
- const deadline = Date.now() + timeoutMs;
125
- while (Date.now() < deadline) {
126
- if ((await readChecks(repo, branch)) === "none") {
127
- return "none";
123
+ const REAL_CLOCK = {
124
+ now: () => Date.now(),
125
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
126
+ };
127
+ export async function pollChecks(probe, options, clock = REAL_CLOCK) {
128
+ const started = clock.now();
129
+ const deadline = started + options.timeoutMs;
130
+ const rest = () => Math.max(0, deadline - clock.now());
131
+ for (;;) {
132
+ if (rest() === 0) {
133
+ return "timeout";
134
+ }
135
+ if ((await probe.list()) === "none") {
136
+ if (clock.now() - started >= options.graceMs) {
137
+ return "none";
138
+ }
128
139
  }
129
- const state = await watchOnce(repo, branch, intervalSeconds, deadline - Date.now());
130
- if (state !== "pending") {
131
- return state;
140
+ else {
141
+ const state = await probe.watch(rest());
142
+ if (state !== "pending") {
143
+ return state;
144
+ }
132
145
  }
133
- await delay(intervalSeconds * 1000);
146
+ await clock.sleep(Math.min(options.intervalSeconds * 1000, rest()));
134
147
  }
135
- return "timeout";
136
148
  }
137
- function delay(ms) {
138
- return new Promise((resolve) => setTimeout(resolve, ms));
149
+ export function waitForChecks(repo, branch, options) {
150
+ return pollChecks({
151
+ list: async () => ((await readChecks(repo, branch)) === "none" ? "none" : "some"),
152
+ watch: (timeoutMs) => watchOnce(repo, branch, options.intervalSeconds, timeoutMs, options.inherit),
153
+ }, options);
139
154
  }
140
155
  export async function failedCheckLogs(repo, branch, maxChars) {
141
156
  const checks = await readChecks(repo, branch);
@@ -152,6 +167,7 @@ export async function failedCheckLogs(repo, branch, maxChars) {
152
167
  }
153
168
  const logs = await run("gh", ["run", "view", runId, "--log-failed", "--repo", slug(repo)], {
154
169
  cwd: repo.root,
170
+ tailChars: maxChars,
155
171
  });
156
172
  parts.push(logs.stdout.slice(-maxChars));
157
173
  }
package/dist/pipeline.mjs CHANGED
@@ -4,9 +4,28 @@ import { run } from "./exec.mjs";
4
4
  import { addWorktree, branchName, commitAll, diff, hasWorktree, push, removeWorktree, worktreePath, } from "./git.mjs";
5
5
  import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
- import { fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
7
+ import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, reviewPrompt } from "./prompts.mjs";
8
8
  import { readState, writeState } from "./state.mjs";
9
9
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
10
+ export const PORTS = {
11
+ addWorktree,
12
+ assignIssue,
13
+ commentOnPr,
14
+ commitAll,
15
+ createPr,
16
+ diff,
17
+ failedCheckLogs,
18
+ findPr,
19
+ hasWorktree,
20
+ issueComments,
21
+ markPrReady,
22
+ push,
23
+ removeWorktree,
24
+ runAgent,
25
+ runSetup: async (command, cwd, timeoutMs) => run("bash", ["-lc", command], { cwd, timeoutMs }),
26
+ setLabel,
27
+ waitForChecks,
28
+ };
10
29
  export function skipReason(issue, config, login, resuming) {
11
30
  const labels = new Set(issue.labels);
12
31
  const names = config.labels;
@@ -69,15 +88,15 @@ export async function processIssue(context, issue) {
69
88
  }
70
89
  async function work(context, issue, log, state) {
71
90
  const started = Date.now();
72
- const { repo, config } = context;
91
+ const { repo, config, ports } = context;
73
92
  const branch = state.branch;
74
93
  await log.step("claim", { label: config.labels.inProgress }, async () => {
75
- await assignIssue(repo, issue.number, context.login);
94
+ await ports.assignIssue(repo, issue.number, context.login);
76
95
  await setStatus(context, issue.number, state.pr, config.labels.inProgress);
77
96
  await writeState(repo, state);
78
97
  });
79
- const comments = await log.step("comments", {}, () => issueComments(repo, issue.number));
80
- const worktree = await log.step("worktree", { branch }, () => addWorktree(repo, issue.number, context.base, config.remote));
98
+ const comments = await log.step("comments", {}, () => ports.issueComments(repo, issue.number));
99
+ const worktree = await log.step("worktree", { branch }, () => ports.addWorktree(repo, issue.number, context.base, config.remote));
81
100
  const job = { context, issue, log, state, branch, worktree, comments };
82
101
  const escalate = (reason) => handOver(context, log, issue.number, state, reason);
83
102
  const finish = async (outcome, reason) => {
@@ -93,23 +112,27 @@ async function work(context, issue, log, state) {
93
112
  };
94
113
  };
95
114
  if (config.setupCommand !== undefined) {
96
- const setup = await log.step("setup", { cmd: config.setupCommand }, () => run("bash", ["-lc", config.setupCommand], { cwd: worktree }));
115
+ const setup = await log.step("setup", { cmd: config.setupCommand }, () => ports.runSetup(config.setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
97
116
  if (setup.code !== 0) {
98
- log.event("setup.fail", { code: setup.code, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
99
- await escalate("The setup command failed in the worktree.");
100
- return finish("needs-human", "setup failed");
117
+ const late = setup.timedOut === true;
118
+ log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
119
+ await escalate(late
120
+ ? `The setup command did not finish in ${config.setupTimeoutMinutes} minutes.`
121
+ : "The setup command failed in the worktree.");
122
+ return finish("needs-human", late ? "setup timeout" : "setup failed");
101
123
  }
102
124
  }
103
125
  if (state.phase === "claimed") {
104
- const result = await log.step("implement", {}, () => runAgent(log, {
126
+ const result = await log.step("implement", {}, () => ports.runAgent(log, {
105
127
  name: "implementer",
106
128
  cwd: worktree,
107
129
  prompt: implementPrompt(issue, comments),
108
130
  profile: CODING,
109
131
  model: config.models.implementer,
132
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
110
133
  }));
111
134
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
112
- if (!(await commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
135
+ if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
113
136
  await escalate("The implementer produced no change.");
114
137
  return finish("needs-human", "no implementation commit");
115
138
  }
@@ -117,36 +140,51 @@ async function work(context, issue, log, state) {
117
140
  state.phase = "implemented";
118
141
  await writeState(repo, state);
119
142
  }
120
- await log.step("push", { branch }, () => push(worktree, config.remote, branch));
143
+ await log.step("push", { branch }, () => ports.push(worktree, config.remote, branch));
121
144
  state.pr = await log.step("pull-request", { branch }, async () => {
122
- const existing = await findPr(repo, branch);
145
+ const existing = await ports.findPr(repo, branch);
123
146
  return (existing ??
124
- createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
147
+ ports.createPr(repo, branch, context.base, issue.title, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
125
148
  });
126
149
  state.phase = "review";
127
150
  await writeState(repo, state);
128
151
  log.event("pull-request.ready", { pr: state.pr }, "quiet");
129
- for (;;) {
130
- const checks = await log.step("checks", { pr: state.pr }, () => waitForChecks(repo, branch, config.checkIntervalSeconds, config.checkTimeoutMinutes * 60_000));
152
+ const gate = async () => {
153
+ const checks = await log.step("checks", { pr: state.pr }, () => ports.waitForChecks(repo, branch, {
154
+ intervalSeconds: config.checkIntervalSeconds,
155
+ graceMs: config.checkGraceSeconds * 1000,
156
+ timeoutMs: config.checkTimeoutMinutes * 60_000,
157
+ inherit: log.level === "verbose",
158
+ }));
131
159
  log.event("checks.state", { state: checks });
132
160
  if (checks === "timeout") {
133
161
  await escalate("The checks did not finish in time.");
134
- return finish("needs-human", "checks timeout");
162
+ return { done: true, report: await finish("needs-human", "checks timeout") };
135
163
  }
136
- if (checks === "fail") {
137
- if (state.ciFixes >= config.maxCiFixes) {
138
- await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
139
- return finish("needs-human", "check budget");
140
- }
141
- state.ciFixes += 1;
142
- await writeState(repo, state);
143
- const logs = await failedCheckLogs(repo, branch, config.logMaxChars);
144
- log.event("checks.logs", { chars: logs.length });
145
- const committed = await fixRound(job, "The continuous integration checks failed.", logs);
146
- if (!committed) {
147
- await escalate("The fixer added no commit for the failed checks.");
148
- return finish("needs-human", "no fix commit");
149
- }
164
+ if (checks !== "fail") {
165
+ return { done: false, fixed: false };
166
+ }
167
+ if (state.ciFixes >= config.maxCiFixes) {
168
+ await escalate(`The limit of ${config.maxCiFixes} check fixes was reached.`);
169
+ return { done: true, report: await finish("needs-human", "check budget") };
170
+ }
171
+ state.ciFixes += 1;
172
+ await writeState(repo, state);
173
+ const logs = await ports.failedCheckLogs(repo, branch, config.logMaxChars);
174
+ log.event("checks.logs", { chars: logs.length });
175
+ const committed = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog));
176
+ if (!committed) {
177
+ await escalate("The fixer added no commit for the failed checks.");
178
+ return { done: true, report: await finish("needs-human", "no fix commit") };
179
+ }
180
+ return { done: false, fixed: true };
181
+ };
182
+ for (;;) {
183
+ const before = await gate();
184
+ if (before.done) {
185
+ return before.report;
186
+ }
187
+ if (before.fixed) {
150
188
  continue;
151
189
  }
152
190
  if (state.reviewRounds >= config.maxReviewRounds) {
@@ -156,13 +194,20 @@ async function work(context, issue, log, state) {
156
194
  state.reviewRounds += 1;
157
195
  await writeState(repo, state);
158
196
  await setStatus(context, issue.number, state.pr, config.labels.inReview);
159
- const patch = await diff(worktree, context.base, config.remote);
160
- const review = await log.step("review", { round: state.reviewRounds, diffChars: patch.length }, () => runAgent(log, {
197
+ const full = await ports.diff(worktree, context.base, config.remote);
198
+ const patch = capDiff(full, config.diffMaxChars);
199
+ const fields = {
200
+ round: state.reviewRounds,
201
+ diffChars: full.length,
202
+ cut: full.length > config.diffMaxChars,
203
+ };
204
+ const review = await log.step("review", fields, () => ports.runAgent(log, {
161
205
  name: "reviewer",
162
206
  cwd: worktree,
163
207
  prompt: reviewPrompt(issue, comments, patch, state.reviewRounds, history(state.reviewLog)),
164
208
  profile: READ_ONLY,
165
209
  model: config.models.reviewer,
210
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
166
211
  outputSchema: VERDICT_SCHEMA,
167
212
  }));
168
213
  const verdict = readVerdict(review.structured);
@@ -178,9 +223,16 @@ async function work(context, issue, log, state) {
178
223
  minor: verdict.findings.length - blocking.length,
179
224
  summary: verdict.summary,
180
225
  }, "quiet");
181
- await commentOnPr(repo, state.pr, formatVerdict(verdict));
226
+ await ports.commentOnPr(repo, state.pr, formatVerdict(verdict));
182
227
  if (isApproved(verdict)) {
183
- if (config.draftPullRequest && !(await markPrReady(repo, state.pr))) {
228
+ const after = await gate();
229
+ if (after.done) {
230
+ return after.report;
231
+ }
232
+ if (after.fixed) {
233
+ continue;
234
+ }
235
+ if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
184
236
  log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
185
237
  }
186
238
  await setStatus(context, issue.number, state.pr, config.labels.done);
@@ -203,31 +255,33 @@ async function work(context, issue, log, state) {
203
255
  }
204
256
  }
205
257
  }
206
- async function fixRound(job, reason, detail, earlier = []) {
258
+ async function fixRound(job, reason, detail, earlier) {
207
259
  const { context, issue, log, worktree, branch } = job;
208
- const result = await log.step("fix", { reason }, () => runAgent(log, {
260
+ const { ports } = context;
261
+ const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
209
262
  name: "fixer",
210
263
  cwd: worktree,
211
- prompt: fixPrompt(issue, reason, detail, earlier.length > 0 ? earlier : history(job.state.reviewLog)),
264
+ prompt: fixPrompt(issue, reason, detail, earlier),
212
265
  profile: CODING,
213
266
  model: context.config.models.fixer,
267
+ timeoutMs: context.config.agentTimeoutMinutes * 60_000,
214
268
  }));
215
269
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
216
- if (!(await commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
270
+ if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
217
271
  log.event("commit.empty", { reason }, "quiet");
218
272
  return false;
219
273
  }
220
274
  log.event("commit", { subject });
221
- await push(worktree, context.config.remote, branch);
275
+ await ports.push(worktree, context.config.remote, branch);
222
276
  return true;
223
277
  }
224
278
  async function cleanUp(context, log, issue) {
225
279
  const path = worktreePath(context.repo, issue);
226
280
  try {
227
- if (!(await hasWorktree(context.repo, path))) {
281
+ if (!(await context.ports.hasWorktree(context.repo, path))) {
228
282
  return;
229
283
  }
230
- if (!(await removeWorktree(context.repo, issue))) {
284
+ if (!(await context.ports.removeWorktree(context.repo, issue))) {
231
285
  log.event("worktree.kept", { path }, "quiet");
232
286
  }
233
287
  }
@@ -237,18 +291,18 @@ async function cleanUp(context, log, issue) {
237
291
  }
238
292
  async function handOver(context, log, issue, state, reason) {
239
293
  log.event("hand-over", { reason }, "quiet");
240
- state.phase = "needs-human";
294
+ state.handedOver = true;
241
295
  await writeState(context.repo, state);
242
296
  await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
243
297
  if (state.pr !== undefined) {
244
- await commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
298
+ await context.ports.commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
245
299
  }
246
300
  }
247
301
  async function setStatus(context, issue, pr, label) {
248
302
  const managed = managedLabels(context.config);
249
- await setLabel(context.repo, "issue", issue, label, managed);
303
+ await context.ports.setLabel(context.repo, "issue", issue, label, managed);
250
304
  if (pr !== undefined) {
251
- await setLabel(context.repo, "pr", pr, label, managed);
305
+ await context.ports.setLabel(context.repo, "pr", pr, label, managed);
252
306
  }
253
307
  }
254
308
  function history(rounds) {
package/dist/prompts.mjs CHANGED
@@ -21,6 +21,11 @@ export function implementPrompt(issue, comments) {
21
21
  COMMIT_RULES,
22
22
  ].join("\n\n");
23
23
  }
24
+ export function capDiff(diff, maxChars) {
25
+ return diff.length <= maxChars
26
+ ? diff
27
+ : `${diff.slice(0, maxChars)}\n... The diff is cut here, after ${maxChars} characters.`;
28
+ }
24
29
  export function reviewPrompt(issue, comments, diff, round, earlier) {
25
30
  const scope = round === 1
26
31
  ? "Check correctness, scope, repository conventions and test cover."
package/dist/state.mjs CHANGED
@@ -15,8 +15,15 @@ export async function readState(repo, issue) {
15
15
  return undefined;
16
16
  }
17
17
  }
18
- export async function clearState(repo, issue) {
19
- await rm(file(repo, issue), { force: true });
18
+ export async function resetState(repo, issue) {
19
+ const state = await readState(repo, issue);
20
+ if (state === undefined) {
21
+ return false;
22
+ }
23
+ const next = { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
24
+ delete next.handedOver;
25
+ await writeState(repo, next);
26
+ return true;
20
27
  }
21
28
  export async function writeState(repo, state) {
22
29
  await mkdir(dir(repo), { recursive: true });
package/dist/verdict.mjs CHANGED
@@ -26,18 +26,37 @@ export const VERDICT_SCHEMA = {
26
26
  },
27
27
  required: ["verdict", "summary", "findings"],
28
28
  };
29
+ const SEVERITIES = ["blocking", "minor"];
30
+ const VERDICTS = ["approve", "request_changes"];
29
31
  export function readVerdict(value) {
30
- const verdict = value;
31
- if (verdict === undefined || typeof verdict.summary !== "string" || !Array.isArray(verdict.findings)) {
32
+ if (typeof value !== "object" || value === null) {
32
33
  return undefined;
33
34
  }
34
- return verdict;
35
+ const { verdict, summary, findings } = value;
36
+ if (typeof verdict !== "string" || !VERDICTS.includes(verdict)) {
37
+ return undefined;
38
+ }
39
+ if (typeof summary !== "string" || !Array.isArray(findings)) {
40
+ return undefined;
41
+ }
42
+ const checked = [];
43
+ for (const item of findings) {
44
+ if (typeof item !== "object" || item === null) {
45
+ return undefined;
46
+ }
47
+ const { severity, detail } = item;
48
+ if (typeof severity !== "string" || !SEVERITIES.includes(severity) || typeof detail !== "string") {
49
+ return undefined;
50
+ }
51
+ checked.push({ severity: severity, detail });
52
+ }
53
+ return { verdict: verdict, summary, findings: checked };
35
54
  }
36
55
  export function blockingFindings(verdict) {
37
56
  return verdict.findings.filter((finding) => finding.severity === "blocking");
38
57
  }
39
58
  export function isApproved(verdict) {
40
- return verdict.verdict === "approve" || blockingFindings(verdict).length === 0;
59
+ return blockingFindings(verdict).length === 0;
41
60
  }
42
61
  export function formatVerdict(verdict) {
43
62
  const head = isApproved(verdict) ? "Review: approved" : "Review: changes requested";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",