next-issue 2.9.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
 
@@ -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.
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
  }
@@ -57,7 +86,7 @@ export async function handOver(context, log, issue, state, reason) {
57
86
  log.event('hand-over', { reason }, 'quiet');
58
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';
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",