next-issue 1.0.8 → 2.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 +43 -13
- package/dist/agents.mjs +118 -157
- package/dist/args.mjs +5 -0
- package/dist/cli.mjs +19 -4
- package/dist/exec.mjs +9 -0
- package/dist/interrupt.mjs +21 -1
- package/dist/loop.mjs +25 -0
- package/dist/observe.mjs +26 -10
- package/dist/prompts.mjs +1 -1
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,7 +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
|
|
4
|
+
the [pi](https://github.com/earendil-works/pi) SDK. Run it inside a git repository.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
npx -y next-issue@latest
|
|
@@ -54,7 +54,7 @@ and exits with code 1. For each issue:
|
|
|
54
54
|
reviewer reads files and runs commands in the worktree, so it can prove a
|
|
55
55
|
finding with the tests or the type check. The harness then throws away every
|
|
56
56
|
change that the reviewer left, so no such change can reach a later commit.
|
|
57
|
-
The reviewer returns a
|
|
57
|
+
The reviewer returns its verdict with a `submit_result` tool: a summary and one entry per
|
|
58
58
|
finding, each rated `blocking` or `minor`. The findings are the only ground
|
|
59
59
|
truth. The reviewer gives no approve or reject field, because such a field
|
|
60
60
|
can contradict the findings. The harness puts the result on the pull
|
|
@@ -146,14 +146,14 @@ Press `Ctrl-C` to stop the run step by step:
|
|
|
146
146
|
one checks round (wait, then fix) or one review round (review, then fix). The
|
|
147
147
|
issue ends as `stopped`. Its state, worktree and labels stay, so the next run
|
|
148
148
|
carries on from that point.
|
|
149
|
-
3. The third press kills the command that runs and every
|
|
150
|
-
dies in the middle of a step. The saved state lets the next run continue the
|
|
149
|
+
3. The third press stops every agent, kills the command that runs and every
|
|
150
|
+
child of it, so the run dies in the middle of a step. The saved state lets the next run continue the
|
|
151
151
|
issue, but the issue keeps `status:in-progress` and the run writes no
|
|
152
152
|
summary.
|
|
153
153
|
|
|
154
154
|
Each of the first two presses prints a line that says what happens next. The
|
|
155
|
-
agents
|
|
156
|
-
the harness.
|
|
155
|
+
agents run inside the harness, and their commands and the harness commands run
|
|
156
|
+
in a process group of their own, so a press reaches only the harness.
|
|
157
157
|
|
|
158
158
|
A status label that the harness puts on the issue goes on the pull request too,
|
|
159
159
|
but only if the pull request exists at that moment. The `status:in-progress`
|
|
@@ -202,10 +202,9 @@ Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
|
|
|
202
202
|
`push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
|
|
203
203
|
`.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
|
|
204
204
|
is recorded with its exit code and duration. A `tool` event holds the tool
|
|
205
|
-
name, and for a `
|
|
205
|
+
name, and for a `bash` tool the command too. Each agent call adds a `usage`
|
|
206
206
|
event with the tokens, the cost estimate in dollars, the turn count, the
|
|
207
|
-
tool-call count, the model and the session id
|
|
208
|
-
again and read the full transcript.
|
|
207
|
+
tool-call count, the model and the session id.
|
|
209
208
|
|
|
210
209
|
```bash
|
|
211
210
|
jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
|
|
@@ -224,8 +223,11 @@ Three levels of console output:
|
|
|
224
223
|
|
|
225
224
|
- Node 24 or later
|
|
226
225
|
- `gh`, authenticated with write access to the repository
|
|
227
|
-
-
|
|
228
|
-
|
|
226
|
+
- pi, logged in to a model provider. The agents use the model, the thinking
|
|
227
|
+
level and the extensions from your pi settings, except the `pi-next-issue`
|
|
228
|
+
extension. The implementer and the fixer get the `read`, `bash`, `edit`,
|
|
229
|
+
`write`, `grep`, `find` and `ls` tools, and the reviewer gets all of them
|
|
230
|
+
except `edit` and `write`.
|
|
229
231
|
|
|
230
232
|
## Configuration
|
|
231
233
|
|
|
@@ -297,7 +299,7 @@ does not know all stop the run, so a typo cannot pass without a word.
|
|
|
297
299
|
| `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
|
|
298
300
|
| `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
|
|
299
301
|
| `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
|
|
300
|
-
| `models` | `null` per role | The model per agent role
|
|
302
|
+
| `models` | `null` per role | The pi model per agent role, as `provider/model`: `implementer`, `reviewer`, `fixer` |
|
|
301
303
|
| `labels` | see above | The names of the labels that the harness reads and sets |
|
|
302
304
|
|
|
303
305
|
An empty `labels.ready` turns the ready requirement off. The harness then
|
|
@@ -307,7 +309,7 @@ The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
|
|
|
307
309
|
`in-flight` or `blocked`, the last one with the open blockers.
|
|
308
310
|
|
|
309
311
|
A role set to `null`, or with no entry in `models`, uses the default model of
|
|
310
|
-
|
|
312
|
+
your pi settings.
|
|
311
313
|
|
|
312
314
|
Without a `setupCommand`, the harness looks in the new worktree for a lock file
|
|
313
315
|
and runs the install command that goes with it:
|
|
@@ -358,6 +360,7 @@ that a label still holds back stays as it was.
|
|
|
358
360
|
| `--once` | Stop after the first handled issue |
|
|
359
361
|
| `--max <n>` | Handle at most n issues |
|
|
360
362
|
| `--merge` | Squash-merge the pull request after the approval, delete the remote branch and fix a failure of the checks on the base branch |
|
|
363
|
+
| `--watch` | Keep running, and read the open issues again after each pass |
|
|
361
364
|
| `--reset` | Drop the saved budgets and findings, and keep the rest of the state |
|
|
362
365
|
| `--json` | Print the machine summary instead of the text one |
|
|
363
366
|
| `--verbose` | Show every command and all agent output |
|
|
@@ -367,6 +370,33 @@ that a label still holds back stays as it was.
|
|
|
367
370
|
The exit code is 1 when an issue needs a person or failed, and 0 when it does
|
|
368
371
|
not, so a cron job or a CI step can read the result of the run.
|
|
369
372
|
|
|
373
|
+
## Watch for new issues
|
|
374
|
+
|
|
375
|
+
A run without `--watch` ends when no unseen issue is left. With `--watch`, the
|
|
376
|
+
harness then waits, reads the open issues again and runs another pass, so it
|
|
377
|
+
picks up an issue that someone opens during the run, or one whose blocker
|
|
378
|
+
merges. Each pass tries every open issue again, also one that an earlier pass
|
|
379
|
+
skipped.
|
|
380
|
+
|
|
381
|
+
The waits follow a fixed back-off in minutes: 1, 2, 3, 5, 10, then 10 from then
|
|
382
|
+
on. A pass that handles an issue, with any outcome other than `skipped`, resets
|
|
383
|
+
the back-off to 1 minute. A pass where every issue is skipped does not reset
|
|
384
|
+
it. Each wait prints a line on standard error with the minutes until the next
|
|
385
|
+
pass.
|
|
386
|
+
|
|
387
|
+
After each pass that handled an issue, the harness prints the summary of that
|
|
388
|
+
pass. The token totals in it cover that pass only. The exit code at the end
|
|
389
|
+
covers all passes. `--max <n>` counts the handled issues across all passes,
|
|
390
|
+
and `--once` is the same as `--max 1`.
|
|
391
|
+
|
|
392
|
+
The watch stops:
|
|
393
|
+
|
|
394
|
+
- at the first `Ctrl-C` during a wait, at once;
|
|
395
|
+
- at the first `Ctrl-C` during an issue, after that issue;
|
|
396
|
+
- after a pass that halts the run, for example when the base branch is broken
|
|
397
|
+
after `--merge`;
|
|
398
|
+
- when the open issues cannot be read.
|
|
399
|
+
|
|
370
400
|
## Development
|
|
371
401
|
|
|
372
402
|
```bash
|
package/dist/agents.mjs
CHANGED
|
@@ -1,186 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
-
import { track } from "./exec.mjs";
|
|
1
|
+
import { join, sep } from "node:path";
|
|
2
|
+
import { createAgentSession, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { onStop } from "./exec.mjs";
|
|
6
4
|
import { emptyUsage } from "./observe.mjs";
|
|
7
5
|
const REVIEWING = {
|
|
8
|
-
tools: ["
|
|
9
|
-
permissionMode: "dontAsk",
|
|
6
|
+
tools: ["read", "bash", "grep", "find", "ls"],
|
|
10
7
|
};
|
|
11
8
|
const CODING = {
|
|
12
|
-
tools: ["
|
|
13
|
-
permissionMode: "bypassPermissions",
|
|
14
|
-
allowDangerouslySkipPermissions: true,
|
|
9
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"],
|
|
15
10
|
};
|
|
16
11
|
export { CODING, REVIEWING };
|
|
17
|
-
const
|
|
18
|
-
|
|
12
|
+
export const SUBMIT_TOOL = "submit_result";
|
|
13
|
+
export function isOwnExtension(path) {
|
|
14
|
+
return path.split(sep).includes("pi-next-issue");
|
|
15
|
+
}
|
|
19
16
|
export async function runAgent(recorder, request) {
|
|
20
17
|
const log = recorder.scope({ role: request.name });
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
let text = "";
|
|
24
|
-
let toolCalls = 0;
|
|
25
|
-
let turns = 0;
|
|
26
|
-
let costUsd = 0;
|
|
27
|
-
let model = request.model;
|
|
28
|
-
let sessionId;
|
|
29
|
-
let structured;
|
|
30
|
-
let stderr = "";
|
|
31
|
-
let stderrClosed = Promise.resolve();
|
|
18
|
+
const runtime = request.runtime ?? {};
|
|
19
|
+
const agentDir = runtime.agentDir ?? getAgentDir();
|
|
32
20
|
const started = Date.now();
|
|
33
21
|
log.event("agent.start", { model: request.model, timeoutMs: request.timeoutMs }, "normal");
|
|
34
|
-
|
|
22
|
+
let structured;
|
|
23
|
+
const customTools = [];
|
|
24
|
+
if (request.outputSchema !== undefined) {
|
|
25
|
+
customTools.push(defineTool({
|
|
26
|
+
name: SUBMIT_TOOL,
|
|
27
|
+
label: "Submit result",
|
|
28
|
+
description: "Give the result. Call it once, as your last action.",
|
|
29
|
+
parameters: request.outputSchema,
|
|
30
|
+
async execute(_toolCallId, params) {
|
|
31
|
+
structured = params;
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: "text", text: "Result saved." }],
|
|
34
|
+
details: {},
|
|
35
|
+
terminate: true,
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
41
|
+
cwd: request.cwd,
|
|
42
|
+
agentDir,
|
|
43
|
+
extensionsOverride: (base) => ({
|
|
44
|
+
...base,
|
|
45
|
+
extensions: base.extensions.filter((extension) => !isOwnExtension(extension.resolvedPath)),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
await resourceLoader.reload();
|
|
49
|
+
const modelRuntime = runtime.modelRuntime ?? (await ModelRuntime.create({
|
|
50
|
+
authPath: join(agentDir, "auth.json"),
|
|
51
|
+
modelsPath: join(agentDir, "models.json"),
|
|
52
|
+
}));
|
|
53
|
+
const model = request.model === undefined ? undefined : await findModel(modelRuntime, request.model);
|
|
54
|
+
const { session, modelFallbackMessage } = await createAgentSession({
|
|
55
|
+
cwd: request.cwd,
|
|
56
|
+
agentDir,
|
|
57
|
+
resourceLoader,
|
|
58
|
+
sessionManager: SessionManager.inMemory(request.cwd),
|
|
59
|
+
tools: [...request.profile.tools, ...customTools.map((tool) => tool.name)],
|
|
60
|
+
customTools,
|
|
61
|
+
modelRuntime,
|
|
62
|
+
...(runtime.settingsManager === undefined ? {} : { settingsManager: runtime.settingsManager }),
|
|
63
|
+
...(model === undefined ? {} : { model }),
|
|
64
|
+
});
|
|
65
|
+
if (modelFallbackMessage !== undefined) {
|
|
66
|
+
log.event("agent.model", { note: modelFallbackMessage }, "quiet");
|
|
67
|
+
}
|
|
68
|
+
let timedOut = false;
|
|
35
69
|
const timer = request.timeoutMs === undefined
|
|
36
70
|
? undefined
|
|
37
|
-
: setTimeout(() =>
|
|
38
|
-
|
|
71
|
+
: setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
void session.abort();
|
|
74
|
+
}, request.timeoutMs);
|
|
75
|
+
const unsubscribe = session.subscribe((event) => observe(log, event));
|
|
76
|
+
const release = onStop(() => void session.abort());
|
|
39
77
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
model: request.model,
|
|
45
|
-
tools: request.profile.tools,
|
|
46
|
-
allowedTools: request.profile.tools,
|
|
47
|
-
permissionMode: request.profile.permissionMode,
|
|
48
|
-
allowDangerouslySkipPermissions: request.profile.allowDangerouslySkipPermissions,
|
|
49
|
-
systemPrompt: { type: "preset", preset: "claude_code" },
|
|
50
|
-
abortController: controller,
|
|
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
|
-
},
|
|
59
|
-
outputFormat: request.outputSchema === undefined
|
|
60
|
-
? undefined
|
|
61
|
-
: { type: "json_schema", schema: request.outputSchema },
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
for await (const message of stream) {
|
|
65
|
-
if (message.type === "assistant") {
|
|
66
|
-
for (const block of message.message.content) {
|
|
67
|
-
if (block.type === "tool_use") {
|
|
68
|
-
toolCalls += 1;
|
|
69
|
-
toolNames.set(block.id, block.name);
|
|
70
|
-
log.event("tool", toolFields(block.name, block.input));
|
|
71
|
-
}
|
|
72
|
-
if (block.type === "text") {
|
|
73
|
-
text += block.text;
|
|
74
|
-
if (log.level === "verbose") {
|
|
75
|
-
process.stderr.write(block.text);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (message.type === "user" && Array.isArray(message.message.content)) {
|
|
81
|
-
for (const block of message.message.content) {
|
|
82
|
-
if (block.type === "tool_result" && block.is_error === true) {
|
|
83
|
-
log.event("tool.error", { tool: toolNames.get(block.tool_use_id) }, "quiet");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (message.type === "result") {
|
|
88
|
-
sessionId = message.session_id;
|
|
89
|
-
turns = message.num_turns;
|
|
90
|
-
costUsd = message.total_cost_usd;
|
|
91
|
-
model = main(message.modelUsage) ?? model;
|
|
92
|
-
for (const entry of Object.values(message.modelUsage)) {
|
|
93
|
-
usage.input += entry.inputTokens;
|
|
94
|
-
usage.output += entry.outputTokens;
|
|
95
|
-
usage.cacheRead += entry.cacheReadInputTokens;
|
|
96
|
-
usage.cacheWrite += entry.cacheCreationInputTokens;
|
|
97
|
-
usage.total +=
|
|
98
|
-
entry.inputTokens +
|
|
99
|
-
entry.outputTokens +
|
|
100
|
-
entry.cacheReadInputTokens +
|
|
101
|
-
entry.cacheCreationInputTokens;
|
|
102
|
-
}
|
|
103
|
-
if (message.subtype === "error_during_execution") {
|
|
104
|
-
throw new Error(`The ${request.name} agent failed: ${message.errors.join("; ")}`);
|
|
105
|
-
}
|
|
106
|
-
if (message.subtype === "success") {
|
|
107
|
-
text = message.result.length > 0 ? message.result : text;
|
|
108
|
-
structured = message.structured_output;
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
log.event("agent.incomplete", { subtype: message.subtype }, "quiet");
|
|
112
|
-
}
|
|
113
|
-
}
|
|
78
|
+
await session.bindExtensions({});
|
|
79
|
+
await session.prompt(request.prompt, { expandPromptTemplates: false });
|
|
80
|
+
if (timedOut) {
|
|
81
|
+
throw new Error(`The ${request.name} agent did not finish in ${request.timeoutMs} ms.`);
|
|
114
82
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
throw late();
|
|
83
|
+
const last = session.messages.findLast((message) => message.role === "assistant");
|
|
84
|
+
if (last !== undefined && "stopReason" in last && last.stopReason === "error") {
|
|
85
|
+
throw new Error(`The ${request.name} agent failed: ${last.errorMessage ?? "no reason given"}`);
|
|
119
86
|
}
|
|
120
|
-
|
|
121
|
-
|
|
87
|
+
const stats = session.getSessionStats();
|
|
88
|
+
const usage = { ...emptyUsage(), ...stats.tokens };
|
|
89
|
+
const name = session.model === undefined ? undefined : `${session.model.provider}/${session.model.id}`;
|
|
90
|
+
const ms = Date.now() - started;
|
|
91
|
+
recorder.usage(request.name, usage, ms, {
|
|
92
|
+
turns: stats.assistantMessages,
|
|
93
|
+
toolCalls: stats.toolCalls,
|
|
94
|
+
model: name,
|
|
95
|
+
session: stats.sessionId,
|
|
96
|
+
costUsd: stats.cost,
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
text: (session.getLastAssistantText() ?? "").trim(),
|
|
100
|
+
usage,
|
|
101
|
+
turns: stats.assistantMessages,
|
|
102
|
+
toolCalls: stats.toolCalls,
|
|
103
|
+
ms,
|
|
104
|
+
costUsd: stats.cost,
|
|
105
|
+
model: name,
|
|
106
|
+
sessionId: stats.sessionId,
|
|
107
|
+
structured,
|
|
108
|
+
};
|
|
122
109
|
}
|
|
123
110
|
finally {
|
|
124
111
|
clearTimeout(timer);
|
|
112
|
+
release();
|
|
113
|
+
unsubscribe();
|
|
114
|
+
session.dispose();
|
|
125
115
|
}
|
|
126
|
-
|
|
127
|
-
|
|
116
|
+
}
|
|
117
|
+
async function findModel(modelRuntime, name) {
|
|
118
|
+
const slash = name.indexOf("/");
|
|
119
|
+
if (slash <= 0) {
|
|
120
|
+
throw new Error(`The model ${name} is not in the form provider/model.`);
|
|
128
121
|
}
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
session: sessionId,
|
|
135
|
-
costUsd,
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
text: text.trim(),
|
|
139
|
-
usage,
|
|
140
|
-
turns,
|
|
141
|
-
toolCalls,
|
|
142
|
-
ms,
|
|
143
|
-
costUsd,
|
|
144
|
-
model,
|
|
145
|
-
sessionId,
|
|
146
|
-
structured,
|
|
147
|
-
};
|
|
122
|
+
const found = modelRuntime.getModel(name.slice(0, slash), name.slice(slash + 1));
|
|
123
|
+
if (found === undefined) {
|
|
124
|
+
throw new Error(`The model ${name} is not known to pi.`);
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
148
127
|
}
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
128
|
+
function observe(log, event) {
|
|
129
|
+
if (event.type === "tool_execution_start") {
|
|
130
|
+
log.event("tool", toolFields(event.toolName, event.args));
|
|
131
|
+
}
|
|
132
|
+
else if (event.type === "tool_execution_end" && event.isError) {
|
|
133
|
+
log.event("tool.error", { tool: event.toolName }, "quiet");
|
|
134
|
+
}
|
|
135
|
+
else if (event.type === "message_update" &&
|
|
136
|
+
event.assistantMessageEvent.type === "text_delta" &&
|
|
137
|
+
log.level === "verbose") {
|
|
138
|
+
process.stderr.write(event.assistantMessageEvent.delta);
|
|
157
139
|
}
|
|
158
|
-
return name;
|
|
159
140
|
}
|
|
160
141
|
export function toolFields(name, input) {
|
|
161
|
-
if (name !== "
|
|
142
|
+
if (name !== "bash" || typeof input !== "object" || input === null) {
|
|
162
143
|
return { tool: name };
|
|
163
144
|
}
|
|
164
145
|
const { command } = input;
|
|
165
146
|
return typeof command === "string" ? { tool: name, command } : { tool: name };
|
|
166
147
|
}
|
|
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) {
|
|
175
|
-
const child = spawn(options.command, options.args, {
|
|
176
|
-
cwd: options.cwd,
|
|
177
|
-
env: options.env,
|
|
178
|
-
signal: options.signal,
|
|
179
|
-
detached: true,
|
|
180
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
181
|
-
});
|
|
182
|
-
child.stderr.setEncoding("utf8");
|
|
183
|
-
child.stderr.on("data", onStderr);
|
|
184
|
-
track(child);
|
|
185
|
-
return child;
|
|
186
|
-
}
|
package/dist/args.mjs
CHANGED
|
@@ -6,6 +6,7 @@ export const USAGE = `next-issue [command] [options]
|
|
|
6
6
|
--once stop after the first handled issue
|
|
7
7
|
--max <n> handle at most n issues
|
|
8
8
|
--merge squash-merge the pull request after the approval
|
|
9
|
+
--watch keep running and read the issues again after each pass
|
|
9
10
|
--reset drop the saved budgets and findings first
|
|
10
11
|
--json print the machine summary instead of the text one
|
|
11
12
|
--force replace an existing config file, with init
|
|
@@ -22,6 +23,7 @@ export function parseArgs(argv) {
|
|
|
22
23
|
reset: false,
|
|
23
24
|
force: false,
|
|
24
25
|
merge: false,
|
|
26
|
+
watch: false,
|
|
25
27
|
level: "normal",
|
|
26
28
|
};
|
|
27
29
|
for (let index = 0; index < argv.length; index += 1) {
|
|
@@ -44,6 +46,9 @@ export function parseArgs(argv) {
|
|
|
44
46
|
else if (arg === "--merge") {
|
|
45
47
|
args.merge = true;
|
|
46
48
|
}
|
|
49
|
+
else if (arg === "--watch") {
|
|
50
|
+
args.watch = true;
|
|
51
|
+
}
|
|
47
52
|
else if (arg === "--reset") {
|
|
48
53
|
args.reset = true;
|
|
49
54
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { setCommandObserver, setDefaultTimeout } from "./exec.mjs";
|
|
|
7
7
|
import { detectRepo, ensureIgnored, repoRoot, WORKTREE_DIR } from "./git.mjs";
|
|
8
8
|
import { defaultBranch } from "./github.mjs";
|
|
9
9
|
import { listenForInterrupts } from "./interrupt.mjs";
|
|
10
|
-
import { exitCode, runIssues } from "./loop.mjs";
|
|
10
|
+
import { exitCode, runIssues, watchIssues } from "./loop.mjs";
|
|
11
11
|
import { message, pruneRuns, Recorder } from "./observe.mjs";
|
|
12
12
|
import { pendingIssues, PORTS, processIssue } from "./pipeline.mjs";
|
|
13
13
|
import { STATE_DIR } from "./state.mjs";
|
|
@@ -82,7 +82,7 @@ async function handle(args, config, repo, recorder) {
|
|
|
82
82
|
base,
|
|
83
83
|
log: recorder.logFile,
|
|
84
84
|
}, "quiet");
|
|
85
|
-
const
|
|
85
|
+
const loop = {
|
|
86
86
|
issues: async () => {
|
|
87
87
|
const issues = await pendingIssues(context);
|
|
88
88
|
recorder.event("issues", { open: issues.length }, "quiet");
|
|
@@ -90,7 +90,20 @@ async function handle(args, config, repo, recorder) {
|
|
|
90
90
|
},
|
|
91
91
|
process: (issue) => processIssue(context, issue),
|
|
92
92
|
stop: interrupts.afterIssue,
|
|
93
|
-
}
|
|
93
|
+
};
|
|
94
|
+
const print = (summary) => write(process.stdout, args.json ? `${JSON.stringify(summary, null, 2)}\n` : formatSummary(summary));
|
|
95
|
+
let passStart = recorder.summary();
|
|
96
|
+
const { reports, stoppedAt, error } = args.watch
|
|
97
|
+
? await watchIssues(args, {
|
|
98
|
+
...loop,
|
|
99
|
+
wait: (minutes) => {
|
|
100
|
+
recorder.event("watch.wait", { minutes }, "quiet");
|
|
101
|
+
passStart = recorder.summary();
|
|
102
|
+
return interrupts.sleep(minutes * 60_000);
|
|
103
|
+
},
|
|
104
|
+
passEnd: (issues) => print({ ...recorder.summary(passStart), issues }),
|
|
105
|
+
})
|
|
106
|
+
: await runIssues(args, loop);
|
|
94
107
|
if (stoppedAt !== undefined) {
|
|
95
108
|
recorder.event("run.stop", { issue: stoppedAt }, "quiet");
|
|
96
109
|
}
|
|
@@ -107,7 +120,9 @@ async function handle(args, config, repo, recorder) {
|
|
|
107
120
|
const summary = { ...recorder.summary(), issues: reports };
|
|
108
121
|
const summaryFile = join(repo.root, STATE_DIR, "runs", `${recorder.runId}.summary.json`);
|
|
109
122
|
await writeFile(summaryFile, `${JSON.stringify(summary, null, 2)}\n`);
|
|
110
|
-
|
|
123
|
+
if (!args.watch) {
|
|
124
|
+
await print(summary);
|
|
125
|
+
}
|
|
111
126
|
return error === undefined ? exitCode(reports) : 1;
|
|
112
127
|
}
|
|
113
128
|
function count(reports, outcome) {
|
package/dist/exec.mjs
CHANGED
|
@@ -3,7 +3,16 @@ let observer;
|
|
|
3
3
|
let defaultTimeoutMs = 10 * 60_000;
|
|
4
4
|
const STDERR_TAIL = 200_000;
|
|
5
5
|
const groups = new Set();
|
|
6
|
+
const stoppers = new Set();
|
|
7
|
+
export function onStop(stopper) {
|
|
8
|
+
stoppers.add(stopper);
|
|
9
|
+
return () => stoppers.delete(stopper);
|
|
10
|
+
}
|
|
6
11
|
export function stopChildren(signal) {
|
|
12
|
+
for (const stopper of stoppers) {
|
|
13
|
+
stopper();
|
|
14
|
+
}
|
|
15
|
+
stoppers.clear();
|
|
7
16
|
for (const pid of groups) {
|
|
8
17
|
try {
|
|
9
18
|
process.kill(-pid, signal);
|
package/dist/interrupt.mjs
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { stopChildren } from "./exec.mjs";
|
|
2
2
|
export function listenForInterrupts(say) {
|
|
3
3
|
let presses = 0;
|
|
4
|
+
let wake;
|
|
4
5
|
const press = () => {
|
|
5
6
|
presses += 1;
|
|
6
|
-
if (presses === 1) {
|
|
7
|
+
if (presses === 1 && wake !== undefined) {
|
|
8
|
+
say("Stopping now.");
|
|
9
|
+
wake();
|
|
10
|
+
}
|
|
11
|
+
else if (presses === 1) {
|
|
7
12
|
say("Stopping after this issue. Press Ctrl-C again to stop after this round.");
|
|
8
13
|
}
|
|
9
14
|
else if (presses === 2) {
|
|
@@ -18,5 +23,20 @@ export function listenForInterrupts(say) {
|
|
|
18
23
|
return {
|
|
19
24
|
afterIssue: () => presses >= 1,
|
|
20
25
|
afterRound: () => presses >= 2,
|
|
26
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
27
|
+
if (presses >= 1) {
|
|
28
|
+
resolve(false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
wake = undefined;
|
|
33
|
+
resolve(true);
|
|
34
|
+
}, ms);
|
|
35
|
+
wake = () => {
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
wake = undefined;
|
|
38
|
+
resolve(false);
|
|
39
|
+
};
|
|
40
|
+
}),
|
|
21
41
|
};
|
|
22
42
|
}
|
package/dist/loop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
|
|
1
2
|
export async function runIssues(args, loop) {
|
|
2
3
|
const reports = [];
|
|
3
4
|
const seen = new Set();
|
|
@@ -25,6 +26,30 @@ export async function runIssues(args, loop) {
|
|
|
25
26
|
}
|
|
26
27
|
return { reports };
|
|
27
28
|
}
|
|
29
|
+
export async function watchIssues(args, watch) {
|
|
30
|
+
const limit = args.once ? 1 : args.max;
|
|
31
|
+
const reports = [];
|
|
32
|
+
let step = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
const pass = await runIssues({ once: false, max: limit === undefined ? undefined : limit - handled(reports) }, watch);
|
|
35
|
+
reports.push(...pass.reports);
|
|
36
|
+
if (handled(pass.reports) > 0) {
|
|
37
|
+
await watch.passEnd(pass.reports);
|
|
38
|
+
step = 0;
|
|
39
|
+
}
|
|
40
|
+
if (pass.error !== undefined || pass.stoppedAt !== undefined) {
|
|
41
|
+
return { ...pass, reports };
|
|
42
|
+
}
|
|
43
|
+
if ((limit !== undefined && handled(reports) >= limit) || watch.stop()) {
|
|
44
|
+
return { reports };
|
|
45
|
+
}
|
|
46
|
+
const minutes = BACK_OFF_MINUTES[Math.min(step, BACK_OFF_MINUTES.length - 1)];
|
|
47
|
+
step += 1;
|
|
48
|
+
if (!(await watch.wait(minutes))) {
|
|
49
|
+
return { reports };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
28
53
|
export function exitCode(reports) {
|
|
29
54
|
return reports.some((report) => report.outcome === "needs-human" || report.outcome === "error") ? 1 : 0;
|
|
30
55
|
}
|
package/dist/observe.mjs
CHANGED
|
@@ -13,6 +13,15 @@ export function addUsage(target, source) {
|
|
|
13
13
|
target.cacheWrite += source.cacheWrite;
|
|
14
14
|
target.total += source.total;
|
|
15
15
|
}
|
|
16
|
+
function negate(usage) {
|
|
17
|
+
return {
|
|
18
|
+
input: -usage.input,
|
|
19
|
+
output: -usage.output,
|
|
20
|
+
cacheRead: -usage.cacheRead,
|
|
21
|
+
cacheWrite: -usage.cacheWrite,
|
|
22
|
+
total: -usage.total,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
16
25
|
class Sink {
|
|
17
26
|
totals = new Map();
|
|
18
27
|
counts = new Map();
|
|
@@ -98,20 +107,27 @@ export class Recorder {
|
|
|
98
107
|
throw error;
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
|
-
summary() {
|
|
110
|
+
summary(since) {
|
|
102
111
|
const total = emptyUsage();
|
|
103
112
|
const roles = {};
|
|
104
113
|
for (const [role, value] of this.sink.totals) {
|
|
105
|
-
roles[role]
|
|
106
|
-
|
|
114
|
+
const earlier = since?.roles[role] ?? { ...emptyUsage(), calls: 0, ms: 0 };
|
|
115
|
+
if (value.calls === earlier.calls) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const usage = { ...value, calls: value.calls - earlier.calls, ms: value.ms - earlier.ms };
|
|
119
|
+
addUsage(usage, negate(earlier));
|
|
120
|
+
roles[role] = usage;
|
|
121
|
+
addUsage(total, usage);
|
|
107
122
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
123
|
+
const counts = {};
|
|
124
|
+
for (const [kind, value] of this.sink.counts) {
|
|
125
|
+
const count = value - (since?.counts[kind] ?? 0);
|
|
126
|
+
if (count > 0) {
|
|
127
|
+
counts[kind] = count;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { runId: this.runId, logFile: this.sink.file, roles, counts, total };
|
|
115
131
|
}
|
|
116
132
|
async close() {
|
|
117
133
|
await this.sink.close();
|
package/dist/prompts.mjs
CHANGED
|
@@ -60,7 +60,7 @@ export function reviewPrompt(issue, comments, diff, earlier) {
|
|
|
60
60
|
"The harness discards each change that you make, so a change is only lost work.",
|
|
61
61
|
scope,
|
|
62
62
|
"Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.",
|
|
63
|
-
"Give the result
|
|
63
|
+
"Give the result with the submit_result tool: a short summary and one entry per finding. The findings decide the outcome, so a problem that you leave out counts as no problem.",
|
|
64
64
|
].join("\n\n");
|
|
65
65
|
}
|
|
66
66
|
const UNRELATED_RULE = [
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-issue",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Autonomous issue harness driven by the
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Autonomous issue harness driven by the pi SDK",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"github",
|
|
7
7
|
"issues",
|
|
8
8
|
"agent",
|
|
9
|
-
"
|
|
9
|
+
"pi",
|
|
10
10
|
"automation",
|
|
11
11
|
"cli"
|
|
12
12
|
],
|
|
@@ -37,10 +37,12 @@
|
|
|
37
37
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "^1.5.0",
|
|
40
41
|
"@types/node": "^26.6.6",
|
|
41
42
|
"typescript": "^7.0.2"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
|
-
"@
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^1.5.0",
|
|
46
|
+
"typebox": "^1.4.3"
|
|
45
47
|
}
|
|
46
48
|
}
|