next-issue 0.6.0 → 0.8.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
 
@@ -48,9 +50,15 @@ when:
48
50
 
49
51
  - the check budget or the review budget runs out;
50
52
  - a fixer round adds no commit, so there is no progress;
53
+ - the fixer reports that the checks fail for a reason the change did not cause,
54
+ such as a test already broken on the base branch or one that fails only under
55
+ load. It changes nothing and the reason reaches the pull request, rather than
56
+ the fixer spending its whole budget on a failure it cannot fix;
51
57
  - the reviewer repeats a finding set from an earlier round, which shows a
52
58
  ping-pong between the fixer and the reviewer;
53
59
  - the reviewer gives no verdict, or the checks do not finish in time;
60
+ - an agent does not finish in `agentTimeoutMinutes`;
61
+ - the setup command fails, or does not finish in `setupTimeoutMinutes`;
54
62
  - a step of the run fails with an error.
55
63
 
56
64
  The fixer sees the findings of all earlier rounds, not only the last one. From
@@ -78,8 +86,9 @@ command that writes a file outside `.gitignore` puts that file in the commit.
78
86
 
79
87
  ## Safety
80
88
 
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
89
+ The implementer and the fixer run with the permission checks off. They start in
90
+ the worktree, but nothing holds them there: they can run any command, with your
91
+ rights, anywhere on the machine. The prompt holds the title, the body and the
83
92
  comments of the issue, which come from GitHub.
84
93
 
85
94
  Thus a person who can write an issue or a comment can try to give an
@@ -144,7 +153,8 @@ already; `--force` replaces it. The file holds no `setupCommand`, because that
144
153
  field has no default.
145
154
 
146
155
  A field that is not in the file keeps its default. A file that is not valid
147
- JSON stops the run.
156
+ JSON, a field with a value of the wrong type and a field name that the harness
157
+ does not know all stop the run, so a typo cannot pass without a word.
148
158
 
149
159
  ```json
150
160
  {
@@ -153,15 +163,15 @@ JSON stops the run.
153
163
  "maxCiFixes": 3,
154
164
  "maxReviewRounds": 3,
155
165
  "checkIntervalSeconds": 15,
166
+ "checkGraceSeconds": 60,
156
167
  "checkTimeoutMinutes": 60,
168
+ "agentTimeoutMinutes": 30,
169
+ "setupTimeoutMinutes": 15,
170
+ "commandTimeoutMinutes": 10,
157
171
  "logMaxChars": 20000,
172
+ "diffMaxChars": 60000,
158
173
  "draftPullRequest": true,
159
- "setupCommand": "npm ci",
160
- "models": {
161
- "implementer": "claude-opus-5",
162
- "reviewer": "claude-opus-5",
163
- "fixer": "claude-opus-5"
164
- },
174
+ "models": {},
165
175
  "labels": {
166
176
  "ready": "status:todo",
167
177
  "inProgress": "status:in-progress",
@@ -180,8 +190,13 @@ JSON stops the run.
180
190
  | `maxCiFixes` | `3` | The budget for check fixes per issue |
181
191
  | `maxReviewRounds` | `3` | The budget for review rounds per issue |
182
192
  | `checkIntervalSeconds` | `15` | The time between two check states |
193
+ | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
183
194
  | `checkTimeoutMinutes` | `60` | The limit for one check wait |
195
+ | `agentTimeoutMinutes` | `30` | The limit for one agent call |
196
+ | `setupTimeoutMinutes` | `15` | The limit for the setup command |
197
+ | `commandTimeoutMinutes` | `10` | The limit for one `git` or `gh` command |
184
198
  | `logMaxChars` | `20000` | The maximum length of the failed job logs |
199
+ | `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
185
200
  | `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
186
201
  | `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
187
202
  | `models` | `{}` | The model per agent role |
@@ -191,12 +206,22 @@ An empty `labels.ready` turns the ready requirement off. The harness then
191
206
  claims every open issue that no other label and no assignee holds back.
192
207
 
193
208
  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.
209
+ `in-flight` or `assigned`.
196
210
 
197
211
  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.
212
+ `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
213
+ hands the issue to a person. The wait for the checks has its own limit, so
214
+ `commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
215
+ issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
216
+ run stopped.
217
+
218
+ ## Retry an issue that waits for a person
219
+
220
+ A hand-over leaves the state file with the used budgets in it, so the next run
221
+ would hand the issue over again at once. To give the issue another try, take the
222
+ `status:needs-human` label off and run with `--reset`. The flag puts the budgets
223
+ 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.
200
225
 
