Skip to content

From Ticket to merge ​

Nobody watches a Run, so nothing may merge on a single session's word. Every Ticket goes through the same gates in the same order: deterministic Checks, a second session that tries to prove the work wrong, and CI. A failure a fresh session could mend buys exactly one fix Stage; anything else goes to a human. The Tickets of a Run are implemented side by side, but they land one at a time, so what CI graded is what reaches the base branch.

This page follows one Ticket from its claim to its merge. What happens when it stops short, whether by a Hand-off, a Release, a Stop or a Run that never came back, is on Hand-offs, stopping and resuming.

StepWhat happensWho decides
ClaimGuards, state file, assign, ready-for-agent → in-progressthe pipeline
Worktreeagent/<n>-<slug> in .worktrees/ticket-<n>git
implement Stage/mattpocock-skills:implement in the worktreean agent
ChecksNothing uncommitted, then each Check commandexit codes
verify StageAdversarial grading of the Acceptance Criteriathe pipeline, from the verdict
fix StageOne fresh session given the failure, once per Ticketthe fix budget
LandingRebase, conflict Stage, pull request, CI, squash mergeone Lane at a time
After the mergeTick met criteria, clean upthe pipeline

Every edge to the fix Stage is taken at most once per Ticket. A second failure of any kind, or any failure the fix Stage cannot act on, is a Hand-off. A Stage stopped by the rate limit, an API failure or the API budget, and a failure the base branch shares (below), leave this diagram entirely: they release the Ticket.

A Hand-off undoes the claim, puts ready-for-human on, comments the failure and where the work is, and puts an open pull request back into draft. The branch and the state file stay on the Target's remote, with the handed-off Stages' transcripts beside them, since the Host the work was done on may be gone by the time a human looks. A human who relabels the Ticket ready-for-agent hands it back, and it carries on from what it reached rather than paying for the implement Stage again. A Hand-off at setup that leaves no work of the pipeline's removes the state file: the Ticket started fresh and either failed before its worktree existed, or found a branch in the way that the Run would not branch over. No Stage of this Run worked there, so nothing is the pipeline's to resume.

The claim ​

The claim marks a Ticket as taken, so no other Run picks it: the Frontier leaves out every assigned Ticket. The guards run before anything is written. An issue the pipeline will not take is never marked as taken.

Then, in this order:

  1. The state file is written: the Ticket's branch, the state it reached (claimed) and whether the fix budget is spent.
  2. The Ticket is assigned to the gh user, in-progress goes on and ready-for-agent comes off. A Stranded Ticket already wears this claim, so it is not written again.
  3. Any hand-off comment still current on the Ticket is marked as history (Handing a Ticket back).

The worktree and branch ​

Value
Branchagent/<n>-<slug>: the title in lowercase kebab-case, cut at a word boundary to 40 characters
Worktree.worktrees/ticket-<n> under the Target's root, gitignored
Branched fromThe remote's base branch, freshly fetched, with no upstream set

A branch of that name that already exists is never reused. The Ticket is handed off at setup, and the comment says whose branch it is and the command that clears it. A resumed Ticket carries on in the branch its state file names instead; see Hand-offs, stopping and resuming.

How a Stage runs ​

A Stage is one claude -p child process with one job (ADR-0002).

Every Stage runs in the Ticket's worktree with --permission-prompts none, the configured permissionMode, and its own model, effort, maxTurns and maxMinutes (see Configuration). The wall-clock limit is enforced by killing the process and everything it started.

What a Stage sees. The branch's files, the Target's CLAUDE.md among them, so what init wrote reaches a Stage once it is on the base branch. No MCP server, the claude.ai connectors included, no auto memory, and none of the tools that act beyond the session, such as scheduling. Work that needs an MCP server cannot be done by a Stage.

StagePrompt opens withStructured answer
implement/mattpocock-skills:implement <issue URL>optional title, notes and noCommitReason
verifyTicket: <issue URL>, no skillthe verdict, optional notes
fixTicket: <issue URL>, no skilloptional title, notes and noCommitReason
conflictTicket: <issue URL>, no skillnone

The prompt ends with the Stage's extraPrompt.

