An increment is one ticket, one process, one agent session: it claims a
ticket, runs the session, writes up what happened and lets the ticket go. The
loop is increments back to back, a fresh process for each, so no session
carries a night's context into its last ticket, and the earliest decisions in
that context are the ones nobody can audit afterwards. Both need a checkout, or
a workspace to clone into (the runner); hatch --help lists every
command, and hatch work -h and hatch go-to-work -h say what these two take.
One increment: hatch work#
hatch work the next actionable issue anywhere on the board
hatch work AER-12 ...or this one, whatever else is above it
hatch work --under AER-1 ...or the next one under that epic's subtree
hatch work --mine ...or the next one assigned to you
A key names the ticket, and --under and --mine each narrow where to look
for one, so a key with either is refused.
hatch work --dry-run print the prompt and exit
hatch work -i AER-12 a session you sit in, rather than a headless one
hatch work --quiet say nothing until it is finished
hatch work --model opus --effort xhigh AER-12
hatch work --agent codex which agent CLI spawns the session
hatch work --no-keys read no keys from this terminal
hatch work --repo /path/to/a/checkout also serve that checkout, repeatable
hatch work --workspace /clones clone what the board binds, into /clones
--dry-runclaims nothing, spawns nothing and clones nothing, even where a workspace is set, and prints the prompt a session would be handed (what a session is told). It beats-i. Ahead of the prompt it prints the header a claimed run would have acted on:# <KEY> <From> -> <To>,# model <model>, effort <effort>, the playbook line, and# tools: <denied, or "all">.-iruns the session in your terminal instead of headless. There is no readout then, since the terminal is the session's.--quietprints nothing until the increment is finished.--modeland--effortbeat the playbook for this run only. They are one opinion about one ticket, which is why the loop has neither.--agentbeatsHATCH_AGENTfor this run:claude,codex,grokorollama.--no-keysreads no keys from this terminal; what it prints has the keys.--repoand--workspaceeach replace their setting (HATCH_REPOS,HATCH_WORKSPACE) for the run rather than adding to it.--workspace offnever clones.
work does touch git. It fetches and resets each checkout the ticket uses,
goes onto the ticket's branch, and puts the tree right afterwards, exactly as
the loop does. The one difference is a dirty tree: work refuses one ("commit
or stash them first"), where the loop stashes it, because somebody is sitting
there and the changes are theirs.
It beats once against Agents → Runners, so an increment run by hand shows beside the loops rather than being a session nobody can see.
The claim#
A claim is a five-minute lease on the ticket, taken before the session spawns
and refreshed by a heartbeat every max(5s, ttl/5) — four heartbeats inside
one lease, so one missed beat is never the difference between holding it and
losing it. A 409 on a heartbeat means the lease is already gone — preempted,
or taken by somebody else's claim — and the runner kills the session and its
whole process tree at once, writing nothing further for it. Any other
heartbeat failure is weather and is retried, but one that keeps failing past
the ticket's stall lapse (Hatch__StallLapseMinutes, five by default) stops
the session itself rather than let it keep working a ticket the board may
already have handed to somebody else.
There is no wall-clock cap on an increment beyond that: a session runs as long as it has a lease to renew. Ctrl-C waits up to ten seconds for the session's own output to drain before it lets go.
What it prints#
The opening banner, 🥚🥚🥚🥚🥚 STARTING WORK ON <KEY>, names the ticket, what
the increment is for (a move, a conflict or a failing build), the model and
effort, and the runner's name with which increment of the night this is. The
session's tool calls stream under it, with a thinking-token pulse and a
heartbeat naming what it is still waiting on: the agent CLI's own default
output prints nothing until the run ends, and four minutes of blank terminal is
indistinguishable from a hang.
The session opens and closes with its own id, hatch: session <id> followed
by hatch: join it with <resume command>, the agent's own (claude --resume <id>, codex exec resume <id>, grok -r <id>, and for ollama codex exec resume --oss --local-provider ollama <id>), so either end of a run
carries the command to reattach to it. It closes with one more line, hatch: done or hatch: ended with an error, the minutes, the turns and the cost:
hatch: done in 4m12s, 9 turns, $0.86. --quiet holds every line back until
the increment finishes, then prints that closing prose whole, exactly as an
attended run would have seen it as it happened. A glyph row closes it:
🐣 the ticket moved, a conflict was resolved, or a fix was pushed
🥚 it did not move
🍳 the session exited badly, was interrupted, or lost its lease
🍳 outranks the other two. A pass that spawns no session prints no banner.
On an interactive terminal (not redirected, not TERM=dumb, and never
work -i) a readout is pinned to the bottom. Its rows are the issue; whether
the agent is alive (elapsed, tokens, how long it has been quiet, and what it
was last inside of); one bar per usage window the agent CLI reports; and the
runner's own row. Under it is a legend of keys, read only where the readout
draws and stdin is a terminal. Anywhere else one line says so and names Ctrl-C
and --stop-file. --no-keys keeps the readout and drops the keys and the
legend.
n check now, skipping the wait: ends the idle nap early (the loop only)
s stop after this increment; press again to undo
c cancel: asks, then ends the run as Ctrl-C does
p pause and resume: still heartbeating, takes nothing
k skip this increment: asks, then ends only this one, touching the ticket no further
? swap the legend for a longer one
A skip counts neither as a failure nor as a success, so it leaves the failure streak where it was. Every key that changes what the runner does writes a line to the log.
Exit codes#
0 an increment ran, whatever the session exited with; or the ticket was
carried across a column with no session, or a conflict or failing build
had already cleared
1 a refusal: no agent CLI, an unreadable board, a flag it does not take, a
tree it could not reset or that is dirty, a --mine key that belongs to
nobody, a refused runner name, or the runner itself failing
2 nothing to do: the board is idle, the ticket is blocked or somebody else
has it, or it needs a person to say which of two branches
130 interrupted
With -i, the exit code is the session's own.
A check increment#
An issue whose column has a check playbook (Rows that edit, and rows that judge) runs the same increment with one difference throughout: it ends in a verdict, not a move.
The tree is read before anything is applied, in every checkout the increment
entered: git status --porcelain must be empty, and HEAD must be exactly
where the runner left it. Only then does the runner ask the board to apply
the session's verdict.
- A fail is an ordinary move. The first one sends the ticket back to the build column with the findings as a comment, and counts as progress, not a stall.
- A second consecutive fail is held. The board moves nothing and asks a person instead; the runner writes no comment of its own.
- A dirty tree, a moved
HEAD, or a session that ended with no verdict at all counts as a stall (Stalls) — nothing is applied, and issues filed during the session count for nothing: a check that filed children and recorded no verdict is still a failed increment. - A tree check or an apply call that itself fails counts no stall. The runner prints the error and says it does not know whether the tree was clean, or whether the verdict was applied.
- A lost lease, a usage limit, a preemption or a hard limit skips the tree check and the apply call altogether — none of them is this ticket's verdict to apply.
hatch work --dry-run prints the two destinations under the # tools: line:
# check: pass -> <column>, fail -> <column>. No other playbook prints that
line. hatch work -i runs the same tree check and the same apply, but
attended, so nothing is let go or counted as a stall: it comments that the
verdict was not applied on a changed tree, that the session ended without one
on no verdict, and says so on the terminal with no comment at all if the
apply call itself fails.
The loop: hatch go-to-work#
work in a circle: the next actionable issue, one increment, ask again, until
the board has nothing an agent may move, and then wait and ask again every
interval.
hatch go-to-work until told to stop
hatch go-to-work --once one pass, and out
hatch go-to-work --under AER-1 only inside that epic's subtree
hatch go-to-work --interval 300 seconds to wait when there was nothing to do (default 60)
hatch go-to-work --merge-interval 10 seconds between the merge train's checks (default 5)
hatch go-to-work --quiet no per-increment stream, only what each one ended as
hatch go-to-work --agent codex which agent CLI, once for the night
hatch go-to-work --no-keys read no keys from this terminal
hatch go-to-work --repo /path/to/a/checkout also serve that checkout, repeatable
hatch go-to-work --workspace /clones clone what the board binds, into /clones
--onceis one pass and out. It heartbeats as a one-off and reads no instruction back, so a pause, a stop or a bound set on the board does not reach it, and it never restarts.--intervaland--merge-intervaltake at least one second; anything else is refused with exit 1. The interval is only what to do when a pass found nothing. The merge train keeps its own clock, so approved pull requests merge without waiting for it (letting the loop merge for you).--quiet,--no-keys,--repoand--workspaceare as forwork.--agentis resolved once, before the loop starts, and carried the whole night, unlike the model and effort, which the playbook settles fresh each increment.
A ticket key is refused here. This command's question is "what is next", asked
again and again, and one ticket cannot be the answer twice. One increment on a
named ticket is hatch work AER-12.
Sharing a board#
If you share one board with other people, each running their own agent on their
own budget, plain go-to-work takes anything on the board an agent may move
and skips every ticket assigned to a person, including your own.
hatch go-to-work --mine only tickets assigned to you
hatch do-my-work exactly the same, every flag included
--mine takes only a ticket assigned to the person the key belongs to, or to
the key itself, and folds every other one. No one loop works both the shared
pool and your own tickets: run go-to-work for the pool and do-my-work
separately for your own.
A key that belongs to nobody has no tickets of its own, and is refused: the
loop ends its night (exit 0) saying so, and work --mine exits 1. Who sets a
key's owner is in people, roles and keys.
One pass, in order#
- Usage probe. For an agent that has a usage probe (Claude), the loop asks the CLI for its own usage, at most every two minutes and after an increment, so the bars are the account's and not the last session's.
- Heartbeat and stop conditions. The heartbeat says this runner is here
and reads back what Agents → Runners would like it to do: paused, stopping, a
different scope, different bounds. Anything the board changed is printed
(
the board set --max-runs 3). Then the bounds, below. This is the one moment no claim is held. - Age restart. Under Hatch's own supervisor a loop that has run long enough asks to come back as a newer build (Restarts).
- Pause. Paused from the board or from the keyboard, it keeps heartbeating and takes nothing. The board wins if both are true.
- Usage-limit wait. A runner whose account ran out of usage takes nothing until it resets, obeying every bound and Ctrl-C meanwhile.
- Review poll. On the interval, pull requests in review are read for a failing build or a conflict, which become work of their own.
- Pick and claim. The first issue the dispatcher clears, folding past
everything it does not, and a claim on it. No claim, no spawn, which is what
keeps two runners off one ticket. If the ticket binds a repository this loop
has no checkout of, it is cloned here, with the lease already held. A ticket
that a hop carries across a column with no session is not an increment:
--max-runsand--oncedo not count it, and the next pass starts at once. - Reset. Fetch each checkout the increment will use and put its tree on its default branch at the tip the remote has right now.
- Source check. If the loop was rebuilt on the trunk under it, it stops here and asks to come back as the new build, holding no ticket.
- Conflict or build recheck, and branch entry. A conflict or failing build is read again against the refs just fetched, and if it has cleared, nothing is spawned. The checkout then goes onto the ticket's branch.
- Spawn the increment, and record what it cost and whether the ticket moved.
An increment that ran is followed by the next one immediately. While idle the
loop prints the reasons the board is folded once, again only when they change,
and still nothing every ten minutes.
Stalls#
A ticket that did not move is a stall: the session ended with the ticket in
the column it found it in, or with a plain advance and no work-log, which
counts as not moved. Stalls escalate:
- The first in a row is let go. The loop comments why, with the command to resume that session, and the ticket is free for the very next pass, since most of the time whatever happened is weather.
- The second marks it stalled. The loop comments and sets the mark, which
blocks the next dispatch and badges the card. The board resumes the ticket
by itself after 15 minutes (
0never resumes it). - Three in a row hold it for a person. The third stall is marked like the second, and after it the board stops resuming the ticket.
An open question on the ticket is itself the flag, so the second stall on a
ticket that already has one only comments. A session that asked a question is
no stall. Nor is running out of usage, being preempted, losing the lease, or a
skip with k: none counts toward a stall or toward the three failed increments
below, and a session stopped by a hard limit published what it left.
Flow, WIP and the battery has the numbers.
Running out of usage commits and pushes whatever the session left onto the
issue's own branch, and the runner writes one comment: that it ran out, when
it expects to resume, and the agent's resume command for that session (claude --resume <id>,
codex exec resume <id>, grok -r <id>, or codex exec resume --oss --local-provider ollama <id>). No
question is asked, and nothing blocks the next dispatch — the loop itself
stops taking tickets until the account resets, obeying every bound
meanwhile, and waiting out a limit still fetches its own checkout's trunk
every ten minutes, restarting as a newer build the same way a claimed pass
would if its own source changed there in the meantime.
Bounds, timeouts and stopping#
None is set by default. An unattended run that stopped for a reason nobody asked for is a run somebody has to go and check on.
hatch go-to-work --max-runs 5 stop after five increments
hatch go-to-work --max-spend 20 stop once the night has cost $20
hatch go-to-work --until 08:00 stop at that wall-clock hour (tomorrow, if it has gone by today)
hatch go-to-work --stop-file /tmp/stop stop once that path exists
--max-runs and --max-spend take at least 0, so --max-runs 0 stops before
the first increment. --max-spend counts priced increments only, and the
night's closing tally says how many were unpriced. --until takes HH:MM.
--stop-file is refused if the path already exists, because otherwise an empty
board and a stale stop file would look identical.
They are checked between increments and through every wait, so the increment
in flight always finishes, is committed and is pushed. --stop-file is the one
to reach for from another terminal or another machine: with no pid to find and
no signal that could land mid-push, touch /tmp/stop ends the loop after the
increment in flight.
The same bounds are controls on Agents → Runners, and a value set there is folded in on the next heartbeat, including being cleared (Watching the loop).
Ctrl-C lets go of the claim on the way out, prints the night, and exits 0
from go-to-work (work exits 130). SIGTERM and a closed terminal do the
same, and so do c then y. A second Ctrl-C is not handled, so the process
is killed at once and the ticket stays claimed until the lease ages out, five
minutes by default.
Other things end a run without a bound having been reached:
three increments in a row failed whatever is broken is broken for every ticket
the workspace could not be reset every ticket after it would be built on the wrong tree
the board asked this runner to stop the Agents → Runners page, mid-night
there is no agent CLI to spawn nothing was ever going to run
the keyboard asked (s) after the increment in flight
a --mine pass the board refused the key belongs to nobody
the night's totals could not be written for a restart
An increment fails when its session exits non-zero, or a clone fails; one clean exit resets the streak. The run finishes by printing the night: why it stopped, how many increments and what they cost, what moved, and what stalled.
Restarts#
A loop whose own source changed on the trunk asks to come back as the new
build — exit 75, because a process cannot rebuild itself. Its own source is
scripts/hatch.sh, scripts/hatch.ps1, src/Hatch.Cli and
src/Hatch.Contracts — the loop itself, the contract it is built against,
and the two wrappers that run it.
hatch go-to-work --restart-after 60 come back as a newer build at least that often (default 30)
hatch go-to-work --restart-after 0 ...only when its own source actually changed
hatch go-to-work --no-restart ...never
0 is not "off" — it still asks, only on an actual change rather than a
clock running out. --no-restart is the only one of the three that is
really off.
Two more things turn restarts off before any of those flags is even read:
--once, since it heartbeats as a one-off and never comes back regardless;
and an unset HATCH_NIGHT_STATE, which only Hatch's own supervisor sets. A
hatch started by hand has nothing standing over it, so it is the loop it
started as, and all three flags do nothing there.
The supervisor is scripts/hatch.sh, or scripts\hatch.ps1 in PowerShell on
Windows: it catches the 75, rebuilds and runs the loop again, carrying the
night's totals forward, so a restart cannot outspend --max-spend. A failed
rebuild is never fatal: the supervisor says so and carries on with the binary
already there rather than ending the night over it. On Windows, the
PowerShell supervisor runs the loop from a copy of the build made under
%TEMP%, since Windows will not let a rebuild overwrite a binary a running
process is still holding open.
Windows client editions refuse scripts by default (execution policy
Restricted). powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\hatch.ps1 go-to-work runs the supervisor anyway, and
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned allows it for the
account once; a clone carries no mark of the web, so RemoteSigned is
enough. Group Policy outranks both: if it is what refuses, it is the thing
to change, and no flag will get round it.
The container runner never asks.
Exit codes:
0 the night ended, for one of the reasons above - Ctrl-C, SIGTERM and a
closed terminal included
1 a refusal: a flag it does not take, a stop file that already exists, a
lock it could not take
75 asking the supervisor to rebuild and run it again
One loop per served checkout#
A loop is not limited to the checkout it is standing in: --repo /path/to/a/checkout (repeatable) or HATCH_REPOS (checkout paths joined on
the platform's path separator) names others it serves too — which is what
lets one loop on a laptop work the two or three repositories already cloned
there, with no checkout of its own. Every named path is checked at startup:
it must exist and carry a .git, and a project's binding can only ever
match it once it has an origin — before anything is locked or claimed.
A second go-to-work naming any checkout this loop already serves is
refused, naming the pid of the one that already holds it. Two loops no
longer collide over the board — the claim divides it — but they would still
collide over a tree they share, one increment's reset landing in the middle
of another's branch. Two checkouts, two loops, and both are welcome, whether
each loop found its checkout by standing in it or by naming it.
The lock itself lives outside the checkout, under the system's temporary directory, named for the checkout's canonical path rather than the path as typed — so two spellings of the same checkout (a symlink, a different drive letter case) still take the same lock. A lock left by a process that is no longer running is cleared automatically, with one line on the terminal saying so, rather than refusing a loop nothing is actually holding the checkout for.
There is a second lock, on the workspace, named hatch-workspace-<fingerprint>
for the workspace's canonical path and holding the pid and the standing
checkout. go-to-work takes it for the night and work takes it for the
increment — not work --dry-run, and not the read commands. It is released on
every exit, the restart included, and a stale one is cleared the same way.
A loop with none of its own checkouts of what a board binds can still serve
it. Cloning is on by default: an unset HATCH_WORKSPACE is a per-user
directory, and only --workspace off (or HATCH_WORKSPACE=off) never
clones — hatch config --show prints which this runner would do. Named or
default, the workspace is a directory the loop owns entirely, and it clones
whatever a project binds that it has no checkout of into
<dir>/<host>/<owner>/<repo> the first time a ticket needs it — a port in a
host, if there is one, spelled with _ in place of :, since Windows will
not take a : in a directory name. A remote two projects both bind is
cloned once and served to both. A clone that fails releases the ticket,
comments on it with git's own line, and counts as a failed increment — three
of them in a row end the night the same way three failed spawns do. A
directory already holding clones from an earlier night is served as it is
found, nothing re-cloned.
A second loop that never named its own workspace shares the default one with the first rather than being refused: it serves only the checkouts it already has and clones nothing, since the first loop already owns whatever directory the default's clones land in. A workspace may not sit inside a named checkout, nor a named checkout inside it — a clone under a tree being reset, or a tree under a directory of clones, is two owners of one path.
What it does to your checkout#
Before every increment, on every checkout the ticket's project is bound to — so a session never has to work out its own base:
- It fetches, and puts the checkout back on the default branch at the tip
the remote has it at right now — a binding's own base branch beats
origin/HEADfor that checkout, andHATCH_BASE_BRANCHonly ever overrides the checkout the loop is standing in. - Uncommitted and untracked changes go into a stash, named for the hour
it was taken. Nothing is discarded —
git stash popis how you get it back — and ignored files are never touched, so a dependency directory or a local.envis safe. - A local trunk ahead of origin is reset to match it too — only
git reflogkeeps what it had, since this is the one case where committed work is not safe from the reset. - A branch whose upstream on origin has been deleted is deleted locally too,
named on the terminal with the short sha it pointed at, so
git branch <name> <sha>puts it back. - Then it goes onto the ticket's branch. If origin has one branch named
for the key — the lowercased key, or that and a hyphen and anything — the
checkout goes onto it at origin's tip and the trunk is merged in; a
conflict is left in progress for the session, and the prompt says so
(what a session is told). With no
branch, or only one already merged, the checkout stays on the trunk and
the prompt names a branch to cut. With two unmerged branches the loop
asks on the ticket which to use and spawns nothing. In a project that
lands on the trunk, the checkout stays on
<trunk>when origin has no branch for the ticket, and the session pushes<trunk>itself; the runner never does. Never a rebase, never a force-push. - When the increment ends, the tree is put right before the ticket is let
go: a merge or rebase left half-done is aborted, uncommitted changes are
stashed under a message naming the ticket, unpushed commits and commits
left on the local trunk are named on the ticket (the latter moved to a
-rescued-branch), and a pull request whose branch has fallen behind the trunk gets the trunk merged into it on origin, when that merge is clean. Everything found is one comment on the ticket. The checkout is back on the trunk afterwards.
The practical consequence: work that matters is work that is committed. Do not leave something half-finished in the tree and then start the loop in the same checkout.
Where the prompt comes from#
Each increment's prompt, model and effort come from a playbook — a row per column transition and issue type, yours to change and never your agents': the API refuses the write from a key, on purpose. What a session is told has the prompt section by section.