201
226
  ## Commands and options
202
227
 
@@ -204,10 +229,9 @@ A role without an entry in `models` uses the default model of the SDK. A
204
229
  | --- | --- |
205
230
  | `init` | Write the config file with the defaults |
206
231
  | `--force` | Replace an existing config file, with `init` |
207
- | `--issue <n>` | Handle one issue only |
208
232
  | `--once` | Stop after the first handled issue |
209
233
  | `--max <n>` | Handle at most n issues |
210
- | `--reset` | Drop the saved budgets and findings first |
234
+ | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
211
235
  | `--json` | Print the machine summary instead of the text one |
212
236
  | `--verbose` | Show every command and all agent output |
213
237
  | `--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, parseUnrelated, 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,57 @@ 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 fix = await fixRound(job, "The continuous integration checks failed.", logs, history(state.reviewLog), "checks");
176
+ if (!fix.committed) {
177
+ const note = fix.unrelated;
178
+ await escalate(note === undefined
179
+ ? "The fixer added no commit for the failed checks."
180
+ : `The checks fail for a reason this change did not cause: ${note}`);
181
+ return {
182
+ done: true,
183
+ report: await finish("needs-human", note === undefined ? "no fix commit" : "unrelated failure"),
184
+ };
185
+ }
186
+ return { done: false, fixed: true };
187
+ };
188
+ for (;;) {
189
+ const before = await gate();
190
+ if (before.done) {
191
+ return before.report;
192
+ }
193
+ if (before.fixed) {
150
194
  continue;
151
195
  }
152
196
  if (state.reviewRounds >= config.maxReviewRounds) {
@@ -156,13 +200,20 @@ async function work(context, issue, log, state) {
156
200
  state.reviewRounds += 1;
157
201
  await writeState(repo, state);
158
202
  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, {
203
+ const full = await ports.diff(worktree, context.base, config.remote);
204
+ const patch = capDiff(full, config.diffMaxChars);
205
+ const fields = {
206
+ round: state.reviewRounds,
207
+ diffChars: full.length,
208
+ cut: full.length > config.diffMaxChars,
209
+ };
210
+ const review = await log.step("review", fields, () => ports.runAgent(log, {
161
211
  name: "reviewer",
162
212
  cwd: worktree,
163
213
  prompt: reviewPrompt(issue, comments, patch, state.reviewRounds, history(state.reviewLog)),
164
214
  profile: READ_ONLY,
165
215
  model: config.models.reviewer,
216
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
166
217
  outputSchema: VERDICT_SCHEMA,
167
218
  }));
168
219
  const verdict = readVerdict(review.structured);
@@ -178,9 +229,16 @@ async function work(context, issue, log, state) {
178
229
  minor: verdict.findings.length - blocking.length,
179
230
  summary: verdict.summary,
180
231
  }, "quiet");
181
- await commentOnPr(repo, state.pr, formatVerdict(verdict));
232
+ await ports.commentOnPr(repo, state.pr, formatVerdict(verdict));
182
233
  if (isApproved(verdict)) {
183
- if (config.draftPullRequest && !(await markPrReady(repo, state.pr))) {
234
+ const after = await gate();
235
+ if (after.done) {
236
+ return after.report;
237
+ }
238
+ if (after.fixed) {
239
+ continue;
240
+ }
241
+ if (config.draftPullRequest && !(await ports.markPrReady(repo, state.pr))) {
184
242
  log.event("pull-request.undraft.fail", { pr: state.pr }, "quiet");
185
243
  }
186
244
  await setStatus(context, issue.number, state.pr, config.labels.done);
@@ -196,38 +254,45 @@ async function work(context, issue, log, state) {
196
254
  const earlier = history(state.reviewLog);
197
255
  state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(blocking) });
198
256
  await writeState(repo, state);
199
- const committed = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier);
200
- if (!committed) {
257
+ const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier, "review");
258
+ if (!fix.committed) {
201
259
  await escalate("The fixer added no commit for the review findings.");
202
260
  return finish("needs-human", "no fix commit");
203
261
  }
204
262
  }
205
263
  }
206
- async function fixRound(job, reason, detail, earlier = []) {
264
+ async function fixRound(job, reason, detail, earlier, kind) {
207
265
  const { context, issue, log, worktree, branch } = job;
208
- const result = await log.step("fix", { reason }, () => runAgent(log, {
266
+ const { ports } = context;
267
+ const result = await log.step("fix", { reason }, () => ports.runAgent(log, {
209
268
  name: "fixer",
210
269
  cwd: worktree,
211
- prompt: fixPrompt(issue, reason, detail, earlier.length > 0 ? earlier : history(job.state.reviewLog)),
270
+ prompt: fixPrompt(issue, reason, detail, earlier, kind),
212
271
  profile: CODING,
213
272
  model: context.config.models.fixer,
273
+ timeoutMs: context.config.agentTimeoutMinutes * 60_000,
214
274
  }));
275
+ const unrelated = parseUnrelated(result.text);
276
+ if (unrelated !== undefined) {
277
+ log.event("fix.unrelated", { reason, note: unrelated }, "quiet");
278
+ return { committed: false, unrelated };
279
+ }
215
280
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
216
- if (!(await commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
281
+ if (!(await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`))) {
217
282
  log.event("commit.empty", { reason }, "quiet");
218
- return false;
283
+ return { committed: false };
219
284
  }