What a Stage is billed to. A Stage runs on the Claude login, with the Host's ANTHROPIC_API_KEY taken out of its environment, unless the Run was started with --api-key, when it runs on that key. When the session's apiKeySource says otherwise, the Run's log and summary name the Stage.

What each Stage leaves on disk is on Logs and transcripts.

How a Stage can end. The runner reads the session's last result event, never what the session talked about:

FailureProgress cellMeaning
timed-out❌ timed outKilled at maxMinutes
turn-capped❌ turn cappedReached maxTurns
out-of-budget⏸ out of API budgetThe API key's credit or spend limit, met only on an --api-key Run. Releases the Ticket
rate-limited⏸ rate limited, ⏸ API rate limited on an --api-key RunThe plan's rate limit, or the API's on an --api-key Run. Releases the Ticket
api-error⏸ API failureA transient API failure the CLI gave up retrying. Releases the Ticket
nonzero-exit❌ exited non-zeroAny other failed session
invalid-result❌ invalid resultThe verify Stage answered no verdict at all

The implement Stage ​

The implement skill reads the Ticket, builds it and reviews it. When the session ends, the pipeline routes its Notes, records its title, and pushes the branch if it has commits, so a Run on another Host can resume from it. Then:

  • A Stage that did not finish is a Hand-off at implement.
  • A branch with no commits beyond the base branch is a Hand-off: an agent that gave up silently leaves nothing to grade. The hand-off comment quotes the reason the Stage gave in noCommitReason, when it gave one.
  • Otherwise the state file moves to implemented, so no later Run pays for this Stage again.

The Checks ​

Two gates, run by the pipeline itself rather than by an agent:

  1. Nothing uncommitted. A worktree holding changes no commit carries fails, naming every path. Gitignored files, such as installed dependencies, do not count.
  2. The Check commands, in order, each through a shell in the worktree, each under checkTimeoutMinutes (default 15). The first failure stops the rest. A command killed at the limit is reported as timed out, with a line appended to its output saying it hung rather than failed.

The commands are checks. Both failures spend the fix budget. An empty checks, which only gates.checks off allows, runs nothing, so the row reads ⚠️ no checks rather than ✅ passed, here and after a conflict Stage alike.

A failure the base branch shares ​

A test on the base branch that starts failing, one that reads the wall clock say, fails on every branch at once. Charged to each Ticket, it costs every one its fix budget, and each fix Stage mends it differently in its own pull request. So before a failure is charged, the pipeline asks whether the branch brought it or inherited it:

What failedRun at the merge-base
A Check commandthat command
CI on the pull requestevery Check command, stopping at the first that fails

The merge-base, as the remote holds it, is checked out clean, and the command runs there under the same checkTimeoutMinutes, once per Run however many Lanes fail on it. Nothing is installed there: the command finds its dependencies in the main checkout, as a Ticket's worktree does, so a command that needs an install or a build step of its own fails there too, and the Note's output shows why.

The merge-baseProgress cellResult
passes❌ `<command>` failed, or CI's ❌ failedSpends the fix budget, as above
fails too⏸ `<command>` fails on the base branchA Release; the budget stays as it was, spent or not
could not be checked out or runas if it passedSpends the fix budget, and the Run log says why

The Release posts the pipeline's own Note on the standing Notes issue, naming the command and quoting the tail of its output, once per Run and merge-base commit however many Tickets it releases. Only the command is compared, so a branch that fails it differently is released all the same; the Note quotes the branch's own failure beside the merge-base's, marked as the branch's: the tail of the failing Check's output, or for CI the failing jobs' logs a fix Stage would have been handed. It is the failure of the Ticket that posted the Note; a Ticket released later over the same commit adds nothing. What to do next is on Release over a failing base branch.

The verify Stage and its verdict ​

No human reviews the code before it merges, and the session that wrote it is the last one to trust on whether it works. So a fresh session, with no plugin skill, tries to prove each Acceptance Criterion is not met. It reads the criteria from the body and the comments, runs the code, may write throwaway tests, and must not commit. A criterion that cannot be met without overturning a documented decision or another criterion is unmet, with evidence saying a human decision is needed. An unmet criterion's evidence states the failure: where it sits, what was run or read, what was expected, what came back, and the counterexample. It proposes no fix, though it may quote a limit the Ticket, a documented decision or another criterion sets, naming where it comes from. Its scratch files are discarded afterwards, so they never reach the pull request. What it finds beside the criteria leaves the session as a Note, because the verdict has a slot per criterion and none for anything else.

