next-issue 0.9.0 → 0.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
@@ -40,13 +40,21 @@ For each open issue, oldest first:
40
40
  7. On a red build, give the failed job logs to the fixer agent, then go to step
41
41
  6 again. The budget is `maxCiFixes`.
42
42
  8. Let the reviewer agent judge the diff, at most `diffMaxChars` of it. The
43
- reviewer returns a structured verdict that rates each finding `blocking` or
44
- `minor`. The harness puts the result on the pull request.
45
- 9. No blocking finding means approval: mark the pull request ready for review
46
- and set `status:done`. The merge stays with you. The worktree, the local
47
- branch and the state file go away. The remote branch stays. A blocking
48
- finding always stops the approval, even when the reviewer also says approve.
49
- 10. With a blocking finding, run the fixer agent and go to step 6 again. The
43
+ reviewer reads files and runs commands in the worktree, so it can prove a
44
+ finding with the tests or the type check. The harness then throws away every
45
+ change that the reviewer left, so no such change can reach a later commit.
46
+ The reviewer returns a structured verdict that rates each finding `blocking`
47
+ or `minor`. The harness puts the result on the pull request.
48
+ 9. An open finding is any finding, blocking or minor, while a review round is
49
+ left. In the last round, only a blocking finding stays open: a minor finding
50
+ there goes on the pull request but starts no fix, because a fix in the last
51
+ round gets no review. Thus `maxReviewRounds` of `1` never fixes a minor
52
+ finding.
53
+ 10. No open finding means approval: mark the pull request ready for review and
54
+ set `status:done`. The merge stays with you. The worktree, the local branch
55
+ and the state file go away. The remote branch stays. An open finding always
56
+ stops the approval, even when the reviewer also says approve.
57
+ 11. With an open finding, run the fixer agent and go to step 6 again. The
50
58
  budget is `maxReviewRounds`.
51
59
 
52
60
  ## Stops for a loop
@@ -86,16 +94,20 @@ too, from the first review round. Thus a draft pull request shows work that the
86
94
  harness has not finished, and a `status:needs-human` pull request shows work
87
95
  that waits for a person.
88
96
 
89
- The harness runs all git and `gh` commands itself. The agents only read and
90
- change files. The commit holds all the changes in the worktree, so a setup
91
- command that writes a file outside `.gitignore` puts that file in the commit.
97
+ The harness runs all git and `gh` commands itself. The commit holds all the
98
+ changes in the worktree, so a setup command that writes a file outside
99
+ `.gitignore` puts that file in the commit. The reviewer is the one agent whose
100
+ changes never get that far: the harness discards them before it reads the
101
+ verdict.
92
102
 
93
103
  ## Safety
94
104
 
95
- The implementer and the fixer run with the permission checks off. They start in
96
- the worktree, but nothing holds them there: they can run any command, with your
97
- rights, anywhere on the machine. The prompt holds the title, the body and the
98
- comments of the issue, which come from GitHub.
105
+ Every agent can run a shell command. The implementer and the fixer run with the
106
+ permission checks off; the reviewer runs with a smaller tool set, but its shell
107
+ has no limit on the commands. They all start in the worktree, but nothing holds
108
+ them there: they can run any command, with your rights, anywhere on the machine.
109
+ The prompt holds the title, the body and the comments of the issue, which come
110
+ from GitHub.
99
111
 
100
112
  Thus a person who can write an issue or a comment can try to give an
101
113
  instruction to the agent. Use the harness only on a repository where you trust
@@ -158,8 +170,8 @@ npx -y next-issue@latest init
158
170
 
159
171
  The command writes `next-issue.config.json` in the root of the repository and
160
172
  adds `.next-issue/` to `.gitignore`. It stops when the config file is there
161
- already; `--force` replaces it. The file holds no `setupCommand`, because that
162
- field has no default.
173
+ already; `--force` replaces it. The file holds no `setupCommand`, because the
174
+ harness deduces one from the lock file of the project.
163
175
 
164
176
  A field that is not in the file keeps its default. A file that is not valid
165
177
  JSON, a field with a value of the wrong type and a field name that the harness
@@ -209,7 +221,7 @@ does not know all stop the run, so a typo cannot pass without a word.
209
221
  | `diffMaxChars` | `60000` | The maximum length of the diff that the reviewer reads |
210
222
  | `keepRunDays` | `7` | The number of days to keep the run files; `0` keeps them all |
211
223
  | `draftPullRequest` | `true` | Open the pull request as a draft, until the review approves |
212
- | `setupCommand` | none | A shell command to run in a new worktree, before the implementer |
224
+ | `setupCommand` | from the lock file | A shell command to run in a new worktree, before the implementer |
213
225
  | `models` | `{}` | The model per agent role |
