next-issue 0.19.0 → 1.0.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
@@ -1,8 +1,7 @@
1
1
  # next-issue
2
2
 
3
3
  A small harness that works through the open issues of a GitHub repository with
4
- the Claude Agent SDK, or with [pi](https://pi.dev). Run it inside a git
5
- repository.
4
+ the Claude Agent SDK. Run it inside a git repository.
6
5
 
7
6
  ```bash
8
7
  npx -y next-issue@latest
@@ -33,7 +32,9 @@ For each open issue, oldest first:
33
32
  local branch that fell behind the remote is fast-forwarded. A fast-forward
34
33
  of the base that is not safe leaves the local base branch as it is and
35
34
  writes a `base.behind` event. A new branch then starts from the remote base.
36
- 4. Let the implementer agent do the work, then commit and push.
35
+ 4. Let the implementer agent do the work, then commit and push. When a git
36
+ hook stops a commit, the agent that made the change gets the hook output,
37
+ fixes it, and the harness commits again. The budget is `maxCommitFixes`.
37
38
  5. Open a draft pull request that closes the issue.
38
39
  6. Wait for the checks, once the pull request shows the pushed commit. A
39
40
  pending state waits again, up to `checkTimeoutMinutes`. A read that fails
@@ -157,8 +158,7 @@ verdict.
157
158
 
158
159
  Every agent can run a shell command. The implementer and the fixer run with the
159
160
  permission checks off; the reviewer runs with a smaller tool set, but its shell
160
- has no limit on the commands. pi has no permission checks, so with the `pi`
161
- agent the reviewer only runs without the `edit` and `write` tools. They all start in the worktree, but nothing holds
161
+ has no limit on the commands. They all start in the worktree, but nothing holds
162
162
  them there: they can run any command, with your rights, anywhere on the machine.
163
163
  The prompt holds the title, the body and the comments of the issue, which come
164
164
  from GitHub.
@@ -213,7 +213,6 @@ Three levels of console output:
213
213
  - `gh`, authenticated with write access to the repository
214
214
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
215
215
  Claude subscription login
216
- - With the `pi` agent: `pi` on the `PATH`, set up with a model instead
217
216
 
218
217
  ## Configuration
219
218
 
@@ -234,10 +233,10 @@ does not know all stop the run, so a typo cannot pass without a word.
234
233
 
235
234
  ```json
236
235
  {
237
- "agent": "claude",
238
236
  "remote": "origin",
239
237
  "issueLimit": 100,
240
238
  "maxCiFixes": 5,
239
+ "maxCommitFixes": 10,
241
240
  "maxReviewRounds": 5,
242
241
  "checkIntervalSeconds": 15,
243
242
  "checkGraceSeconds": 60,
@@ -267,10 +266,10 @@ does not know all stop the run, so a typo cannot pass without a word.
267
266
 
268
267
  | Field | Default | Effect |
269
268
  | --- | --- | --- |
270
- | `agent` | `claude` | The agent that does the work: `claude` or `pi` |
271
269
  | `remote` | `origin` | The git remote for the repository and the branches |
272
270
  | `issueLimit` | `100` | The maximum number of open issues to read |
273
271
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
272
+ | `maxCommitFixes` | `10` | The budget for fixes of a commit that a git hook stopped, per issue |
274
273
  | `maxReviewRounds` | `5` | The budget for review rounds per issue |
275
274
  | `checkIntervalSeconds` | `15` | The time between two check states |
276
275
  | `checkGraceSeconds` | `60` | The time to wait for the first check to show |
@@ -295,22 +294,6 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
295
294
  A role set to `null`, or with no entry in `models`, uses the default model of
296
295
  the agent.
297
296
 
298
- ## The pi agent
299
-
300
- With `"agent": "pi"` or `--agent pi`, each agent call runs `pi --mode json` in
301
- the worktree with the prompt on standard input. pi loads your own settings,
302
- extensions, skills and context files, as it does when you run it yourself. The
303
- flag wins over the config file.
304
-
305
- - The implementer and the fixer use your default pi tools.
306
- - The reviewer runs with `--tools -edit,-write`, so it keeps `bash`.
307
- - A model in `models` goes to `pi --model` as it is, so use a name that pi
308
- knows.
309
- - pi has no structured output, so the reviewer is asked to end its reply with
310
- a JSON verdict. A reply with no valid verdict hands the issue to a person.
311
- - A run fails when its last reply ends with an error, when pi exits with an
312
- error, or when pi stops before the run settles.
313
-
314
297
  Without a `setupCommand`, the harness looks in the new worktree for a lock file
315
298
  and runs the install command that goes with it:
316
299
 
@@ -357,7 +340,6 @@ that a label still holds back stays as it was.
357
340
  | `--force` | Replace an existing config file, with `init` |
358
341
  | `--once` | Stop after the first handled issue |
359
342
  | `--max <n>` | Handle at most n issues |
360
- | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
361
343
  | `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
362
344
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
363
345
  | `--json` | Print the machine summary instead of the text one |
package/dist/agents.mjs CHANGED
@@ -2,11 +2,9 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { spawn } from "node:child_process";
3
3
  import { track } from "./exec.mjs";
4
4
  import { emptyUsage } from "./observe.mjs";
5
- import { runPi } from "./pi.mjs";
6
5
  const REVIEWING = {
7
6
  tools: ["Read", "Grep", "Glob", "Bash"],
8
7
  permissionMode: "dontAsk",
9
- piTools: "-edit,-write",
10
8
  };
11
9
  const CODING = {
12
10
  tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
@@ -15,9 +13,6 @@ const CODING = {
15
13
  };
16
14
  export { CODING, REVIEWING };
17
15
  export async function runAgent(recorder, request) {
18
- if (request.agent === "pi") {
19
- return runPi(recorder, request);
20
- }
21
16
  const log = recorder.scope({ role: request.name });
22
17
  const usage = emptyUsage();
23
18
  const toolNames = new Map();
@@ -146,7 +141,7 @@ function main(models) {
146
141
  return name;
147
142
  }
148
143
  export function toolFields(name, input) {
149
- if ((name !== "Bash" && name !== "bash") || typeof input !== "object" || input === null) {
144
+ if (name !== "Bash" || typeof input !== "object" || input === null) {
150
145
  return { tool: name };
151
146
  }
152
147
  const { command } = input;
package/dist/args.mjs CHANGED
@@ -1,11 +1,10 @@
1
- import { AGENT_NAMES, CONFIG_FILE } from "./config.mjs";
1
+ import { CONFIG_FILE } from "./config.mjs";
2
2
  export const USAGE = `next-issue [command] [options]
3
3
 
4
4
  init write ${CONFIG_FILE} with the defaults
5
5
 
6
6
  --once stop after the first handled issue
7
7
  --max <n> handle at most n issues
8
- --agent <a> run the agents with claude or pi
9
8
  --merge squash-merge the pull request after the approval
10
9
  --reset drop the saved budgets and findings first
11
10
  --json print the machine summary instead of the text one
@@ -57,13 +56,6 @@ export function parseArgs(argv) {
57
56
  else if (arg === "--quiet") {
58
57
  args.level = "quiet";
59
58
  }
60
- else if (arg === "--agent") {
61
- const value = argv[++index];
62
- if (value === undefined || !AGENT_NAMES.includes(value)) {
63
- throw new Error("--agent needs claude or pi");
64
- }
65
- args.agent = value;
66
- }
67
59
  else if (arg === "--max") {
68
60
  args.max = whole(argv[++index], arg);
69
61
  }
package/dist/cli.mjs CHANGED
@@ -29,7 +29,6 @@ async function main() {
29
29
  return init(root, args.force);
30
30
  }
31
31
  const config = await loadConfig(root);
32
- config.agent = args.agent ?? config.agent;
33
32
  setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
34
33
  const repo = await detectRepo(root, config.remote);
35
34
  const recorder = await Recorder.create(repo.root, args.level);
package/dist/config.mjs CHANGED
@@ -1,12 +1,11 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { message } from "./observe.mjs";
4
- export const AGENT_NAMES = ["claude", "pi"];
5
4
  const DEFAULTS = {
6
- agent: "claude",
7
5
  remote: "origin",
8
6
  issueLimit: 100,
9
7
  maxCiFixes: 5,
8
+ maxCommitFixes: 10,
10
9
  maxReviewRounds: 5,
11
10
  checkIntervalSeconds: 15,
12
11
  checkGraceSeconds: 60,
@@ -35,6 +34,7 @@ const FIELDS = [...Object.keys(DEFAULTS), "setupCommand"];
35
34
  const WHOLE_FIELDS = [
36
35
  ["issueLimit", 1],
37
36
  ["maxCiFixes", 0],
37
+ ["maxCommitFixes", 0],
38
38
  ["maxReviewRounds", 1],
39
39
  ["checkIntervalSeconds", 1],
40
40
  ["checkGraceSeconds", 0],
@@ -93,12 +93,6 @@ function parseConfig(value) {
93
93
  }
94
94
  }
95
95
  const config = defaults();
96
- if (file.agent !== undefined) {
97
- if (typeof file.agent !== "string" || !AGENT_NAMES.includes(file.agent)) {
98
- throw new Error(`${CONFIG_FILE}: agent must be claude or pi.`);
99
- }
100
- config.agent = file.agent;
101
- }
102
96
  if (file.remote !== undefined) {
103
97
  config.remote = text(file.remote, "remote", false);
104
98
  }
package/dist/pipeline.mjs CHANGED
@@ -4,7 +4,7 @@ import { run } from "./exec.mjs";
4
4
  import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, isDirty, push, removeWorktree, resetToBase, revision, worktreePath, } from "./git.mjs";
5
5
  import { commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, reopenIssue, setLabel, waitForBaseChecks, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
- import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
7
+ import { capDiff, fixPrompt, hookPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
9
9
  import { dropState, readState, resetState, writeState, } from "./state.mjs";
10
10
  import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
@@ -162,11 +162,10 @@ async function work(context, issue, log, state) {
162
162
  prompt: implementPrompt(issue, comments),
163
163
  profile: CODING,
164
164
  model: config.models.implementer,
165
- agent: config.agent,
166
165
  timeoutMs: config.agentTimeoutMinutes * 60_000,
167
166
  }));
168
167
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
169
- if (!(await commitWork(job, before, subject))) {
168
+ if (!(await commitWork(job, before, subject, "implementer"))) {
170
169
  await escalate(job, "The implementer produced no change.");
171
170
  return finish(job, "needs-human", "no implementation commit");
172
171
  }
@@ -276,7 +275,6 @@ async function reviewRound(job) {
276
275
  prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
277
276
  profile: REVIEWING,
278
277
  model: config.models.reviewer,
279
- agent: config.agent,
280
278
  timeoutMs: config.agentTimeoutMinutes * 60_000,
281
279
  outputSchema: VERDICT_SCHEMA,
282
280
  }));
@@ -440,7 +438,6 @@ async function fixRound(job, reason, detail, earlier, kind) {
440
438
  prompt: fixPrompt(issue, reason, detail, earlier, kind),
441
439
  profile: CODING,
442
440
  model: context.config.models.fixer,
443
- agent: context.config.agent,
444
441
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
445
442
  }));