The verdict grades each criterion met, unmet or unverifiable, with evidence. The agent's own pass or fail is advisory; the pipeline decides for itself: no criterion unmet and at least one met.

VerdictProgress cellResult
Passes✅ <k> met · <v> unverifiableOn to the Landing
Some unmet❌ <u> unmetSpends the fix budget; the fix Stage gets each unmet criterion with its evidence
All unverifiable❌ no evidenceHand-off: there is nothing to merge on
Missing or malformed❌ no verdictHand-off

A verify after a fix for unmet criteria gets the first verdict's unmet criteria and their evidence, and is told such a criterion is met only when that counterexample is gone and the whole criterion holds.

The fix Stage and the fix budget ​

The fix budget is one fix Stage per Ticket, and whatever still fails after it goes to a human. The fix Stage is a fresh session on the same branch, with no plugin skill, given the failure and its evidence to mend that one defect. Sent in for unmet criteria, it commits nothing and says why in noCommitReason when one cannot be met without overturning a documented decision or another criterion, which hands the Ticket to a human. Otherwise it takes a fix the evidence proposes as a lead, not the answer: it finds the cause itself, looks for a fix of that cause within the Ticket's scope before settling for a narrower one, and before committing tests its fix with inputs built from that cause, not only with the counterexample it was given.

Only a defect a fresh session could mend spends the budget:

FailureAtSpends the budget?
Changes no commit carrieschecksYes
A Check failed or timed outchecksYes
A Check failed that the merge-base fails toochecksNo, a Release
A criterion unmetverifyYes
The conflict Stage did not finish the rebaserebaseYes
A pull request check failedciYes
A pull request check failed, and the merge-base fails a CheckciNo, a Release
A Stage that did not finish (timed out, turn-capped, non-zero, invalid result)its StageNo, Hand-off
implement left no commitsimplementNo, Hand-off
A verdict with no evidence, or none at allverifyNo, Hand-off
The conflict Stage could not be run, or git could not be askedrebaseNo, Hand-off
The base branch could not be pulledrebaseNo, Hand-off
Pushing or opening the pull request failedprNo, Hand-off
CI timed out, found conflicting, or had no checksciNo, Hand-off
The merge failedmergeNo, Hand-off
A rate limit, a transient API failure or an API key out of budget, anywhereanyNo, a Release

After a fix Stage, the Ticket starts again at the Checks. Every gate grades the fix, the verify Stage included, so a fix is held to the same bar as the first attempt. A fix Stage whose branch did not grow is a Hand-off straight away: re-grading an untouched branch could only fail the same way. A squashed or amended branch counts as not grown. Its hand-off comment carries the evidence the fix Stage was sent in with, and quotes the reason the Stage gave in noCommitReason for committing nothing, whatever it was sent in for.

A fix Stage stopped by the rate limit, an API failure or the API budget spends nothing. The fix budget is one fix Stage each time the pipeline takes the Ticket up: a Release carries a spent budget over to the next Run, having changed nothing about the Ticket, and a Hand-off restores it, because the Ticket only comes back through a human's hands and whatever they did to it is what the fresh budget is for. More on Handing a Ticket back.

Lanes and the Frontier refill ​

A Run holds one Ticket per Lane and refills a Lane the moment its Ticket ends. While another Lane is busy, a free Lane also looks at the Frontier again at intervals, so a Ticket that joins it mid-Run need not wait for a busy Lane to end. The order it fills them in is on Running, when more Lanes help on lanes, and what keeps two Tickets out of each other's way on Blockers.

The Landing ​

The Landing, from the pull of the base branch before the rebase to the pull after the merge, holds one Ticket at a time, in arrival order, so the base branch cannot move between a Ticket's rebase and its merge, and what CI graded is what lands (ADR-0005). implement and verify, where the time goes, stay parallel. The cost is that one Lane's conflict Stage or slow CI holds the others' Landing.

A Ticket also leaves the Landing for a fix Stage, a Hand-off or a Release, so nobody waits behind a session; one back from a fix Stage rejoins at the back. A Ticket released over a failing base branch passes through the Landing once when a later Run takes it up, for the pull and the rebase alone, before its Checks, and leaves it again at once, since nothing is merged behind them.