214
226
  | `labels` | see above | The names of the labels that the harness reads and sets |
215
227
 
@@ -219,8 +231,30 @@ claims every open issue that no other label and no assignee holds back.
219
231
  The log gives the reason for a skipped issue as `stop-label`, `not-ready`,
220
232
  `in-flight` or `assigned`.
221
233
 
222
- A role without an entry in `models` uses the default model of the SDK. A
223
- `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
234
+ A role without an entry in `models` uses the default model of the SDK.
235
+
236
+ Without a `setupCommand`, the harness looks in the new worktree for a lock file
237
+ and runs the install command that goes with it:
238
+
239
+ | Lock file | Command |
240
+ | --- | --- |
241
+ | `pnpm-lock.yaml` | `pnpm install --frozen-lockfile` |
242
+ | `package-lock.json` | `npm ci` |
243
+ | `yarn.lock` | `yarn install --frozen-lockfile`, or `yarn install --immutable` with a `.yarnrc.yml` |
244
+ | `bun.lock`, `bun.lockb` | `bun install --frozen-lockfile` |
245
+ | `Cargo.lock` | `cargo fetch` |
246
+ | `go.sum` | `go mod download` |
247
+ | `uv.lock` | `uv sync` |
248
+ | `poetry.lock` | `poetry install` |
249
+ | `Gemfile.lock` | `bundle install` |
250
+
251
+ The first match in that order wins. A project with no lock file in the list
252
+ runs no setup, and a `setupCommand` in the config file always wins. Thus the
253
+ agents find a worktree with its dependencies in place, and the reviewer can run
254
+ the tests. Each command holds to the lock file, so a lock file that does not
255
+ match the manifest stops the issue rather than changing the lock file.
256
+
257
+ A `setupCommand` that fails, or that does not finish in `setupTimeoutMinutes`,
224
258
  hands the issue to a person. The wait for the checks has its own limit, so
225
259
  `commandTimeoutMinutes` holds for every other `git` and `gh` command. State per
226
260
  issue goes to `.next-issue/<issue>.json`, so a new run continues where the last
package/dist/agents.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { emptyUsage } from "./observe.mjs";
3
- const READ_ONLY = {
4
- tools: ["Read", "Grep", "Glob"],
3
+ const REVIEWING = {
4
+ tools: ["Read", "Grep", "Glob", "Bash"],
5
5
  permissionMode: "dontAsk",
6
6
  };
7
7
  const CODING = {
@@ -9,7 +9,7 @@ const CODING = {
9
9
  permissionMode: "bypassPermissions",
10
10
  allowDangerouslySkipPermissions: true,
11
11
  };
12
- export { CODING, READ_ONLY };
12
+ export { CODING, REVIEWING };
13
13
  export async function runAgent(recorder, request) {
14
14
  const log = recorder.scope({ role: request.name });
15
15
  const usage = emptyUsage();
package/dist/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { message, pruneRuns, Recorder } from "./observe.mjs";
11
11
  import { PORTS, processIssue } from "./pipeline.mjs";
12
12
  import { STATE_DIR, takeStop } from "./state.mjs";
13
13
  import { formatSummary } from "./summary.mjs";
14
+ import { packageVersion } from "./version.mjs";
14
15
  function write(stream, text) {
15
16
  return new Promise((resolve, reject) => {
16
17
  stream.write(text, (error) => (error === null || error === undefined ? resolve() : reject(error)));
@@ -61,7 +62,13 @@ async function init(root, force) {
61
62
  async function handle(args, config, repo, recorder) {
62
63
  const [base, login] = await Promise.all([defaultBranch(repo), currentLogin(repo)]);
63
64
  const context = { repo, config, base, login, reset: args.reset, recorder, ports: PORTS };
64
- recorder.event("run.start", { repo: `${repo.owner}/${repo.name}`, base, login, log: recorder.logFile }, "quiet");
65
+ recorder.event("run.start", {
66
+ version: await packageVersion(),
67
+ repo: `${repo.owner}/${repo.name}`,
68
+ base,
69
+ login,
70
+ log: recorder.logFile,
71
+ }, "quiet");
65
72
  const issues = await openIssues(repo, config.issueLimit);
66
73
  recorder.event("issues", { open: issues.length }, "quiet");
67
74
  const { reports, stoppedAt } = await runIssues(args, {
package/dist/git.mjs CHANGED
@@ -100,6 +100,14 @@ export async function commitAll(cwd, message) {
100
100
  await must("git", ["commit", "-m", message], { cwd });
101
101
  return true;
102
102
  }
103
+ export async function discardChanges(cwd) {
104
+ if (!(await isDirty(cwd))) {
105
+ return false;
106
+ }
107
+ await must("git", ["checkout", "--", "."], { cwd });
108
+ await must("git", ["clean", "-fd"], { cwd });
109
+ return true;
110
+ }
103
111
  export async function push(cwd, remote, branch) {
104
112
  await must("git", ["push", "-u", remote, branch], { cwd });
105
113
  }
package/dist/pipeline.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { CODING, READ_ONLY, runAgent } from "./agents.mjs";
1
+ import { CODING, REVIEWING, runAgent } from "./agents.mjs";
2
2
  import { managedLabels } from "./config.mjs";
3
3
  import { run } from "./exec.mjs";
4
- import { addWorktree, branchName, commitAll, deleteBranch, diff, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
4
+ import { addWorktree, branchName, commitAll, deleteBranch, diff, discardChanges, hasWorktree, push, removeWorktree, revision, worktreePath, } from "./git.mjs";
5
5
  import { assignIssue, commentOnPr, createPr, failedCheckLogs, findPr, issueComments, markPrReady, setLabel, waitForChecks, } from "./github.mjs";
6
6
  import { message } from "./observe.mjs";
7
7
  import { capDiff, fixPrompt, implementPrompt, parseCommitSubject, parseUnrelated, reviewPrompt, } from "./prompts.mjs";
8
+ import { detectSetupCommand } from "./setup.mjs";
8
9
  import { dropState, readState, resetState, writeState, } from "./state.mjs";
9
- import { blockingFindings, fingerprint, formatFindings, formatVerdict, isApproved, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
10
+ import { blockingFindings, fingerprint, formatFindings, formatVerdict, openFindings, readVerdict, VERDICT_SCHEMA, } from "./verdict.mjs";
10
11
  export const PORTS = {
11
12
  addWorktree,
12
13
  assignIssue,
@@ -14,7 +15,9 @@ export const PORTS = {
14
15
  commitAll,
15
16
  createPr,
16
17
  deleteBranch,
18
+ detectSetupCommand,
17
19
  diff,
20
+ discardChanges,
18
21
  failedCheckLogs,
19
22
  findPr,
20
23
  hasWorktree,
@@ -120,8 +123,9 @@ async function work(context, issue, log, state) {
120
123
  ms: Date.now() - started,
121
124
  };
122
125
  };
123
- if (config.setupCommand !== undefined) {
124
- const setup = await log.step("setup", { cmd: config.setupCommand }, () => ports.runSetup(config.setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
126
+ const setupCommand = config.setupCommand ?? (await ports.detectSetupCommand(worktree));
127
+ if (setupCommand !== undefined) {
128
+ const setup = await log.step("setup", { cmd: setupCommand, found: config.setupCommand === undefined }, () => ports.runSetup(setupCommand, worktree, config.setupTimeoutMinutes * 60_000));
125
129
  if (setup.code !== 0) {
126
130
  const late = setup.timedOut === true;
127
131
  log.event("setup.fail", { code: setup.code, timedOut: late, stderr: setup.stderr.trim().slice(-2000) }, "quiet");
@@ -224,26 +228,31 @@ async function work(context, issue, log, state) {
224
228
  name: "reviewer",
225
229
  cwd: worktree,
226
230
  prompt: reviewPrompt(issue, comments, patch, history(state.reviewLog)),
227
- profile: READ_ONLY,
231
+ profile: REVIEWING,
228
232
  model: config.models.reviewer,
229
233
  timeoutMs: config.agentTimeoutMinutes * 60_000,
230
234
  outputSchema: VERDICT_SCHEMA,
231
235
  }));
236
+ if (await ports.discardChanges(worktree)) {
237
+ log.event("review.discard", { round: state.reviewRounds }, "quiet");
238
+ }
232
239
  const verdict = readVerdict(review.structured);
233
240
  if (verdict === undefined) {
234
241
  await escalate("The reviewer gave no verdict.");
235
242
  return finish("needs-human", "no verdict");
236
243
  }
237
244
  const blocking = blockingFindings(verdict);
245
+ const open = openFindings(verdict, state.reviewRounds >= config.maxReviewRounds);
238
246
  log.event("verdict", {
239
247
  round: state.reviewRounds,
240
248
  verdict: verdict.verdict,
241
249
  blocking: blocking.length,
242
250
  minor: verdict.findings.length - blocking.length,
251
+ open: open.length,
243
252
  summary: verdict.summary,
244
253
  }, "quiet");
245
- await ports.commentOnPr(repo, state.pr, formatVerdict(verdict));
246
- if (isApproved(verdict)) {
254
+ await ports.commentOnPr(repo, state.pr, formatVerdict(verdict, open));
255
+ if (open.length === 0) {
247
256
  const after = await gate();
248
257
  if (after.done) {
249
258
  return after.report;
@@ -257,15 +266,15 @@ async function work(context, issue, log, state) {
257
266
  await setStatus(context, issue.number, state.pr, config.labels.done);
258
267
  return finish("done");
259
268
  }
260
- const mark = fingerprint(blocking);
269
+ const mark = fingerprint(open);
261
270
  if (state.reviewLog.some((round) => round.fingerprint === mark)) {
262
271
  await escalate("The reviewer repeated findings that an earlier round did not fix.");
263
272
  return finish("needs-human", "repeated findings");
264
273
  }
265
274
  const earlier = history(state.reviewLog);
266
- state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(blocking) });
275
+ state.reviewLog.push({ round: state.reviewRounds, fingerprint: mark, findings: formatFindings(open) });
267
276
  await writeState(repo, state);
268
- const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(blocking), earlier, "review");
277
+ const fix = await fixRound(job, "The reviewer requested changes.", formatFindings(open), earlier, "review");
269
278
  if (!fix.committed) {
270
279
  await escalate("The fixer added no commit for the review findings.");
271
280
  return finish("needs-human", "no fix commit");
package/dist/prompts.mjs CHANGED
@@ -43,7 +43,9 @@ export function reviewPrompt(issue, comments, diff, earlier) {
43
43
  diff,
44
44
  "```",
