next-issue 0.20.0 → 1.0.1
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 +30 -50
- package/dist/agents.mjs +31 -10
- package/dist/args.mjs +1 -9
- package/dist/cli.mjs +0 -1
- package/dist/config.mjs +0 -8
- package/dist/git.mjs +3 -3
- package/dist/github.mjs +19 -3
- package/dist/pipeline.mjs +26 -10
- package/dist/prompts.mjs +2 -2
- package/dist/state.mjs +13 -2
- package/package.json +1 -1
- package/dist/pi.mjs +0 -159
package/README.md
CHANGED
|
@@ -1,20 +1,12 @@
|
|
|
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
|
|
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
|
|
9
8
|
```
|
|
10
9
|
|
|
11
|
-
Or install it once:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install -g next-issue@latest
|
|
15
|
-
next-issue
|
|
16
|
-
```
|
|
17
|
-
|
|
18
10
|
## What it does
|
|
19
11
|
|
|
20
12
|
For each open issue, oldest first:
|
|
@@ -35,11 +27,16 @@ For each open issue, oldest first:
|
|
|
35
27
|
writes a `base.behind` event. A new branch then starts from the remote base.
|
|
36
28
|
4. Let the implementer agent do the work, then commit and push. When a git
|
|
37
29
|
hook stops a commit, the agent that made the change gets the hook output,
|
|
38
|
-
fixes it, and the harness commits again.
|
|
39
|
-
|
|
30
|
+
fixes it, and the harness commits again. A `commit:` line in the reply of
|
|
31
|
+
the agent replaces the subject, so a hook that rejects the subject can pass.
|
|
32
|
+
The budget is `maxCommitFixes`.
|
|
33
|
+
5. Open a draft pull request that closes the issue. Its title is the subject
|
|
34
|
+
of the commit that starts it, so a squash merge gives a conventional
|
|
35
|
+
commit.
|
|
40
36
|
6. Wait for the checks, once the pull request shows the pushed commit. A
|
|
41
37
|
pending state waits again, up to `checkTimeoutMinutes`. A read that fails
|
|
42
|
-
is tried three times before the run stops. A
|
|
38
|
+
is tried three times before the run stops. A watch that exits with an error
|
|
39
|
+
while no check failed counts as such a read, not as a red build. A repository that reports no
|
|
43
40
|
check keeps asking for `checkGraceSeconds`, because a new pull request often
|
|
44
41
|
shows no check yet, and only then goes straight to the review. The later
|
|
45
42
|
gates of the same issue ask once.
|
|
@@ -62,16 +59,16 @@ For each open issue, oldest first:
|
|
|
62
59
|
10. No open finding means approval: mark the pull request ready for review and
|
|
63
60
|
set `status:done`. The merge stays with you, unless you pass `--merge`:
|
|
64
61
|
then the harness squash-merges the pull request, deletes the remote
|
|
65
|
-
branch
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
count.
|
|
62
|
+
branch, waits until the pull request is merged, for example through a
|
|
63
|
+
merge queue, and then waits for the checks that the merge commit starts on
|
|
64
|
+
the base branch. A cancelled run there is no failure. When those checks
|
|
65
|
+
fail, the fixer gets their logs on a branch reset to the base branch, from
|
|
66
|
+
the check budget, and the harness reopens the issue and opens a new pull
|
|
67
|
+
request that goes to step 6 again, with fresh review rounds. The next
|
|
68
|
+
issue waits for this, and a second ctrl-c stops only after it. The
|
|
69
|
+
worktree, the local branch and the state file go away. Without `--merge`,
|
|
70
|
+
the remote branch stays. A problem that the reviewer leaves out of the
|
|
71
|
+
findings thus gets an approval: only the findings count.
|
|
75
72
|
11. With an open finding, run the fixer agent and go to step 6 again. The
|
|
76
73
|
budget is `maxReviewRounds`. A fixer that holds every finding to be wrong
|
|
77
74
|
changes nothing and gives its reason instead. The reason goes on the pull
|
|
@@ -103,6 +100,8 @@ when:
|
|
|
103
100
|
- the reviewer gives no verdict, or the checks do not finish in time;
|
|
104
101
|
- with `--merge`, the merge of an approved pull request fails, for example
|
|
105
102
|
because branch protection asks for a human approval;
|
|
103
|
+
- with `--merge`, a queued merge does not finish in `checkTimeoutMinutes`, or
|
|
104
|
+
the pull request closes without a merge;
|
|
106
105
|
- with `--merge`, the checks on the base branch fail after the merge and the
|
|
107
106
|
fixer cannot fix them, or they do not finish in time. The base branch is
|
|
108
107
|
then broken, so the run stops after this issue;
|
|
@@ -152,15 +151,14 @@ and a `status:needs-human` pull request shows work that waits for a person.
|
|
|
152
151
|
The harness runs all git and `gh` commands itself. The commit holds all the
|
|
153
152
|
changes in the worktree, so a setup command that writes a file outside
|
|
154
153
|
`.gitignore` puts that file in the commit. The reviewer is the one agent whose
|
|
155
|
-
changes never get that far: the harness discards them
|
|
156
|
-
verdict.
|
|
154
|
+
changes never get that far: the harness discards them, and undoes any commit
|
|
155
|
+
that the reviewer made, before it reads the verdict.
|
|
157
156
|
|
|
158
157
|
## Safety
|
|
159
158
|
|
|
160
159
|
Every agent can run a shell command. The implementer and the fixer run with the
|
|
161
160
|
permission checks off; the reviewer runs with a smaller tool set, but its shell
|
|
162
|
-
has no limit on the commands.
|
|
163
|
-
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
|
|
164
162
|
them there: they can run any command, with your rights, anywhere on the machine.
|
|
165
163
|
The prompt holds the title, the body and the comments of the issue, which come
|
|
166
164
|
from GitHub.
|
|
@@ -215,7 +213,6 @@ Three levels of console output:
|
|
|
215
213
|
- `gh`, authenticated with write access to the repository
|
|
216
214
|
- Anthropic credentials for the Claude Agent SDK: `ANTHROPIC_API_KEY`, or a
|
|
217
215
|
Claude subscription login
|
|
218
|
-
- With the `pi` agent: `pi` on the `PATH`, set up with a model instead
|
|
219
216
|
|
|
220
217
|
## Configuration
|
|
221
218
|
|
|
@@ -236,7 +233,6 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
236
233
|
|
|
237
234
|
```json
|
|
238
235
|
{
|
|
239
|
-
"agent": "claude",
|
|
240
236
|
"remote": "origin",
|
|
241
237
|
"issueLimit": 100,
|
|
242
238
|
"maxCiFixes": 5,
|
|
@@ -270,7 +266,6 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
270
266
|
|
|
271
267
|
| Field | Default | Effect |
|
|
272
268
|
| --- | --- | --- |
|
|
273
|
-
| `agent` | `claude` | The agent that does the work: `claude` or `pi` |
|
|
274
269
|
| `remote` | `origin` | The git remote for the repository and the branches |
|
|
275
270
|
| `issueLimit` | `100` | The maximum number of open issues to read |
|
|
276
271
|
| `maxCiFixes` | `5` | The budget for check fixes per issue |
|
|
@@ -299,22 +294,6 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
|
|
|
299
294
|
A role set to `null`, or with no entry in `models`, uses the default model of
|
|
300
295
|
the agent.
|
|
301
296
|
|
|
302
|
-
## The pi agent
|
|
303
|
-
|
|
304
|
-
With `"agent": "pi"` or `--agent pi`, each agent call runs `pi --mode json` in
|
|
305
|
-
the worktree with the prompt on standard input. pi loads your own settings,
|
|
306
|
-
extensions, skills and context files, as it does when you run it yourself. The
|
|
307
|
-
flag wins over the config file.
|
|
308
|
-
|
|
309
|
-
- The implementer and the fixer use your default pi tools.
|
|
310
|
-
- The reviewer runs with `--tools -edit,-write`, so it keeps `bash`.
|
|
311
|
-
- A model in `models` goes to `pi --model` as it is, so use a name that pi
|
|
312
|
-
knows.
|
|
313
|
-
- pi has no structured output, so the reviewer is asked to end its reply with
|
|
314
|
-
a JSON verdict. A reply with no valid verdict hands the issue to a person.
|
|
315
|
-
- A run fails when its last reply ends with an error, when pi exits with an
|
|
316
|
-
error, or when pi stops before the run settles.
|
|
317
|
-
|
|
318
297
|
Without a `setupCommand`, the harness looks in the new worktree for a lock file
|
|
319
298
|
and runs the install command that goes with it:
|
|
320
299
|
|
|
@@ -340,7 +319,8 @@ A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
|
|
|
340
319
|
hands the issue to a person. The wait for the checks has its own limit, so
|
|
341
320
|
`commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
|
|
342
321
|
issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
|
|
343
|
-
run stopped.
|
|
322
|
+
run stopped. A state file that is not valid JSON gives the issue an `error`
|
|
323
|
+
outcome that names the file, rather than a silent skip.
|
|
344
324
|
|
|
345
325
|
## Retry an issue that waits for a person
|
|
346
326
|
|
|
@@ -361,7 +341,6 @@ that a label still holds back stays as it was.
|
|
|
361
341
|
| `--force` | Replace an existing config file, with `init` |
|
|
362
342
|
| `--once` | Stop after the first handled issue |
|
|
363
343
|
| `--max <n>` | Handle at most n issues |
|
|
364
|
-
| `--agent <a>` | Run the agents with `claude` or `pi`, whatever the config file says |
|
|
365
344
|
| `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
|
|
366
345
|
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
367
346
|
| `--json` | Print the machine summary instead of the text one |
|
|
@@ -396,6 +375,7 @@ The workflow then bumps the version and publishes the package to npm with a
|
|
|
396
375
|
[trusted publish](https://docs.npmjs.com/trusted-publishers), so no token is
|
|
397
376
|
stored. Only after the publish does it push the tag and make the GitHub release,
|
|
398
377
|
so a failed publish leaves no half-release behind. A manual run of the workflow
|
|
399
|
-
|
|
400
|
-
a release that reached GitHub but not npm.
|
|
401
|
-
GitHub runner, because npm takes provenance only
|
|
378
|
+
checks out the last tag and publishes that commit under the tag version, and
|
|
379
|
+
changes nothing else. This repairs a release that reached GitHub but not npm.
|
|
380
|
+
The release job stays on a GitHub runner, because npm takes provenance only
|
|
381
|
+
from a GitHub runner.
|
package/dist/agents.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
5
|
import { track } from "./exec.mjs";
|
|
4
6
|
import { emptyUsage } from "./observe.mjs";
|
|
5
|
-
import { runPi } from "./pi.mjs";
|
|
6
7
|
const REVIEWING = {
|
|
7
8
|
tools: ["Read", "Grep", "Glob", "Bash"],
|
|
8
9
|
permissionMode: "dontAsk",
|
|
9
|
-
piTools: "-edit,-write",
|
|
10
10
|
};
|
|
11
11
|
const CODING = {
|
|
12
12
|
tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob"],
|
|
@@ -14,10 +14,9 @@ const CODING = {
|
|
|
14
14
|
allowDangerouslySkipPermissions: true,
|
|
15
15
|
};
|
|
16
16
|
export { CODING, REVIEWING };
|
|
17
|
+
const STDERR_TAIL = 2000;
|
|
18
|
+
const STDERR_GRACE_MS = 500;
|
|
17
19
|
export async function runAgent(recorder, request) {
|
|
18
|
-
if (request.agent === "pi") {
|
|
19
|
-
return runPi(recorder, request);
|
|
20
|
-
}
|
|
21
20
|
const log = recorder.scope({ role: request.name });
|
|
22
21
|
const usage = emptyUsage();
|
|
23
22
|
const toolNames = new Map();
|
|
@@ -28,6 +27,8 @@ export async function runAgent(recorder, request) {
|
|
|
28
27
|
let model = request.model;
|
|
29
28
|
let sessionId;
|
|
30
29
|
let structured;
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let stderrClosed = Promise.resolve();
|
|
31
32
|
const started = Date.now();
|
|
32
33
|
log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
|
|
33
34
|
const controller = new AbortController();
|
|
@@ -47,7 +48,14 @@ export async function runAgent(recorder, request) {
|
|
|
47
48
|
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
48
49
|
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
49
50
|
abortController: controller,
|
|
50
|
-
|
|
51
|
+
pathToClaudeCodeExecutable: request.executable,
|
|
52
|
+
spawnClaudeCodeProcess: (options) => {
|
|
53
|
+
const child = spawnClaude(options, (chunk) => {
|
|
54
|
+
stderr = (stderr + chunk).slice(-STDERR_TAIL);
|
|
55
|
+
});
|
|
56
|
+
stderrClosed = once(child.stderr, "close").catch(() => undefined);
|
|
57
|
+
return child;
|
|
58
|
+
},
|
|
51
59
|
outputFormat: request.outputSchema === undefined
|
|
52
60
|
? undefined
|
|
53
61
|
: { type: "json_schema", schema: request.outputSchema },
|
|
@@ -106,7 +114,11 @@ export async function runAgent(recorder, request) {
|
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
catch (error) {
|
|
109
|
-
|
|
117
|
+
if (controller.signal.aborted) {
|
|
118
|
+
throw late();
|
|
119
|
+
}
|
|
120
|
+
await Promise.race([stderrClosed, delay(STDERR_GRACE_MS, undefined, { ref: false })]);
|
|
121
|
+
throw withStderr(error, stderr);
|
|
110
122
|
}
|
|
111
123
|
finally {
|
|
112
124
|
clearTimeout(timer);
|
|
@@ -146,20 +158,29 @@ function main(models) {
|
|
|
146
158
|
return name;
|
|
147
159
|
}
|
|
148
160
|
export function toolFields(name, input) {
|
|
149
|
-
if (
|
|
161
|
+
if (name !== "Bash" || typeof input !== "object" || input === null) {
|
|
150
162
|
return { tool: name };
|
|
151
163
|
}
|
|
152
164
|
const { command } = input;
|
|
153
165
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
154
166
|
}
|
|
155
|
-
export function
|
|
167
|
+
export function withStderr(error, stderr) {
|
|
168
|
+
const tail = stderr.trim();
|
|
169
|
+
if (tail.length === 0 || !(error instanceof Error)) {
|
|
170
|
+
return error;
|
|
171
|
+
}
|
|
172
|
+
return new Error(`${error.message}\nClaude CLI stderr:\n${tail}`, { cause: error });
|
|
173
|
+
}
|
|
174
|
+
export function spawnClaude(options, onStderr = () => undefined) {
|
|
156
175
|
const child = spawn(options.command, options.args, {
|
|
157
176
|
cwd: options.cwd,
|
|
158
177
|
env: options.env,
|
|
159
178
|
signal: options.signal,
|
|
160
179
|
detached: true,
|
|
161
|
-
stdio: ["pipe", "pipe", "
|
|
180
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
181
|
});
|
|
182
|
+
child.stderr.setEncoding("utf8");
|
|
183
|
+
child.stderr.on("data", onStderr);
|
|
163
184
|
track(child);
|
|
164
185
|
return child;
|
|
165
186
|
}
|
package/dist/args.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
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,9 +1,7 @@
|
|
|
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,
|
|
@@ -95,12 +93,6 @@ function parseConfig(value) {
|
|
|
95
93
|
}
|
|
96
94
|
}
|
|
97
95
|
const config = defaults();
|
|
98
|
-
if (file.agent !== undefined) {
|
|
99
|
-
if (typeof file.agent !== "string" || !AGENT_NAMES.includes(file.agent)) {
|
|
100
|
-
throw new Error(`${CONFIG_FILE}: agent must be claude or pi.`);
|
|
101
|
-
}
|
|
102
|
-
config.agent = file.agent;
|
|
103
|
-
}
|
|
104
96
|
if (file.remote !== undefined) {
|
|
105
97
|
config.remote = text(file.remote, "remote", false);
|
|
106
98
|
}
|
package/dist/git.mjs
CHANGED
|
@@ -100,11 +100,11 @@ export async function commitAll(cwd, message) {
|
|
|
100
100
|
await must("git", ["commit", "-m", message], { cwd });
|
|
101
101
|
return true;
|
|
102
102
|
}
|
|
103
|
-
export async function discardChanges(cwd) {
|
|
104
|
-
if (!(await isDirty(cwd))) {
|
|
103
|
+
export async function discardChanges(cwd, target) {
|
|
104
|
+
if (!(await isDirty(cwd)) && (await revision(cwd)) === target) {
|
|
105
105
|
return false;
|
|
106
106
|
}
|
|
107
|
-
await must("git", ["reset", "--hard",
|
|
107
|
+
await must("git", ["reset", "--hard", target], { cwd });
|
|
108
108
|
await must("git", ["clean", "-fd"], { cwd });
|
|
109
109
|
return true;
|
|
110
110
|
}
|
package/dist/github.mjs
CHANGED
|
@@ -168,7 +168,11 @@ async function watchOnce(repo, branch, intervalSeconds, timeoutMs, show) {
|
|
|
168
168
|
if (watch.code === CHECK_PENDING_CODE) {
|
|
169
169
|
return "pending";
|
|
170
170
|
}
|
|
171
|
-
|
|
171
|
+
const checks = await readChecks(repo, branch);
|
|
172
|
+
if (checks !== "none" && checks.some((check) => check.bucket === "fail")) {
|
|
173
|
+
return "fail";
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`gh pr checks ${branch} --watch failed with code ${watch.code} and no check failed: ${watch.stderr.trim()}`);
|
|
172
176
|
}
|
|
173
177
|
const REAL_CLOCK = {
|
|
174
178
|
now: () => Date.now(),
|
|
@@ -293,8 +297,20 @@ export async function mergePr(repo, pr) {
|
|
|
293
297
|
const result = await run("gh", ["pr", "merge", String(pr), "--squash", "--delete-branch", "--repo", slug(repo)], { cwd: repo.root });
|
|
294
298
|
return result.code === 0;
|
|
295
299
|
}
|
|
296
|
-
export async function mergeCommit(repo, pr) {
|
|
297
|
-
|
|
300
|
+
export async function mergeCommit(repo, pr, options, clock = REAL_CLOCK) {
|
|
301
|
+
const deadline = clock.now() + options.timeoutMs;
|
|
302
|
+
for (;;) {
|
|
303
|
+
const raw = await gh(repo, ["pr", "view", String(pr), "--json", "state,mergeCommit"]);
|
|
304
|
+
const view = JSON.parse(raw);
|
|
305
|
+
if (view.state === "MERGED" && view.mergeCommit?.oid) {
|
|
306
|
+
return view.mergeCommit.oid;
|
|
307
|
+
}
|
|
308
|
+
const rest = deadline - clock.now();
|
|
309
|
+
if (view.state === "CLOSED" || rest <= 0) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
await clock.sleep(Math.min(options.intervalSeconds * 1000, rest));
|
|
313
|
+
}
|
|
298
314
|
}
|
|
299
315
|
export async function reopenIssue(repo, issue) {
|
|
300
316
|
await gh(repo, ["issue", "reopen", String(issue)]);
|
package/dist/pipeline.mjs
CHANGED
|
@@ -58,7 +58,14 @@ export function skipReason(issue, config, resuming) {
|
|
|
58
58
|
export async function processIssue(context, issue) {
|
|
59
59
|
const started = Date.now();
|
|
60
60
|
const log = context.recorder.scope({ issue: issue.number });
|
|
61
|
-
|
|
61
|
+
let saved;
|
|
62
|
+
try {
|
|
63
|
+
saved = await readState(context.repo, issue.number);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
log.event("issue.error", { error: message(error), ms: Date.now() - started }, "quiet");
|
|
67
|
+
return { ...skipped(issue), outcome: "error", reason: message(error), ms: Date.now() - started };
|
|
68
|
+
}
|
|
62
69
|
const skip = skipReason(issue, context.config, saved !== undefined);
|
|
63
70
|
if (skip !== undefined) {
|
|
64
71
|
log.event("issue.skip", { title: issue.title, reason: skip });
|
|
@@ -162,7 +169,6 @@ async function work(context, issue, log, state) {
|
|
|
162
169
|
prompt: implementPrompt(issue, comments),
|
|
163
170
|
profile: CODING,
|
|
164
171
|
model: config.models.implementer,
|
|
165
|
-
agent: config.agent,
|
|
166
172
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
167
173
|
}));
|
|
168
174
|
const subject = parseCommitSubject(result.text, `fix: resolve issue #${issue.number}`);
|
|
@@ -205,7 +211,7 @@ async function openPr(job) {
|
|
|
205
211
|
const pr = await log.step("pull-request", { branch }, async () => {
|
|
206
212
|
const existing = await ports.findPr(repo, branch);
|
|
207
213
|
return (existing ??
|
|
208
|
-
ports.createPr(repo, branch, context.base,
|
|
214
|
+
ports.createPr(repo, branch, context.base, state.title ?? `fix: resolve issue #${issue.number}`, `Closes #${issue.number}\n\nOpened by next-issue.`, config.draftPullRequest));
|
|
209
215
|
});
|
|
210
216
|
state.pr = pr;
|
|
211
217
|
await writeState(repo, state);
|
|
@@ -270,17 +276,17 @@ async function reviewRound(job) {
|
|
|
270
276
|
diffChars: full.length,
|
|
271
277
|
cut: full.length > config.diffMaxChars,
|
|
272
278
|
};
|
|
279
|
+
const before = await ports.revision(worktree);
|
|
273
280
|
const review = await log.step("review", fields, () => ports.runAgent(log, {
|
|
274
281
|
name: "reviewer",
|
|
275
282
|
cwd: worktree,
|
|
276
283
|
prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
|
|
277
284
|
profile: REVIEWING,
|
|
278
285
|
model: config.models.reviewer,
|
|
279
|
-
agent: config.agent,
|
|
280
286
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
281
287
|
outputSchema: VERDICT_SCHEMA,
|
|
282
288
|
}));
|
|
283
|
-
if (await ports.discardChanges(worktree)) {
|
|
289
|
+
if (await ports.discardChanges(worktree, before)) {
|
|
284
290
|
log.event("review.discard", { round: state.reviewRounds }, "quiet");
|
|
285
291
|
}
|
|
286
292
|
const verdict = readVerdict(review.structured);
|
|
@@ -353,7 +359,14 @@ async function reviewRound(job) {
|
|
|
353
359
|
async function watchBase(job) {
|
|
354
360
|
const { context, issue, log, state, worktree } = job;
|
|
355
361
|
const { repo, config, ports } = context;
|
|
356
|
-
const sha = await ports.mergeCommit(repo, state.pr
|
|
362
|
+
const sha = await ports.mergeCommit(repo, state.pr, {
|
|
363
|
+
intervalSeconds: config.checkIntervalSeconds,
|
|
364
|
+
timeoutMs: config.checkTimeoutMinutes * 60_000,
|
|
365
|
+
});
|
|
366
|
+
if (sha === undefined) {
|
|
367
|
+
await escalate(job, "The pull request did not merge in time, or it closed without a merge.");
|
|
368
|
+
return { done: true, report: await finish(job, "needs-human", "not merged") };
|
|
369
|
+
}
|
|
357
370
|
const checks = await log.step("main-checks", { pr: state.pr, sha }, () => ports.waitForBaseChecks(repo, sha, {
|
|
358
371
|
intervalSeconds: config.checkIntervalSeconds,
|
|
359
372
|
graceMs: config.checkGraceSeconds * 1000,
|
|
@@ -440,7 +453,6 @@ async function fixRound(job, reason, detail, earlier, kind) {
|
|
|
440
453
|
prompt: fixPrompt(issue, reason, detail, earlier, kind),
|
|
441
454
|
profile: CODING,
|
|
442
455
|
model: context.config.models.fixer,
|
|
443
|
-
agent: context.config.agent,
|
|
444
456
|
timeoutMs: context.config.agentTimeoutMinutes * 60_000,
|
|
445
457
|
}));
|
|
446
458
|
const unrelated = parseUnrelated(result.text);
|
|
@@ -465,7 +477,11 @@ async function commitWork(job, before, subject, role) {
|
|
|
465
477
|
if (await ports.commitAll(worktree, `${subject}\n\nRefs #${issue.number}`)) {
|
|
466
478
|
log.event("commit", { subject });
|
|
467
479
|
}
|
|
468
|
-
|
|
480
|
+
if ((await ports.revision(worktree)) === before) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
state.title = subject;
|
|
484
|
+
return true;
|
|
469
485
|
}
|
|
470
486
|
catch (error) {
|
|
471
487
|
const used = state.commitFixes ?? 0;
|
|
@@ -476,15 +492,15 @@ async function commitWork(job, before, subject, role) {
|
|
|
476
492
|
await writeState(repo, state);
|
|
477
493
|
const output = message(error).slice(-config.logMaxChars);
|
|
478
494
|
log.event("commit.fail", { role, output }, "quiet");
|
|
479
|
-
await log.step("commit-fix", { role }, () => ports.runAgent(log, {
|
|
495
|
+
const fix = await log.step("commit-fix", { role }, () => ports.runAgent(log, {
|
|
480
496
|
name: role,
|
|
481
497
|
cwd: worktree,
|
|
482
498
|
prompt: hookPrompt(issue, comments, role, output),
|
|
483
499
|
profile: CODING,
|
|
484
500
|
model: config.models[role],
|
|
485
|
-
agent: config.agent,
|
|
486
501
|
timeoutMs: config.agentTimeoutMinutes * 60_000,
|
|
487
502
|
}));
|
|
503
|
+
subject = parseCommitSubject(fix.text, subject);
|
|
488
504
|
}
|
|
489
505
|
}
|
|
490
506
|
}
|
package/dist/prompts.mjs
CHANGED
|
@@ -95,12 +95,12 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
|
|
|
95
95
|
.join("\n\n");
|
|
96
96
|
}
|
|
97
97
|
export function parseUnrelated(text) {
|
|
98
|
-
const match = /^unrelated:\s*(.+)$/
|
|
98
|
+
const match = [...text.matchAll(/^unrelated:\s*(.+)$/gim)].at(-1);
|
|
99
99
|
const note = match?.[1]?.trim();
|
|
100
100
|
return note !== undefined && note.length > 0 ? note : undefined;
|
|
101
101
|
}
|
|
102
102
|
export function parseCommitSubject(text, fallback) {
|
|
103
|
-
const match = /^commit:\s*(.+)$/
|
|
103
|
+
const match = [...text.matchAll(/^commit:\s*(.+)$/gim)].at(-1);
|
|
104
104
|
const subject = match?.[1]
|
|
105
105
|
?.trim()
|
|
106
106
|
.replace(/^`+|`+$/g, "")
|
package/dist/state.mjs
CHANGED
|
@@ -8,11 +8,22 @@ function file(repo, issue) {
|
|
|
8
8
|
return join(dir(repo), `${issue}.json`);
|
|
9
9
|
}
|
|
10
10
|
export async function readState(repo, issue) {
|
|
11
|
+
const path = file(repo, issue);
|
|
12
|
+
let text;
|
|
11
13
|
try {
|
|
12
|
-
|
|
14
|
+
text = await readFile(path, "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === "ENOENT") {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text);
|
|
13
24
|
}
|
|
14
25
|
catch {
|
|
15
|
-
|
|
26
|
+
throw new Error(`the state file ${path} is not valid JSON`);
|
|
16
27
|
}
|
|
17
28
|
}
|
|
18
29
|
export function resetState(state) {
|
package/package.json
CHANGED
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
|
-
}
|