next-issue 2.14.0 → 2.15.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
@@ -12,8 +12,16 @@ npx -y next-issue@latest
12
12
  Before each issue, fetch the open issues again and take the oldest one not yet
13
13
  seen in this run. An issue seen once in a run, also a skipped or failed one, is
14
14
  not tried again in that run. The run ends when no unseen issue is left. When
15
- the fetch fails, the run ends there, writes the summary of the issues it handled
16
- and exits with code 1. For each issue:
15
+ the fetch fails, the run claims no new issue, ends once the running issues end,
16
+ writes the summary of the issues it handled and exits with code 1.
17
+
18
+ A run handles up to `concurrency` issues at the same time, 1 by default. When an
19
+ issue ends, the run fetches the open issues again and claims the oldest unseen
20
+ one for the free slot. Each issue has its own worktree and branch. Step 3 below
21
+ and the removal of a worktree or branch run for one issue at a time, so two
22
+ issues never change the git refs or worktrees of the repository at once. An issue blocked by a running issue is skipped and not tried again in
23
+ that run, even when the blocker closes before the run ends. `--max <n>` counts the handled issues across the run, and with `--once`
24
+ the run handles one issue. For each issue:
17
25
 
18
26
  1. Claim the issue only when it holds the `status:todo` label and holds no
19
27
  `status:in-progress` or `status:in-review` label. A `status:done`,
@@ -104,7 +112,7 @@ and exits with code 1. For each issue:
104
112
 
105
113
  ## Issue format
106
114
 
107
- Every agent prompt holds the body and the comments of the issue. An issue reads best with these sections, in this order:
115
+ Every agent prompt holds the body of the issue and the comments of users with write or admin access to the repository. An issue reads best with these sections, in this order:
108
116
 
109
117
  1. `Problem`
110
118
  2. `Context and findings`
@@ -120,7 +128,9 @@ The harness hands the issue to a person, with the `status:needs-human` label,
120
128
  and comments the reason on the pull request, or on the issue when there is no
121
129
  pull request yet, when:
122
130
 
123
- - the check budget or the review budget runs out;
131
+ - the check budget or the review budget runs out. A finding that the reviewer
132
+ repeats, even one that the fixer disputed, goes back to the fixer until the
133
+ review budget runs out;
124
134
  - a fixer round adds no commit and gives no reason, so there is no progress;
125
135
  - the fixer reports that the checks fail for a reason the change did not cause,
126
136
  such as a test already broken on the base branch or one that fails only under
@@ -138,9 +148,6 @@ pull request yet, when:
138
148
  holds the reason of the dispute, and the change is on the machine that ran
139
149
  the harness. The `<n>-dispute-<commit>` branches go away with the `<n>`
140
150
  branch when the issue is done;
141
- - the reviewer repeats a finding set from an earlier round, which shows a
142
- ping-pong between the fixer and the reviewer, or a finding that the fixer
143
- disputed and the reviewer holds to. The person then decides;
144
151
  - the reviewer gives no verdict, or the checks do not finish in time;
145
152
  - someone pushes to the pull request while the harness waits for the checks,
146
153
  so the pull request no longer shows the commit that the harness pushed;
@@ -150,7 +157,8 @@ pull request yet, when:
150
157
  the pull request closes without a merge;
151
158
  - with `--merge`, the checks on the base branch fail after the merge and the
152
159
  fixer cannot fix them, or they do not finish in time. The base branch is
153
- then broken, so the run stops after this issue;
160
+ then broken, so the run claims no new issue and ends once the running
161
+ issues end;
154
162
  - an agent does not finish in `agentTimeoutMinutes`;
155
163
  - the setup command fails, or does not finish in `setupTimeoutMinutes`;
156
164
  - a step of the run fails with an error. A `gh` call that is safe to repeat,
@@ -175,12 +183,12 @@ exception is the answer to a hand-over, see
175
183
 
176
184
  Press `Ctrl-C` to stop the run step by step:
177
185
 