45
45
  "## Rules",
46
- "Read files for context when you must. Do not change any file.",
46
+ "Read files and run commands to prove what you say: the tests, the type check or the linter.",
47
+ "The worktree is ready to use. Do not change a file. Do not write with git or gh.",
48
+ "The harness discards each change that you make, so a change is only lost work.",
47
49
  scope,
48
50
  "Mark a finding blocking only for a wrong result, a missing part of the issue, a regression or a broken convention.",
49
51
  "Give the result as the structured output: the verdict, a short summary and one entry per finding.",
package/dist/setup.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const MARKERS = [
4
+ ["pnpm-lock.yaml", "pnpm install --frozen-lockfile"],
5
+ ["package-lock.json", "npm ci"],
6
+ [".yarnrc.yml", "yarn install --immutable"],
7
+ ["yarn.lock", "yarn install --frozen-lockfile"],
8
+ ["bun.lock", "bun install --frozen-lockfile"],
9
+ ["bun.lockb", "bun install --frozen-lockfile"],
10
+ ["Cargo.lock", "cargo fetch"],
11
+ ["go.sum", "go mod download"],
12
+ ["uv.lock", "uv sync"],
13
+ ["poetry.lock", "poetry install"],
14
+ ["Gemfile.lock", "bundle install"],
15
+ ];
16
+ export async function detectSetupCommand(root) {
17
+ for (const [file, command] of MARKERS) {
18
+ try {
19
+ await access(join(root, file));
20
+ return command;
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
package/dist/verdict.mjs CHANGED
@@ -55,13 +55,18 @@ export function readVerdict(value) {
55
55
  export function blockingFindings(verdict) {
56
56
  return verdict.findings.filter((finding) => finding.severity === "blocking");
57
57
  }
58
- export function isApproved(verdict) {
59
- return blockingFindings(verdict).length === 0;
58
+ export function openFindings(verdict, lastRound) {
59
+ return lastRound ? blockingFindings(verdict) : verdict.findings;
60
60
  }
61
- export function formatVerdict(verdict) {
62
- const head = isApproved(verdict) ? "Review: approved" : "Review: changes requested";
61
+ const LEFT = "The findings above stay as they are: no blocking finding is open and the review budget is spent.";
62
+ export function formatVerdict(verdict, open) {
63
+ const approved = open.length === 0;
64
+ const head = approved ? "Review: approved" : "Review: changes requested";
63
65
  const lines = verdict.findings.map((finding) => `- **${finding.severity}** ${finding.detail}`);
64
- return [`### ${head}`, verdict.summary, lines.join("\n")].filter((part) => part.length > 0).join("\n\n");
66
+ const note = approved && verdict.findings.length > 0 ? LEFT : "";
67
+ return [`### ${head}`, verdict.summary, lines.join("\n"), note]
68
+ .filter((part) => part.length > 0)
69
+ .join("\n\n");
65
70
  }
66
71
  export function formatFindings(findings) {
67
72
  return findings.map((finding) => `- ${finding.detail}`).join("\n");
@@ -0,0 +1,6 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export async function packageVersion() {
4
+ const raw = await readFile(join(import.meta.dirname, "..", "package.json"), "utf8");
5
+ return JSON.parse(raw).version;
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-issue",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Autonomous issue harness driven by the Claude Agent SDK",
5
5
  "keywords": [
6
6
  "github",