Skip to content

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 ​

PortThe effect it isolatesAdapter · fake for tests
TrackerGitHub: issues, labels, comments, pull requests, CI, mergesGhTracker · FakeTracker
AgentRunnerClaude: one Stage sessionClaudeAgentRunner · FakeAgentRunner
Workspacegit: worktrees, Checks, rebase, push, the State and the Run lockGitWorkspace · 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_draft and /ready_for_review routes.
  • It polls CI every 15 seconds.

ClaudeAgentRunner ​

ClaudeAgentRunner runs one Stage as one child process, with --json-schema only where the Stage answers one:

text
claude --print <prompt> --output-format stream-json --verbose \
  --permission-prompts none --permission-mode … \
  --model … --effort … --max-turns … [--json-schema …]
  • It sets TICKET_RUNNER_STAGE in the child's environment.
  • It writes <stage>.command, .stdout, .stderr and .transcript.jsonl from the moment the Stage starts.
  • It kills the child at maxMinutes, and classifies a failure as rate-limited, timed-out, turn-capped, nonzero-exit or invalid-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.ts spawns a child with a timeout and streaming sinks. It reports timedOut separately, because a command may exit 124 on its own.
  • repo-root.ts finds the main checkout, even from inside a worktree, so the lock, .worktrees/ and the logs land in one place per clone.
  • version.ts reads 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

ModuleOne line
cli.tsreads the Version, dispatches init, run, stop, remove, -v; builds the adapters
command-line.tsparses arguments and holds the usage text, before anything is looked at
stage-guard.tsrefuses every command inside a Stage's shell (the Stage mark)
start.tsrefusals, the Run lock, the Stop listener, the summary and the exit code
startup.tsrefuses a Run with nothing to gate a merge; warns about gates turned off

Run and Ticket

ModuleOne line
run.tsfills Lanes from the Stranded Tickets, then the Frontier; stops claiming on a Release or Stop
orchestrator.tsone Ticket from guards to merge, Hand-off or Release, with the Fix budget
frontier.tssplits candidates into the Frontier and the blocked
guards.tswhy a candidate is skipped: the Guards and the refusals
landing.tsthe Landing: one Lane between rebase and pull at a time, in arrival order
lifecycle.tswhere a Ticket failed, and which failures a fix Stage may mend
base-branch.tsresolves the Base branch once per Run
branch.tsagent/<n>-<slug> and .worktrees/ticket-<n>
stranded.tsthe sweep for Stranded Tickets
resume.tsthe State file's shape and name
handoff.tsmarks old hand-off comments as history when a Ticket is taken again
stop.tsticket-runner stop, and the Run's end of SIGTERM
lock.tswhat the Run lock file says, and whether its holder is alive
host.tsworkstation or cloud, and which one

What Stages are told and what they answer

ModuleOne line
prompts.tseach Stage's prompt, which expands /mattpocock-skills:<skill>
self-hosting.tswhether the Target is the pipeline's own repository, which decides what a Stage is told about its checkout
verdict.tsthe Verdict schema and whether it passes
title.tsthe pull request title from the Stages' answers, then the commits, then the Ticket
note-schema.ts, notes.tsNotes: how a Stage declares them and where they are routed
acceptance-criteria.ts, criteria.tswhat a criterion looks like; ticking the met ones after a merge
progress.tsthe one Progress comment per Ticket, edited in place
templates.tsevery 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

ModuleOne line
init.tsticket-runner init: writes, sets up GitHub, reports the rest
readiness.tsTarget readiness, which init provides and run checks
conventions.tsthe conventions document's text and its Version mark
operator-skill.tsthe Operator's skill init writes into a Target
labels.tsthe six triage labels and their colours
config.tsticket-runner.json: schema and defaults
remove.tsticket-runner remove

Versions

ModuleOne line
version-number.tsnumber comparison, shared by every Version question
staleness.tsthe newer-Version line and the conventions-document warning
version-pr.tswhat 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.

LayerTested against
orchestrator.ts, run.ts, start.ts, stranded.ts …in-memory fakes of all three ports
Lanes running side by sidethe fakes, plus Hold and settle
GitWorkspacereal git: a temporary repository with a bare remote
GhTracker, ClaudeAgentRunnerrecorded runs, through their run seam
  • Each fake keeps an ordered calls log. Tests assert on it and on the resulting state, never on internal helpers. FakeWorkspace keeps the State and the lock in memory, and can play THIS_HOST or ANOTHER_HOST.
  • A Hold parks 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 GitWorkspace tests run worktrees, rebases, leases and the state and lock branches for real.
  • For the other two adapters, testing/executions.ts builds the Execution a 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.json and the lock file, adds the Version's section to CHANGELOG.md, and re-marks this repository's own conventions document with npx tsx scripts/version.ts mark. The /cut-a-version skill 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 upgrading heading. 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 printsWhen
0.5.2an installed copy: the number is the whole of it
0.5.2+331d79ca development checkout, clean
0.5.2+331d79c.dirtya development checkout with uncommitted changes
unknownno readable package.json

The same string is stamped on:

WhereSo that
the Run summary's header, the init report's first linea terminal says which pipeline ran
each Run's section of a Progress commentthe board says which pipeline wrote it
each State file's versionan unreadable file can still name what wrote it
version.txt in the Run's directorya 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 knowa 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 -p child 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:

bash
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 --noEmit

A 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 Target
  • GLOSSARY.md: name things with the glossary; a concept that needs a new word goes there first
  • docs/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.

References ​