178
- 1. The first press stops the run after the current issue. The harness finishes
179
- the issue that it holds and then writes the summary as usual.
180
- 2. The second press stops the run after the current round: the implementation,
181
- one checks round (wait, then fix) or one review round (review, then fix). The
182
- issue ends as `stopped`. Its state, worktree and labels stay, so the next run
183
- carries on from that point.
186
+ 1. The first press stops new claims. The harness finishes every issue that it
187
+ holds and then writes the summary as usual.
188
+ 2. The second press stops every running issue after its current round: the
189
+ implementation, one checks round (wait, then fix) or one review round
190
+ (review, then fix). The issue ends as `stopped`. Its state, worktree and
191
+ labels stay, so the next run carries on from that point.
184
192
  3. The third press stops every agent, kills the command that runs and every
185
193
  child of it, so the run dies in the middle of a step. The saved state lets the next run continue the
186
194
  issue, but the issue keeps `status:in-progress` and the run writes no
@@ -208,12 +216,15 @@ Every agent can run a shell command. The implementer and the fixer run with the
208
216
  permission checks off; the reviewer runs with a smaller tool set, but its shell
209
217
  has no limit on the commands. They all start in the worktree, but nothing holds
210
218
  them there: they can run any command, with your rights, anywhere on the machine.
211
- The prompt holds the title, the body and the comments of the issue, which come
212
- from GitHub.
219
+ The prompt holds the title and the body of the issue, which come from GitHub.
220
+ It also holds the comments of the issue, but only those by users with write or
221
+ admin access to the repository. The harness leaves out every other comment and
222
+ writes a `comment.skip` event with its author to the run log. If an access check
223
+ fails for a reason other than a 404, the issue stops with an error.
213
224
 
214
- Thus a person who can write an issue or a comment can try to give an
215
- instruction to the agent. Use the harness only on a repository where you trust
216
- the issue authors, or run it in a container.
225
+ Thus a person who can write an issue, or a writer who can comment, can try to
226
+ give an instruction to the agent. Use the harness only on a repository where you
227
+ trust the issue authors, or run it in a container.
217
228
 
218
229
  ## Observability
219
230
 
