SlashKitsBuy

Add a release gate to Claude Code: stop shipping half-finished AI code

Build a Claude Code release gate: a shipper subagent that runs your real tests, typecheck, lint and build, then returns a SHIP or BLOCK verdict.

Aryan Thakor, author

9 min read

A release gate in Claude Code is a dedicated subagent whose only job is to run your project's real checks (tests, typecheck, lint, build) and return a plain verdict: SHIP or BLOCK. It never writes code. It just tells you, with evidence, whether the work is actually finished.

You need one because "done" from a coding agent means "I stopped typing." It doesn't mean the tests pass. This post walks through the pattern, a complete .claude/agents/shipper.md file, a /ship slash command that drives it, and a non-AI backstop for when you forget to run it.

The problem: "done" isn't done

If you use Claude Code or Codex daily, you've seen this. The agent finishes a feature, writes a tidy summary, and says it's ready. Then:

  • The tests were never run, or were run once before the last three edits.
  • tsc fails on a type the agent "fixed" by changing a signature in one place.
  • There's a // TODO: handle error case sitting in the new handler.
  • A console.log(user) made it into the diff.
  • npm run build breaks because of an import that only resolves in dev.

None of this is the model being bad at code. It's a process gap: the agent that wrote the code is also deciding whether it's finished. The fix is to separate building from judging.

The shipper pattern

The loop:

  1. An implementer (the main thread, or a specialist subagent) writes the code.
  2. A separate shipper subagent runs the project's actual check commands.
  3. The shipper reports each command, its result, a list of blockers, and a single verdict.
  4. On BLOCK, the blockers go back to the implementer. Fix, rerun, repeat.

The shipper has three hard rules: it never fixes code, it never skips a check because it "looks fine," and if a check can't run, that's a BLOCK, not a pass.

Why it has to be a separate agent

Fresh context. Each subagent runs in its own context window. The shipper doesn't see the 40 turns where the implementer convinced itself the edge case was handled. It sees the repo and the commands.

No sunk-cost bias. An agent asked "is your code ready?" tends to say yes. An agent whose entire system prompt is "find reasons this can't ship, change nothing" behaves differently. Dropping its edit tools removes the easy path to "just fixing" things.

If you're new to how subagents are defined and invoked, the Claude Code subagents guide covers the file format and delegation in detail.

Why the main thread orchestrates

Subagents can't spawn other subagents. So the shipper can't call the implementer back when it finds a problem, and an implementer subagent can't call the shipper when it's finished. The loop has to live in the main conversation: implement, call shipper, read verdict, hand blockers back, call shipper again.

That's exactly what a slash command is good at, since it runs in the main thread.

Which checks to run

The core four, in order:

CheckWhyTypical command
TestsBehavior still worksnpm test, pytest, go test ./...
TypecheckCatches broken signatures across filestsc --noEmit, mypy ., pyright
LintCatches unused vars, bad patternseslint ., ruff check .
BuildProves it actually compiles/bundlesnpm run build, cargo build

