next-issue 2.8.0 → 2.10.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
 
@@ -379,14 +381,25 @@ silent skip.
379
381
 
380
382
  ## Retry an issue that waits for a person
381
383
 
382
- A hand-over leaves the state file with the used budgets in it, so the next run
383
- would hand the issue over again at once. To give the issue another try, take the
384
- `status:needs-human` label off and run with `--reset`. The flag puts the budgets
385
- and the findings back to zero and keeps the branch, the pull request and the
386
- step that the last run reached, so the work carries on where it stopped. A
387
- commit that you pushed to the pull request in the meantime is fetched first.
388
- The flag touches only an issue that the run handles, so the state of an issue
389
- that a label still holds back stays as it was.
384
+ A hand-over marks the state file as handed over. To give the issue another try,
385
+ take the `status:needs-human` label off. The next run that claims the issue puts
386
+ the budgets and the findings back to zero, clears the mark and keeps the branch,
387
+ the pull request and the step that the last run reached, so the work carries on
388
+ where it stopped. A commit that you pushed to the pull request in the meantime
389
+ is fetched first. An issue that still has the label is not claimed.
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
+
400
+ The `--reset` flag does the same for any issue that the run handles, whether or
401
+ not it was handed over. It touches only an issue that the run handles, so the
402
+ state of an issue that a label still holds back stays as it was.
390
403
 
391
404
  ## Commands and options
392
405
 
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] | @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] = parsed;
97
+ return { author, body: body ?? '' };
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,35 @@
1
1
  import { setStatus } from './job.mjs';
2
2
  import { message } from './observe.mjs';
3
3
  import { dropState, writeState } from './state.mjs';
4
+ export const HAND_OVER = 'next-issue needs help:';
5
+ export async function handOverReply(context, issue, pr) {
6
+ const { repo, ports } = context;
7
+ const comments = await ports.threadComments(repo, pr ?? issue);
8
+ const access = new Map();
9
+ const writer = async (author) => {
10
+ if (!access.has(author)) {
11
+ access.set(author, await ports.canWrite(repo, author));
12
+ }
13
+ return access.get(author) === true;
14
+ };
15
+ let last = -1;
16
+ for (let index = comments.length - 1; index >= 0 && last === -1; index -= 1) {
17
+ const comment = comments[index];
18
+ if (comment.body.startsWith(HAND_OVER) && (await writer(comment.author))) {
19
+ last = index;
20
+ }
21
+ }
22
+ if (last === -1) {
23
+ return [];
24
+ }
25
+ const reply = [];
26
+ for (const comment of comments.slice(last + 1)) {
27
+ if (await writer(comment.author)) {
28
+ reply.push(`@${comment.author}: ${comment.body}`);
29
+ }
30
+ }
31
+ return reply;
32
+ }
4
33
  export function escalate(job, reason) {
5
34
  return handOver(job.context, job.log, job.issue.number, job.state, reason);
6
35
  }
@@ -55,9 +84,9 @@ export async function cleanUp(context, log, branch) {
55
84
  }
56
85
  export async function handOver(context, log, issue, state, reason) {
57
86
  log.event('hand-over', { reason }, 'quiet');
58
- await writeState(context.repo, state);
87
+ await writeState(context.repo, { ...state, handedOver: true });
59
88
  await setStatus(context, issue, state.pr, context.config.labels.needsHuman);
60
- const body = `next-issue needs help: ${reason}`;
89
+ const body = `${HAND_OVER} ${reason}`;
61
90
  if (state.pr === undefined) {
62
91
  await context.ports.commentOnIssue(context.repo, issue, body);
63
92
  }
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';
@@ -106,9 +106,13 @@ export async function processIssue(context, issue) {
106
106
  reviewRounds: 0,
107
107
  reviewLog: [],
108
108
  };
109
- const state = saved === undefined ? fresh : context.reset ? resetState(saved) : saved;
109
+ const state = saved === undefined
110
+ ? fresh
111
+ : context.reset || saved.handedOver === true
112
+ ? resetState(saved)
113
+ : saved;
110
114
  try {
111
- const report = await work(context, issue, log, state, saved === undefined);
115
+ const report = await work(context, issue, log, state, saved === undefined, saved?.handedOver === true);
112
116
  log.event('issue.end', { outcome: report.outcome, reason: report.reason, ms: report.ms }, 'quiet');
113
117
  return report;
114
118
  }
@@ -132,7 +136,7 @@ export async function processIssue(context, issue) {
132
136
  };
133
137
  }
134
138
  }
135
- async function work(context, issue, log, state, start) {
139
+ async function work(context, issue, log, state, start, handedOver) {
136
140
  const started = Date.now();
137
141
  const { repo, config, ports } = context;
138
142
  const branch = state.branch;
@@ -141,6 +145,9 @@ async function work(context, issue, log, state, start) {
141
145
  await writeState(repo, state);
142
146
  });
143
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
+ : [];
144
151
  const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start));
145
152
  const worktree = added.path;
146
153
  if (!added.baseUpdated) {
@@ -154,6 +161,7 @@ async function work(context, issue, log, state, start) {
154
161
  branch,
155
162
  worktree,
156
163
  comments,
164
+ reply,
157
165
  started,
158
166
  noChecks: false,
159
167
  mainBroken: false,
@@ -175,7 +183,7 @@ async function work(context, issue, log, state, start) {
175
183
  const result = await log.step('implement', {}, () => ports.runAgent(log, {
176
184
  name: 'implementer',
177
185
  cwd: worktree,
178
- prompt: implementPrompt(issue, comments),
186
+ prompt: implementPrompt(issue, comments, reply),
179
187
  profile: CODING,
180
188
  model: config.models.implementer,
181
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/state.mjs CHANGED
@@ -60,7 +60,8 @@ function isState(value) {
60
60
  (value.baseFix === undefined || isBaseFix(value.baseFix)) &&
61
61
  typeof value.reviewRounds === 'number' &&
62
62
  Array.isArray(value.reviewLog) &&
63
- value.reviewLog.every(isReviewRound));
63
+ value.reviewLog.every(isReviewRound) &&
64
+ (value.handedOver === undefined || typeof value.handedOver === 'boolean'));
64
65
  }
65
66
  export async function savedIssues(repo) {
66
67
  let names;
@@ -79,7 +80,8 @@ export async function savedIssues(repo) {
79
80
  });
80
81
  }
81
82
  export function resetState(state) {
82
- return { ...state, ciFixes: 0, commitFixes: 0, reviewRounds: 0, reviewLog: [] };
83
+ const { handedOver: _handedOver, ...rest } = state;
84
+ return { ...rest, ciFixes: 0, commitFixes: 0, reviewRounds: 0, reviewLog: [] };
83
85
  }
84
86
  export async function writeState(repo, state) {
85
87
  await mkdir(dir(repo), { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",