@@ -298,6 +309,7 @@ does not know all stop the run, so a typo cannot pass without a word.
298
309
  {
299
310
  "remote": "origin",
300
311
  "issueLimit": 100,
312
+ "concurrency": 1,
301
313
  "maxCiFixes": 5,
302
314
  "maxCommitFixes": 10,
303
315
  "maxReviewRounds": 5,
@@ -331,6 +343,7 @@ does not know all stop the run, so a typo cannot pass without a word.
331
343
  | ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
332
344
  | `remote` | `origin` | The git remote for the repository and the branches |
333
345
  | `issueLimit` | `100` | The maximum number of open issues to read. With `labels.ready` set, only issues with the ready, in-progress or in-review label count |
346
+ | `concurrency` | `1` | The most issues that a run handles at the same time |
334
347
  | `maxCiFixes` | `5` | The budget for check fixes per issue |
335
348
  | `maxCommitFixes` | `10` | The budget for fixes of a commit that a git hook stopped, per issue |
336
349
  | `maxReviewRounds` | `5` | The budget for review rounds per issue |
@@ -453,7 +466,7 @@ and `--once` is the same as `--max 1`.
453
466
  The watch stops:
454
467
 
455
468
  - at the first `Ctrl-C` during a wait, at once;
456
- - at the first `Ctrl-C` during an issue, after that issue;
469
+ - at the first `Ctrl-C` during an issue, after the running issues;
457
470
  - after a pass that halts the run, for example when the base branch is broken
458
471
  after `--merge`;
459
472
  - when the open issues cannot be read.
@@ -475,8 +488,8 @@ to it. The worker:
475
488
  once. Each run is like `next-issue` without `--watch`, and the client says
476
489
  whether it merges;
477
490
  - sends every client the progress of every run when it connects and on every
478
- change: the issue, the step, the tool count, the last tool and the handled
479
- issues;
491
+ change: for each running issue the issue, the step, the tool count and the
492
+ last tool, and the handled issues;
480
493
  - sends every client a `questions` message for each repository: the open
481
494
  issues with the `needsHuman` label, each with its number, title, the reason
482
495
  of the hand-over, the url of the comment that holds it, the `owner/name`
@@ -491,8 +504,8 @@ to it. The worker:
491
504
  a newer one. When the client adds an `"id"` to its message, the list that
492
505
  answers it carries the same `id` and is read after the message arrived. When the list cannot be read, the client gets
493
506
  `{ "type": "questions-error", "root": "<path>", "message": "<text>" }`;
494
- - stops a run, when a client asks, like a second `Ctrl-C`: after the current
495
- round, with the state kept;
507
+ - stops a run, when a client asks, like a second `Ctrl-C`: each running issue
508
+ after its current round, with the state kept;
496
509
  - stays when all clients close;
497
510
  - closes once no run is going when a client with a newer version connects, so
498
511
  that the client can start the newer worker;
package/dist/config.mjs CHANGED
@@ -4,6 +4,7 @@ import { message } from './observe.mjs';
4
4
  const DEFAULTS = {
5
5
  remote: 'origin',
6
6
  issueLimit: 100,
7
+ concurrency: 1,
7
8
  maxCiFixes: 5,
8
9
  maxCommitFixes: 10,
9
10
  maxReviewRounds: 5,
@@ -33,6 +34,7 @@ function defaults() {
33
34
  const FIELDS = [...Object.keys(DEFAULTS), 'setupCommand'];
34
35
  const WHOLE_FIELDS = [
35
36
  ['issueLimit', 1],
37
+ ['concurrency', 1],
36
38
  ['maxCiFixes', 0],
37
39
  ['maxCommitFixes', 0],
38
40
  ['maxReviewRounds', 1],
package/dist/github.mjs CHANGED
@@ -72,7 +72,7 @@ export async function viewIssue(repo, issue) {
72
72
  export async function issueComments(repo, issue) {
73
73
  const raw = await gh(repo, ['issue', 'view', String(issue), '--json', 'comments']);
74
74
  const parsed = JSON.parse(raw);
75
- return parsed.comments.map((comment) => `@${comment.author.login}: ${comment.body}`);
75
+ return parsed.comments.map((comment) => ({ author: comment.author.login, body: comment.body }));
76
76
  }
77
77
  export async function threadComments(repo, number) {
78
78
  const raw = await repeatable([
package/dist/handover.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { setStatus } from './job.mjs';
1
+ import { oneAtATime, setStatus, } from './job.mjs';
2
2
  import { message } from './observe.mjs';
3
3
  import { dropState, readState, writeState } from './state.mjs';
4
4
  export const HAND_OVER = 'next-issue needs help:';
@@ -40,6 +40,20 @@ export async function waitingQuestions(repo, config, ports) {
40
40
  }
41
41
  return questions;
42
42
  }
43
+ export async function writerComments(context, log, issue) {
44
+ const { repo, ports } = context;
45
+ const writer = writers(repo, ports);
46
+ const kept = [];
47
+ for (const comment of await ports.issueComments(repo, issue)) {
48
+ if (await writer(comment.author)) {
49
+ kept.push(`@${comment.author}: ${comment.body}`);
50
+ }
51
+ else {
52
+ log.event('comment.skip', { author: comment.author }, 'quiet');
53
+ }
54
+ }
55
+ return kept;
56
+ }
43
57
  export async function handOverReply(context, issue, pr) {
44
58
  const { repo, ports } = context;
45
59
  const comments = await ports.threadComments(repo, pr ?? issue);
@@ -77,7 +91,7 @@ export async function finish(job, outcome, reason) {
77
91
  await cleanUp(context, log, branch);
78
92
  if (outcome === 'done') {
79
93
  for (const name of [branch, ...(await ports.disputeBranches(repo, branch))]) {
80
- if (!(await ports.deleteBranch(repo, name))) {
94
+ if (!(await oneAtATime(repo.root, () => ports.deleteBranch(repo, name)))) {
81
95
  log.event('branch.kept', { branch: name }, 'quiet');
82
96
  }
83
97
  }
@@ -100,7 +114,7 @@ export async function cleanUp(context, log, branch) {
100
114
  if (path === undefined) {
101
115
  return;
102
116
  }
103
- if (!(await context.ports.removeWorktree(context.repo, path))) {
117
+ if (!(await oneAtATime(context.repo.root, () => context.ports.removeWorktree(context.repo, path)))) {
104
118
  log.event('worktree.kept', { path }, 'quiet');
105
119
  }
106
120
  }
@@ -9,10 +9,10 @@ export function listenForInterrupts(say) {
9
9
  wake();
10
10
  }
11
11
  else if (presses === 1) {
12
- say('Stopping after this issue. Press Ctrl-C again to stop after this round.');
12
+ say('Stopping after the running issues. Press Ctrl-C again to stop after the current round.');
13
13
  }
14
14
  else if (presses === 2) {
15
- say('Stopping after this round. Press Ctrl-C again to stop now.');
15
+ say('Stopping after the current round. Press Ctrl-C again to stop now.');
16
16
  }
17
17
  else {
18
18
  process.off('SIGINT', press);
package/dist/job.mjs CHANGED
@@ -1,5 +1,11 @@
1
1
  import { managedLabels } from './config.mjs';
2
2
  import { writeState } from './state.mjs';
3
+ const worktreeQueues = new Map();
4
+ export function oneAtATime(root, work) {
5
+ const result = (worktreeQueues.get(root) ?? Promise.resolve()).then(work);
6
+ worktreeQueues.set(root, result.catch(() => undefined));
7
+ return result;
8
+ }
3
9
  export async function openPr(job) {
4
10
  const { context, issue, log, state, branch } = job;
5
11
  const { repo, config, ports } = context;
package/dist/loop.mjs CHANGED
@@ -1,29 +1,55 @@
1
1
  export const BACK_OFF_MINUTES = [1, 2, 3, 5, 10];
2
2
  export async function runIssues(args, loop, seen = new Set()) {
3
+ const limit = args.once ? Math.min(1, args.max ?? 1) : args.max;
4
+ const concurrency = loop.concurrency ?? 1;
3
5
  const reports = [];
4
- while (args.max === undefined || handled(reports) < args.max) {
5
- let issues;
6
- try {
7
- issues = await loop.issues();
8
- }
9
- catch (error) {
10
- return { reports, error };
6
+ const running = new Map();
7
+ let stoppedAt;
8
+ let error;
9
+ let crash;
10
+ let claiming = true;
11
+ const room = () => running.size < concurrency && (limit === undefined || handled(reports) + running.size < limit);
12
+ for (;;) {
13
+ if (claiming && room()) {
14
+ try {
15
+ const issues = await loop.issues();
16
+ for (const issue of issues.filter((candidate) => !seen.has(candidate.number))) {
17
+ if (!room()) {
18
+ break;
19
+ }
20
+ seen.add(issue.number);
21
+ running.set(issue.number, loop.process(issue).then((report) => ({ issue: issue.number, report }), (failure) => ({ issue: issue.number, failure })));
22
+ }
23
+ }
24
+ catch (failure) {
25
+ error = failure;
26
+ claiming = false;
27
+ }
11
28
  }
12
- const issue = issues.find((candidate) => !seen.has(candidate.number));
13
- if (issue === undefined) {
29
+ if (running.size === 0) {
14
30
  break;
15
31
  }
16
- seen.add(issue.number);
17
- const report = await loop.process(issue);
18
- reports.push(report);
19
- if (loop.stop() || report.halt === true) {
20
- return { reports, stoppedAt: issue.number };
32
+ const { issue, report, failure } = await Promise.race(running.values());
33
+ running.delete(issue);
34
+ if (report === undefined) {
35
+ crash ??= { failure };
36
+ claiming = false;
37
+ continue;
21
38
  }
22
- if (args.once && report.outcome !== 'skipped') {
23
- break;
39
+ reports.push(report);
40
+ if (claiming && (loop.stop() || report.halt === true)) {
41
+ stoppedAt = issue;
42
+ claiming = false;
24
43
  }
25
44
  }
26
- return { reports };
45
+ if (crash !== undefined) {
46
+ throw crash.failure;
47
+ }
48
+ return {
49
+ reports,
50
+ ...(stoppedAt === undefined ? {} : { stoppedAt }),
51
+ ...(error === undefined ? {} : { error }),
52
+ };
27
53
  }
28
54
  export async function watchIssues(args, watch) {
29
55
  const limit = args.once ? 1 : args.max;
package/dist/pipeline.mjs CHANGED
@@ -2,8 +2,8 @@ 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, handOverReply, skipped, stop } from './handover.mjs';
6
- import { openPr, setStatus } from './job.mjs';
5
+ import { cleanUp, escalate, finish, handOver, handOverReply, skipped, stop, writerComments, } from './handover.mjs';
6
+ import { oneAtATime, openPr, setStatus } from './job.mjs';
7
7
  import { afterMerge, fixBase } from './merge.mjs';
8
8
  import { message } from './observe.mjs';
9
9
  import { implementPrompt, parseCommitSubject } from './prompts.mjs';
@@ -144,11 +144,11 @@ async function work(context, issue, log, state, start, handedOver) {
144
144
  await setStatus(context, issue.number, state.pr, config.labels.inProgress);
145
145
  await writeState(repo, state);
146
146
  });
147
- const comments = await log.step('comments', {}, () => ports.issueComments(repo, issue.number));
147
+ const comments = await log.step('comments', {}, () => writerComments(context, log, issue.number));
148
148
  const reply = handedOver
149
149
  ? await log.step('reply', {}, () => handOverReply(context, issue.number, state.pr))
150
150
  : [];
151
- const added = await log.step('worktree', { branch }, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start));
151
+ const added = await log.step('worktree', { branch }, () => oneAtATime(repo.root, () => ports.addWorktree(repo, issue.number, branch, context.base, config.remote, start)));
152
152
  const worktree = added.path;
153
153
  if (!added.baseUpdated) {
154
154
  log.event('base.behind', { base: context.base }, 'quiet');
package/dist/progress.mjs CHANGED
@@ -1,11 +1,7 @@
1
1
  const TOOL_LINE_MAX = 80;
2
2
  const NOT_STAGES = new Set(['issue.start', 'agent.start', 'run.start']);
3
3
  export function startProgress() {
4
- return { status: 'running', tools: 0, handled: [] };
5
- }
6
- function idle(progress) {
7
- const { issue: _issue, title: _title, stage: _stage, lastTool: _lastTool, issueStartedAt: _at, ...rest } = progress;
8
- return { ...rest, tools: 0 };
4
+ return { status: 'running', issues: [], handled: [] };
9
5
  }
10
6
  function toolLine(record) {
11
7
  const tool = String(record['tool']);
@@ -13,36 +9,52 @@ function toolLine(record) {
13
9
  const line = typeof command === 'string' ? `${tool} ${command.split('\n')[0]}` : tool;
14
10
  return line.length > TOOL_LINE_MAX ? `${line.slice(0, TOOL_LINE_MAX - 1)}…` : line;
15
11
  }
12
+ function change(progress, issue, update) {
13
+ return {
14
+ ...progress,
15
+ issues: progress.issues.map((current) => (current.issue === issue ? update(current) : current)),
16
+ };
17
+ }
16
18
  export function applyEvent(progress, record) {
17
19
  const kind = String(record['kind']);
20
+ const issue = Number(record['issue']);
18
21
  if (kind === 'issue.start') {
19
22
  const next = {
20
- ...idle(progress),
21
- issue: Number(record['issue']),
22
- issueStartedAt: Date.parse(String(record['ts'])),
23
+ issue,
24
+ tools: 0,
25
+ startedAt: Date.parse(String(record['ts'])),
23
26
  };
24
27
  if (typeof record['title'] === 'string') {
25
28
  next.title = record['title'];
26
29
  }
27
- return next;
30
+ const others = progress.issues.filter((current) => current.issue !== issue);
31
+ return { ...progress, issues: [...others, next] };
28
32
  }
29
33
  if (kind === 'tool') {
30
- return { ...progress, tools: progress.tools + 1, lastTool: toolLine(record) };
34
+ return change(progress, issue, (current) => ({
35
+ ...current,
36
+ tools: current.tools + 1,
37
+ lastTool: toolLine(record),
38
+ }));
31
39
  }
32
40
  if (kind === 'issue.end' || kind === 'issue.error') {
33
41
  const outcome = kind === 'issue.error' ? 'error' : record['outcome'];
34
42
  const handled = outcome === 'done' || outcome === 'needs-human' || outcome === 'error'
35
- ? [...progress.handled, { issue: Number(record['issue']), outcome }]
43
+ ? [...progress.handled, { issue, outcome }]
36
44
  : progress.handled;
37
- return { ...idle(progress), handled };
45
+ const issues = progress.issues.filter((current) => current.issue !== issue);
46
+ return { ...progress, issues, handled };
38
47
  }
39
48
  if (kind === 'issues.fail') {
40
49
  return { ...progress, error: String(record['error']) };
41
50
  }
42
- if (kind.endsWith('.start') && !NOT_STAGES.has(kind) && progress.issue !== undefined) {
51
+ if (kind.endsWith('.start') && !NOT_STAGES.has(kind)) {
43
52
  const stage = kind.slice(0, -'.start'.length);
44
53
  const round = record['round'];
45
- return { ...progress, stage: typeof round === 'number' ? `${stage} ${round}` : stage };
54
+ return change(progress, issue, (current) => ({
55
+ ...current,
56
+ stage: typeof round === 'number' ? `${stage} ${round}` : stage,
57
+ }));
46
58
  }
47
59
  return progress;
48
60
  }
package/dist/review.mjs CHANGED
@@ -6,7 +6,7 @@ import { history, setStatus } from './job.mjs';
6
6
  import { afterMerge } from './merge.mjs';
7
7
  import { capDiff, reviewPrompt } from './prompts.mjs';
8
8
  import { writeState } from './state.mjs';
9
- import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, parseVerdict, } from './verdict.mjs';
9
+ import { blockingFindings, formatFindings, formatVerdict, openFindings, parseVerdict, } from './verdict.mjs';
10
10
  export async function reviewRound(job) {
11
11
  const { context, issue, log, state, worktree, comments } = job;
12
12
  const { repo, config, ports } = context;
@@ -73,15 +73,9 @@ export async function reviewRound(job) {
73
73
  await setStatus(context, issue.number, pr, config.labels.done);
74
74
  return { done: true, report: await finish(job, 'done') };
75
75
  }
76
- const mark = fingerprint(open);
77
- if (state.reviewLog.some((round) => round.fingerprint === mark)) {
78
- await escalate(job, 'The reviewer repeated findings that an earlier round did not fix.');
79
- return { done: true, report: await finish(job, 'needs-human', 'repeated findings') };
80
- }
81
76
  const earlier = history(state.reviewLog);
82
77
  state.reviewLog.push({
83
78
  round: state.reviewRounds,
84
- fingerprint: mark,
85
79
  findings: formatFindings(open),
86
80
  });
87
81
  await writeState(repo, state);
package/dist/run.mjs CHANGED
@@ -68,6 +68,7 @@ async function handle(args, config, repo, recorder, controls) {
68
68
  },
69
69
  process: (issue) => processIssue(context, issue),
70
70
  stop: interrupts.afterIssue,
71
+ concurrency: config.concurrency,
71
72
  };
72
73
  const print = controls.print ?? (async () => undefined);
73
74
  let passStart = recorder.summary();
package/dist/state.mjs CHANGED
@@ -44,7 +44,6 @@ function isBaseFix(value) {
44
44
  function isReviewRound(value) {
45
45
  return (isRecord(value) &&
46
46
  typeof value.round === 'number' &&
47
- typeof value.fingerprint === 'string' &&
48
47
  typeof value.findings === 'string' &&
49
48
  isOptional(value.dispute, 'string'));
50
49
  }
package/dist/verdict.mjs CHANGED
@@ -54,12 +54,3 @@ export function formatVerdict(verdict, open) {
54
54
  export function formatFindings(findings) {
55
55
  return findings.map((finding) => `- ${finding.detail}`).join('\n');
56
56
  }
57
- export function fingerprint(findings) {
58
- return findings
59
- .map((finding) => finding.detail
60
- .toLowerCase()
61
- .replace(/[^a-z0-9]+/g, ' ')
62
- .trim())
63
- .sort()
64
- .join('|');
65
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Autonomous issue harness driven by the pi SDK",
5
5
  "keywords": [
6
6
  "github",