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 +25 -3
- package/dist/agents.mjs +6 -1
- package/dist/args.mjs +9 -1
- package/dist/cli.mjs +1 -0
- package/dist/config.mjs +8 -0
- package/dist/exec.mjs +1 -0
- package/dist/pi.mjs +159 -0
- package/dist/pipeline.mjs +3 -0
- package/package.json +3 -3
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
44
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.310"
|
|
45
45
|
}
|
|
46
46
|
}
|