Rebase ​

The base branch is first brought up to the remote's, then git rebase <base> runs in the worktree. A clean rebase goes straight to the pull request. A conflict is left exactly where git stopped, for the conflict Stage.

The conflict Stage ​

A conflict is not a defect in the branch: the base branch moved on underneath it. So one conflict Stage runs in the stopped rebase with git's output and no plugin skill, and it does not spend the fix budget. It is told to finish the rebase and never abort it, to keep both intents where they are compatible and the Ticket's where they are not, and to change a file the conflict did not touch only to repair what the merge broke, such as a renamed name or a moved field. It implements nothing new and changes no behaviour the criteria cover.

The worktree decides, not the session's exit. The rebase counts as resolved only when it has finished, the branch sits on top of the base branch with no merge commit between them, and no unmerged path or conflict marker is left.

What the worktree showsResult
Resolved, however the session ended (even rate-limited or stopped by an API failure)✅ rebased; push, then the Checks again
Unresolved, session rate-limited or stopped by an API failure or the API budgetAbort the rebase, Release
Unresolved otherwise❌ unresolved; abort the rebase, spend the fix budget
The Stage could not run, or git could not be asked❌ unknown; abort the rebase, Hand-off

The Checks run again because the resolution is code no gate has seen. verify is not asked again: the Stage changes no behaviour the criteria cover. An unresolved rebase hands the fix Stage the conflict and what is still wrong as its evidence. The abort always happens, so a fix Stage or a human never inherits a half-finished rebase.

Pull request ​

The branch is pushed with --force-with-lease, since the rebase rewrote it. The pipeline opens a ready (non-draft) pull request against the base branch, or updates the one it already has: a new title and body, and out of draft (a draft often runs no workflows, so a draft left from a Hand-off would read as "no checks").

The title is the first of these in the commit convention's shape, <type>(<scope>): <summary> on one line (a trailing (#<n>) is dropped):

  1. The latest title the implement or fix Stage answered for the whole branch, in this Run or recorded in the state file by an earlier one.
  2. The branch's first commit subject.
  3. The Ticket title, as it is.

The body is Closes #<n>, the verdict counts, the criteria in a folded list (evidence shown for any not met), and a line naming the Run. On a workstation that line also points at .ticket-runner/runs/<runId>/<n>/; a cloud Host's run directory does not outlive its session, so there it names the Run alone.

CI wait and grace period ​

The pipeline polls the head commit's check runs and commit statuses until they settle or ciTimeoutMinutes runs out.

What GitHub showsProgress cellResult
Every check passed or skipped✅ passedMerge
A check failed, was cancelled, timed out, needs action or could not start❌ failedSpends the fix budget
The same, and the merge-base fails a Check command⏸ `<command>` fails on the base branchA Release (above)
Checks still pending at the timeout❌ timed outHand-off
No checks, and GitHub finds the pull request conflicting❌ conflictingHand-off at once
No checks after the grace period, gates.ci on❌ no checksHand-off
No checks after the grace period, gates.ci off⚠️ no checksMerge, with the warning row

A failed check hands the fix Stage the tail of the failing Actions jobs' logs. A conflicting pull request is handed off without waiting, because GitHub runs no checks on it; only a human merging meanwhile can cause it.

The grace period. Right after a pull request opens, GitHub reports no checks for a while, so "no checks" counts only after ciGraceMinutes.

Squash merge and its commit ​

The pipeline squash-merges through GitHub's REST API with a commit message it composed itself:

