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 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 ​

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 … \
  --strict-mcp-config \
  --disallowedTools CronCreate CronDelete RemoteTrigger PushNotification ScheduleWakeup Workflow \
  --model … --effort … --max-turns … [--json-schema …]
  • --strict-mcp-config leaves the session no MCP server, the claude.ai connectors of the Host user's account included. --disallowedTools takes away the tools that act beyond the worktree and the session.

  • It sets the child's environment:

    VariableValueWhy
    ANTHROPIC_API_KEYremoved, unless the Run was started with --api-keyunder -p a key outranks the Claude login without asking, so a stray one would be billed
    TICKET_RUNNER_STAGEthe Stagethe Stage mark, which makes the CLI refuse inside a Stage
    TICKET_RUNNER_SESSIONa UUID per Stage sessionthe session mark the kill at maxMinutes looks for
    CLAUDE_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_TOKEN and 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 apiKeySource of the session's system/init event, 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 as rate-limited, api-error, out-of-budget, timed-out, turn-capped, nonzero-exit or invalid-result. A session that succeeds is never any of the first three, and they are read off a failed one like this:

    FailureRead off
    out-of-budgetapi_error_status: 400 with a message naming credit balance is too low or workspace API usage limits; the spend limit's message also names a usage limit, but it is the budget
    rate-limitedapi_error_status: 429, a rate_limit_event whose status is rejected (a warning-only one is not a stop), or, read last because its wording has changed before, usage limit, session limit, rate limit or rate_limit in the result or stderr
    api-errorterminal_reason: "api_error" with a 5xx api_error_status or 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-verify and --force-with-lease on 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.ts spawns a child with a timeout and streaming sinks. It reports timedOut separately, because a command may exit 124 on its own.
  • process-tree.ts kills 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.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.

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
failing-base.tsruns a failing Check at the merge-base, once per Run per commit, and its Note once
base-branch.tsresolves the base branch once per Run
branch.tsagent/<n>-<slug>, .worktrees/ticket-<n> and .worktrees/base-<commit>
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
credential.tsthe subscription or the API key, what each Stage should report, and when --api-key is refused

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
log-tail.tsthe 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

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

A development checkout adds the commit because between two versions the number alone says nothing.

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 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 -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, so Ctrl+C reaches every claude session 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:

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 Release PR check beside them.

This repository is a Target of its own pipeline. Its conventions are in CONTRIBUTING.md.

References ​