Take a GitHub issue to a verified preview, and to a merge only when every gate allows it.
issue ─▶ worktree ─▶ agent ─▶ gates ─┬─▶ push ─▶ PR ─▶ preview ─▶ merge gate ─▶ merged
▲ │ │
└─ fix loop ─┘ (bounded) └─▶ a person
A coding agent is good at writing the change. It is not the thing you want deciding whether the change reaches production. ship-loop splits those jobs: the agent works in an isolated checkout, and everything after it is plain code with rules you can read. Tests and builds must pass, the diff is scored for risk, the preview has to actually serve a page, and a merge happens only when a named CI check is green and the risk is low. Anything else stops and waits for a person, with the reason written on the PR.
I built the first version of this as the delivery loop for my own products, which I ship next to a full-time job. This is that loop as a standalone tool that works on any GitHub repo.
See it work: ship-loop-demo#1 is a real issue with five acceptance criteria. ship-loop took it to PR #2: Claude Code wrote the CSV export and its tests in an isolated worktree, typecheck and tests passed on the first attempt, the diff scored low risk, and the PR carries all of it.
| Phase | What happens | Stops for a person when |
|---|---|---|
worktree |
Fetches origin/main and cuts the issue's branch into its own git worktree, beside the repo. Copies in gitignored files a build needs (.env, .vercel). Installs dependencies. |
the branch is already checked out somewhere else, or install fails |
built |
Hands the agent a brief made from the issue: the description, the checkbox acceptance criteria and the rules of the job. Commits whatever it changed. | the agent fails or changes nothing |
gated |
Runs every gate (typecheck, tests, build, anything you configure). Failures go back to the agent with their output, for up to 3 rounds. | gates still fail after the last round |
pushed / pr-open |
Pushes the branch and opens a PR carrying the acceptance criteria, the gate results and the risk report. Labels high-risk PRs needs-review. |
|
preview-verified |
Waits for the host's deployment on that exact commit, then requests the page. A redirect to a login wall is reported as protected, never as a pass. | no preview appears, or it answers an error |
merged |
Only with merge.auto: polls the PR's checks and merges once the required check is green, nothing failed and the risk is within the limit. Merges pinned to the SHA it checked. |
any check fails, the required check never runs, the risk is too high, or it times out |
Every phase is recorded in .git/ship-loop/<issue>.json. Running the same issue again resumes after the last completed phase, so an interrupted run never pushes twice, opens a second PR or redeploys. After fixing whatever stopped a run, ship-loop run <issue> --retry continues from where it stopped with a fresh fix budget.
npm install -g github:ahzab/ship-loop
cd your-repo # origin must be on GitHub
export GITHUB_TOKEN=... # or be logged in with the gh CLI
ship-loop plan 42 # show the branch, gates, merge policy and the agent's brief
ship-loop run 42 # do it
ship-loop status # every run and where it is
ship-loop risk # score the current branch's diff, no issue neededExit codes: 0 finished (merged, or a verified PR ready for review), 1 stopped for a person, 2 usage or setup error.
The default agent is Claude Code in headless mode. Any command that reads a prompt on stdin and edits files in the current directory works: set agent.command.
ship-loop.config.json at the repo root. Everything is optional; this is a typical setup:
{
"install": ["npm", "ci"],
"agent": { "command": ["claude", "-p", "--permission-mode", "acceptEdits"], "maxFixRounds": 3 },
"gates": [
{ "name": "typecheck", "run": ["npm", "run", "typecheck"] },
{ "name": "test", "run": ["npm", "test"] },
{ "name": "build", "run": ["npm", "run", "build"] }
],
"preview": { "enabled": true, "timeoutSec": 600, "path": "/api/health" },
"merge": { "auto": true, "requiredCheck": "test", "maxRisk": "low", "method": "squash" }
}Merging is off by default. Turning it on requires naming the CI check that must pass (requiredCheck); the config is rejected without one.
The risk score decides who looks at a change, not whether it is good. It is based on what the diff touches:
- high: migrations and SQL, auth and permission code, middleware, CI workflows, or more than twice
maxLineschanged lines - medium: dependency manifests and lockfiles, env files, deploy config, deleted files, more than
maxLineschanged lines, or source changes with no test changes - low: everything else
Each factor lists the files that raised it, and the PR shows them. The globs are configurable under risk.high and risk.medium. A high-risk change is never merged unattended; merge.maxRisk only accepts low or medium.
flowchart TB
CLI[cli.ts] --> P[pipeline.ts<br/>phase machine + resume]
P --> S[state.ts<br/>.git/ship-loop/*.json]
P --> G[git.ts<br/>worktrees, commit, diff, push]
P --> A[agent command<br/>prompt from ticket.ts]
P --> T[gates.ts]
P --> R[risk.ts]
P --> H[github.ts<br/>issues, PRs, checks, deployments]
P --> V[verify.ts<br/>preview request]
P --> M[merge.ts<br/>pure verdict]
G & T & A --> X[exec.ts runner]
pipeline.ts owns the order of the phases and nothing else. Every side effect goes through an injected dependency (the process runner, git, the GitHub client, fetch, the clock), which is what lets the tests drive whole runs, including crashes, retries and slow CI, without a network or an agent. The merge decision is a pure function of the PR, its checks, the risk and the config, so every refusal is a unit test.
- The agent never touches git. It is told not to commit, push or change branches, and ship-loop does all of it. The agent's job is the diff; the pipeline's job is everything that makes the diff safe to ship. A model that can push can also push the wrong thing.
- A worktree per issue, cut from a freshly fetched
origin/main. Branching in your working copy moves your HEAD, stacks the new work on whatever branch you had checked out, and lets agit add -Apick up your half-written files. A separate worktree beside the repo makes all three impossible, and it is idempotent, so a resumed run lands in the same checkout. - "All checks passed" is not a merge condition. It is true of a PR with no checks, and of a PR whose only checks are the hosting provider's deploy statuses, green while nothing ran the tests. ship-loop requires one named CI check to be present and green, refuses when there are no checks at all, and merges with the SHA it verified so a late push cannot slip in.
- A preview behind a login wall is not a verified preview. Vercel and Netlify protect previews by redirecting to a login page, and that redirect looks like success to a naive check. ship-loop reports it as
protected, says so on the PR, and will not auto-merge on it. - Bounded loops everywhere. The fix loop, the preview wait and the merge wait all have a limit. When one runs out, the run stops with the reason and resumes later from the same place. A loop that never gives up burns tokens and CI minutes on a problem that needs a person.
- Refusals are answers. When the merge gate says no, ship-loop comments the reason on the PR and exits. It does not look for another route to merge.
- State lives in
.git. Run state is local, shared by every worktree, never committed, and written atomically, so a run killed mid-write cannot corrupt whatresumereads. - No runtime dependencies. Node 20+,
git, andfetch. TypeScript and Vitest are development-only.
npm install
npm test # 34 tests: units, pipeline scenarios with fakes, real git worktrees
npm run typecheck
npm run build && node dist/cli.js --help- GitHub only, and one repo per run.
- Preview detection reads GitHub deployment statuses, which Vercel and Netlify both publish. A host that reports previews another way needs
preview.enabled: false. - The risk score is about what a change touches, not whether it is correct. That is what the gates and the reviewer are for.
MIT