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 reading the source or changing it. To use the pipeline, start at Installation.
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 … \
--model … --effort … --max-turns … [--json-schema …]- It sets
TICKET_RUNNER_STAGEin the child's environment. - It writes
<stage>.command,.stdout,.stderrand.transcript.jsonlfrom the moment the Stage starts. - It kills the child at
maxMinutes, and classifies a failure asrate-limited,timed-out,turn-capped,nonzero-exitorinvalid-result.
GitWorkspace
GitWorkspace keeps the worktrees under .worktrees/, runs the Checks, rebases and pushes, and keeps the ticket-runner/state and ticket-runner/lock branches (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.
Smaller adapters
exec.tsspawns a child with a timeout and streaming sinks. It reportstimedOutseparately, because a command may exit 124 on its own.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. It is an adapter because it runs git, though no port describes it.
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 |
base-branch.ts | resolves the Base branch once per Run |
branch.ts | agent/<n>-<slug> and .worktrees/ticket-<n> |
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 |
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 |
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 Version PR must get right; the notes a Release publishes |
Tests only: testing/ holds the fakes and helpers described next.
How the orchestrator is tested
No test spawns gh or claude. Each layer is tested at the seam that suits it.
| Layer | Tested against |
|---|---|
orchestrator.ts, run.ts, start.ts, stranded.ts … | in-memory fakes of all three ports |
| Lanes running side by side | the fakes, plus Hold and settle |
GitWorkspace | real git: a temporary repository with a bare remote |
GhTracker, ClaudeAgentRunner | recorded runs, through their run seam |
- Each fake keeps an ordered
callslog. Tests assert on it and on the resulting state, never on internal helpers.FakeWorkspacekeeps the State and the lock in memory, and can playTHIS_HOSTorANOTHER_HOST. - A
Holdparks a Ticket at a chosen point, inside a Stage or a CI wait, until the test lets it go, so what another Lane does meanwhile can be observed. - The
GitWorkspacetests run worktrees, rebases, leases and the state and lock branches for real. - For the other two adapters,
testing/executions.tsbuilds theExecutiona process would have returned.
This is why every external effect goes through a port, the pipeline's own bookkeeping included: State and the lock are effects on the remote, so they are Workspace methods and the fakes cover them (ADR-0004, last amendment).
Versions
Without Versions, two installs could report the same number and run code weeks apart, so a bug read off a transcript could not be tied to a commit, and a stale install showed only when a Target behaved like last week's pipeline. Nothing the pipeline left in a Target said which pipeline wrote it either, so two machines on different installs rewrote the same conventions document back and forth.
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).
How one is cut
- The number. It goes up by a minor when a Spec has closed since the last Version, and by a patch for everything else, a small feature included. The only question is whether a Spec closed. A human decides when to cut, because that is a Planning decision.
- The Version PR. It raises
package.jsonand the lock file, adds the Version's section toCHANGELOG.md, and re-marks this repository's own conventions document withnpx tsx scripts/version.ts mark. The/cut-a-versionskill drafts it; it never merges. - The check lists every reason at once: the number is not above every tag, the lock file disagrees, the section is missing, or the section has no
### After upgradingheading. A pull request that leaves the number alone passes untouched. - The tag workflow tags the merge commit, publishes the tagged commit to npm by trusted publishing, with no npm token anywhere, and then publishes the section as the Release, or GitHub's generated notes if the section is missing. It makes whichever of the three is missing, so a re-run heals a half-cut Version.
release-please and Changesets were both turned down. The first needed a yearly-expiring token, a repository setting and a rule that only feat/fix cut a Version. The second would have put a changeset file into every Stage's job.
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 |
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 summary's header reads ticket-runner <version> run <runId> · <n>m. The file is .ticket-runner/runs/<runId>/version.txt, and a Hand-off keeps it with the transcripts.
The conventions document's mark, <!-- ticket-runner:version <number> -->, has a direction. An older pipeline leaves a newer document alone and asks to be upgraded. A Run behind the document warns, and a Run ahead of it names init. Neither ever refuses.
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.
When a newer one is out, staleness.ts · newerVersionLine prints one line, at the top of the Run log and again at the head of the summary:
A newer Version is out: 0.6.0, and this is 0.5.2 — upgrade with `npm install -g ticket-runner`.A lookup that cannot be made (no network, no gh, a repository nobody can see) prints nothing. Nothing is ever refused over it.
Install and upgrade with npm install -g ticket-runner, or try it through npx without one (Try it without installing). The GitHub tag range, npm install -g "github:jjongs2/ticket-runner#semver:*", also installs the highest Version tag. See Installation.
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.
- 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 Version 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: Version 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.
- 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 Version PR check beside them.
This repository is a Target of its own pipeline, so it follows the same rules as any other Target. Read these before committing, not a copy here:
CONTRIBUTING.md: branches, commits, pull requests, Version PRs, issues, and code rules (strict ESM TypeScript, tests beside the code, every effect through a port,scripts/kept thin)docs/agents/pipeline-conventions.md: what the pipeline requires of any TargetGLOSSARY.md: name things with the glossary; a concept that needs a new word goes there firstdocs/templates/: change a shape there before the code that writes it
A Stage may not run the pipeline. Its shell carries TICKET_RUNNER_STAGE, and every command refuses while it is set, leaving the pipeline to the Run that started it. In this repository, a Stage's prompt also tells it to exercise the pipeline through the tests and fakes instead.
Related pages
- 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