The Agent Gauntlet
Uncle Bob's multi-agent methodology, as a portable skill. Core idea: don't stuff quality rules into prompts - wrap the agent in deterministic gates. Agents treat written rules as vague suggestions and forget the middle of long instructions; tools don't decay. Put the agent in a loop against a checker until the numbers pass.
The pipeline
story ──▶ specifier ──▶ coder ──▶ cleaner ──▶ hardener ──▶ (qa: phase 2)
Gherkin + tests + CRAP gate mutation
QA proc impl passes score clean
Each stage runs in a fresh context ("born, do the task, die") and in its own git worktree - both deliberate. Handoff artifacts are files, not chat: features/<slug>.feature (Gherkin) and features/<slug>.qa.md (QA procedure) are the contract, the stage's commits are the payload, and a handoff file under .gauntlet/runs/<slug>/handoffs/ carries the report to the next stage. A fresh context per stage means no trajectory contamination from the previous stage's struggles; a worktree per stage means the developer's checkout is never touched and every stage boundary is a commit you can diff.
Stage contracts
Run each stage as a separate subagent/session. Full stage prompts live in stages/ next to this file (specifier.md, coder.md, cleaner.md, hardener.md, qa.md) - use them as the stage's system prompt, or read them and follow them directly if your harness has no subagents. Summary:
- specifier - story text →
features/<slug>.feature(3-7 Given/When/Then scenarios, user-observable only, no implementation terms) +features/<slug>.qa.md(numbered manual QA steps). Never writes code. - coder - feature file → unit tests + implementation until green. May not edit the feature file; if the spec is unimplementable, it stops and reports. No refactoring of surrounding code.
- cleaner - changed files → tests or refactors until every function is under the CRAP threshold. Behavior frozen: structure changes only, tests stay green, assertions never weakened.
- hardener - changed modules → mutation testing scoped to the diff; write/strengthen tests until zero unjustified surviving mutants. Never weakens production code; waivers require one-line justification each.
- qa (phase 2, optional) - QA procedure → executable UI check with screenshot evidence, PASS/FAIL per scenario.
Pipeline rules: stages run sequentially; a failing stage stops the pipeline (no skipping); stages commit only in their own worktree, nothing is merged or pushed - the run's only output is the branch gauntlet/<slug>, which the human reviews.
The handoff protocol
scripts/gauntlet/gauntlet.sh is the runtime (bash + git, no daemon). Full contract in references/handoff-protocol.md; the short version:
scripts/gauntlet/gauntlet.sh start <slug> --story story.md [--qa] # branch gauntlet/<slug> at HEAD, story queued for the specifier
scripts/gauntlet/gauntlet.sh stage <slug> coder # run by the stage: worktree at the inbound commit, prints TASK/WORKTREE/PAYLOAD
scripts/gauntlet/gauntlet.sh handoff <slug> coder --gate "swift test" # run by the stage when done
scripts/gauntlet/gauntlet.sh status <slug> # where the run is, what to run next
scripts/gauntlet/gauntlet.sh finish <slug> [--purge] # after review: remove worktrees (purge: branch + trail too)
handoff is where the discipline lives. It refuses uncommitted files, commits without the byline By <stage>., a HEAD that does not descend from the inbound commit, a coder that edited features/, a missing report, and a failing gate (run inside the worktree, output kept under gates/). The first valid call answers AUDIT_REQUIRED with the stage's checklist and exits 4; the unchanged second call queues the handoff and fast-forwards the branch. Agents declare "done" too early; the second call is where they look again. Exit codes: 0 ok, 1 refused, 2 setup, 3 NO_TASK, 4 AUDIT_REQUIRED.
Every stage prompt tells the agent to begin with stage, work only in the printed WORKTREE:, use only the printed task payload, commit with the byline, write <worktree>/.gauntlet/report.md, and end with handoff. An orchestrator only needs to loop: launch the subagent named in NEXT:, then status.
The deterministic gates
| Gate | Tool | Stage | Threshold |
|---|---|---|---|
| Tests green | swift test / project test cmd |
coder | 100% pass |
| CRAP per function | lizard + coverage export, joined by crap.py via crap-gate.sh |
cleaner | 6 (GAUNTLET_CRAP_THRESHOLD) |
| Module line coverage | llvm-cov totals, SPM mode only | cleaner | floor 70% (GAUNTLET_COV_FLOOR, 0 disables) |
| Structure (optional) | SwiftLint swiftlint-gauntlet.yml, when installed |
cleaner | body 100 lines, nesting 3 |
| Mutation score | muter, diff-scoped | hardener | zero unjustified survivors |
CRAP = complexity^2 * (1 - coverage)^3 + complexity, per function. At full coverage a function scores exactly its complexity, so a threshold of 6 means "at most six paths, all of them tested". Uncoverage is cubed: complexity 12 at 0% scores 156. That is what makes it a change-risk gate rather than two disconnected numbers - a module-level coverage floor lets an untested complex function hide behind well-covered neighbours; the per-function score does not.
Thresholds are Uncle Bob's agent-calibrated values: he held humans to CRAP 4 and gives agents 6 (perfect short-term memory handles more complexity), and is trying 8. Adjust thresholds for agents, keep values; don't impose human disciplines (no strict TDD choreography).
Other stacks
Complexity is lizard in every stack (27 languages, pure Python, auto-installed into .crap/venv). Coverage is whatever the repo already exports; hand it to the gate:
scripts/gauntlet/crap-gate.sh --lcov coverage/lcov.info src # c8/nyc/jest/vitest, coverage.py lcov, gcov
scripts/gauntlet/crap-gate.sh --cobertura build/coverage.xml src # JaCoCo, .NET, coverage.py xml
scripts/gauntlet/crap-gate.sh --xcresult .crap/cov.xcresult Sources # iOS/macOS APP project
Mutation stays per stack: Stryker (JS/TS/C#), mutmut (Python), PIT (JVM), cargo-mutants (Rust). Keep the shape: one script, hard exit code, run in a loop until green.
Setup
- Copy
scripts/from this skill into the target project asscripts/gauntlet/(the gate script expects to live at<repo-root>/scripts/gauntlet/and findscrap.pynext to itself). Add.crap/and.gauntlet/to.gitignore(gate venv and exports; run state and worktrees) and commitscripts/gauntlet/: stages work in fresh worktrees that contain only committed files, andstartrefuses to run while the gates are untracked.crap.pyis the scorer from crap-check, vendored so the gate needs no second skill install; the upstream commit is pinned in its header. Installing crap-check alongside is optional: the cleaner uses its repair loop when present. - Swift/SPM: Xcode or a Linux swift toolchain. Optional:
brew install swiftlint(structure rules) andbrew install muter-mutation-testing/formulae/muter(hardener). - Gate a module:
scripts/gauntlet/crap-gate.sh <ModuleName>runs lizard,swift test --enable-code-coverage,llvm-cov export(never--summary-only, it strips per-function data) and the scorer. Env:GAUNTLET_MODULES_DIR(defaultModules),GAUNTLET_CRAP_THRESHOLD(6),GAUNTLET_COV_FLOOR(70).
On an app project, SPM mode cannot run - use --xcresult. swift test builds nothing for an app target, and nothing for a package whose dependencies are iOS-only, so there is no profdata for llvm-cov. Such a project tests through the simulator, and its coverage lives in an .xcresult:
xcodebuild test -workspace App.xcworkspace -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-enableCodeCoverage YES -resultBundlePath .crap/cov.xcresult
scripts/gauntlet/crap-gate.sh --xcresult .crap/cov.xcresult Packages/Networking/Sources
The src-dirs you pass double as the xccov path filter, which matters: xccov needs one invocation per file at ~0.7s, so scoping to the sources being gated turns minutes into seconds (measured: 8.5s for a 196-function package inside an 847-file bundle). Override with GAUNTLET_XCCOV_INCLUDE. There is no module coverage floor in this mode - an .xcresult carries no per-module total - so GAUNTLET_COV_FLOOR is ignored and per-function CRAP is the whole gate.
4. Mutation config: copy muter.conf.template.yml into the module as muter.conf.yml; inner loop uses muter --files-to-mutate <changed files>.
Exit codes: 0 passed, 1 something over threshold, 2 could not measure (missing sources, red tests, no coverage export). A function flagged not in the coverage report means the test run never loaded that file - a build problem, not a testing gap.
Practical notes
- Keep stories small (one story, 3-7 scenarios). Cost of change is ~zero; iterate instead of plan-maxing.
- Specs are throwaway scaffolding, not documentation: the feature file lives for one story cycle; the code is the only source of truth. Don't build a spec↔code sync habit - it never pays off.
- Prefer running the loop in fast, host-testable modules (SPM packages, workspace packages): mutation testing rebuilds per mutant, so test-loop speed decides whether the hardener is affordable. Each worktree also pays its own cold build and
.crap/venv; that is the price of isolation, sofinishruns as soon as the branch is reviewed. - A stage that went wrong is restarted with
stage <slug> <stage> --retry(the failed attempt stays underrefs/gauntlet/<slug>/<stage>/attempt-N); a lost orchestrator recovers withstatus. - In Claude Code with this repo installed as a plugin, the whole pipeline is one command:
/gauntlet <story text>(add--qafor phase 2). Elsewhere, any harness that can run staged sessions drives the same fivegauntlet.shcommands by hand.