next-issue 0.14.1 → 0.16.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
@@ -21,8 +22,10 @@ For each open issue, oldest first:
21
22
  1. Claim the issue only when it holds the `status:todo` label and holds no
22
23
  `status:in-progress` or `status:in-review` label. A `status:done`,
23
24
  `status:needs-human` or `status:blocked` label always stops the claim, a
24
- resume too. An issue with saved state under `.next-issue/` resumes instead,
25
- whatever its other labels.
25
+ resume too. An issue that GitHub lists as blocked by an open issue waits,
26
+ because its blocker closes only when the pull request of the blocker merges.
27
+ An issue with saved state under `.next-issue/` resumes instead, whatever its
28
+ other labels and blockers.
26
29
  2. Add `status:in-progress` to the issue.
27
30
  3. Fetch the remote, fast-forward the local copy of the default branch when
28
31
  that is safe, then add a git worktree at `../<repo>-issue-<n>` on the
@@ -131,7 +134,8 @@ verdict.
131
134
 
132
135
  Every agent can run a shell command. The implementer and the fixer run with the
133
136
  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
137
+ has no limit on the commands. pi has no permission checks, so with the `pi`
138
+ agent the reviewer only runs without the `edit` and `write` tools. They all start in the worktree, but nothing holds
135
139
  them there: they can run any command, with your rights, anywhere on the machine.
136
140
  The prompt holds the title, the body and the comments of the issue, which come
137
141
  from GitHub.
@@ -186,6 +190,7 @@ Three levels of console output:
186
190
  - `gh`, authenticated with write access to the repository
187
191
  - Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
188
192
  Claude subscription login
193
+ - With the `pi` agent: `pi` on the `PATH`, set up with a model instead
189
194
 
190
195
  ## Configuration
191
196
 
@@ -206,6 +211,7 @@ does not know all stop the run, so a typo cannot pass without a word.
206
211
 
207
212
  ```json
208
213
  {
214
+ "agent": "claude",
209
215
  "remote": "origin",
210
216
  "issueLimit": 100,
211
217
  "maxCiFixes": 5,
@@ -238,6 +244,7 @@ does not know all stop the run, so a typo cannot pass without a word.
238
244
 
239
245
  | Field | Default | Effect |
240
246
  | --- | --- | --- |
247
+ | `agent` | `claude` | The agent that does the work: `claude` or `pi` |
241
248
  | `remote` | `origin` | The git remote for the repository and the branches |
242
249
  | `issueLimit` | `100` | The maximum number of open issues to read |
243
250
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
@@ -259,11 +266,27 @@ does not know all stop the run, so a typo cannot pass without a word.
259
266
  An empty `labels.ready` turns the ready requirement off. The harness then
260
267
  claims every open issue that no other label holds back.
261
268
 
262
- The log gives the reason for a skipped issue as `stop-label`, `not-ready` or
263
- `in-flight`.
269
+ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
270
+ `in-flight` or `blocked`, the last one with the open blockers.
264
271
 
265
272
  A role set to `null`, or with no entry in `models`, uses the default model of
266
- the SDK.
273
+ the agent.
274
+
275
+ ## The pi agent
276
+
277
+ With `"agent": "pi"` or `--agent pi`, each agent call runs `pi --mode json` in
278
+ the worktree with the prompt on standard input. pi loads your own settings,
279
+ extensions, skills and context files, as it does when you run it yourself. The
280
+ flag wins over the config file.
281
+
282
+ - The implementer and the fixer use your default pi tools.
283
+ - The reviewer runs with `--tools -edit,-write`, so it keeps `bash`.
284
+ - A model in `models` goes to `pi --model` as it is, so use a name that pi
285
+ knows.
286
+ - pi has no structured output, so the reviewer is asked to end its reply with
287
+ a JSON verdict. A reply with no valid verdict hands the issue to a person.
288
+ - A run fails when its last reply ends with an error, when pi exits with an
289
+ error, or when pi stops before the run settles.
267
290
 
268
291
  Without a `setupCommand`, the harness looks in the new worktree for a lock file
269
292
  and runs the install command that goes with it:
@@ -311,6 +334,7 @@ that a label still holds back stays as it was.
311
334
  | `--force` | Replace an existing config file, with `init` |
312
335
  | `--once` | Stop after the first handled issue |
313
336
  | `--max <n>` | Handle at most n issues |
337
+ | `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
314
338
  | `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
315
339
  | `--json` | Print the machine summary instead of the text one |
316
340
  | `--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/github.mjs CHANGED