text
<title> (#<pr>)

Closes #<n>

Verdict: <k> met · <u> unmet · <v> unverifiable

- <branch commit subject>
- <branch commit subject>

Co-authored-by: <name> <email>

The subject is the pull request title. Commit subjects and Co-authored-by trailers are read after the rebase, since those are the commits that land. The per-criterion evidence stays in the pull request body.

After the merge ​

The Ticket is merged, so nothing below can hand it off. Each step's failure is logged and the next step still runs:

  1. Remove the state file, so the Ticket never looks resumable again.
  2. Tick the met criteria (below).
  3. Remove in-progress. The merge closed the issue.
  4. Pull the base branch. This ends the Landing, and the next Lane's Ticket rebases onto a base branch that already carries this one.
  5. Remove the worktree and the local branch, and delete the remote branch unless GitHub already did.

What the board is told ​

The progress comment. A Ticket gets one progress comment, found again by a hidden marker and rewritten in place as each step ends, so only the first write notifies anyone. It is posted when the first row is recorded. Each Run that works the Ticket has its own section below earlier Runs' sections, headed by the version, the Run id and the branch:

text
**ticket-runner** `0.4.0` · run `2026-09-17T09-00-00-000` · `agent/4-planning-guards`

| Stage | Outcome | Turns | Duration |
|---|---|---|---|
| implement | ✅ committed | 46 | 21m |
| checks | ❌ `npm test` failed | – | 2m |
| fix | ✅ committed | 12 | 6m |
| checks | ✅ passed | – | 2m |
| verify | ✅ 6 met · 1 unverifiable | 9 | 4m |
| ci | ✅ passed | – | 3m |
| merge | ✅ #31 | – | – |

Rows carry a few words only. Anything a human has to act on is its own comment, since those are worth a notification: a hand-off, a guard warning, a Note.

Ticking met criteria. After the merge, every criterion the verdict marked met is ticked (- [ ] → - [x]) wherever it is written: the body, or any comment, such as the one triage posted its brief in. Matching forgives differences in whitespace and case. A criterion that matches no checkbox even so, ticked or not, gets a second chance: with one trailing parenthetical dropped, such as the (table) the verify Stage appends to a criterion whose content sits in a block below it, and with each ... or … read as any run of characters. That looser match is tried against the lines the first pass left unticked, across the body and every comment together, and ticks a line only when exactly one matches; with none or more than one, the criterion stays unticked. An unverifiable criterion stays unticked, because nobody gathered evidence for it. A criterion the Stage reworded past matching is left unticked, and the Run log says how many were not matched.

Notes ​

A Stage keeps meeting defects that are not its Ticket's. Fixing them widens the Ticket past what verify grades; ignoring them loses them in a transcript. So the implement, verify and fix Stages end with a list of Notes, and the pipeline posts each one where a human will meet it. A Note is a defect outside the Ticket, something that behaves wrongly or breaks, never a preference, a refactor or a test that would be nice to have, and never acted on in it. A defect the branch itself introduced, one that behaves correctly on the base branch and wrongly on the branch or sits in code the branch added, belongs to the Ticket: the implement and fix Stages fix it rather than note it, and only verify, which fixes nothing, still notes one it demonstrates beside the criteria. Notes are routed before the Stage's own outcome is judged, so a Stage that ran out of turns still reports what it noticed.

The Note namesWhere it goes
An open, unclaimed Ticket that is not a SpecA comment on that Ticket
No Ticket, or the Stage's own Ticket (about to close)The standing Notes issue
A closed, claimed (in-progress) or Spec issueThe standing Notes issue, saying which Ticket it was meant for and why it did not go there
A number that will not take the comment (invented, locked)The standing Notes issue, with the number it was meant for

The standing Notes issue is one open needs-triage issue, titled "Notes from the pipeline", that collects Notes as comments. It is found by a marker in its body, not by its title, so renaming it opens no second one. It is opened by the first Note that needs it and shared by every Lane of the Run. Work through it as triage does: turn each Note worth doing into a Ticket, or a comment on one, then close the issue. The next Note opens a fresh one. When one is open, each noting Stage's prompt carries its number, so the Stage can read what is already reported and add only what would change a reader's action.

A - [ ] at the head of a line in a Note is escaped, so a Note is never read as Acceptance Criteria. The Run summary lists each Note as a noted row under the Ticket that made it.

  • Planning: the guards that run before the claim, and how to write Acceptance Criteria verify can grade.
  • Running: starting a Run, --lanes, --api-key, a narrowed Run, the summary, exit codes and transcripts.
  • Configuration: checks, gates, the Stage limits, ciTimeoutMinutes, ciGraceMinutes.
  • Hand-offs, stopping and resuming: Hand-off, Release, Stop, Stranded Tickets and handing a Ticket back.
  • Internals: the ports and adapters behind each step.

References ​