220
285
  log.event("commit", { subject });
221
- await push(worktree, context.config.remote, branch);
222
- return true;
286
+ await ports.push(worktree, context.config.remote, branch);
287
+ return { committed: true };
223
288
  }
224
289
  async function cleanUp(context, log, issue) {
225
290
  const path = worktreePath(context.repo, issue);
226
291
  try {
227
- if (!(await hasWorktree(context.repo, path))) {
292
+ if (!(await context.ports.hasWorktree(context.repo, path))) {
228
293
  return;
229
294
  }
230
- if (!(await removeWorktree(context.repo, issue))) {
295
+ if (!(await context.ports.removeWorktree(context.repo, issue))) {
231
296
  log.event("worktree.kept", { path }, "quiet");
232
297
  }
233
298
  }
@@ -237,18 +302,18 @@ async function cleanUp(context, log, issue) {
237
302
  }
238
303
  async function handOver(context, log, issue, state, reason) {
239
304
  log.event("hand-over", { reason }, "quiet");
240
- state.phase = "needs-human";
305
+ state.handedOver = true;
241
306
  await writeState(context.repo, state);
242
307
  await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
243
308
  if (state.pr !== undefined) {
244
- await commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
309
+ await context.ports.commentOnPr(context.repo, state.pr, `next-issue needs help: ${reason}`);
245
310
  }
246
311
  }
247
312
  async function setStatus(context, issue, pr, label) {
248
313
  const managed = managedLabels(context.config);
249
- await setLabel(context.repo, "issue", issue, label, managed);
314
+ await context.ports.setLabel(context.repo, "issue", issue, label, managed);
250
315
  if (pr !== undefined) {
251
- await setLabel(context.repo, "pr", pr, label, managed);
316
+ await context.ports.setLabel(context.repo, "pr", pr, label, managed);
252
317
  }
253
318
  }
254
319
  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."
@@ -44,7 +49,12 @@ export function reviewPrompt(issue, comments, diff, round, earlier) {
44
49
  "Give the result as the structured output: the verdict, a short summary and one entry per finding.",
45
50
  ].join("\n\n");
46
51
  }
47
- export function fixPrompt(issue, reason, detail, earlier) {
52
+ const UNRELATED_RULE = [
53
+ "The checks can fail for a reason this change did not cause: a test that is already broken on the base branch, one that exercises code the diff does not touch, or one that fails only under load.",
54
+ "Establish that before you fix anything. If the failure is not yours, change nothing and end your final message with one line in the form `unrelated: <one sentence on what fails and why it is not this change>`.",
55
+ "A person then reads it. Do not weaken, skip or re-record a test to make the checks pass.",
56
+ ].join("\n");
57
+ export function fixPrompt(issue, reason, detail, earlier, kind) {
48
58
  return [
49
59
  `You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
50
60
  `## Reason\n${reason}`,
@@ -58,11 +68,17 @@ export function fixPrompt(issue, reason, detail, earlier) {
58
68
  : "",
59
69
  "## Rules",
60
70
  "Fix the cause, not the symptom. Keep the change minimal.",
71
+ kind === "checks" ? UNRELATED_RULE : "",
61
72
  COMMIT_RULES,
62
73
  ]
63
74
  .filter((part) => part.length > 0)
64
75
  .join("\n\n");
65
76
  }
77
+ export function parseUnrelated(text) {
78
+ const match = /^unrelated:\s*(.+)$/im.exec(text);
79
+ const note = match?.[1]?.trim();
80
+ return note !== undefined && note.length > 0 ? note : undefined;
81
+ }
66
82
  export function parseCommitSubject(text, fallback) {
67
83
  const match = /^commit:\s*(.+)$/im.exec(text);
68
84
  const subject = match?.[1]?.trim();
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.8.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",