next-issue 0.14.1 → 0.15.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,7 +1,8 @@
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. Run it inside a git repository.
4
+ the Claude Agent SDK, or with [pi](https://pi.dev). Run it inside a git
5
+ repository.
5
6
 
6
7
  ```bash
7
8
  npx -y next-issue@latest
@@ -131,7 +132,8 @@ verdict.
131
132
 
132
133
  Every agent can run a shell command. The implementer and the fixer run with the
133
134
  permission checks off; the reviewer runs with a smaller tool set, but its shell
134
- has no limit on the commands. They all start in the worktree, but nothing holds
135
+ has no limit on the commands. pi has no permission checks, so with the `pi`
136
+ agent the reviewer only runs without the `edit` and `write` tools. They all start in the worktree, but nothing holds
135
137
  them there: they can run any command, with your rights, anywhere on the machine.
136
138
  The prompt holds the title, the body and the comments of the issue, which come
137
139
  from GitHub.
@@ -186,6 +188,7 @@ Three levels of console output:
186
188
  - `gh`, authenticated with write access to the repository
187
189
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
188
190
  Claude subscription login
191
+ - With the `pi` agent: `pi` on the `PATH`, set up with a model instead
189
192
 
190
193
  ## Configuration
191
194
 
@@ -206,6 +209,7 @@ does not know all stop the run, so a typo cannot pass without a word.
206
209
 
207
210
  ```json
208
211
  {
212
+ "agent": "claude",
209
213
  "remote": "origin",
210
214
  "issueLimit": 100,
211
215
  "maxCiFixes": 5,
@@ -238,6 +242,7 @@ does not know all stop the run, so a typo cannot pass without a word.
238
242
 
239
243
  | Field | Default | Effect |
240
244
  | --- | --- | --- |
245
+ | `agent` | `claude` | The agent that does the work: `claude` or `pi` |
241
246
  | `remote` | `origin` | The git remote for the repository and the branches |
242
247
  | `issueLimit` | `100` | The maximum number of open issues to read |
243
248
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
@@ -263,7 +268,23 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
263
268
  `in-flight`.
264
269
 
265
270
  A role set to `null`, or with no entry in `models`, uses the default model of
266
- the SDK.
271
+ the agent.
272
+
273
+ ## The pi agent
274
+
275
+ With `"agent": "pi"` or `--agent pi`, each agent call runs `pi --mode json` in
276
+ the worktree with the prompt on standard input. pi loads your own settings,
277
+ extensions, skills and context files, as it does when you run it yourself. The
278
+ flag wins over the config file.
279
+
280
+ - The implementer and the fixer use your default pi tools.
281
+ - The reviewer runs with `--tools -edit,-write`, so it keeps `bash`.
282
+ - A model in `models` goes to `pi --model` as it is, so use a name that pi
283
+ knows.
284
+ - pi has no structured output, so the reviewer is asked to end its reply with
285
+ a JSON verdict. A reply with no valid verdict hands the issue to a person.
286
+ - A run fails when its last reply ends with an error, when pi exits with an
287
+ error, or when pi stops before the run settles.
267
288
 
268
289
  Without a `setupCommand`, the harness looks in the new worktree for a lock file
269
290
  and runs the install command that goes with it:
@@ -311,6 +332,7 @@ that a label still holds back stays as it was.
311
332
  | `--force` | Replace an existing config file, with `init` |
312
333
  | `--once` | Stop after the first handled issue |
313
334
  | `--max <n>` | Handle at most n issues |
335
+ | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
314
336
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
315
337
  | `--json` | Print the machine summary instead of the text one |
316
338
  | `--verbose` | Show every command and all agent output |
package/dist/agents.mjs CHANGED
@@ -1,8 +1,10 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { emptyUsage } from "./observe.mjs";
3
+ import { runPi } from "./pi.mjs";
3
4
  const REVIEWING = {
4
5
  tools: ["Read", "Grep", "Glob", "Bash"],
5
6
  permissionMode: "dontAsk",
7
+ piTools: "-edit,-write",
6
8
  };
7
9
  const CODING = {
8
10
  tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
@@ -11,6 +13,9 @@ const CODING = {
11
13
  };
12
14
  export { CODING, REVIEWING };
13
15
  export async function runAgent(recorder, request) {
16
+ if (request.agent === "pi") {
17
+ return runPi(recorder, request);
18
+ }
14
19
  const log = recorder.scope({ role: request.name });
15
20
  const usage = emptyUsage();
16
21
  const toolNames = new Map();
@@ -138,7 +143,7 @@ function main(models) {
138
143
  return name;
139
144
  }
140
145
  export function toolFields(name, input) {
141
- if (name !== "Bash" || typeof input !== "object" || input === null) {
146
+ if ((name !== "Bash" && name !== "bash") || typeof input !== "object" || input === null) {
142
147
  return { tool: name };
143
148
  }
144
149
  const { command } = input;
package/dist/args.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { CONFIG_FILE } from "./config.mjs";
1
+ import { AGENT_NAMES, 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
8
9
  --reset drop the saved budgets and findings first
9
10
  --json print the machine summary instead of the text one
10
11
  --force replace an existing config file, with init
@@ -51,6 +52,13 @@ export function parseArgs(argv) {
51
52
  else if (arg === "--quiet") {
52
53
  args.level = "quiet";
53
54
  }
55
+ else if (arg === "--agent") {
56
+ const value = argv[++index];
57
+ if (value === undefined || !AGENT_NAMES.includes(value)) {
58
+ throw new Error("--agent needs claude or pi");
59
+ }
60
+ args.agent = value;
61
+ }
54
62
  else if (arg === "--max") {
55
63
  args.max = whole(argv[++index], arg);
56
64
  }
package/dist/cli.mjs CHANGED
@@ -28,6 +28,7 @@ async function main() {
28
28
  return init(root, args.force);
29
29
  }
30
30
  const config = await loadConfig(root);
31
+ config.agent = args.agent ?? config.agent;
31
32
  setDefaultTimeout(config.commandTimeoutMinutes * 60_000);
32
33
  const repo = await detectRepo(root, config.remote);
33
34
  const recorder = await Recorder.create(repo.root, args.level);
package/dist/config.mjs CHANGED
@@ -1,7 +1,9 @@
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"];
4
5
  const DEFAULTS = {
6
+ agent: "claude",
5
7
  remote: "origin",
6
8
  issueLimit: 100,
7
9
  maxCiFixes: 5,
@@ -91,6 +93,12 @@ function parseConfig(value) {
91
93
  }
92
94
  }
93
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
+ }
94
102
  if (file.remote !== undefined) {
95
103
  config.remote = text(file.remote, "remote", false);
96
104
  }
package/dist/exec.mjs CHANGED
@@ -64,6 +64,7 @@ export function run(command, args, options = {}) {
64
64
  child.stdout?.setEncoding("utf8");
65
65
  child.stderr?.setEncoding("utf8");
66
66
  child.stdout?.on("data", (chunk) => {
67
+ options.onStdout?.(chunk);
67
68
  stdout += chunk;
68
69
  if (options.tailChars !== undefined && stdout.length > options.tailChars) {
69
70
  stdout = stdout.slice(-options.tailChars);
package/dist/pi.mjs ADDED
@@ -0,0 +1,159 @@
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
+ }
package/dist/pipeline.mjs CHANGED
@@ -137,6 +137,7 @@ async function work(context, issue, log, state) {
137
137
  prompt: implementPrompt(issue, comments),
138
138
  profile: CODING,
139
139
  model: config.models.implementer,
140
+ agent: config.agent,
140
141
  timeoutMs: config.agentTimeoutMinutes * 60_000,
141
142
  }));
142
143
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
@@ -236,6 +237,7 @@ async function reviewRound(job) {
236
237
  prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
237
238
  profile: REVIEWING,
238
239
  model: config.models.reviewer,
240
+ agent: config.agent,
239
241
  timeoutMs: config.agentTimeoutMinutes * 60_000,
240
242
  outputSchema: VERDICT_SCHEMA,
241
243
  }));
@@ -329,6 +331,7 @@ async function fixRound(job, reason, detail, earlier, kind) {
329
331
  prompt: fixPrompt(issue, reason, detail, earlier, kind),
330
332
  profile: CODING,
331
333
  model: context.config.models.fixer,
334
+ agent: context.config.agent,
332
335
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
333
336
  }));
334
337
  const unrelated = parseUnrelated(result.text);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",
@@ -37,10 +37,10 @@
37
37
  "prepublishOnly": "npm run typecheck && npm test"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "^26.4.1",
40
+ "@types/node": "^26.6.5",
41
41
  "typescript": "^7.0.2"
42
42
  },
43
43
  "dependencies": {
44
- "@anthropic-ai/claude-agent-sdk": "^0.3.261"
44
+ "@anthropic-ai/claude-agent-sdk": "^0.3.310"
45
45
  }
46
46
  }