@@ -44,6 +44,19 @@ export async function issueComments(repo, issue) {
44
44
  const parsed = JSON.parse(raw);
45
45
  return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
46
46
  }
47
+ export async function openBlockers(repo, issue) {
48
+ const raw = await must("gh", [
49
+ "api",
50
+ "--paginate",
51
+ `repos/${slug(repo)}/issues/${issue}/dependencies/blocked_by`,
52
+ "--jq",
53
+ '.[] | select(.state == "open") | (.repository_url | split("/") | .[-2:] | join("/")) + "#" + (.number | tostring)',
54
+ ], { cwd: repo.root });
55
+ return raw
56
+ .split("\n")
57
+ .filter((line) => line.length > 0)
58
+ .map((ref) => (ref.startsWith(`${slug(repo)}#`) ? ref.slice(slug(repo).length) : ref));
59
+ }
47
60
  async function labelsOf(repo, kind, number) {
48
61
  const raw = await gh(repo, [kind, "view", String(number), "--json", "labels"]);
49
62
  const parsed = JSON.parse(raw);
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
@@ -2,7 +2,7 @@ import { CODING, REVIEWING, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
4
  import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, isDirty, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
- import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
5
+ import { commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, openBlockers, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
8
  import { detectSetupCommand } from "./setup.mjs";
@@ -23,6 +23,7 @@ export const PORTS = {
23
23
  isDirty,
24
24
  issueComments,
25
25
  markPrReady,
26
+ openBlockers,
26
27
  push,
27
28
  removeWorktree,
28
29
  revision,
@@ -55,7 +56,21 @@ export async function processIssue(context, issue) {
55
56
  const skip = skipReason(issue, context.config, saved !== undefined);
56
57
  if (skip !== undefined) {
57
58
  log.event("issue.skip", { title: issue.title, reason: skip });
58
- return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
59
+ return skipped(issue);
60
+ }
61
+ if (saved === undefined) {
62
+ let blockers;
63
+ try {
64
+ blockers = await context.ports.openBlockers(context.repo, issue.number);
65
+ }
66
+ catch (error) {
67
+ log.event("issue.error", { error: message(error), ms: Date.now() - started }, "quiet");
68
+ return { ...skipped(issue), outcome: "error", reason: message(error), ms: Date.now() - started };
69
+ }
70
+ if (blockers.length > 0) {
71
+ log.event("issue.skip", { title: issue.title, reason: "blocked", blockers });
72
+ return skipped(issue);
73
+ }
59
74
  }
60
75
  log.event("issue.start", { title: issue.title, createdAt: issue.createdAt }, "quiet");
61
76
  const fresh = {
@@ -92,6 +107,9 @@ export async function processIssue(context, issue) {
92
107
  };
93
108
  }
94
109
  }
110
+ function skipped(issue) {
111
+ return { issue: issue.number, outcome: "skipped", ciFixes: 0, reviewRounds: 0, ms: 0 };
112
+ }
95
113
  async function work(context, issue, log, state) {
96
114
  const started = Date.now();
97
115
  const { repo, config, ports } = context;
@@ -137,6 +155,7 @@ async function work(context, issue, log, state) {
137
155
  prompt: implementPrompt(issue, comments),
138
156
  profile: CODING,
139
157
  model: config.models.implementer,
158
+ agent: config.agent,
140
159
  timeoutMs: config.agentTimeoutMinutes * 60_000,
141
160
  }));
142
161
  const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
@@ -236,6 +255,7 @@ async function reviewRound(job) {
236
255
  prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
237
256
  profile: REVIEWING,
238
257
  model: config.models.reviewer,
258
+ agent: config.agent,
239
259
  timeoutMs: config.agentTimeoutMinutes * 60_000,
240
260
  outputSchema: VERDICT_SCHEMA,
241
261
  }));
@@ -329,6 +349,7 @@ async function fixRound(job, reason, detail, earlier, kind) {
329
349
  prompt: fixPrompt(issue, reason, detail, earlier, kind),
330
350
  profile: CODING,
331
351
  model: context.config.models.fixer,
352
+ agent: context.config.agent,
332
353
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
333
354
  }));
334
355
  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.16.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
  }