Internals
The pipeline touches three things outside itself: GitHub, Claude and git. Each is reached through a port, an interface the orchestrator depends on, so the whole unattended flow can be tested in memory, with no subscription spent and nothing written to GitHub. The adapters behind the ports are kept thin. They build arguments and parse output; decisions live in the modules above them.
This page is for anyone curious how the pipeline works inside, and links the modules it names. To use the pipeline, start at Install and remove.
At a glance
| Port | The effect it isolates | Adapter · fake for tests |
|---|---|---|
Tracker | GitHub: issues, labels, comments, pull requests, CI, merges | GhTracker · FakeTracker |
AgentRunner | Claude: one Stage session | ClaudeAgentRunner · FakeAgentRunner |
Workspace | git: worktrees, Checks, rebase, push, the state and the Run lock | GitWorkspace · FakeWorkspace |
The CLI is the only place that constructs adapters. Everything under start.ts receives them as interfaces.
The adapters
GhTracker
GhTracker makes every GitHub call, through gh api.
- It speaks REST on every Host, because a cloud Host refuses GraphQL outright.
- The one exception is moving a pull request into or out of draft, which REST cannot do. A workstation uses the GraphQL mutation; a cloud Host uses the proxy's
/pulls/{n}/ccr/convert_to_draftand/ready_for_reviewroutes. - It polls CI every 15 seconds.
ClaudeAgentRunner
ClaudeAgentRunner runs one Stage as one child process, with --json-schema only where the Stage answers one:
claude --print <prompt> --output-format stream-json --verbose \
--permission-prompts none --permission-mode … \
--strict-mcp-config \
--disallowedTools CronCreate CronDelete RemoteTrigger PushNotification ScheduleWakeup Workflow \
--model … --effort … --max-turns … [--json-schema …]--strict-mcp-configleaves the session no MCP server, the claude.ai connectors of the Host user's account included.--disallowedToolstakes away the tools that act beyond the worktree and the session.It sets the child's environment:
Variable Value Why ANTHROPIC_API_KEYremoved, unless the Run was started with --api-keyunder -pa key outranks the Claude login without asking, so a stray one would be billedTICKET_RUNNER_STAGEthe Stage the Stage mark, which makes the CLI refuse inside a Stage TICKET_RUNNER_SESSIONa UUID per Stage session the session mark the kill at maxMinuteslooks forCLAUDE_CODE_DISABLE_AUTO_MEMORY1the Host user's memory is about their projects, not the Target CLAUDE_CODE_SHELL/bin/bash, only where it existsa zsh shell aborts a command on a glob that matches nothing ANTHROPIC_AUTH_TOKENand every other variable are inherited as they are.Every process a Stage starts inherits both marks. The Stage mark is a tripwire against an honest mistake, not a sandbox: a session that unsets it gets through, and keeping two Runs off one Target is the Run lock's job. The session mark is kept apart from it because two Lanes in the same Stage share the Stage mark.
It writes the Stage's files from the moment the Stage starts, never with the key's value in them: command files reach the Target's remote with a Hand-off.
It reads the
apiKeySourceof the session'ssystem/initevent, which the orchestrator sets against what the Run's Stages are billed to.It kills the child at
maxMinutes, with everything it started, and classifies a failure asrate-limited,api-error,out-of-budget,timed-out,turn-capped,nonzero-exitorinvalid-result. A session that succeeds is never any of the first three, and they are read off a failed one like this:Failure Read off out-of-budgetapi_error_status: 400with a message namingcredit balance is too loworworkspace API usage limits; the spend limit's message also names a usage limit, but it is the budgetrate-limitedapi_error_status: 429, arate_limit_eventwhose status isrejected(a warning-only one is not a stop), or, read last because its wording has changed before,usage limit,session limit,rate limitorrate_limitin the result or stderrapi-errorterminal_reason: "api_error"with a 5xxapi_error_statusor none
GitWorkspace
GitWorkspace keeps the worktrees under .worktrees/, a Ticket's and the detached one at a merge-base a failing Check runs again in, runs the Checks, rebases and pushes, and keeps the ticket-runner/state and ticket-runner/lock branches (Hand-offs, stopping and resuming).
- Commands in the main checkout run one at a time, because git fails on a held ref or index lock rather than waiting. Commands inside a worktree stay parallel.
- The state branch is rewritten as one snapshot commit with no parent, built from git objects and a scratch index so the main checkout's tree is never touched. It is pushed unsigned, with
--no-verifyand--force-with-leaseon the tip it read, and an edit a newer snapshot beat is reapplied, up to three tries. Within one Run, changes take turns, so two Lanes never push snapshots that each miss the other's Ticket. - The lock is taken by compare-and-swap: each take and release is a commit on the tip it read, pushed with a lease on that tip.
Smaller adapters
exec.tsspawns a child with a timeout and streaming sinks. It reportstimedOutseparately, because a command may exit 124 on its own.process-tree.tskills a command at its time limit together with its descendants and the process groups they lead: SIGTERM, then SIGKILL after 5 seconds. The command's own group would not do, since a Stage shares the Run's (ADR-0006) and Claude Code runs each Bash command in a session of its own. A background process whose shell has exited descends from nothing the Stage holds, so the kill also takes every process carrying the Stage's session mark.repo-root.tsfinds the main checkout, even from inside a worktree, so the lock,.worktrees/and the logs land in one place per clone.version.tsreads which version this copy is.
A map of src/
Tests sit beside each module as *.test.ts.
Entry
| Module | One line |
|---|---|
cli.ts | reads the version, dispatches init, run, stop, remove, -v; builds the adapters |
command-line.ts | parses arguments and holds the usage text, before anything is looked at |
stage-guard.ts | refuses every command inside a Stage's shell (the Stage mark) |
start.ts | refusals, the Run lock, the Stop listener, the summary and the exit code |
startup.ts | refuses a Run with nothing to gate a merge; warns about gates turned off |
Run and Ticket
| Module | One line |
|---|---|
run.ts | fills Lanes from the Stranded Tickets, then the Frontier; stops claiming on a Release or Stop |
orchestrator.ts | one Ticket from guards to merge, Hand-off or Release, with the fix budget |
frontier.ts | splits candidates into the Frontier and the blocked |
guards.ts | why a candidate is skipped: the guards and the refusals |
landing.ts | the Landing: one Lane between rebase and pull at a time, in arrival order |
lifecycle.ts | where a Ticket failed, and which failures a fix Stage may mend |
failing-base.ts | runs a failing Check at the merge-base, once per Run per commit, and its Note once |
base-branch.ts | resolves the base branch once per Run |
branch.ts | agent/<n>-<slug>, .worktrees/ticket-<n> and .worktrees/base-<commit> |
stranded.ts | the sweep for Stranded Tickets |
resume.ts | the state file's shape and name |
handoff.ts | marks old hand-off comments as history when a Ticket is taken again |
stop.ts | ticket-runner stop, and the Run's end of SIGTERM |
lock.ts | what the Run lock file says, and whether its holder is alive |
host.ts | workstation or cloud, and which one |
credential.ts | the subscription or the API key, what each Stage should report, and when --api-key is refused |
What Stages are told and what they answer
| Module | One line |
|---|---|
prompts.ts | each Stage's prompt, which expands /mattpocock-skills:<skill> |
self-hosting.ts | whether the Target is the pipeline's own repository, which decides what a Stage is told about its checkout |
verdict.ts | the verdict schema and whether it passes |
title.ts | the pull request title from the Stages' answers, then the commits, then the Ticket |
note-schema.ts, notes.ts | Notes: how a Stage declares them and where they are routed |
acceptance-criteria.ts, criteria.ts | what a criterion looks like; ticking the met ones after a merge |
progress.ts | the one progress comment per Ticket, edited in place |
templates.ts | every shape written to GitHub, and the Run summary; docs/templates/ is the source of truth |
log-tail.ts | the end of a log, under 40 lines and 4,000 characters, that a CI excerpt and a failing base branch's Note both quote |
run-log.ts | .ticket-runner/runs/<runId>/ and what a Hand-off keeps of it |
Target setup
| Module | One line |
|---|---|
init.ts | ticket-runner init: writes, sets up GitHub, reports the rest |
readiness.ts | Target readiness, which init provides and run checks |
conventions.ts | the conventions document's text and its version mark |
operator-skill.ts | the Operator's skill init writes into a Target |
labels.ts | the six triage labels and their colours |
config.ts | ticket-runner.json: schema and defaults |
remove.ts | ticket-runner remove |
Versions
| Module | One line |
|---|---|
version-number.ts | number comparison, shared by every version question |
staleness.ts | the newer-version line and the conventions-document warning |
version-pr.ts | what a release PR must get right; the notes a Release publishes |
Tests only: testing/ holds the in-memory fakes of the three ports that the tests reach the orchestrator through; how each layer is tested is in CONTRIBUTING.md.
Versions
A version is a number that main carries as a tag v<x.y.z>, published to npm and with a GitHub Release. An installed copy reports it. Machines install a version, never main, so two machines that report 0.5.2 run the same code (ADR-0007).
A human cuts one by merging a release PR, which /cut-a-version drafts; the steps and the checks on it are in CONTRIBUTING.md. The pull request raises the number and carries the release notes. The agent running the skill proposes the number by the rule CONTRIBUTING.md gives and drafts the notes, and the human's review and merge are the decision, because when to cut a version is a Planning decision, never inferred from what merged.
The release notes say what changed since the last version, grouped for a reader rather than listed per pull request, and what a Target or its human has to do after upgrading. They are kept in CHANGELOG.md, one section per version, and published as the body of that version's GitHub Release.
The stamp
The version is resolved once, in the CLI, and handed down. Every stamp one Run writes is therefore the same string.
-v prints | When |
|---|---|
0.5.2 | an installed copy: the number is the whole of it |
0.5.2+331d79c | a development checkout, clean |
0.5.2+331d79c.dirty | a development checkout with uncommitted changes |
unknown | no readable package.json |
A development checkout adds the commit because between two versions the number alone says nothing.
The same string is stamped on:
| Where | So that |
|---|---|
the Run summary's header, the init report's first line | a terminal says which pipeline ran |
| each Run's section of a progress comment | the board says which pipeline wrote it |
each state file's version | an unreadable file can still name what wrote it |
version.txt in the Run's directory | a transcript sits beside the pipeline that wrote it |
| the conventions document's first line (the number only) | init and a Run can tell which side is behind |
| the refusal of a config key the install does not know | a stale install is told apart from a wrong config |
The conventions document's stamp is also the version an Operator installs on a cloud Host whose own setup installed none. A Host whose setup did install one runs that one, whatever the stamp says.
The newer-version check
A Run and init each look up the highest published version of the pipeline's own repository. They read it from package.json's repository field, so a fork asks about itself. The lookup reads GitHub Releases rather than tags, skipping drafts and prereleases. The tag workflow makes a Release only once npm has the version, so a Release names one the upgrade can install. Only numbers are compared, so a checkout running past the latest version is not stale.
What it prints is on Install.
The ADRs in brief
The full records are in docs/adr/. Several have amendments; this is where they stand now.
ADR-0001: Humans plan, the pipeline executes
- Decision: Humans plan; only execution is unattended.
- Why: A wrong answer to a grilling question hardens into a Spec, Tickets and merged code with no gate left.
- Cost: Planning quality is a human's job; the pipeline only guards against known defects.
ADR-0002: One claude -p child process per Stage
- Decision: Each Stage is its own
claude -pchild process. - Why: Only headless mode expands a user-invoked
/plugin:skill. A process also gives per-Stage limits,--json-schema, and a command line to replay. - Cost: One to two seconds of startup per Stage. No shared context, so each Stage reads the Ticket and the branch.
ADR-0003: Trust only GitHub-native issue relations
- Decision: Only GitHub-native blockers and sub-issues count.
- Why: Two sources of truth would let a stale body silently block or unblock work.
- Cost: Humans must create the native edges; blockers named only in a body are skipped with a warning.
ADR-0004: Resume state lives on the Target's remote, not in a tracker comment
- Decision: Resume state is a state file kept off the board, now on the Target's remote. A Hand-off keeps it too.
- Why: The board is for humans. A cloud Host is discarded, so the state must outlive it. A relabel should resume a Ticket.
- Cost: No migration of old local state; the state branch goes through
Workspace.
ADR-0005: Landing is a serialized section, not a merge queue
- Decision: The Landing is a section one Lane is in at a time, not a merge queue.
- Why: What CI graded is exactly what lands, without paying for CI twice. GitHub's merge queue would have made a repository setting part of Target readiness.
- Cost: One slow CI or conflict Stage holds every other Lane's Landing.
ADR-0006: Stop is a signal, and Ctrl+C stays a kill
- Decision: A Stop is SIGTERM and nothing else, sent only from the Run's own Host; Ctrl+C stays a kill.
- Why: A signal arrives at once and leaves nothing to clean up. The Stages share the Run's process group, so Ctrl+C reaches every
claudesession too, and making it graceful would mean detaching the Stages into their own group. - Cost: A Stop cannot be taken back, and a Run on another Host cannot be stopped from here.
ADR-0007: A version is cut by a human, and installs follow tags
- Decision: A human cuts a version by merging a release PR; the tag workflow publishes it to npm, and installs come from there. The GitHub tag range keeps working.
- Why: When to cut is a Planning decision. A number is only a useful stamp if two machines with it run the same code.
- Cost: Release notes are written by hand, with an agent's help.
ADR-0008: A cloud Host is a Claude Code cloud session, and the Run lock lives on GitHub
- Decision: A cloud Host is a Claude Code cloud session with an Operator; the Run lock lives on GitHub.
- Why: No Actions minutes, and a Run steered from the app. Every Host must see the lock. A lease that expires unless renewed was rejected: a live Run would lose its Target to one missed heartbeat.
- Cost: REST only. Nothing on the remote may depend on deleting a ref. A lock from a vanished Host needs a human to release it.
Working on the pipeline itself
To change the pipeline, run it from a checkout. bin/ticket-runner.js loads the TypeScript through tsx, so there is no build step:
git clone https://github.com/jjongs2/ticket-runner && cd ticket-runner
npm install
npm run ticket-runner -- run # drain this repository's Frontier
npm run ticket-runner -- run 3 7 # a Run narrowed to #3 and #7
npm test # vitest
npm run typecheck # tsc --noEmitA checkout reports its commit beside the number (see the stamp). CI runs npm run typecheck and npm test on every pull request, and the Release PR check beside them.
This repository is a Target of its own pipeline. Its conventions are in CONTRIBUTING.md.
Related pages
- Hand-offs, stopping and resuming: the state branch and the Run lock that
GitWorkspacekeeps - From Ticket to merge: the lifecycle
orchestrator.tsdrives - Running: the commands
cli.tsdispatches, and-v - Installation: installing a version,
init, andremove
References
src/ports/,src/adapters/,src/testing/src/cli.ts,src/start.ts,src/run.ts,src/orchestrator.tssrc/adapters/version.ts,src/version-number.ts,src/version-pr.ts,src/staleness.tsscripts/version.ts,.github/workflows/,.claude/skills/cut-a-version/,docs/templates/version-notes.mdsrc/stage-guard.ts,docs/templates/operator-skill.mdCONTRIBUTING.md,CHANGELOG.md- ADR-0001 to ADR-0008