thirdshift
A factory that turns a GitHub issue into a ready-for-review pull request, or on request a merged one, by running unattended agent sessions against it. The humans are the day shift; the agents work the third shift, overnight, on any server, while you do something else.
Quality over quantity: a pull request marked ready for review is one the factory stands behind. When it can't stand behind the work, the Run is a Failed run: the work is pushed so nothing is lost, and the pull request goes back to a draft.
Terms in bold are defined in CONTEXT.md.
What a Run does
From a clone of the issue's repository, thirdshift <Issue URL>:
-
Checks that the Run makes sense, before creating anything:
- the Origin match: the Issue URL must belong to the same GitHub repository as the
originremote of the directory you run it from; - the issue is open;
- the Base branch is well defined: HEAD is not detached, the branch exists on
origin, and your local copy is not ahead of it, so unpushed commits can't leak into the pull request; - a local Issue branch, if you have one, points at the same commit as its copy on
origin, so local-only commits are never destroyed.
Any failed check stops the Run before any work happens.
- the Origin match: the Issue URL must belong to the same GitHub repository as the
-
Picks the Issue branch (
issue-<n>, orissue-<n>-branch-<k>once earlier ones are finished) and the Base branch, normally the branch you have checked out. -
Creates a git worktree next to your clone, named
<repo>-<Issue branch>, so your own checkout is never touched. -
Runs a headless Claude Code session in that worktree with the Factory skills in
skills/loaded, started with one of the Session prompts inprompts/. The agent implements the issue, reviews its work against the Base branch, addresses the Standards findings and Spec findings it agrees with, and opens a ready-for-review pull request that lists every Unaddressed finding with a reason and closes the issue. -
Takes over deterministically: pushes the Issue branch, skipping your repo's git hooks since the session runs the tests and CI gates the pull request, checks through
ghthat the pull request exists, is open and targets the Base branch, and marks it ready for review (gh pr ready) if the agent left it as a draft. -
Keeps the pull request mergeable and green: merges the Base branch in and watches CI, starting a Repair session for a merge conflict or failing checks, at most 5 per Run. If a CI-fix Repair leaves the head unchanged, with no commit of its own and no Base branch move to merge, the Run ends as a Failed run, since CI would stay red on the same head. If the Base branch moves while CI runs, it merges it again and goes round, at most 5 times per Run. In a Merge run, each round also takes in Foreign commits first: see Foreign commits in a Merge run.
-
In a Merge run (
thirdshift merge <Issue URL>), does the Self-merge: once the pull request is open, ready for review, mergeable and green, thirdshift merges it into the Base branch with a merge commit, on exactly the head commit whose CI it watched (gh pr merge --merge --match-head-commit <sha>). It never uses GitHub's auto-merge (ADR-0004). A merge that fails goes back round step 6, within the same budgets, and is tried again on the new green head. If that round finds nothing to fix, the refusal is a policy refusal, such as merge commits being disallowed or a review being required. After the merge, thirdshift deletes the Issue branch onorigin, and closes the issue if it is still open, with the commentClosed by #<pr>, merged into <base> by a thirdshift Merge run.GitHub closes it on its own only for a merge into the repository's default branch, and then thirdshift leaves it alone. -
Cleans up: removes the worktree, the local Issue branch and the temporary plugin directory, whether the Run succeeded or not. The one exception is a Failed run whose work could not be pushed: see below.
Status
The designed behaviour described in this README is implemented. Known gaps and planned work are tracked in the open issues.
Install
|
The shell installer and the binary both come from this repository's GitHub Releases. It puts thirdshift in ~/.local/bin, where Claude Code's installer puts claude, and needs no Rust toolchain. thirdshift is a single binary with the Factory skills compiled in, so it runs without a checkout of this repository (ADR-0001, ADR-0003).
The installer ends by pointing you to Setup. Run thirdshift setup to choose your defaults, or just start a Run and thirdshift will offer it (see User config).
If you use Rust, you can install it from crates.io instead:
Updating
It replaces the installed binary with the latest stable GitHub Release, or says it is already on it. Messages go to stderr and stdout stays empty. It exits 0 when it updated or was already up to date, and 1 on any failure, such as no network. Updating is safe while a Run is using the old binary, and a Run never checks for updates or updates itself.
thirdshift update only replaces a copy put in place by the shell installer (the curl command above), which leaves an install receipt in ~/.config/thirdshift/. It refuses to touch any other copy and lists the command that updates each other kind of install: cargo install thirdshift for a crates.io install, pulling and reinstalling for a build from source (see Building from source), and the curl command for a copy placed by hand.
Keep a single copy: with copies in more than one directory on your PATH (say ~/.local/bin and ~/.cargo/bin), you can end up running a stale one without noticing. type -a thirdshift lists every copy on your PATH.
Supported platforms
- Rocky Linux 9.8 and later, x86_64.
- Ubuntu 24.04.3 and later, x86_64.
- WSL2, latest release, with Ubuntu 24.04 or later, x86_64. Keep your repositories on the Linux filesystem (e.g.
~/repos), not under/mnt/c, where git is slow and file permissions misbehave. WSL1 is not supported. - macOS Tahoe 26.5.2 and later, Apple Silicon.
One statically linked Linux binary covers the first three, so it doesn't depend on the host's glibc; macOS gets a native build.
Prerequisites
The user account that runs thirdshift needs:
claude(Claude Code), logged in.gh(GitHub CLI), logged in.gitwith a globaluser.nameanduser.email, and credentials that can push to the repository (gh auth setup-gitmakes git usegh's login). The agents commit as this identity; without it, an agent may borrow the author of the last commit.
Auto mode
Sessions run headless in Claude Code's auto mode (claude -p --permission-mode auto), with the full permissions of that user account, including sudo if the user has it. Nobody is there to approve anything; instead, auto mode's classifier checks each action and may block ones it judges risky, such as destructive commands or actions outside the task. A blocked action the agent can't work around can end the Run as a Failed run.
claude -p exits as soon as the agent ends its turn, killing any background task it started. So every prompt tells the agent to run long commands, such as tests, in the foreground. If a session still ends while waiting on background work, thirdshift gives it a Resume: it continues that same session once (claude -p --resume), asking the agent to re-run the work in the foreground and finish. If the Resume ends the same way, the Run fails and says which task was killed.
What sessions leave behind
Cleanup removes only thirdshift's own worktree, local Issue branch and temporary plugin directory. Anything else an agent does as your user stays. For example, one Run downloaded a JDK to ~/.local/jdk/ because the server had no Java, installed Playwright in /tmp/pw, and left a pull request body draft in /tmp/. This is by design, but worth knowing:
- Install the target repository's toolchains up front (language runtimes, package managers, test tools), so the agents don't download their own on every Run.
- For UI work, install the headless browser dependencies, e.g.
libgbm1on Ubuntu server, which headless Chromium needs. Without it, agents can't take screenshots to check a UI change and fall back to reading the code.
Usage and cost
When claude is logged in with a claude.ai subscription, Runs use the plan's usage allowance, not API billing. The total_cost_usd in the session logs is the API-price equivalent, not a charge. An unattended Run that hits the plan's usage limit stalls or fails partway through.
Usage
A Run takes minutes to tens of minutes.
To have the Run merge its pull request instead of leaving it for your review, start a Merge run:
thirdshift --merge <Issue URL> does the same, and either goes before or after the URL. The agent gets the same implement prompt either way; the differences are the Self-merge at the end and the review of Foreign commits. To make every Run on a machine a Merge run, set merge.always in the User config; no-merge (or --no-merge), before or after the URL, then makes one Run end ready for review instead. Giving a flag twice, or merge together with no-merge, is an argument error.
A Merge run ends in one of three ways:
- Merged: exit
0, the pull request's URL on stdout, andPR <url> is mergedas stderr's last line. - Policy refusal: GitHub refused the merge and a round of merging the Base branch and watching CI found nothing left to fix. Exit
1, the pull request's URL on stdout, and GitHub's error on stderr. The pull request stays open and ready for review, not a draft, for you to merge by hand or to change the repository's settings. thirdshift never reads GitHub's error text to decide this. - Any other failure: a Failed run, as for a Run without
merge.
To be emailed when the Run ends, add --email (or email), optionally followed by the address, before or after the URL and alongside merge:
It sends one Run notification, whatever the outcome. To have every Run on a machine send one, set email.always in the User config; no-email (or --no-email) then skips it for one Run.
- stdout carries only the pull request's URL: on success, and on a Failed run that leaves an open pull request, a draft or, after a policy refusal, one ready for review. The exit code tells the two apart, so script it as
url=$(thirdshift "$issue") && echo "ready: $url". - stderr carries everything else: errors, cleanup problems, and progress lines while sessions run. Each line starts with the local time it was printed, as in
thirdshift: 12:14:49 pushing issue-7, so a quiet terminal shows how long the Run has been on its last step. A successful Run's last line names the pull request too:PR <url> is ready for review, orPR <url> is mergedafter a Merge run, followed only by awarning:line if a Run notification can't be sent. - Exit code
0means the Run ended with a pull request the factory stands behind, merged in a Merge run. Once the Self-merge has merged, the Run succeeds even if deleting the Issue branch onoriginor closing the issue then fails: the merge can't be undone, so each failed step is awarning:line on stderr naming the command to run by hand, and the Run still exits0with the URL on stdout. Ctrl-C likewise: before the merge it makes a Failed run, after it thirdshift finishes these steps and exits as merged.2means the Issue URL is missing or isn't a GitHub Issue URL, there is an argument other than the URL and the Run flags, a Run flag is repeated or contradicts another, orparallelisn't followed by a whole number from 1 up; the error and the help text go to stderr, before any work. A User config thirdshift can't use exits1, also before any work. Any other failure exits1.
The other commands:
version and help print to stdout and exit 0. update, setup and email-test follow the Run's rule: stdout stays empty, messages go to stderr.
Uncommitted changes in your clone are fine: the Run works in its own worktree from origin, so they are simply left out. Unpushed commits on the Base branch are not: push them first, or the Run stops.
User config
A User config at ~/.thirdshift/config.toml sets this machine's defaults for every Run. It is optional: with no file, or one that says nothing about a setting, a Run does only what its command asks for.
[]
= true # every Run is a Merge run, without the merge word
[]
= true # every Run first fast-forwards your checkout of the Base branch
[]
= true # every Run sends a Run notification, without the email word
= "you@example.com" # where email goes when the command names no address
= "thirdshift@your-verified-domain.com" # the sender; onboarding@resend.dev if unset
[]
= "~/elsewhere/logs" # where session logs go, instead of ~/.thirdshift/logs
[]
= 2 # how many Tickets a Spec run runs at once, instead of 3
thirdshift setup writes this file for you, listing every setting at its default so the file itself shows what can be changed:
[]
= false # every Run is a Merge run, without the merge word; default false
[]
= false # every Run first fast-forwards your checkout of the Base branch; default false
[]
= false # every Run sends a Run notification, without the email word; default false
# to = "you@example.com" # where email goes when the command names no address; no default
= "onboarding@resend.dev" # the sender; default onboarding@resend.dev, which only delivers to your Resend account's address
[]
= "~/.thirdshift/logs" # where session logs go; default ~/.thirdshift/logs
[]
= 3 # how many Tickets a Spec run runs at once; default 3
Every key holds its real value, so a Run reading it does exactly what it does with no file. email.to has no default, so setup suggests one: the public email of your GitHub profile (from gh api user), else your global git user.email, unless that is a @users.noreply.github.com address, which can't receive mail. With neither, email.to is the only line written commented out, as above. setup never asks gh for more scopes, so a private GitHub email is not read, and a Run never looks the suggestion up: --email with no address and no email.to still stops the Run. From a terminal (stdin and stderr both terminals), setup first asks, on stderr:
- Every Run a Merge run? (
merge.always) - Every Run first fast-forwards your checkout of the Base branch? (
launch.pull) - Run notifications? (
email.always). If yes, the address (email.to), asked again until it has an@, then the sender (email.from). - With notifications on, the Resend API key, with input hidden. With none saved it asks
Resend API key (input hidden, Enter to skip):; with one in the Credentials,Resend API key (input hidden, Enter keeps the saved one):, and a new one replaces it. Surrounding spaces are trimmed, and anything that doesn't start withre_is asked again. A key you give is saved in the Credentials,~/.thirdshift/credentials.toml, created with mode 600 (and~/.thirdshiftwith it) or edited in place, keeping its comments and anything else in it and changing onlyresend.key;setupthen printswrote the Credentials <path>. The key is never printed, nor written to the User config. Skipping writes no Credentials and says how to add a key later: rerunthirdshift setup, or setRESEND_API_KEY. WithRESEND_API_KEYset and not empty, which wins over the Credentials, nothing is asked, and it says the key comes fromRESEND_API_KEY. With a key found or given, it offers to send a test email (default No), asthirdshift email-testdoes, once the files are written.
Pressing Enter takes the default shown, which is the file's current value, or else the setting's default, and for the address the suggested email above. logs.dir is not asked about. The answers are written like everything else below: in place, keeping your comments. Ctrl-C during the questions, the key included, writes nothing: neither the User config nor the Credentials. With notifications off, nothing about a key is asked, and saved Credentials stay as they were, so --email on a single Run still works. Credentials a Run would refuse (see Email) are refused before any question, exit 1, and not touched. With no terminal, as from cron or thirdshift setup </dev/null, setup asks nothing and never writes the Credentials. Either way it prints the file's path on stderr and exits 0 with stdout empty. Over a User config that is already there, setup edits it in place: its comments and key order stay, as do the values it didn't ask about, and each key it lacks is added at its default with its comment, so afterwards the file lists every setting this version knows. One that already does, down to the commented-out email.to line, is left byte for byte as it was. A key added to an inline table, such as launch = { pull = true }, gets no comment, since TOML has no place for one there. One a Run would refuse is refused the same way, exit 1, and not touched. Any argument after setup is an argument error (exit 2).
The first Run on a machine with no User config, started from a terminal, offers Setup before any work, on stderr: No User config at <path>. Set your defaults now? [Y/n]. Yes (or Enter) asks the questions above, writes the file, and the Run carries on using your answers; a flag in the command, such as --no-merge, --email or --no-email, still wins over them. No writes every setting at its default, as setup with no terminal does, asks nothing about a key, writes no Credentials, says that thirdshift setup changes it, and the Run carries on; later Runs find the file and don't offer again. If the file can't be written, stderr gets a warning: line and the Run carries on with the defaults. Ctrl-C during the offer or the questions writes nothing and ends the command before any work, with no Run notification. A command thirdshift can't parse exits 2 before any offer. A Run with no terminal, as from cron, CI, nohup or an agent's shell, offers nothing, writes nothing, and runs on the defaults, so a later Run from a terminal still gets the offer.
With merge.always = true, thirdshift <Issue URL> is a Merge run, and thirdshift --no-merge <Issue URL> (or no-merge, before or after the URL) leaves that one Run's pull request ready for review.
With launch.pull = true, every Run brings the Base branch checked out in the directory you start it from (the Launch directory) up to date with origin, so you no longer git pull by hand before each Run. It happens after the pre-flight checks pass and before the worktree is created, as git merge --ff-only origin/<Base branch>: fast-forward only, never a merge commit or a rebase, always from origin, whatever the branch's upstream or your pull.* settings. A progress line on stderr says when it updates the branch; an already up-to-date branch is left quietly as it is. It is skipped when the checked-out branch isn't the Base branch, as in a Continuation whose open pull request targets another base, or on a detached HEAD. If the update can't happen, for example because uncommitted changes are in the way, stderr gets a warning: line with git's error and the command to run by hand, your changes are left as they were, and the Run carries on with the same outcome and exit code. The setting only affects your checkout: the Run's worktree starts from origin/<Base branch> either way.
With email.always = true, every Run sends a Run notification to email.to, as if given --email, and thirdshift --no-email <Issue URL> (or no-email, before or after the URL) sends none for that one Run.
spec.parallel sets how many Tickets a Spec run runs at once, by default 3. It must be a whole number from 1 up; parallel <n> on the command line wins over it for one Spec run.
logs.dir sets the directory session logs are written to, created if missing. It must be an absolute path, ~ or a path starting with ~/, where ~ stands for $HOME. A relative path stops the Run before any work, since the directory a Run is launched from is no base for a setting that holds for every Run.
A Run reads the file before any work. One that isn't valid TOML, or that has a key or section thirdshift doesn't know, such as alway for always, or a value of the wrong type, such as anything but true or false for always, or 0 for spec.parallel, stops the Run with an error naming the file and the offending key, so a typo can't silently leave a setting off. email-test and setup read it the same way. update, version and help never read it, so a broken User config can't block them, and they never offer Setup; nor does email-test.
thirdshift sends email itself, with one HTTPS request to Resend's API, so it needs no mail server on the machine and works where SMTP ports are blocked (ADR 0005). It needs a Resend account and an API key:
-
The API key comes from the
RESEND_API_KEYenvironment variable when it is set and not empty, and otherwise from the Credentials,~/.thirdshift/credentials.toml, a file only you should be able to read (mode 600):[] = "re_..."thirdshift setupasks for the key, hidden, and saves it there for you. The environment variable wins, so a CI secret or anexportoverrides the saved key for one command. The Credentials are what let a Run started from cron,nohupor an agent's shell find the key, since those read no shell profile. thirdshift never reads the key from the User config, so that file holds no secret. -
email.toin the User config is the address email goes to when the command gives none. -
email.fromis the sender. Without it, email comes fromonboarding@resend.dev, Resend's shared sender, which only delivers to the address of your own Resend account. To send to any other address, setemail.fromto an address on a domain you have verified with Resend.
To check the setup without starting a Run:
It sends one test email, whose subject marks it as a test and whose body names the host, the time and the sender. Before sending, it checks that it has an address (the argument, else email.to) and a key (RESEND_API_KEY, else the Credentials); if either is missing, it exits 1 naming what's missing and sends nothing. With no key anywhere, it lists every way to give one:
no Resend API key. Either:
- run `thirdshift setup`, or
- add it to /home/you/.thirdshift/credentials.toml (mode 600):
[resend]
key = "re_..."
- or set RESEND_API_KEY in the environment the Run starts from
(a crontab line, CI secret, or a shell profile the Run's shell reads)
The Credentials are read only when RESEND_API_KEY is unset or empty, and only by email-test, setup, and a Run that asks for a notification. A missing file just means no key from it. One that isn't valid TOML, or holds anything but a quoted resend.key, such as a typo like kye, stops the command with exit 1, naming the file and the offending key, and nothing is sent. One that others can read is still used, with a warning: line on stderr saying to chmod 600 it. Nothing is sent to check the key itself. When Resend accepts the email, it prints accepted by Resend; check your inbox and exits 0; that is all it can verify, so check that the email arrives. When Resend refuses it, for example for a bad key or a sender it won't send from, it prints Resend's error text word for word, with where the key came from, and exits 1. It gives up after 30 seconds without an answer.
Run notifications
--email (or email) asks a Run for a Run notification: one email, sent when the Run ends, whatever the outcome. The word after the flag is the address only if it contains @ and doesn't start with https://, so the Issue URL is never taken for it; otherwise the email goes to email.to. With email.always = true in the User config, a Run asks for one without the flag, and --no-email (or no-email) skips it for that Run; an address after --email still wins over email.to. Giving a flag twice, or --email together with --no-email, is an argument error.
A Run that asks for a notification, by the flag or by email.always, makes the same checks as email-test before any other work: an address is known, and a key is found, in RESEND_API_KEY or else the Credentials. If either fails, or the Credentials are broken, the Run stops, exits 1 naming what's wrong (with no key, the message above listing every way to give one), and sends nothing. A Run that asks for no notification never reads the Credentials, so a broken file can't stop it, and update, version and help never read it either. Once they pass, every way the Run ends sends exactly one notification, after its outcome is final and its cleanup done: ready for review, merged, a Failed run (including a later preflight failure such as an origin mismatch), or interrupted by Ctrl-C, SIGTERM or a closed terminal.
- Subject:
[thirdshift] <owner>/<repo>#<n> <issue title>: <outcome>, where the outcome isready for review,merged,failedorinterrupted. The title is left out if it can't be read from GitHub. - Body, plain text: the pull request URL (if any), the failure cause (if failed), the session log path (if any), the hostname and how long the Run took.
A Spec run sends at most one notification for the whole Spec, under the same rules, with its checks made once before any Ticket starts. Its subject names the Spec, its outcome is the Spec run's, and its body, after what a Run's holds (the Spec PR, if any), lists each Ticket's outcome, one line per Ticket as in the summary on stderr, such as #21 landed with https://github.com/acme/widgets/pull/1 or #22 blocked by #21. The Ticket Runs inside it never send a notification of their own, whatever the User config says.
A notification that can't be sent is a warning: line on stderr with Resend's error. It never changes the Run's outcome, stdout or exit code.
Logs
Each session's full transcript, as Claude Code's stream-json output, is written to its own file under ~/.thirdshift/logs/, or the logs.dir set in the User config (created if missing):
~/.thirdshift/logs/<owner>-<repo>-issue-<n>-<timestamp>-implement.jsonl
~/.thirdshift/logs/<owner>-<repo>-issue-<n>-<timestamp>-repair-<i>.jsonl
A Resume is logged as its session's kind plus -resume, e.g. implement-resume.jsonl.
All sessions in a Run share the Run's UTC timestamp, so a Run's logs sort together. When a Run fails, stderr ends with the path of its most recent session log, the place to start looking.
Foreign commits in a Merge run
A Merge run merges only code an agent wrote or reviewed. If someone else pushes to the Issue branch during the Run, their Foreign commits are reviewed before they can be merged:
- Each round of step 6 starts by fetching the Issue branch from
origin. Any new commits there are merged into the Run's branch, by fast-forward or a merge commit, never a rebase, each logged on stderr asmerging new commit <sha> from origin/<branch>. Foreign commits that arrive while CI runs on a green head send the Run round again rather than on to the merge. A merge that conflicts with the Run's own work gets a conflict Repair. - A review Repair then runs
/thirdshift:code-reviewwith the head thirdshift last knew as the Run's own as the fixed point. The agent fixes the findings it agrees with, adds the rest to the pull request body's "Unaddressed findings" section marked as coming from the Foreign commits, and pushes. - The round goes on as usual: the Base branch is merged in, CI watched, and the merge tried once the head is green.
Review Repairs are logged as repair-<i>, numbered with the other Repairs, count against the cap of 5 Repairs, and get a Resume like any session. A round that picks up Foreign commits counts against the same 5, like a Base branch move. A Run that spends either budget fails.
A Run without merge does none of this: it never fetches someone else's commits into its branch.
Continuation
Running thirdshift again on an issue picks up where the last Run stopped (ADR-0002). It looks at the highest-numbered Issue branch:
- No Issue branch yet: a fresh Run creates
issue-<n>from the Base branch. - The branch exists with no pull request, or an open one: the Run is a Continuation. It checks out that branch, and the agent builds on its commits instead of starting over, then creates the pull request or updates the open one. With an open pull request, that pull request's base is the Base branch, whatever you have checked out.
- The branch's pull request was merged or closed: finished work is never reopened. The Run starts fresh on the next number,
issue-<n>-branch-2,-3, and so on. A number counts as used even if GitHub deleted the branch after merging.
So you can retry a Failed run with the same command, or start an issue on one server and continue it on another.
A Run is not idempotent: re-running builds on whatever is already on the branch, including a Failed run's work-in-progress commit.
Failed runs
A Failed run is one that ends, including by Ctrl-C or a closed terminal, without an open pull request from its Issue branch that targets the Base branch, is mergeable and has passing CI, or, for a Merge run, without that pull request merged. Causes include the session exiting non-zero, no pull request or one with the wrong base, running out of Repairs, a CI-fix Repair that finds nothing on the branch to fix (a declined CI fix: the head is unchanged after it, so CI would stay red), a session that still ends while waiting on background work after its Resume, and a Base branch that keeps moving while CI runs, or in a Merge run, an Issue branch that keeps getting Foreign commits.
A Failed run:
- Commits any uncommitted work as
thirdshift: failed run (<reason>), with a timestamp and the hostname, and pushes the Issue branch, so nothing is lost. If the branch has no changes against the Base branch, nothing is pushed. - Converts its open pull request, if any, back to a draft, so a pull request only claims to be ready when the factory stands behind it. The next successful Continuation marks it ready again. The exception is a Merge run's policy refusal: the pull request is ready, mergeable and green and only the Self-merge could not happen, so it stays ready for review, and no failure commit is pushed onto the head whose CI was watched.
- Cleans up as usual, prints the reason to stderr and exits non-zero. If the push failed, the worktree and local Issue branch are kept instead, and stderr names the branch, its head commit and the worktree path, so you can recover the work or push it by hand.
Merges, never rebases or force-pushes: a branch worked on from several servers never loses history.
Spec runs
A Spec is an issue with sub-issues, its Tickets. thirdshift <Issue URL> on a Spec is a Spec run: it works through the Tickets in the order their GitHub "blocked by" links allow, each Ticket's Run a Merge run into the Spec branch, then leaves one Spec PR from the Spec branch into the Base branch ready for review (ADR-0006). An issue with no sub-issues is an ordinary Run.
The Spec PR opens as a draft as soon as the first Ticket lands, titled from the Spec, with Closes #<spec> and a Tickets checklist: one line per Ticket, ticked once it is done, with its pull request, or saying it is running, failed, blocked or unready. thirdshift rewrites the checklist between its <!-- thirdshift:tickets --> markers as each Ticket starts and ends, leaving the rest of the body as it is. Once every Ticket is done, the Spec review rewrites the body, and thirdshift puts the checklist back, appending it if the markers are gone, before marking the Spec PR ready.
The Spec review session reviews the whole Spec branch against the Base branch and the Spec. Once it is marked ready, the Spec PR goes through the same step 6 as a Run's pull request, with the Spec as the issue and the Spec branch as the Issue branch: the Base branch is merged in (never rebased), CI watched, and a conflict or red CI handed to a Repair, within the same budgets.
Tickets always merge into the Spec branch, whatever the command or the User config says. merge (or --merge, or merge.always = true without no-merge) applies to the Spec PR alone: the Spec run ends with the Self-merge of the Spec PR into the Base branch, then deletes the Spec branch on origin and closes the Spec if the merge did not. Without it, the Spec run ends with the Spec PR ready for review. A Spec PR that fails from the Spec review on follows the Failed run rules for its pull request, back to draft unless only the Self-merge could not happen (a policy refusal), and the Spec run exits 1.
A Spec run takes every Ticket it can reach. A Ticket runs once it is open, has every blocker closed, and is not an Unready Ticket: an open Ticket labelled ready-for-human, needs-info, wontfix or needs-triage. An open Ticket with no triage label is taken. A Ticket with sub-issues of its own is never run either, and is reported as unready. A blocker outside the Spec counts once it is closed. The graph is read again from GitHub whenever a Ticket's Run ends, so removing a label, adding a Ticket or closing one by hand takes effect in the same Spec run.
A Ticket whose Run fails is not tried again in that Spec run, and stops only the Tickets it blocks; every other Ticket it can reach still runs. Nor do Unready Tickets, Tickets blocked by an open issue outside the Spec, or Tickets in a cycle of "blocked by" links run, nor any Ticket downstream of them.
Independent Tickets run at once, up to 3 by default. Whenever a Ticket's Run ends, the Spec run reads the graph from GitHub again and starts ready Tickets until the limit is reached. To change the limit for one Spec run, add parallel <n> (or --parallel <n>) before or after the URL; spec.parallel in the User config sets it for every Spec run on the machine:
parallel followed by 0, a negative number or anything but a whole number, or given twice, is an argument error (exit 2). parallel on an issue with no sub-issues stops the Run before any work, since there are no Tickets to run at once. The Tickets' Runs share the Launch directory: they create their worktrees there one at a time, and a git command that finds a lock file held by another waits and tries again.
When nothing is left to run and any Ticket is not done, the Spec run is a Failed spec run: it leaves the Spec PR a draft, its checklist showing what's missing, prints its URL on stdout (if any Ticket has landed, so there is one), exits 1, and lists on stderr each Ticket that landed, with its pull request, and each one not done, with why:
thirdshift: 03:12:40 #21 failed: claude exited 1 (session log: ~/.thirdshift/logs/acme-widgets-issue-21-….jsonl)
thirdshift: 03:12:40 #22 blocked by #21
thirdshift: 03:12:40 #23 landed with https://github.com/acme/widgets/pull/1
thirdshift: 03:12:40 #24 unready: labelled needs-info
thirdshift: 03:12:40 #25 blocked by #99 (outside the Spec)
thirdshift: 03:12:40 #26 in a cycle: #26 blocked by #27 blocked by #26
thirdshift: 03:12:40 #27 in a cycle: #27 blocked by #26 blocked by #27
thirdshift: 03:12:40 Tickets not done: #21, #22, #24, #25, #26, #27
Running the Spec again picks up where the last Spec run stopped. The Spec branch is on origin, so the Spec run continues it (ADR-0002) and updates its draft Spec PR rather than opening another. Closed Tickets are done and not run again, and a Ticket that failed still has its Issue branch and draft pull request, so its Run is a Continuation into the Spec branch. With every Ticket closed, the Spec run goes straight to the Spec review and the Spec PR. With every Ticket closed and no Spec branch, as when the Spec was done some other way, it stops with every Ticket is closed and there is no Spec branch; nothing to do and exits 1, creating no branch or pull request and leaving the Spec open.
Building from source
Building needs the Rust toolchain. From a clone of this repository:
cargo install puts the binary in ~/.cargo/bin. To update it, pull and reinstall:
&&
If the shell installer also put a copy in ~/.local/bin, typing thirdshift runs whichever copy comes first on your PATH, and the installer's copy often does. To see every copy, in the order the shell tries them:
To run the build from source, give its path, as in ~/.cargo/bin/thirdshift <Issue URL>, or put ~/.cargo/bin first on your PATH, for example in ~/.bashrc:
thirdshift version can't tell the two apart: a build from main reports the last released version until the next release bumps it.
Editing a skill in skills/ has no effect until you rebuild and reinstall (ADR-0001).
The Prompts and skills page is generated from the prompts, the claude arguments and the skills, and each Session prompt is also generated as a Markdown file in prompts/, with a README.md that lists them with the claude command lines. cargo test fails until they are regenerated after a change to any of them. Regenerate the page and prompts/ with UPDATE_PROMPTS=1 cargo test prompts_page, which also deletes any file in prompts/ that it doesn't generate.
Running the test suite (cargo test) also needs python3 on PATH: the integration tests swap in fake gh and claude, which are Python scripts in tests/fakes/.
Releasing
From a clone of this repo, signed in to gh and with claude logged in, run the release script with the new version:
It works from origin/main in a temporary worktree, so your checked-out branch and uncommitted changes don't matter and aren't touched. It opens a pull request from release-<version> that bumps the version in Cargo.toml and Cargo.lock and nothing else. Its body is a summary of the release, then the version diff. claude -p, with no tools, writes the summary from the version diff and the title, number and body of each pull request merged since the last tag, following the prompt in scripts/release-summary.md. If claude fails or prints nothing, the script warns and uses GitHub's generated notes as the summary instead, with a note saying so. It writes the summary before it pushes anything. With --review (scripts/release.sh --review 0.4.0), it then prints the summary and asks [y]es / [e]dit / [n]o: y carries on, e opens the summary in $EDITOR and carries on with what you save, and n stops with no branch, pull request or tag pushed. Without --review it never prompts. It waits for the pull request's checks, merges it with a merge commit, then tags that merge commit v<version> and pushes the tag. It prints a line on stderr for each step. If the checks fail, it stops before merging and leaves the pull request open.
Before it pushes anything, it refuses a release that can't be cut and says why: a version that isn't plain X.Y.Z (no leading v), a version that isn't higher than the one in Cargo.toml on origin/main, a v<version> tag that already exists locally or on origin, or a latest CI run on origin/main that failed or hasn't finished.
If it's interrupted, run it again with the same version: it picks up the existing branch or pull request and carries on from the first step not yet done, without the checks above, which the first run passed. With --review, it asks about a new summary before opening a pull request for a branch already pushed. Once the tag is on the merge of the pull request, it says the release is already tagged and exits 0; a v<version> tag anywhere else on origin is still refused.
The tag starts the release workflow, generated by dist from dist-workspace.toml. It builds the Linux and macOS binaries and the installer, checks that the Linux binary starts on Rocky Linux 9 and in WSL2 with Ubuntu 24.04, and only then publishes the GitHub Release. It then publishes the same version to crates.io, skipping it if it is already there, and finally replaces the Release's body with the bump pull request's summary, then notes generated from the merged pull requests, then the install instructions. scripts/release-notes.sh builds that body; for a tag pushed by hand, with no bump pull request summary, it is the generated notes and the install instructions alone.
Credits and license
thirdshift is released under the MIT License.
The Factory skills in skills/ are adapted from Matt Pocock's mattpocock/skills, changed to run headless with no human in the loop. They are used under the MIT License; its copyright notice is kept in skills/LICENSE and is embedded in the binary and written out with the skills on every Run. The pr skill also reproduces part of Dex Horthy's show-me skill; see skills/pr/CREDITS.md.