next-issue
v0.14.1
Published
Autonomous issue harness driven by the Claude Agent SDK
Maintainers
Readme
next-issue
A small harness that works through the open issues of a GitHub repository with the Claude Agent SDK. Run it inside a git repository.
npx -y next-issue@latestOr install it once:
npm install -g next-issue@latest
next-issueWhat it does
For each open issue, oldest first:
- Claim the issue only when it holds the
status:todolabel and holds nostatus:in-progressorstatus:in-reviewlabel. Astatus:done,status:needs-humanorstatus:blockedlabel always stops the claim, a resume too. An issue with saved state under.next-issue/resumes instead, whatever its other labels. - Add
status:in-progressto the issue. - Fetch the remote, fast-forward the local copy of the default branch when
that is safe, then add a git worktree at
../<repo>-issue-<n>on theissue-<n>branch. A branch that exists on the remote is the start, and a local branch that fell behind the remote is fast-forwarded. A fast-forward of the base that is not safe leaves the local base branch as it is and writes abase.behindevent. A new branch then starts from the remote base. - Let the implementer agent do the work, then commit and push.
- Open a draft pull request that closes the issue.
- Wait for the checks, once the pull request shows the pushed commit. A
pending state waits again, up to
checkTimeoutMinutes. A read that fails is tried three times before the run stops. A repository that reports no check keeps asking forcheckGraceSeconds, because a new pull request often shows no check yet, and only then goes straight to the review. The later gates of the same issue ask once. - On a red build, give the failed job logs to the fixer agent, then go to step
6 again. The budget is
maxCiFixes. - Let the reviewer agent judge the diff, at most
diffMaxCharsof it. The reviewer reads files and runs commands in the worktree, so it can prove a finding with the tests or the type check. The harness then throws away every change that the reviewer left, so no such change can reach a later commit. The reviewer returns a structured verdict: a summary and one entry per finding, each ratedblockingorminor. The findings are the only ground truth. The reviewer gives no approve or reject field, because such a field can contradict the findings. The harness puts the result on the pull request. - An open finding is any finding, blocking or minor, while a review round is
left. In the last round, only a blocking finding stays open: a minor finding
there goes on the pull request but starts no fix, because a fix in the last
round gets no review. Thus
maxReviewRoundsof1never fixes a minor finding. - No open finding means approval: mark the pull request ready for review and
set
status:done. The merge stays with you. The worktree, the local branch and the state file go away. The remote branch stays. A problem that the reviewer leaves out of the findings thus gets an approval: only the findings count. - With an open finding, run the fixer agent and go to step 6 again. The
budget is
maxReviewRounds. A fixer that holds every finding to be wrong changes nothing and gives its reason instead. The reason goes on the pull request and into the next round, where it answers every finding of its own round, so the reviewer withdraws them all or repeats them all. With only some findings wrong, the fixer fixes the correct ones, and the next round holds the rest.
Stops for a loop
The harness hands the issue to a person, with the status:needs-human label,
when:
- the check budget or the review budget runs out;
- a fixer round adds no commit and gives no reason, so there is no progress;
- the fixer reports that the checks fail for a reason the change did not cause, such as a test already broken on the base branch or one that fails only under load. It changes nothing and the reason reaches the pull request, rather than the fixer spending its whole budget on a failure it cannot fix;
- a fixer disputes the findings and leaves a change behind, either in the worktree or in a commit of its own. A dispute carries a reason, not a diff, so the harness throws none of it away: an unclean worktree stays on the disk, and a commit of the fixer stays on the local branch. Neither is pushed, so the pull request shows no such change. It holds the reason of the dispute, and the change is on the machine that ran the harness;
- the reviewer repeats a finding set from an earlier round, which shows a ping-pong between the fixer and the reviewer, or a finding that the fixer disputed and the reviewer holds to. The person then decides;
- the reviewer gives no verdict, or the checks do not finish in time;
- an agent does not finish in
agentTimeoutMinutes; - the setup command fails, or does not finish in
setupTimeoutMinutes; - a step of the run fails with an error.
The fixer sees the findings of all earlier rounds, not only the last one, with its own disputes next to them. From round two, the reviewer judges only the earlier findings and any regression.
Each agent that writes to the pull request names itself in the first line of the
comment: ### Review: approved, ### Review: changes requested and
### Fixer: no change. The account is the same for all of them, so the heading,
not the author, tells them apart. The agents read the comments of the issue, not
of the pull request, so a comment of an agent never returns to a prompt: the
dispute of the fixer reaches the reviewer through the state file.
Stop after the current issue
Run touch .next-issue/stop in a second terminal. The harness reads the file
only between two issues, finishes the issue that it holds, deletes the file and
then writes the summary as usual. A file from an earlier run is dropped at the
start, so it stops nothing.
Ctrl-C is different: the harness passes the signal on to the command that runs
and to every child of it, so the run dies in the middle of a step. The saved
state lets the next run continue the issue, but the issue keeps
status:in-progress and the run writes no summary.
A status label that the harness puts on the issue goes on the pull request too,
but only if the pull request exists at that moment. The status:in-progress
label of a fresh issue comes before the pull request, and stays on the issue
alone. Thus a draft pull request shows work that the harness has not finished,
and a status:needs-human pull request shows work that waits for a person.
The harness runs all git and gh commands itself. The commit holds all the
changes in the worktree, so a setup command that writes a file outside
.gitignore puts that file in the commit. The reviewer is the one agent whose
changes never get that far: the harness discards them before it reads the
verdict.
Safety
Every agent can run a shell command. The implementer and the fixer run with the permission checks off; the reviewer runs with a smaller tool set, but its shell has no limit on the commands. They all start in the worktree, but nothing holds them there: they can run any command, with your rights, anywhere on the machine. The prompt holds the title, the body and the comments of the issue, which come from GitHub.
Thus a person who can write an issue or a comment can try to give an instruction to the agent. Use the harness only on a repository where you trust the issue authors, or run it in a container.
Observability
Every run has an id, <timestamp>, and writes two files under
.next-issue/runs/:
<id>.jsonl— one JSON object per event, withts,runId,kind, and theissue,roleorroundin scope;<id>.summary.json— token totals per agent role, event counts, and one report per issue with the outcome, the reason, the pull request number and the budgets used.
At the start of a run, the harness deletes the files under .next-issue/runs/
that are older than keepRunDays. Set the field to 0 to keep them all.
A short text summary of the same data goes to standard output. Use --json to
get the full summary object there instead, so the harness fits in a pipe. All
progress goes to standard error.
Recorded for each step: claim, comments, worktree, setup, implement,
push, pull-request, checks, review and fix, each with a .start,
.end or .error event and a duration in ms. Every git and gh command
is recorded with its exit code and duration. A tool event holds the tool
name, and for a Bash tool the command too. Each agent call adds a usage
event with the tokens, the cost estimate in dollars, the turn count, the
tool-call count, the model and the session id, so you can open the session
again and read the full transcript.
jq -r 'select(.kind=="usage") | [.issue,.role,.turns,.total] | @tsv' \
.next-issue/runs/*.jsonlThree levels of console output:
| Level | Shows |
| --- | --- |
| --quiet | Run and issue milestones, verdicts, hand-overs, errors |
| default | The above plus each step end, each tool call and failed commands |
| --verbose | The above plus every command and all agent text |
Requirements
- Node 24 or later
gh, authenticated with write access to the repository- Anthropic credentials for the Claude Agent SDK:
ANTHROPIC_API_KEY, or a Claude subscription login
Configuration
To write the file with the defaults, run the init command in the repository:
npx -y next-issue@latest initThe command writes next-issue.config.json in the root of the repository and
adds .next-issue/ to .gitignore. It stops when the config file is there
already; --force replaces it. The file holds no setupCommand, because the
harness deduces one from the lock file of the project.
A field that is not in the file keeps its default. A file that is not valid JSON, a field with a value of the wrong type and a field name that the harness does not know all stop the run, so a typo cannot pass without a word.
{
"remote": "origin",
"issueLimit": 100,
"maxCiFixes": 5,
"maxReviewRounds": 5,
"checkIntervalSeconds": 15,
"checkGraceSeconds": 60,
"checkTimeoutMinutes": 60,
"agentTimeoutMinutes": 30,
"setupTimeoutMinutes": 15,
"commandTimeoutMinutes": 10,
"logMaxChars": 20000,
"diffMaxChars": 60000,
"keepRunDays": 7,
"draftPullRequest": true,
"models": {
"implementer": null,
"reviewer": null,
"fixer": null
},
"labels": {
"ready": "status:todo",
"inProgress": "status:in-progress",
"inReview": "status:in-review",
"done": "status:done",
"needsHuman": "status:needs-human",
"skip": "status:blocked"
}
}| Field | Default | Effect |
| --- | --- | --- |
| remote | origin | The git remote for the repository and the branches |
| issueLimit | 100 | The maximum number of open issues to read |
| maxCiFixes | 5 | The budget for check fixes per issue |
| maxReviewRounds | 5 | The budget for review rounds per issue |
| checkIntervalSeconds | 15 | The time between two check states |
| checkGraceSeconds | 60 | The time to wait for the first check to show |
| checkTimeoutMinutes | 60 | The limit for one check wait |
| agentTimeoutMinutes | 30 | The limit for one agent call |
| setupTimeoutMinutes | 15 | The limit for the setup command |
| commandTimeoutMinutes | 10 | The limit for one git or gh command |
| logMaxChars | 20000 | The maximum length of the failed job logs, shared between the failed jobs |
| diffMaxChars | 60000 | The maximum length of the diff that the reviewer reads |
| keepRunDays | 7 | The number of days to keep the run files; 0 keeps them all |
| draftPullRequest | true | Open the pull request as a draft, until the review approves |
| setupCommand | from the lock file | A shell command to run in a new worktree, before the implementer |
| models | null per role | The model per agent role: implementer, reviewer, fixer |
| labels | see above | The names of the labels that the harness reads and sets |
An empty labels.ready turns the ready requirement off. The harness then
claims every open issue that no other label holds back.
The log gives the reason for a skipped issue as stop-label, not-ready or
in-flight.
A role set to null, or with no entry in models, uses the default model of
the SDK.
Without a setupCommand, the harness looks in the new worktree for a lock file
and runs the install command that goes with it:
| Lock file | Command |
| --- | --- |
| pnpm-lock.yaml | pnpm install --frozen-lockfile |
| package-lock.json | npm ci |
| yarn.lock | yarn install --frozen-lockfile, or yarn install --immutable with a .yarnrc.yml |
| bun.lock, bun.lockb | bun install --frozen-lockfile |
| Cargo.lock | cargo fetch |
| go.sum | go mod download |
| uv.lock | uv sync |
| poetry.lock | poetry install |
| Gemfile.lock | bundle install |
The first match in that order wins. A project with no lock file in the list
runs no setup, and a setupCommand in the config file always wins. Thus the
agents find a worktree with its dependencies in place, and the reviewer can run
the tests. Each command holds to the lock file, so a lock file that does not
match the manifest stops the issue rather than changing the lock file.
A setupCommand that fails, or that does not finish in setupTimeoutMinutes,
hands the issue to a person. The wait for the checks has its own limit, so
commandTimeoutMinutes holds for every other git and gh command. State per
issue goes to .next-issue/<issue>.json, so a new run continues where the last
run stopped.
Retry an issue that waits for a person
A hand-over leaves the state file with the used budgets in it, so the next run
would hand the issue over again at once. To give the issue another try, take the
status:needs-human label off and run with --reset. The flag puts the budgets
and the findings back to zero and keeps the branch, the pull request and the
step that the last run reached, so the work carries on where it stopped. A
commit that you pushed to the pull request in the meantime is fetched first.
The flag touches only an issue that the run handles, so the state of an issue
that a label still holds back stays as it was.
Commands and options
| Argument | Effect |
| --- | --- |
| init | Write the config file with the defaults |
| --force | Replace an existing config file, with init |
| --once | Stop after the first handled issue |
| --max <n> | Handle at most n issues |
| --reset | Drop the saved budgets and findings, and keep the rest of the state |
| --json | Print the machine summary instead of the text one |
| --verbose | Show every command and all agent output |
| --quiet | Show only the milestones and the summary |
| --help | Show the option list |
The exit code is 1 when an issue needs a person or failed, and 0 when it does not, so a cron job or a CI step can read the result of the run.
Development
npm install
npm run typecheck
npm test
npm run buildnpm run build compiles src/*.mts to dist/*.mjs. The package holds the
compiled files, because Node does not strip types under node_modules.
Releases
Releases are automatic and need no pull request. Push a
conventional commit to main. The
workflow reads the commits since the last tag and picks the bump: feat gives a
minor, fix gives a patch, and a ! mark or a BREAKING CHANGE body gives a
major. Any other type releases nothing.
The workflow then bumps the version and publishes the package to npm with a
trusted publish, so no token is
stored. Only after the publish does it push the tag and make the GitHub release,
so a failed publish leaves no half-release behind. A manual run of the workflow
publishes the version in package.json and changes nothing else, which repairs
a release that reached GitHub but not npm. The release job stays on a
GitHub runner, because npm takes provenance only from a GitHub runner.