446
443
  const unrelated = parseUnrelated(result.text);
@@ -450,19 +447,42 @@ async function fixRound(job, reason, detail, earlier, kind) {
450
447
  return { committed: false, unrelated, stray };
451
448
  }
452
449
  const subject = parseCommitSubject(result.text, `fix: address feedback on issue #${issue.number}`);
453
- if (!(await commitWork(job, before, subject))) {
450
+ if (!(await commitWork(job, before, subject, "fixer"))) {
454
451
  log.event("commit.empty", { reason }, "quiet");
455
452
  return { committed: false };
456
453
  }
457
454
  await ports.push(worktree, context.config.remote, branch);
458
455
  return { committed: true };
459
456
  }
460
- async function commitWork(job, before, subject) {
461
- const { context, issue, log, worktree } = job;
462
- if (await context.ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
463
- log.event("commit", { subject });
457
+ async function commitWork(job, before, subject, role) {
458
+ const { context, issue, log, state, worktree, comments } = job;
459
+ const { repo, config, ports } = context;
460
+ for (;;) {
461
+ try {
462
+ if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
463
+ log.event("commit", { subject });
464
+ }
465
+ return (await ports.revision(worktree)) !== before;
466
+ }
467
+ catch (error) {
468
+ const used = state.commitFixes ?? 0;
469
+ if (used >= config.maxCommitFixes) {
470
+ throw error;
471
+ }
472
+ state.commitFixes = used + 1;
473
+ await writeState(repo, state);
474
+ const output = message(error).slice(-config.logMaxChars);
475
+ log.event("commit.fail", { role, output }, "quiet");
476
+ await log.step("commit-fix", { role }, () => ports.runAgent(log, {
477
+ name: role,
478
+ cwd: worktree,
479
+ prompt: hookPrompt(issue, comments, role, output),
480
+ profile: CODING,
481
+ model: config.models[role],
482
+ timeoutMs: config.agentTimeoutMinutes * 60_000,
483
+ }));
484
+ }
464
485
  }
465
- return (await context.ports.revision(worktree)) !== before;
466
486
  }
467
487
  async function cleanUp(context, log, issue) {
468
488
  const path = worktreePath(context.repo, issue);
package/dist/prompts.mjs CHANGED
@@ -21,6 +21,16 @@ export function implementPrompt(issue, comments) {
21
21
  COMMIT_RULES,
22
22
  ].join("\n\n");
23
23
  }
24
+ export function hookPrompt(issue, comments, role, output) {
25
+ return [
26
+ `You are the ${role}. The harness could not commit your work for the issue below, because a git hook failed.`,
27
+ issueBlock(issue, comments),
28
+ `## Hook output\n${output}`,
29
+ "## Rules",
30
+ "Fix what the hook reports and keep the rest of the work in this worktree. Do not skip or weaken the hook.",
31
+ COMMIT_RULES,
32
+ ].join("\n\n");
33
+ }
24
34
  export function capDiff(diff, maxChars) {
25
35
  return diff.length <= maxChars
26
36
  ? diff
package/dist/state.mjs CHANGED
@@ -16,7 +16,7 @@ export async function readState(repo, issue) {
16
16
  }
17
17
  }
18
18
  export function resetState(state) {
19
- return { ...state, ciFixes: 0, reviewRounds: 0, reviewLog: [] };
19
+ return { ...state, ciFixes: 0, commitFixes: 0, reviewRounds: 0, reviewLog: [] };
20
20
  }
21
21
  export async function writeState(repo, state) {
22
22
  await mkdir(dir(repo), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.19.0",
3
+ "version": "1.0.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",
package/dist/pi.mjs DELETED
@@ -1,159 +0,0 @@
1
- import { toolFields } from "./agents.mjs";
2
- import { run } from "./exec.mjs";
3
- import { emptyUsage } from "./observe.mjs";
4
- const NO_LIMIT = 2 ** 31 - 1;
5
- const OUTPUT_TAIL = 200_000;
6
- export async function runPi(recorder, request) {
7
- const log = recorder.scope({ role: request.name });
8
- const usage = emptyUsage();
9
- let text = "";
10
- let toolCalls = 0;
11
- let turns = 0;
12
- let costUsd = 0;
13
- let model = request.model;
14
- let sessionId;
15
- let settled = false;
16
- let last;
17
- const read = (line) => {
18
- let event;
19
- try {
20
- event = JSON.parse(line);
21
- }
22
- catch {
23
- return;
24
- }
25
- if (event.type === "session") {
26
- sessionId = event.id;
27
- }
28
- else if (event.type === "message_end" && event.message?.role === "assistant") {
29
- const message = event.message;
30
- last = message;
31
- turns += 1;
32
- model = message.model ?? model;
33
- const input = message.usage?.input ?? 0;
34
- const output = message.usage?.output ?? 0;
35
- const cacheRead = message.usage?.cacheRead ?? 0;
36
- const cacheWrite = message.usage?.cacheWrite ?? 0;
37
- usage.input += input;
38
- usage.output += output;
39
- usage.cacheRead += cacheRead;
40
- usage.cacheWrite += cacheWrite;
41
- usage.total += input + output + cacheRead + cacheWrite;
42
- costUsd += message.usage?.cost?.total ?? 0;
43
- const said = textOf(message.content);
44
- if (said.length > 0) {
45
- text = said;
46
- if (log.level === "verbose") {
47
- process.stderr.write(said);
48
- }
49
- }
50
- }
51
- else if (event.type === "tool_execution_start") {
52
- toolCalls += 1;
53
- log.event("tool", toolFields(event.toolName ?? "unknown", event.args));
54
- }
55
- else if (event.type === "tool_execution_end" && event.isError === true) {
56
- log.event("tool.error", { tool: event.toolName }, "quiet");
57
- }
58
- else if (event.type === "agent_settled") {
59
- settled = true;
60
- }
61
- };
62
- const started = Date.now();
63
- log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs, agent: "pi" }, "normal");
64
- let rest = "";
65
- const result = await run("pi", piArgs(request), {
66
- cwd: request.cwd,
67
- input: piPrompt(request),
68
- timeoutMs: request.timeoutMs ?? NO_LIMIT,
69
- tailChars: OUTPUT_TAIL,
70
- onStdout: (chunk) => {
71
- rest += chunk;
72
- let end = rest.indexOf("\n");
73
- while (end !== -1) {
74
- read(rest.slice(0, end).replace(/\r$/, ""));
75
- rest = rest.slice(end + 1);
76
- end = rest.indexOf("\n");
77
- }
78
- },
79
- });
80
- if (rest.length > 0) {
81
- read(rest);
82
- }
83
- const failed = (reason) => new Error(`The ${request.name} agent failed: ${reason}`);
84
- if (result.timedOut) {
85
- throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
86
- }
87
- if (last?.stopReason === "error" || last?.stopReason === "aborted") {
88
- throw failed(last.errorMessage ?? last.stopReason);
89
- }
90
- if (result.code !== 0) {
91
- throw failed(`pi exited with code ${result.code}: ${result.stderr.trim()}`);
92
- }
93
- if (!settled) {
94
- throw failed("pi stopped before the run settled");
95
- }
96
- if (last?.stopReason === "length") {
97
- log.event("agent.incomplete", { subtype: "length" }, "quiet");
98
- }
99
- const ms = Date.now() - started;
100
- recorder.usage(request.name, usage, ms, {
101
- turns,
102
- toolCalls,
103
- model,
104
- session: sessionId,
105
- costUsd,
106
- });
107
- return {
108
- text: text.trim(),
109
- usage,
110
- turns,
111
- toolCalls,
112
- ms,
113
- costUsd,
114
- model,
115
- sessionId,
116
- structured: request.outputSchema === undefined ? undefined : jsonOf(text),
117
- };
118
- }
119
- function piArgs(request) {
120
- return [
121
- "--mode",
122
- "json",
123
- ...(request.model === undefined ? [] : ["--model", request.model]),
124
- ...(request.profile.piTools === undefined ? [] : ["--tools", request.profile.piTools]),
125
- ];
126
- }
127
- function piPrompt(request) {
128
- if (request.outputSchema === undefined) {
129
- return request.prompt;
130
- }
131
- return [
132
- request.prompt,
133
- "",
134
- "End your reply with only a JSON object in a json code block. The object must match this JSON schema:",
135
- JSON.stringify(request.outputSchema),
136
- ].join("\n");
137
- }
138
- function textOf(content) {
139
- if (typeof content === "string") {
140
- return content;
141
- }
142
- if (!Array.isArray(content)) {
143
- return "";
144
- }
145
- return content
146
- .filter((block) => block?.type === "text" && typeof block.text === "string")
147
- .map((block) => block.text)
148
- .join("");
149
- }
150
- function jsonOf(text) {
151
- const fenced = [...text.matchAll(/```(?:json)?[ \t]*\n([\s\S]*?)```/g)].at(-1)?.[1];
152
- const candidate = fenced ?? text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1);
153
- try {
154
- return JSON.parse(candidate);
155
- }
156
- catch {
157
- return undefined;
158
- }
159
- }