next-issue 2.9.0 → 2.11.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
@@ -167,7 +167,9 @@ comment: `### Review: approved`, `### Review: changes requested` and
167
167
  `### Fixer: no change`. The account is the same for all of them, so the heading,
168
168
  not the author, tells them apart. The agents read the comments of the issue, not
169
169
  of the pull request, so a comment of an agent never returns to a prompt: the
170
- dispute of the fixer reaches the reviewer through the state file.
170
+ dispute of the fixer reaches the reviewer through the state file. The one
171
+ exception is the answer to a hand-over, see
172
+ [Retry an issue that waits for a person](#retry-an-issue-that-waits-for-a-person).
171
173
 
172
174
  ## Stop a run
173
175
 
@@ -233,11 +235,11 @@ A short text summary of the same data goes to standard output. Use `--json` to
233
235
  get the full summary object there instead, so the harness fits in a pipe. All
234
236
  progress goes to standard error.
235
237
 
236
- Recorded for each step: `claim`, `comments`, `worktree`, `setup`, `implement`,
237
- `push`, `pull-request`, `checks`, `review` and `fix`, each with a `.start`,
238
- `.end` or `.error` event and a duration in `ms`. Every `git` and `gh` command
239
- is recorded with its exit code and duration. A `tool` event holds the tool
240
- name, and for a `bash` tool the command too. Each agent call adds a `usage`
238
+ Recorded for each step: `claim`, `comments`, `reply`, `worktree`, `setup`,
239
+ `implement`, `push`, `pull-request`, `checks`, `review` and `fix`, each with a
240
+ `.start`, `.end` or `.error` event and a duration in `ms`. Every `git` and `gh`
241
+ command is recorded with its exit code and duration. A `tool` event holds the
242
+ tool name, and for a `bash` tool the command too. Each agent call adds a `usage`
241
243
  event with the tokens, the cost estimate in dollars, the turn count, the
242
244
  tool-call count, the model and the session id.
243
245
 
@@ -386,6 +388,15 @@ the pull request and the step that the last run reached, so the work carries on
386
388
  where it stopped. A commit that you pushed to the pull request in the meantime
387
389
  is fetched first. An issue that still has the label is not claimed.
388
390
 
391
+ To give guidance, answer the hand-over with a comment in the same place: on the
392
+ pull request, or on the issue when there is no pull request. When the run
393
+ resumes the issue, it reads the comments posted after its last
394
+ `next-issue needs help:` comment there. Only comments from users with write
395
+ access to the repository count, for the hand-over comment too. The implementer
396
+ and every fixer of that run get them in their prompt, under the heading
397
+ `Answer of a person to the hand-over`. A short reply such as "ready, take it" is
398
+ enough. With no such comment, the run resumes with no guidance.
399
+
389
400
  The `--reset` flag does the same for any issue that the run handles, whether or
390
401
  not it was handed over. It touches only an issue that the run handles, so the
391
402
  state of an issue that a label still holds back stays as it was.
@@ -450,12 +461,25 @@ to it. The worker:
450
461
  file. It works on macOS and Linux;
451
462
  - holds `next-issue.lock` in the same folder, with its process id, so only one
452
463
  worker runs. A lock whose process is no longer alive is taken over;
464
+ - keeps the repositories it knows in `next-issue.roots.json` in the same
465
+ folder, so it still knows them after a restart;
453
466
  - runs at most one run per repository, and runs in several repositories at
454
467
  once. Each run is like `next-issue` without `--watch`, and the client says
455
468
  whether it merges;
456
469
  - sends every client the progress of every run when it connects and on every
457
470
  change: the issue, the step, the tool count, the last tool and the handled
458
471
  issues;
472
+ - sends every client a `questions` message for each repository: the open
473
+ issues with the `needsHuman` label, each with its number, title, the reason
474
+ of the hand-over and the url of the comment that holds it. It reads them
475
+ from GitHub, so the list is the same after a restart. It sends them when a
476
+ client connects, for each repository it knows, after each issue of a run
477
+ ends, and when a client sends `{ "type": "questions", "root": "<path>" }`.
478
+ An issue that no longer has the label leaves the list. An issue with the
479
+ label but no hand-over comment is in the list with an empty reason and url.
480
+ The issues are read one at a time. A client never gets an older list after
481
+ a newer one. When the list cannot be read, the client gets
482
+ `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
459
483
  - stops a run, when a client asks, like a second `Ctrl-C`: after the current
460
484
  round, with the state kept;
461
485
  - stays when all clients close;
package/dist/fix.mjs CHANGED
@@ -10,7 +10,7 @@ export async function fixRound(job, reason, detail, earlier, kind, force = false
10
10
  const result = await log.step('fix', { reason }, () => ports.runAgent(log, {
11
11
  name: 'fixer',
12
12
  cwd: worktree,
13
- prompt: fixPrompt(issue, reason, detail, earlier, kind),
13
+ prompt: fixPrompt(issue, reason, detail, earlier, kind, job.reply),
14
14
  profile: CODING,
15
15
  model: context.config.models.fixer,
16
16
  timeoutMs: context.config.agentTimeoutMinutes * 60_000,
package/dist/github.mjs CHANGED
@@ -74,6 +74,41 @@ export async function issueComments(repo, issue) {
74
74
  const parsed = JSON.parse(raw);
75
75
  return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
76
76
  }
77
+ export async function threadComments(repo, number) {
78
+ const raw = await repeatable([
79
+ 'api',
80
+ '--paginate',
81
+ `repos/${slug(repo)}/issues/${number}/comments`,
82
+ '--jq',
83
+ '.[] | [.user.login, .body, .html_url] | @json',
84
+ ], { cwd: repo.root });
85
+ return raw
86
+ .split('\n')
87
+ .filter((line) => line.length > 0)
88
+ .map((line) => {
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(line);
92
+ }
93
+ catch {
94
+ throw new Error(`gh gave a comment of #${number} that is not valid JSON: ${line}`);
95
+ }
96
+ const [author, body, url] = parsed;
97
+ return url === undefined ? { author, body: body ?? '' } : { author, body: body ?? '', url };
98
+ });
99
+ }
100
+ export async function canWrite(repo, login) {
101
+ try {
102
+ const permission = await repeatable(['api', `repos/${slug(repo)}/collaborators/${login}/permission`, '--jq', '.permission'], { cwd: repo.root });
103
+ return ['admin', 'write'].includes(permission.trim());
104
+ }
105
+ catch (error) {
106
+ if (error instanceof Error && /HTTP 404/.test(error.message)) {
107
+ return false;
108
+ }
109
+ throw error;
110
+ }
111
+ }
77
112
  export async function openBlockers(repo, issue) {
78
113
  const raw = await repeatable([
79
114
  'api',
package/dist/handover.mjs CHANGED
@@ -1,6 +1,58 @@
1
1
  import { setStatus } from './job.mjs';
2
2
  import { message } from './observe.mjs';
3
- import { dropState, writeState } from './state.mjs';
3
+ import { dropState, readState, writeState } from './state.mjs';
4
+ export const HAND_OVER = 'next-issue needs help:';
5
+ function writers(repo, ports) {
6
+ const access = new Map();
7
+ return (author) => {
8
+ if (!access.has(author)) {
9
+ access.set(author, ports.canWrite(repo, author));
10
+ }
11
+ return access.get(author);
12
+ };
13
+ }
14
+ async function lastHandOver(comments, writer) {
15
+ for (let index = comments.length - 1; index >= 0; index -= 1) {
16
+ const comment = comments[index];
17
+ if (comment.body.startsWith(HAND_OVER) && (await writer(comment.author))) {
18
+ return index;
19
+ }
20
+ }
21
+ return -1;
22
+ }
23
+ export async function waitingQuestions(repo, config, ports) {
24
+ const writer = writers(repo, ports);
25
+ const issues = await ports.openIssues(repo, config.issueLimit, [config.labels.needsHuman]);
26
+ const questions = [];
27
+ for (const issue of issues) {
28
+ const pr = (await readState(repo, issue.number))?.pr;
29
+ const comments = await ports.threadComments(repo, pr ?? issue.number);
30
+ const comment = comments[await lastHandOver(comments, writer)];
31
+ questions.push({
32
+ issue: issue.number,
33
+ title: issue.title,
34
+ reason: comment?.body.slice(HAND_OVER.length).trim() ?? '',
35
+ url: comment?.url ?? '',
36
+ });
37
+ }
38
+ return questions;
39
+ }
40
+ export async function handOverReply(context, issue, pr) {
41
+ const { repo, ports } = context;
42
+ const comments = await ports.threadComments(repo, pr ?? issue);
43
+ const writer = writers(repo, ports);
44
+ const last = await lastHandOver(comments, writer);
45
+ if (last === -1) {
46
+ return [];
47
+ }
48
+ const reply = [];
49
+ for (const comment of comments.slice(last + 1)) {
50
+ if (await writer(comment.author)) {
51
+ reply.push(`@${comment.author}: ${comment.body}`);
52
+ }
53
+ }
54
+ return reply;
55
+ }
4
56
  export function escalate(job, reason) {
5
57
  return handOver(job.context, job.log, job.issue.number, job.state, reason);
6
58
  }
@@ -57,7 +109,7 @@ export async function handOver(context, log, issue, state, reason) {
57
109
  log.event('hand-over', { reason }, 'quiet');
58
110
  await writeState(context.repo, { ...state, handedOver: true });
59
111
  await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
60
- const body = `next-issue needs help: ${reason}`;
112
+ const body = `${HAND_OVER} ${reason}`;
61
113
  if (state.pr === undefined) {
62
114
  await context.ports.commentOnIssue(context.repo, issue, body);
63
115
  }
package/dist/pipeline.mjs CHANGED
@@ -2,7 +2,7 @@ import { CODING } from './agents.mjs';
2
2
  import { commitWork } from './fix.mjs';
3
3
  import { checkGate } from './gate.mjs';
4
4
  import { branchName } from './git.mjs';
5
- import { cleanUp, escalate, finish, handOver, skipped, stop } from './handover.mjs';
5
+ import { cleanUp, escalate, finish, handOver, handOverReply, skipped, stop } from './handover.mjs';
6
6
  import { openPr, setStatus } from './job.mjs';
7
7
  import { afterMerge, fixBase } from './merge.mjs';
8
8
  import { message } from './observe.mjs';
@@ -112,7 +112,7 @@ export async function processIssue(context, issue) {
112
112
  ? resetState(saved)
113
113
  : saved;
114
114
  try {
115
- const report = await work(context, issue, log, state, saved === undefined);
115
+ const report = await work(context, issue, log, state, saved === undefined, saved?.handedOver === true);
116
116
  log.event('issue.end', { outcome: report.outcome, reason: report.reason, ms: report.ms }, 'quiet');
117
117
  return report;
118
118
  }
@@ -136,7 +136,7 @@ export async function processIssue(context, issue) {
136
136
  };
137
137
  }
138
138
  }
139
- async function work(context, issue, log, state, start) {
139
+ async function work(context, issue, log, state, start, handedOver) {
140
140
  const started = Date.now();
141
141
  const { repo, config, ports } = context;
142
142
  const branch = state.branch;
@@ -145,6 +145,9 @@ async function work(context, issue, log, state, start) {
145
145
  await writeState(repo, state);
146
146
  });
147
147
  const comments = await log.step('comments', {}, () => ports.issueComments(repo, issue.number));
148
+ const reply = handedOver
149
+ ? await log.step('reply', {}, () => handOverReply(context, issue.number, state.pr))
150
+ : [];
148
151
  const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start));
149
152
  const worktree = added.path;
150
153
  if (!added.baseUpdated) {
@@ -158,6 +161,7 @@ async function work(context, issue, log, state, start) {
158
161
  branch,
159
162
  worktree,
160
163
  comments,
164
+ reply,
161
165
  started,
162
166
  noChecks: false,
163
167
  mainBroken: false,
@@ -179,7 +183,7 @@ async function work(context, issue, log, state, start) {
179
183
  const result = await log.step('implement', {}, () => ports.runAgent(log, {
180
184
  name: 'implementer',
181
185
  cwd: worktree,
182
- prompt: implementPrompt(issue, comments),
186
+ prompt: implementPrompt(issue, comments, reply),
183
187
  profile: CODING,
184
188
  model: config.models.implementer,
185
189
  timeoutMs: config.agentTimeoutMinutes * 60_000,
package/dist/ports.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import { runAgent } from './agents.mjs';
2
2
  import { run } from './exec.mjs';
3
3
  import { addWorktree, commitAll, deleteBranch, diff, discardChanges, disputeBranches, findWorktree, isDirty, latestSubject, push, removeWorktree, resetToBase, revision, setAside, } from './git.mjs';
4
- import { commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
4
+ import { canWrite, commentOnIssue, commentOnPr, createPr, failedBaseLogs, failedCheckLogs, findPr, issueComments, markPrReady, mergeCommit, mergePr, openBlockers, openIssues, prConflicts, prMerged, reopenIssue, setLabel, threadComments, viewIssue, waitForBaseChecks, waitForChecks, } from './github.mjs';
5
5
  import { detectSetupCommand } from './setup.mjs';
6
6
  export const PORTS = {
7
7
  addWorktree,
8
+ canWrite,
8
9
  commentOnIssue,
9
10
  commentOnPr,
10
11
  commitAll,
@@ -37,6 +38,7 @@ export const PORTS = {
37
38
  runSetup: async (command, cwd, timeoutMs) => run('bash', ['-lc', command], { cwd, timeoutMs }),
38
39
  setAside,
39
40
  setLabel,
41
+ threadComments,
40
42
  viewIssue,
41
43
  waitForBaseChecks,
42
44
  waitForChecks,
package/dist/prompts.mjs CHANGED
@@ -11,15 +11,27 @@ function issueBlock(issue, comments) {
11
11
  .filter((part) => part.length > 0)
12
12
  .join('\n\n');
13
13
  }
14
- export function implementPrompt(issue, comments) {
14
+ function replyBlock(reply) {
15
+ return reply.length > 0
16
+ ? [
17
+ '## Answer of a person to the hand-over',
18
+ 'The last run handed the issue to a person. A person with write access to the repository answered as below. Follow this guidance.',
19
+ reply.join('\n\n'),
20
+ ].join('\n\n')
21
+ : '';
22
+ }
23
+ export function implementPrompt(issue, comments, reply = []) {
15
24
  return [
16
25
  'You are the implementer. Solve the issue below in this worktree.',
17
26
  issueBlock(issue, comments),
27
+ replyBlock(reply),
18
28
  '## Rules',
19
29
  'Read the repository conventions first. Keep the change minimal and focused on the issue.',
20
30
  'Add or update tests when the repository has a test suite.',
21
31
  COMMIT_RULES,
22
- ].join('\n\n');
32
+ ]
33
+ .filter((part) => part.length > 0)
34
+ .join('\n\n');
23
35
  }
24
36
  export function hookPrompt(issue, comments, role, output) {
25
37
  return [
@@ -75,7 +87,7 @@ const DISPUTE_RULE = [
75
87
  'If only some of the findings are wrong, fix the correct ones and change nothing for the wrong ones. Write no `unrelated` line in that round. The next round holds only the findings that are left, and you can dispute them there.',
76
88
  'The reviewer reads that line in the next round. Do not make a change that you hold to be wrong only to end the round.',
77
89
  ].join('\n');
78
- export function fixPrompt(issue, reason, detail, earlier, kind) {
90
+ export function fixPrompt(issue, reason, detail, earlier, kind, reply = []) {
79
91
  return [
80
92
  `You are the fixer. Correct the work for issue #${issue.number}: ${issue.title}.`,
81
93
  `## Reason\n${reason}`,
@@ -87,6 +99,7 @@ export function fixPrompt(issue, reason, detail, earlier, kind) {
87
99
  'Keep those earlier fixes. Do not undo work that a later round did not question.',
88
100
  ].join('\n')
89
101
  : '',
102
+ replyBlock(reply),
90
103
  '## Rules',
91
104
  'Fix the cause, not the symptom. Keep the change minimal.',
92
105
  kind === 'checks' ? UNRELATED_RULE : DISPUTE_RULE,
package/dist/serve.mjs CHANGED
@@ -5,16 +5,21 @@ import { join } from 'node:path';
5
5
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
6
  import { WebSocketServer } from 'ws';
7
7
  import { parseArgs } from './args.mjs';
8
+ import { loadConfig } from './config.mjs';
8
9
  import { stopChildren } from './exec.mjs';
10
+ import { detectRepo } from './git.mjs';
11
+ import { waitingQuestions } from './handover.mjs';
9
12
  import { message } from './observe.mjs';
10
13
  import { applyEvent, startProgress } from './progress.mjs';
11
- import { isNewer } from './protocol.mjs';
14
+ import { PORTS } from './ports.mjs';
15
+ import { isNewer, } from './protocol.mjs';
12
16
  import { runRepo } from './run.mjs';
13
17
  import { packageVersion } from './version.mjs';
14
18
  export function socketPaths(agentDir = getAgentDir()) {
15
19
  return {
16
20
  socketPath: join(agentDir, 'next-issue.sock'),
17
21
  lockPath: join(agentDir, 'next-issue.lock'),
22
+ rootsPath: join(agentDir, 'next-issue.roots.json'),
18
23
  };
19
24
  }
20
25
  function alive(pid) {
@@ -55,12 +60,39 @@ async function lock(path) {
55
60
  }
56
61
  return false;
57
62
  }
63
+ async function readRoots(path) {
64
+ try {
65
+ const value = JSON.parse(await readFile(path, 'utf8'));
66
+ return new Set(Array.isArray(value) ? value.filter((root) => typeof root === 'string') : []);
67
+ }
68
+ catch {
69
+ return new Set();
70
+ }
71
+ }
72
+ export async function listQuestions(root) {
73
+ const config = await loadConfig(root);
74
+ return waitingQuestions(await detectRepo(root, config.remote), config, PORTS);
75
+ }
58
76
  export async function startWorker(options) {
59
77
  if (!(await lock(options.lockPath))) {
60
78
  return undefined;
61
79
  }
62
80
  await rm(options.socketPath, { force: true });
63
81
  const runs = new Map();
82
+ const roots = await readRoots(options.rootsPath);
83
+ let saving = Promise.resolve();
84
+ const remember = (root) => {
85
+ if (roots.has(root)) {
86
+ return;
87
+ }
88
+ roots.add(root);
89
+ const value = `${JSON.stringify([...roots], null, 2)}\n`;
90
+ saving = saving
91
+ .then(() => writeFile(options.rootsPath, value, { mode: 0o600 }))
92
+ .catch(() => undefined);
93
+ };
94
+ const reads = new Map();
95
+ const delivered = new WeakMap();
64
96
  let restart = false;
65
97
  let finish = () => undefined;
66
98
  const closed = new Promise((resolve) => {
@@ -81,6 +113,26 @@ export async function startWorker(options) {
81
113
  send(socket, value);
82
114
  }
83
115
  };
116
+ const sendQuestions = async (root, targets) => {
117
+ remember(root);
118
+ const read = (reads.get(root) ?? 0) + 1;
119
+ reads.set(root, read);
120
+ let value;
121
+ try {
122
+ value = { type: 'questions', root, questions: await options.questions(root) };
123
+ }
124
+ catch (error) {
125
+ value = { type: 'questions-error', root, message: message(error) };
126
+ }
127
+ for (const socket of targets()) {
128
+ const seen = delivered.get(socket) ?? new Map();
129
+ if ((seen.get(root) ?? 0) < read) {
130
+ seen.set(root, read);
131
+ delivered.set(socket, seen);
132
+ send(socket, value);
133
+ }
134
+ }
135
+ };
84
136
  const busy = () => [...runs.values()].some((run) => run.progress.status === 'running' || run.progress.status === 'stopping');
85
137
  const close = async () => {
86
138
  for (const socket of sockets.clients) {
@@ -89,6 +141,7 @@ export async function startWorker(options) {
89
141
  await new Promise((resolve) => sockets.close(() => resolve()));
90
142
  await new Promise((resolve) => server.close(() => resolve()));
91
143
  await rm(options.socketPath, { force: true });
144
+ await saving;
92
145
  await rm(options.lockPath, { force: true });
93
146
  finish();
94
147
  };
@@ -109,6 +162,7 @@ export async function startWorker(options) {
109
162
  }
110
163
  const run = { progress: startProgress(), stop: false };
111
164
  runs.set(root, run);
165
+ remember(root);
112
166
  const interrupts = {
113
167
  afterIssue: () => run.stop,
114
168
  afterRound: () => run.stop,
@@ -132,7 +186,12 @@ export async function startWorker(options) {
132
186
  options
133
187
  .runRepo(root, { ...parseArgs([]), merge }, {
134
188
  interrupts,
135
- onEvent: (record) => update(applyEvent(run.progress, record)),
189
+ onEvent: (record) => {
190
+ update(applyEvent(run.progress, record));
191
+ if (record['kind'] === 'issue.end' || record['kind'] === 'issue.error') {
192
+ void sendQuestions(root, () => sockets.clients);
193
+ }
194
+ },
136
195
  })
137
196
  .then(() => {
138
197
  const status = run.stop
@@ -157,6 +216,9 @@ export async function startWorker(options) {
157
216
  sockets.on('connection', (socket) => {
158
217
  send(socket, { type: 'hello', version: options.version });
159
218
  send(socket, snapshot());
219
+ for (const root of roots) {
220
+ void sendQuestions(root, () => [socket]);
221
+ }
160
222
  socket.on('message', (data) => {
161
223
  let value;
162
224
  try {
@@ -177,6 +239,9 @@ export async function startWorker(options) {
177
239
  else if (value.type === 'stop') {
178
240
  stop(value.root);
179
241
  }
242
+ else if (value.type === 'questions') {
243
+ void sendQuestions(value.root, () => [socket]);
244
+ }
180
245
  });
181
246
  });
182
247
  await new Promise((resolve, reject) => {
@@ -200,6 +265,7 @@ export async function serve() {
200
265
  ...paths,
201
266
  version: await packageVersion(),
202
267
  runRepo,
268
+ questions: listQuestions,
203
269
  });
204
270
  if (worker === undefined) {
205
271
  process.stderr.write('next-issue: another worker is running\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",