Skip to content

Configuration ​

A Target should work with no settings at all, so every field is optional and init writes the file as {}. The file exists for what only the Target can know: which commands prove its code works, how long its CI takes, whether its Checks can share a machine, and what it calls its labels. What belongs to the Host rather than the Target is a flag of run: --lanes overrides lanes, and --api-key chooses what the Stages are billed to, which no field sets.

The file is ticket-runner.json at the Target's root. It is plain JSON, so it cannot hold comments. A Run reads it once, as it starts, from the main checkout's working tree, so an edit applies from the next Run.

FieldDefaultWhat it changes
baseBranchGitHub's default branchThe branch Tickets are branched from, rebased onto and merged into
lanes1How many Tickets a Run holds at once
checksinferred from package.jsonThe commands the pipeline runs itself to gate a Ticket
checkTimeoutMinutes15Wall-clock limit for each Check command
gates.checkstrueWhether a Run may start with no Check at all
gates.citrueWhether a pull request with no CI checks may merge
stagessee belowModel, effort, limits and extra instructions per Stage
permissionMode"auto"What every Stage session may do without asking
ciTimeoutMinutes30How long a Run waits for a pull request's CI
ciGraceMinutes5How long "no checks registered yet" still counts as pending
labelsthe six default namesThe label names the pipeline reads and writes

A file that sets several of them:

json
{
  "baseBranch": "develop",
  "lanes": 2,
  "checks": ["npm test", "npm run typecheck", "npm run lint"],
  "checkTimeoutMinutes": 20,
  "stages": {
    "implement": { "maxTurns": 400, "extraPrompt": "Run `npm run lint -- --fix` before committing." },
    "verify": { "effort": "max" }
  },
  "ciTimeoutMinutes": 45,
  "labels": { "readyForAgent": "agent-ready" }
}

A file that will not load ​

The schema is strict. An unknown key, a wrong type or an out-of-range value is refused by name, and so is a file that is not JSON. Every command that reads the file (init, run, remove) exits 2 over it; stop never reads it.

text
Invalid ticket-runner.json: stages: Unrecognized key: "implment". Refused by ticket-runner 0.5.2, so the key may be newer than this install.

The second sentence is added only for an unknown key: on a stale install, a key a newer version added looks exactly like a typo, so check the spelling, then upgrade.

baseBranch ​

  • baseBranch: string

The base branch is the one every Ticket starts from and merges into: a Run rebases each Ticket onto it, opens the Ticket's pull request against it and pulls the main checkout to it once the Ticket merges. Without this field it is whatever GitHub calls the repository's default branch, so a Target on master needs no setting. It is resolved once at the start of a Run.

lanes ​

  • lanes: whole number ≥ 1

How many Tickets a Run holds at once, one per Lane. run --lanes <n> overrides it for one Run. Lanes run their Checks at the same time, each in its own worktree, so a Target whose Checks need a port, a database or anything else they would share keeps this at 1. Only one Lane at a time is in the Landing (rebase to merge), so more Lanes speed up implement and verify, not merging (ADR-0005).

checks ​

  • checks: array of strings
  • checkTimeoutMinutes: number > 0

A Check is a command the pipeline runs itself. Each one runs through the shell in the Ticket's worktree, after the implement Stage, after a fix Stage and after a resolved rebase conflict. A non-zero exit fails the gate, and the Ticket spends its fix budget, unless the base branch fails the same command (A failure the base branch shares). That question is asked of a red CI by running these commands too, so they should be ones CI runs.

Without this field the pipeline infers npm test and npm run typecheck from whichever of the test and typecheck scripts package.json defines.

checkTimeoutMinutes is one limit for every command, and each command gets the whole of it, at the merge-base too. A command killed at the limit is a failed Check and spends the fix budget like any other.

gates ​

  • gates.checks: boolean
  • gates.ci: boolean
SettingWhen on (the default)When false
gates.checksA Run with no Check command, configured or inferred, refuses to startThe Run starts anyway, with a warning. Check commands that do exist still run; with none, the Ticket's checks row reads ⚠️ no checks
gates.ciA pull request with no CI checks at all is a Hand-offIt merges, with a warning at start and a ⚠️ no checks row on the Ticket

Turning gates.ci off tolerates only a pull request that has no checks. Red CI, CI that times out, and a pull request that conflicts with the base branch still end the Ticket whatever the gates say.

The refusal gates.checks guards against reads:

text
No Check commands are configured and none could be inferred from package.json. Add them to `checks` in ticket-runner.json, add `test` and `typecheck` scripts to package.json, or set `gates.checks` to false in ticket-runner.json to run without a net.

stages ​

  • stages: object

Each Stage is one claude -p session. stages takes an object per Stage, and every key in it is optional:

StageWhat it doesmaxTurnsmaxMinutes
implementDrives /mattpocock-skills:implement on the Ticket30060
verifyTries to prove the Acceptance Criteria are not met, and returns a verdict13030
fixMends the one failure that spent the fix budget15040
conflictResolves the conflict where a rebase stopped12030
KeyTypeDefaultPassed as
modelstring, anything claude --model takes, such as sonnet"claude-opus-5-5"--model
effort"low", "medium", "high", "xhigh" or "max""high"--effort
maxTurnswhole number ≥ 1per Stage, above--max-turns
maxMinutesnumber > 0per Stage, aboveThe wall-clock limit the session is killed at
extraPromptstring""Appended as the last section of the Stage's prompt

The model and effort are always passed, so the machine's own claude defaults never apply. What a Stage that runs out of turns or time costs the Ticket is on From Ticket to merge.

permissionMode ​

  • permissionMode: "auto", "acceptEdits" or "bypassPermissions"

Passed to every Stage as --permission-mode. Every Stage also runs with --permission-prompts none: nobody is watching, so anything that would prompt is denied instead.

CI ​

  • ciTimeoutMinutes: number > 0
  • ciGraceMinutes: number ≥ 0

After the Landing opens a pull request, the Run waits up to ciTimeoutMinutes for its checks. CI that does not finish in time is a Hand-off that spends no fix budget.

ciGraceMinutes covers the gap before GitHub registers a check run, which has taken over three minutes. Until it passes, a pull request with no checks yet counts as pending rather than as having none. It never outlasts ciTimeoutMinutes. Raise it on a Target whose Actions queue slowly. A Target with no CI at all sets it to 0, so the wait answers at its first reading; left at the default, it waits the grace out once per merge, and since only one Lane is in the Landing at a time, that wait holds the other Lanes' merges too. A Target with CI keeps a grace: GitHub's answer cannot tell no CI from CI not registered yet, so under 0 a slow check run reads as none.

labels ​

  • labels: object of strings

Rename the triage vocabulary when the Target already uses other label strings. Only the keys you give change.

KeyDefault
needsTriageneeds-triage
needsInfoneeds-info
readyForAgentready-for-agent
readyForHumanready-for-human
wontfixwontfix
inProgressin-progress

init creates the labels under these names, and a Run refuses a Target missing any of them. What each label means to the pipeline is on Planning the work.

References ​