Optional checks that catch the classic AI leftovers, run only against the diff so you don't fail on old code:

  • Leftover markers: TODO, FIXME, console.log, debugger, print( in added lines.
  • Secrets: anything that looks like an API key, private key, or .env file in the diff.
  • Migrations: if schema/model files changed, a migration file should exist too.

Discovering the right commands

Don't hard-code npm test into the agent. Have it read the project and pick the real commands:

  • package.json → scripts (test, typecheck, lint, build). Check the lockfile to pick npm, pnpm, yarn or bun.
  • Makefile → targets like test, lint, check, build.
  • pyproject.toml → tool sections ([tool.pytest], [tool.ruff], [tool.mypy]) and any task runner config.
  • Cargo.toml, go.mod → the standard toolchain commands.
  • CLAUDE.md → if you've documented "run make check before committing," that wins.

If a check doesn't exist for the project (say, no typecheck in a plain JS repo), the shipper should say so as N/A with the reason. That's different from a check that exists but fails to run, which is a BLOCK.

The SHIP/BLOCK output format

A fixed format matters. You want to scan it in two seconds, and you want the main thread to be able to pass the blockers list straight back to the implementer. Here's what a BLOCK looks like:

text
SHIP CHECK

Checks
- tests      pnpm test               FAIL  2 failed, 118 passed
- typecheck  pnpm tsc --noEmit       FAIL  src/api/orders.ts:42 TS2345
- lint       pnpm lint               PASS
- build      pnpm build              PASS
- diff scan  git diff main...HEAD    FAIL  1 TODO, 1 console.log
- secrets    git diff main...HEAD    PASS
- migrations schema unchanged        N/A

Blockers
1. tests: orders.test.ts > "rejects empty cart" expected 400, got 500
2. tests: orders.test.ts > "applies discount" TypeError: total is undefined
3. typecheck: src/api/orders.ts:42 Argument of type 'string' is not assignable to parameter of type 'number'
4. diff: src/api/orders.ts:57 "// TODO: validate coupon"
5. diff: src/api/orders.ts:63 console.log(cart)

Verdict: BLOCK

And a pass is just as explicit:

text
Verdict: SHIP
All 4 core checks passed. Diff scan clean. No migrations required.

No "mostly ready." No "should be fine once you fix the test." Two states.

The complete shipper agent

Save this as .claude/agents/shipper.md in your project (or ~/.claude/agents/shipper.md to use it everywhere):

md
---
name: shipper
description: Release gate. Use before any release, merge, PR, or when the user says a task is done. Runs the project's real tests, typecheck, lint and build, scans the diff for leftovers and secrets, and returns a SHIP or BLOCK verdict. Never edits code.
tools: Bash, Read, Grep, Glob
model: inherit
---

You are the shipper: a release gate. Your only job is to decide whether the
current changes can ship, using the project's real checks. You report. You
never fix.

## Steps

1. Discover commands. Read package.json scripts (and the lockfile to choose
   npm/pnpm/yarn/bun), Makefile, pyproject.toml, Cargo.toml, go.mod, and
   CLAUDE.md. Prefer commands the project documents over generic defaults.
2. Find the diff. Use `git diff main...HEAD` plus `git diff` for uncommitted
   changes. If the base branch isn't main, detect it from the git remote.
3. Run, in order: tests, typecheck, lint, build. Run each one even if an
   earlier one fails. Capture exit code and the key lines of output.
4. Scan added lines in the diff for: TODO, FIXME, console.log, debugger,
   stray print( calls, hard-coded secrets (API keys, private keys, tokens),
   and committed .env files.
5. If schema or model files changed, confirm a matching migration exists.
6. Produce the report in the exact format below.

## Rules

- Never edit, create, or delete files. Never run formatters with --fix or
  any command that writes to the repo.
- Never skip a check because the code "looks fine".
- If a check exists but cannot run (missing dependency, crash, timeout),
  that is a BLOCK. Report the error.
- If a check does not exist for this project, mark it N/A with the reason.
- Report failures precisely: file, line, test name, error message.
- Do not suggest fixes beyond one short line per blocker.
- Verdict is SHIP only if every applicable check passed and the diff scan
  is clean.

## Output format

SHIP CHECK

Checks
- <check>  <exact command>  PASS|FAIL|N/A  <one-line result>

Blockers
1. <check>: <file:line or test name> <error>
(or "None")

Verdict: SHIP | BLOCK

A few notes on the choices:

  • tools: Bash, Read, Grep, Glob gives it what it needs to run commands and inspect files, and no Edit or Write. Bash can technically still write files, which is why the rules say so explicitly.
  • model: inherit uses whatever model your session is on, so you're not pinned to one.
  • The description says "use before any release, merge, PR, or when the user says a task is done." Claude uses the description to decide when to delegate, so that wording encourages it to call the shipper on its own.

You can always invoke it directly: "use the shipper agent to check this branch."

A /ship slash command to drive the loop

Because the main thread has to orchestrate, put the loop in a slash command. Save as .claude/commands/ship.md:

md
---
description: Run the release gate and loop until SHIP or a real blocker
argument-hint: [base-branch]
---

Run the release gate on the current changes. Base branch: $ARGUMENTS
(default to main if empty).

1. Use the shipper agent to check the current changes against the base
   branch. Show me its full report.
2. If the verdict is SHIP, stop and summarize what passed.
3. If the verdict is BLOCK:
   - Hand the numbered blockers list back to whoever implemented the change
     (you, or the specialist agent that wrote it). Fix only those blockers.
     Do not touch unrelated code. Do not delete or skip failing tests to
     make them pass.
   - Run the shipper agent again.
4. Repeat at most 3 rounds. If it is still BLOCK after 3, stop and show me
   the remaining blockers with your best explanation of each.

Now /ship or /ship develop runs the whole loop. The cap on rounds matters: without it, an agent fighting a flaky test can burn a lot of turns. The "don't delete or skip failing tests" line matters even more, since that's an easy way for an implementer to "fix" a red gate.

The slash command guide covers $ARGUMENTS, argument-hint, and the other frontmatter options.

What to do on BLOCK

Treat the blockers list as the spec for the next round. It's numbered, specific, and scoped, which beats "tests are failing, fix it" because the shipper already did the triage.

If the same blocker survives two rounds, step in yourself. It usually means the test is right and the approach is wrong, or the test is flaky and needs a human decision.

The non-AI backstop: pre-push hook and CI

An agent-based gate only runs when someone remembers to run it. Back it up with something that doesn't depend on a model at all.

A git pre-push hook at .git/hooks/pre-push (make it executable with chmod +x):

bash
#!/bin/sh
set -e
npm test
npx tsc --noEmit
npm run lint
npm run build

And the same checks in CI, for example a GitHub Actions workflow:

yaml
name: checks
on: [pull_request]
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm test
      - run: npx tsc --noEmit
      - run: npm run lint
      - run: npm run build

Swap in your project's real commands. The shipper and CI should run the same checks; the shipper just runs them earlier, explains failures in context, and adds the diff scan that CI usually doesn't do.

Where this fits in a bigger workflow

The gate works best as the last step of a pipeline: a planner breaks down the feature, specialists implement pieces, and the shipper decides. SlashKits ships it that way, with a shipper agent, a /ship command, and a /build-feature command that walks a tech-lead's plan through the specialists and ends at the gate. But the two files above are the whole idea.

Start with the agent file, run it once on your current branch, and see what it finds.