The chapter runner — stoat run-plan
Work a plan's ready nodes unattended: for each ready slice, test-run gate and review — and each issue a person has given the go-ahead — claim it, spawn an agent in its own git worktree, and let evidence decide whether it is done. A slice is done when its specs are green; a gate when a run carrying its BOBCAT_RUN_TAG finishes green; an issue when it is closed, which only a person merging can cause.
stoat run-plan <plan-slug> <projectDir> --agent-args "--dangerously-skip-permissions"IMPORTANT
Read the shared-machine note before choosing that permission flag.
This page is the runner itself. Three more cover the rest: Issues, PRs and reviews, Gates in a run and Running it day to day.
Not every plan suits a runner
The runner is built for well-specified work, where retrying and giving up are the right answers to failure. For work with a lot of novelty, where most failures are questions for a person, one lead agent working the plan with you is often the better shape: see Stoat as a work queue for a lead agent. The two mix.
A first run
What you need:
- A plan with ready work the runner launches: slices, test-run gates, reviews, and issue nodes a person has given the go-ahead.
- A checkout of the repository the work is in. The runner makes a worktree per agent beside it.
- Claude Code signed in on this machine (the default launcher), and the Stoat server running.
- A permission posture for the agents, in
--agent-args. The runner refuses to start without one, because an agent that asks before every write never finishes.
Then, from anywhere:
stoat run-plan critter-parity ~/code/stoat --agent-args "--dangerously-skip-permissions"It says what its workers run on, then a line per agent it launches, with the worktree and the log to watch, and what the evidence said when each one exits:
workers ask for claude-sonnet-5-5 unless the plan or a node names one
→ attempt 1 on 'stoat-green' · claude-sonnet-5-5 · in ~/code/stoat-stoat-green · log: ~/code/stoat-agent-stoat-green.log
agent for 'stoat-green' exited (0) — the evidence will say what it didWithout --watch the run ends when nothing is left that it could do; with it, it stays and takes new work as it becomes ready (Running it day to day). .stoat-stop in the project directory stops new claims at any time.
A resident runner can also be started from the plan page: a repository's band with no runner has Start…, which shows the exact stoat run-plan … --watch command and runs it on the Stoat server's machine (Starting a runner from the page).
What it does each poll
- Reap agents that exited, and release their claims — whatever the exit code. The gate is the verdict, not the process.
- Read the plan's derived status from the host.
- Announce anything newly done, skipped, or given up on.
- Judge each attempt whose agent exited at least
--settle-secondsago, by its evidence. - Stop starting work if a limit is reached.
- Claim, then spawn for each ready node, up to
--parallel, staggered. With more ready nodes than slots, the one that unblocks the most of the plan goes first (itsunblocks, as the ready board shows it), and ties keep the plan's own order. A node added mid-run that half the plan waits on is not queued behind leaves. A node a person expedited goes ahead of all of them.
The rules it follows
Each of these exists because doing otherwise went wrong in practice.
It claims before spawning, and the prompt names the claim. An agent left to re-derive "the next ready node" finds a different one and orphans the claim it is holding.
One worktree per agent, created as a sibling of the project directory (<project>-<nodeId>) on branch stoat/<plan>/<nodeId>. Shared trees lose updates.
Every worktree starts from a fresh base, never the checkout's HEAD (stoat#255). After a git fetch: an issue from the remote's default branch (or --base), since its PR lands there; anything else from the plan's integration branch when it has one, else the repository's branch in the plan's repos:, else --base, else the remote's default branch. A named branch is origin's copy unless the local one has commits origin lacks (the runner's own pin commits). A gate cut from whatever the checkout had out once ran 104 commits behind main. Each launch line says the base and its commit (→ attempt 1 on 'jfx-gate' · … · from origin/main (2f27f66) · in …).
A retry fast-forwards the worktree it reuses. A node claimed again whose worktree has no commits of its own and nothing uncommitted is moved up to its base first; one with commits or changes of its own is left as it is, and the runner says so.
Spawns are staggered (--stagger-seconds, default 5). Simultaneous starts race.
Retry is bounded and the give-up is loud (--max-attempts, default 2). The worktree is left in place so the work can be inspected.
Nothing is ever asserted. An agent exiting cleanly moves no status. Done means the slice's bound {Feature}/{Scenario} identities are green, and nothing else.
No automatic merge into main. A green gate proves the specs pass in the worktree, not that the merge composes. Without an integration branch, finishing prints the command and leaves the decision to a human:
✓ 'propose-home-check' is done — its specs are green on the committed branch. Merge when ready:
git -C /path/to/app merge stoat/my-plan/propose-home-checkAn integration branch: branch: in the plan
A plan delivered as one PR names the branch that PR is opened from:
plan: serverless-with-outbox
branch: gh-5016-serverless-with-outboxThen the runner lands finished work on it, unattended, and never on main:
- A new integration branch is cut from the remote's default branch, after a
git fetch(origin/HEAD, elseorigin/main/origin/master; a repository with nooriginuses its localmain/master). Never from the checkout'sHEAD: a shared checkout is often on somebody else's branch with unmerged work, and every node would inherit it.--base <ref>names the starting point instead. The start-up line says which commit it was cut from, and a branch that already exists is used as it is. - Each node's worktree starts from the integration branch, so it carries every upstream already landed (an issue's starts from the default branch, where its PR lands).
- A finished node lands by rebase first. Its branch is rebased onto the integration branch and fast-forwarded, keeping history linear. Only if the rebase conflicts is it aborted and retried as a
--no-ffmerge commit. If that conflicts too, both are aborted, nothing moves, and the node is parked with the conflicting files named. The runner never resolves a conflict. - A node whose upstream is done but not yet landed is not launched, because its worktree would start without that work. Resolve the parked conflict by hand and run again.
- An upstream issue's merge is brought in too. An issue node's PR goes to the default branch, so once its issue is closed the runner finds that PR's merge commit and brings the integration branch up to it (a fast-forward when the branch has nothing of its own yet, else a merge commit; never a rebase) before launching anything that depends on the issue. A merge that conflicts holds those dependents, with a callout naming the files.
- The landing happens in a worktree the runner owns,
<project>-<plan>-integration. The runner refuses to start if the branch is checked out anywhere else, so it never changes a tree under someone's feet. - Whether a node landed is asked of git, never remembered, so a second run picks up where the first stopped and lands nothing twice.
✓ 'a' is done — its tagged run is green — and landed on 'e2e-integration': rebased stoat/e2e/a onto e2e-integration and fast-forwarded (1 commit(s)).
Finished work is on 'e2e-integration'. Open the PR from it to main when the plan is done; that merge stays a person's.main, master, trunk, develop and HEAD are refused as a plan branch. create_plan / change_plan take branch ("" clears it), and so does the YAML header.
"Merge when ready" is checked, not assumed
A slice's gate reads green runs. It cannot see which tree ran green — Bobcat stamps no commit — so two things have satisfied a gate with nothing to merge: a probe the agent ran and then reverted, and an untagged run of the suite in a different checkout. So before the runner says "merge", it checks the branch:
| What it sees | What it says |
|---|---|
| the node's agent is still running | nothing yet — a green run mid-attempt may be of code not committed |
| no worktree for the node | done by a run outside this one; there is nothing here to merge |
| uncommitted changes in the worktree | not mergeable until they are committed or discarded |
| a slice: a clean tree, no commits beyond the base its worktree was cut from | the green run was of code that never reached the branch |
a slice with no new commits, but every stoat gate exec run that decided it passed on the branch's current commit | verified: done, with nothing to land |
| a test-run gate: a clean tree, no new commits | verified: done, with nothing to land. A verification gate that rightly changed nothing |
| committed work on a clean tree | mergeable — after --verify-command, if given |
A slice's verified case needs stoat gate exec evidence, because gate exec records the commit it ran on and a Bobcat run records none. A test-run gate needs no commit named (stoat#258): its job may be to change nothing, and whether its pass is of the current tree is its status's to say.
Committed work is necessary and not sufficient: the branch can carry an earlier attempt's red commit while the green run was of something since reverted. Only re-running the specs on the committed tree closes that, so give the runner the command that does:
stoat run-plan crittercrush-v3 ~/code/crittercrush \
--verify-command "dotnet run --project src/CritterCrush.Specs -- --filter-uid {specs}"It runs in the node's worktree with the node's BOBCAT_RUN_TAG and --agent-env, so its run is the gate's newest evidence: a red one turns the node back to failed, and the loop retries it while attempts remain. An argument that is exactly {specs} expands to one argument per bound spec identity; {plan}, {node}, {ident} and {worktree} are filled too. Slices already done when the run starts are left alone. The run exits non-zero if any slice ended green with no mergeable branch.
.stoat-stop in the project directory halts new claims without touching work in flight.
Settling
An agent's work is not observable the moment it exits: GitHub has to be swept for its PR, a spec run ingested. So an exited attempt is judged only after --settle-seconds (default 180). Judging it sooner would count finished work as a failure and launch it again.
Scoping a run: --node, --skip, --ignore-dependencies
.stoat-stop halts everything, which is the wrong tool for "these eleven slices are known to be unpassable". Scope the run instead:
stoat run-plan crittercrush-v3 ~/code/crittercrush --node propose-home-check-appointment --ignore-dependencies
stoat run-plan crittercrush-v3 ~/code/crittercrush --skip approve-volunteer reject-volunteer-application--node <id>...launches only those nodes;--skip <id>...never launches those. Every other node still takes part through readiness, exactly like a gate or a publish.--ignore-dependencieslaunches the--nodenodes while an upstream is not done. It needs--node: vouching for one node's upstream is a decision about that node, not a way to run a plan out of order. The plan's own status is unchanged — the node still renders blocked to everyone else.- A mistyped id is refused before anything is claimed. A scope that silently matched nothing would be a run that does nothing and reports success.
Stop conditions
.stoat-stop halts new claims. So does any of these, and in every case agents already running finish — a stop is about what to start, never about killing what is out there:
| Flag | Stops claiming when |
|---|---|
--max-total-usd | with --budget-usd: the per-attempt budgets handed out would pass this (budgets launched, the hard bound). Without: what this run's agents reported they cost reached it, which lags, since an agent reports as it exits; it needs a launcher that reports a cost (claude) |
--max-minutes | the loop has run this long |
--max-consecutive-failures | this many attempts in a row got nowhere — no green specs, no PR. Usually the environment, not the work; the command exits non-zero |
Two refusals before it starts
The runner would rather not start than burn a run that cannot succeed.
No permission posture
If --agent-args names no permission flag, every agent stops and asks before its first file write, and the run makes no progress — presenting as a hung agent, not a missing flag. The runner refuses and says so.
It takes no view on which posture, only that one was chosen — from the launcher's own list. For claude: --dangerously-skip-permissions, --permission-mode, --permission-prompt-tool, --allowedTools / --allowed-tools, --disallowedTools / --disallowed-tools. For codex: --full-auto, --sandbox, --ask-for-approval, --dangerously-bypass-approvals-and-sandbox. For gemini: --yolo, --approval-mode.
Override with --allow-prompting (-p).
Evidence is blind
Checked on the first status, not before the loop — the runner has to see the plan's own view of its evidence, which only exists once the host has answered.
If every slice and gate reports unknown, the evidence source itself is absent: Stoat can see no runs at all, so the suites are not reaching its receiver. Every agent would do its work correctly and every node would still run to give-up. Every, not any — one unknown slice is ordinary, its specs simply have not run.
Override with --allow-unprovable (-e).
The prompt is deliberately thin
It carries the claim, the worktree, what done means, the tag rule and the shared-machine fence — and nothing about how to build a slice. That lives in the installed skills the prompt names (which skills a node's work uses), so the runner works for any repository carrying them and teaches nothing of its own. An issue agent's prompt adds only the issue, the Fixes #n pull request it ends at, that it must not merge, and that its work is not done until that PR is green and mergeable.
Flags
| Flag | Default | |
|---|---|---|
-u, --url | http://localhost:5526 | The Stoat host |
--parallel | 2 | Agents in parallel |
--expedite-slot | off | Let an expedited node take one slot past --parallel |
--max-attempts | 2 | Attempts per node before giving up loudly |
--max-fix-attempts | 2 | Times the runner resumes an issue's agent to fix its own PR's trouble (red CI, an owed gate, a conflict it made) before a person is called |
--max-rebases | 5 | Times the runner resumes an issue's agent to rebase a PR other PRs' merges made conflict or fall behind, one per PR head and base tip, before a person is called |
--fix-stall-minutes | 20 | An agent alive with no push on its red or conflicting PR this long is stalled: restarted once, then a person's. 0 never |
--launcher | claude | Launcher profile: claude, codex, gemini, or one from --launchers |
--launchers | ~/.stoat/launchers.json | Profiles laid over the built-ins |
--cloud-launcher | — | A "cloud": true profile that hands cloud-marked issue nodes to a cloud agent, taking no local slot |
--cloud-url | --url | The Stoat address a cloud agent renews its claim at |
--agent-command | the profile's | Override the profile's executable (a wrapper script, say) |
--model | the profile's default: claude-sonnet-5-5 for claude | Every worker's model, over the plan's model:; a node's model: still wins (what a worker starts with) |
--lean | off | Start workers without your own MCP servers, user settings, skills and plugins, keeping the plan's skills: |
--budget-usd | — | Per-attempt spend cap, through the profile's budget flag |
--agent-args | — | Appended to every agent invocation, split like a shell would (quotes and backslashes). The runner sets no permission posture itself |
-n, --node | — | Launch only these node ids |
-k, --skip | — | Never launch these node ids |
-i, --ignore-dependencies | off | Launch the --node nodes whatever their upstream says |
-E, --agent-env | — | KEY=VALUE per agent: {plan} {node} {ident} {worktree} |
-S, --agent-setup | — | Run in each new worktree before its agent starts |
-v, --verify-command | — | Re-run a done slice's specs (or a gate's suite) on its committed branch before calling it mergeable |
--agent | stoat-runner | Agent identity for claims |
--watch | off | Stay resident, waiting for new ready work |
--paused | off | With --watch: register and wait for a person's Start on the plan page |
-B, --base | the remote's default branch | Where a new integration branch, an issue's worktree, and any worktree with no integration branch or repos: branch, are cut from |
--max-total-usd | — | Stop claiming once launched budgets (with --budget-usd) or the agents' reported cost (without) pass this |
--max-minutes | — | Stop claiming after this long |
--max-consecutive-failures | — | Stop claiming after this many attempts in a row got nowhere |
--settle-seconds | 180 | How long an exited attempt's evidence gets to show before it is judged |
--stagger-seconds | 5 | Between spawns |
--poll-seconds | 15 | Between status polls |
-p, --allow-prompting | off | Start without a permission posture |
-e, --allow-unprovable | off | Start with no observable evidence |
--agent-args is tokenized the way a shell would: --allowedTools "Bash(dotnet:*),Bash(git:*)" reaches the agent as one argument without its quotes, and an unterminated quote is refused.
Each agent's output is piped to stoat-agent-<nodeId>.log beside the project directory (and a setup's or verification's to stoat-setup- / stoat-verify-<nodeId>.log). The command exits non-zero if anything was given up on, or if it stopped on consecutive failures.
See also
- Issues, PRs and reviews — the go-ahead, the merge, and an agent's PR after it opens
- Gates in a run — how a run's agents prove a test-run gate
- Running it day to day —
--watch, the plan page's panel, launchers, cost - Plans and nodes — the
slicenode kind and its spec gate - A person's controls — the go-ahead, the merge pill, answering an agent
- Stoat as a work queue for a lead agent — the same plan, worked by one supervised session
- Runs and evidence — the evidence a gate reads
- REST API — the endpoints the runner drives