Make your coding agent prove its work: hands-on with Uncle Bob's ub-mode quality gates
An AI coding agent will tell you its tests pass. ub-mode makes it prove something harder: that its functions are simple, unduplicated, and tested at their boundaries. This guide installs Robert C. Martin's new skill, runs its three deterministic gates on a demo project, watches them fail instructively, and then makes them pass.
- Who: ub-mode (unclebob/ub-mode on GitHub), created October 25, 2026; 4,376 stars and 241 forks on October 29, 2026. Companion tools crapper (139 stars), dryer (64), mutator (81), each version 0.1.0, Zero-Clause BSD licensed. The skill repository itself carries no license file at check time.
- What: an agent skill (Work, then Right, then Practice) enforced by three deterministic tools: crapper (CRAP = CC² × (1 − coverage)³ + CC, gate under 10), dryer (structural duplication, default threshold 0.82), mutator (mutation testing, exit 3 on a survivor, 0 when clean).
- When: verified October 29, 2026, on Python 3.12.3, Linux x86_64, against shallow clones of all four repositories.
- Requirement: Git and Python 3.11+ for everything verified here, plus pytest and coverage in the demo venv for the coverage step. The full agent path needs a Claude Code, Cursor, or Grok subscription; no GPU, no API key for the verified steps.

Why a skill with teeth, and why now
An AI coding agent will happily tell you it followed good practice. It will write the tests, run them, and report that everything passes. What it will not do, on its own, is prove that its tests would notice if the code were wrong, or that the function it just wrote is not a copy of the one three files over. Those are different claims, and they need different evidence. That gap is what ub-mode is for.
ub-mode is an agent skill published by Robert C. Martin — Uncle Bob, author of Clean Code — in a repository created on October 25, 2026. At verification time on October 29, 2026, the GitHub API showed 4,376 stars and 241 forks for unclebob/ub-mode, and a trending tracker had recorded roughly 1,673 stars in a single day during its first week. The repository itself is strikingly small: a README and a SKILL.md file, nothing else. The skill does not contain the enforcement. It points at three small companion tools that do, and the discipline is in the pairing: the skill tells the agent what to do, and the tools return numbers the agent cannot talk its way around.
The skill's structure is three phases, in order. First, make it work: test-driven development, model the domain before writing logic, attack the premise when two fixes fail the same way, subtract complexity before adding anything. Then, make it right: fix root causes, test behaviour rather than implementation, and run three deterministic gates — crapper for complexity and coverage, dryer for duplication, mutator for test strength. Finally, practice: keep a log of every action and its reason, guard the context window, and commit in small ordered steps. A skill file alone is just prose an agent can nod along to. The gates are what make this one checkable, and this guide runs all three of them on a real demo project, with the failures left in.
What you will need
- Everything verified in this guide: Git and Python 3.11 or newer. The three tools are Python packages; the verification environment ran Python 3.12.3 on Linux x86_64. No account, no API key, no GPU, no model subscription for any step verified here. Total cost: $0.
- For the agent-led loop itself: a coding agent that loads skills — Claude Code, Cursor, or Grok are the examples the README names — plus that agent's own plan or API billing. The
/ub-modeinvocation is described from the skill file, not executed here: the verification environment has no such subscription. Everything runnable without an agent is quoted from actual runs below. - For the coverage step:
pytestandcoveragein your demo project's virtual environment, if you use pytest. The reason is a trap documented in Step 6: the tools' default coverage command runs unittest discovery, not pytest. - What this guide does not run: uml-viewer, the fourth companion repository, which visualises the tools' snapshots as UML diagrams. It is written in Clojure, and it is described from its README only. Nothing in this guide depends on it.
Step 1 — Install the skill (one file, three possible homes)
Installation is a copy, not a package install. Clone the repository, then place SKILL.md in your agent's skills directory under a folder named ub-mode:
git clone https://github.com/unclebob/ub-mode.git
mkdir -p ~/.claude/skills/ub-mode
cp ub-mode/SKILL.md ~/.claude/skills/ub-mode/SKILL.md
ls ~/.claude/skills/ub-mode/
The README gives the other two homes: ~/.cursor/skills/ub-mode/SKILL.md for Cursor and ~/.grok/skills/ub-mode/SKILL.md for Grok. Invoke it with /ub-mode in the agent. Read the file before you do: it is under 4,000 characters, and its most useful lines are the ones that turn vague advice into a check. "Reduce each function under CRAP 10 by splitting it, by testing it, or both." "Use the dryer tool to eliminate duplication that rank 0.8 or above." "Cover the uncovered. Kill the survivors. Delete dead code." Those sentences are the contract; the rest of this guide is the machinery that enforces them.
One licensing note, stated plainly because it is easy to get wrong: at check time the ub-mode repository carried no license file — the GitHub API returned no license for it, and the clone contains only README.md and SKILL.md. The three companion tools are each released under the Zero-Clause BSD (0BSD) license. Do not describe ub-mode itself as MIT or Apache licensed; it is not, on the evidence available here.
Step 2 — Install the three gate tools side by side
Clone all three tools into the same parent directory. The sibling layout matters for mutator, which reads function names from crapper checked out next to it (../crapper):
git clone https://github.com/unclebob/crapper.git
git clone https://github.com/unclebob/dryer.git
git clone https://github.com/unclebob/mutator.git
ls crapper dryer mutator
Each tool is a small shell wrapper — ./crapper, ./dryer, ./mutator — that creates a .venv inside its own directory on first run and installs the tool into it, with tree-sitter and tree-sitter-language-pack as its only runtime dependencies. The tool versions are all 0.1.0, and each requires Python 3.11 or newer. At check time the companion repositories showed 139 stars (crapper), 64 (dryer), and 81 (mutator): small numbers beside ub-mode's thousands, which is the point — the skill is the trending object, and the tools are its quiet machinery.
Expect one flaky first run. On the first crapper invocation in this verification, the tree-sitter parser download failed with a peer-closed connection from the sandbox's proxy, and the run died with a DownloadError. A plain retry succeeded, and every later run worked. If you hit the same failure, retry before you conclude anything about the tool. The tools analyse Clojure, Java, Go, TypeScript, Rust, and Python; this guide verifies the Python path end to end, and the other languages are documented from the READMEs, not demonstrated.
Step 3 — Build the demo project the gates will judge
A gate demo needs code that is good enough to look plausible and weak enough to fail instructively. Create a project directory with a small shop module. It has a discount function with several branches, two totals functions that do the same job under different names, an age check with a boundary, and a trivial addition function:
"""Tiny shop module used to exercise ub-mode's three quality gates."""
def price_with_discount(total, is_member, coupon):
"""Price after membership and coupon rules."""
price = total
if is_member:
price = price * 0.9
elif coupon == "SAVE10":
price = price * 0.9
elif coupon == "SAVE20":
price = price * 0.8
if price > 100:
price = price - 10
return round(price, 2)
def invoice_total(items):
"""Total an invoice: lines, bulk discount, tax, rounding."""
subtotal = 0.0
for item in items:
subtotal = subtotal + item["price"] * item["qty"]
if subtotal > 100:
subtotal = subtotal * 0.9
tax = subtotal * 0.2
total = subtotal + tax
return round(total, 2)
def receipt_total(lines):
"""Total a receipt: lines, bulk discount, tax, rounding."""
amount = 0.0
for line in lines:
amount = amount + line["price"] * line["qty"]
if amount > 100:
amount = amount * 0.9
vat = amount * 0.2
grand = amount + vat
return round(grand, 2)
def is_adult(age):
"""True when the customer is 18 or older."""
return age >= 18
def add(a, b):
"""Add two numbers."""
return a + b
Now add deliberately weak tests. One test checks the member-and-bulk price path only, one checks the age function only well above its boundary, and one checks addition. Nothing tests the coupon branches, nothing tests either totals function, and nothing probes the number 18 itself:
from shop import add, is_adult, price_with_discount
def test_add():
assert add(2, 3) == 5
def test_is_adult_only_checks_well_above_the_boundary():
# Deliberately weak: no boundary test at 17 or 18.
assert is_adult(25) is True
def test_price_member_and_bulk():
assert price_with_discount(200, True, "") == 170.0
That last figure is worth pausing on, because the verification caught an error in it. The first draft of this guide asserted 162.0 for the member-and-bulk case; the test failed, and the arithmetic shows why: 200 × 0.9 = 180, and 180 is over 100, so minus 10 gives 170. A guide that claims every command runs should be embarrassed by less. Run your tests before you run the gates, and fix the number:
python3 -m venv .venv
.venv/bin/pip install pytest coverage
.venv/bin/python -m pytest -q tests/test_shop.py
Expected output: 3 passed. The gates that follow are only meaningful against a green baseline — mutator in particular refuses to proceed usefully if the baseline fails, since a failing test cannot tell a killed mutant from a broken suite.
Step 4 — Run crapper: complexity first, coverage second
Start with complexity alone, so you see what the tool counts before coverage muddies the picture:
./crapper/crapper --no-coverage shop.py
Verified output:
CRAP Report
===========
Languages: python
Function Namespace CC Cov% CRAP
----------------------------------------------------------------------------------------
price_with_discount shop 5 N/A N/A
invoice_total shop 3 N/A N/A
receipt_total shop 3 N/A N/A
add shop 1 N/A N/A
is_adult shop 1 N/A N/A
The CC column is cyclomatic complexity: decision points plus one. For Python, crapper counts if, elif, for, while, except, each match case, comprehension filters, conditional expressions, and each and / or. So price_with_discount scores 5 (one if, two elif, one more if, plus the base 1), and each totals function scores 3 (one loop, one if, plus base 1). The run also writes .metrics/crap.edn, the snapshot uml-viewer reads. Note what --no-coverage means: coverage and CRAP are both N/A. A complexity-only run cannot pass or fail the ub-mode gate, which is a CRAP threshold, not a complexity threshold.

Step 5 — Run dryer: the duplicate you already suspect
./dryer/dryer shop.py
Verified output:
DUPLICATE score=1.00
shop.py:18-27
shop.py:30-39
Lines 18–27 are invoice_total; lines 30–39 are receipt_total. A score of 1.00 means the two functions have identical structural fingerprints after normalisation. Dryer compares Jaccard similarity of structural fingerprints: what stays is the call or method name, the operator, and the shape of the tree; what drops out is the local name, the field name, the literal, and the keyword. That is why renaming subtotal to amount, tax to vat, and total to grand changed nothing. The functions are the same function wearing different variable names, and the tool says so in one line.
Two defaults shape what dryer reports, and both are worth knowing before you tune them. The default similarity threshold is 0.82, and the default minimum is four lines and twenty normalised syntax nodes. The skill file says to eliminate duplication "that rank 0.8 or above"; the tool defaults to 0.82. That small mismatch is harmless in practice — run the default, and only lower the threshold deliberately if you want a wider net — but quote the tool's default, not the skill's rounded figure, when you set a gate in CI. Dryer also writes .metrics/dry.edn with the same report in EDN form.
Step 6 — Add real coverage, or crapper will score you at zero
Now run crapper with coverage, the way a newcomer would:
./crapper/crapper shop.py
In this verification, that run scored every function at 0.0% and printed CRAP values of 30.0, 12.0, 12.0, 2.0, and 2.0. The reason was visible in its own log: it ran coverage run -m unittest discover, which found zero tests, because the demo's tests are pytest tests. The README says coverage runs pytest "when the project uses it", but in this project it did not detect that, and a failed coverage run scores the language at 0% and still writes the snapshot. A reader who stopped here would conclude their tested code was untested. Do not stop here.
The documented escape is to generate the coverage report yourself and hand it over. From the demo directory:
.venv/bin/python -m coverage run -m pytest -q tests/test_shop.py
.venv/bin/python -m coverage lcov -o target/coverage/python/lcov.info
./crapper/crapper --use-existing-coverage shop.py
The report must land at target/coverage/python/lcov.info, the path the README lists for Python. Verified output with the project's own coverage in place:
Function Namespace CC Cov% CRAP
----------------------------------------------------------------------------------------
invoice_total shop 3 11.1% 9.3
receipt_total shop 3 11.1% 9.3
price_with_discount shop 5 63.6% 6.2
add shop 1 100.0% 1.0
is_adult shop 1 100.0% 1.0
CRAP is computed as CC² × (1 − coverage)³ + CC. Check one line by hand: for price_with_discount, 25 × (1 − 0.636)³ + 5 ≈ 25 × 0.048 + 5 ≈ 6.2, matching the table. Two readings matter. First, every function is under ub-mode's CRAP 10 threshold — even the untested totals functions, at 9.3, squeak under it — which shows why a single threshold is not the whole story: the totals functions are barely under the line and, as Step 5 showed, duplicated. Second, the totals functions are not at 0% despite having no tests at all; their definition lines count toward the function-level figure. Coverage percentages are a measurement with a definition, not a verdict, and the next gate exists precisely because of that.
Step 7 — Run mutator: change the code and see if the tests notice
Mutation testing is the gate most developers have never run, and it is the one that justifies the whole setup. First, list the mutation sites without running anything:
./mutator/mutator --scan shop.py
Verified output: Scan: 21 mutation sites in shop.py, including shop.py:44 >= -> > [defn/is_adult] and shop.py:49 + -> - [defn/add]. A mutant is a small, deliberate break — flip a comparison, swap an operator, change a boolean — applied to a copy of the code. It is killed when the tests fail; it survives when the tests still pass. A site on a line the coverage report does not hit is uncovered and is not run at all. Now run the full gate, pointing it at the same coverage report and at an explicit pytest command:
./mutator/mutator --use-existing-coverage \
--test-command "$PWD/.venv/bin/python -m pytest -q tests/test_shop.py" shop.py
Verified summary:
Function Namespace Killed Survived Uncovered Sites Score
-------------------------------------------------------------------------
invoice_total shop 0 0 6 6 n/a
receipt_total shop 0 0 6 6 n/a
is_adult shop 0 1 0 1 0.0%
price_with_discount shop 2 5 0 7 28.6%
add shop 1 0 0 1 100.0%
-------------------------------------------------------------------------
Total 3 6 12 21 33.3%
Read this against Step 6 and the lesson sharpens. The discount function had 63.6% coverage and a passing CRAP score, yet its mutation score is 28.6%: five of its seven mutants survived. The coupon-branch mutants survived even though their lines counted as covered, because no test ever takes the coupon path — line coverage records that a line was visited during evaluation, not that every branch outcome was checked. The age check's single mutant survived for the reason the test itself announces: the only test asserts that 25 is an adult, so changing >= to > changes nothing the test observes. And the twelve uncovered sites in the two totals functions were not run at all; mutator will not flatter you with a pass on code your tests never touch. A second run of the same command in this state exited with code 3, the documented "a mutant survives" exit, while a clean run later exited 0. If you wire this into CI, those are the two codes to gate on.
Step 8 — Close the loop: fix what the gates found, then re-run them
This is the step that turns a diagnostic into a workflow. Make three changes. First, delete one of the duplicated functions and keep a single order_total. Second, add the tests the survivors were asking for: coupon paths for SAVE10 and SAVE20, a no-discount case at exactly 100, the age boundary at 17 and 18, and a totals case. Third, re-run all three gates in the same order:
def test_is_adult_boundary():
assert is_adult(18) is True
assert is_adult(17) is False
assert is_adult(25) is True
def test_price_coupons_and_bulk_boundary():
assert price_with_discount(100, False, "SAVE10") == 90.0
assert price_with_discount(100, False, "SAVE20") == 80.0
assert price_with_discount(100, False, "") == 100.0
def test_order_total():
assert order_total([{"price": 50, "qty": 3}]) == 162.0
The intermediate result is instructive: after those tests, mutator reported 14 killed, 1 survived, 93.3% overall. The survivor was the totals function's > to >= mutant, because a subtotal of 150 triggers the bulk discount under either operator. One more boundary test kills it — a single item at exactly 100, which is not over 100, so the total is 100 plus 20 tax, while the mutant would discount it to 108:
def test_order_total_exactly_at_bulk_boundary():
# 100 is NOT over 100, so no bulk discount: 100 + 20 tax.
assert order_total([{"price": 100, "qty": 1}]) == 120.0
Final verified state, all three gates together: crapper shows CRAP 5.0 for price_with_discount, 3.0 for order_total, and 1.0 for both add and is_adult, all at 100% function coverage; dryer reports "No duplicate candidates found."; mutator reports 15 killed, 0 survived, 0 uncovered, 100.0%, and exits 0. That is what "make it right" looks like as an outcome rather than an intention: the same code, the same tests-plus-boundaries, and three tools that now agree. Notice what the loop did not do: it did not lower complexity by splitting the discount function, because at CC 5 it never needed splitting. Gates tell you where to work; they do not tell you to refactor everything.

Step 9 — Hand the loop back to the agent
With the gates verified by hand, the agent loop is straightforward to describe — and to bound honestly. In an agent session with the skill installed, invoking /ub-mode before a change asks the agent to work in the skill's three phases: write the failing test first, make the smallest change that passes it, then run crapper, dryer, and mutator, fix what they report, and commit in small ordered steps with a log of every action and its reason. The "Practice" phase is easy to skip and is the part most worth keeping: a log that says why each change was made is what lets a later reader — human or agent — tell a deliberate boundary decision from an accident.
What was not verified here matters as much as what was. No agent session was run, so this guide cannot tell you how faithfully any particular agent follows the skill, how often it argues with a gate instead of fixing the code, or how the loop behaves on a real codebase with thousands of functions rather than five. Treat the first real project as a pilot: run the three tools on one module, fix what they find, and only then let the agent apply the loop more widely. The tools are fast on a small module — the whole mutator run above took seconds — but mutation testing scales with the number of covered sites, and a large suite will take proportionally longer. Start with the file you are working on, as the mutator README itself advises, not the whole tree.
When to use this, and when to use something else
Use ub-mode when the problem is agent behaviour, not just code quality. If your agent writes plausible code with plausible tests and you have no independent check on either, the skill plus its three gates gives you one: a complexity number, a duplication report, and a mutation score that a green test suite cannot fake. It fits small-to-medium Python, Java, Go, TypeScript, Rust, or Clojure modules where the team already writes tests and wants those tests held to a standard.
Use a different tool when the need is narrower. If you only want complexity numbers in CI, crapper alone — or any complexity checker — is enough, without adopting a skill. If you only want to know whether your tests execute code, a coverage report alone answers that, and this guide's Step 6 shows exactly what it does not answer. If you want deterministic agent workflows rather than quality gates — fixed step sequences, durable run records, approval pauses — that is a different product shape, closer to the harness-builder approach covered in this blog's Archon tutorial than to ub-mode's skill-plus-gates shape. And if your agent's problem is methodology drift rather than test strength — it invents its own process each session — a methodology skill such as Superpowers, covered here in October, addresses that layer instead. These are complements, not rivals: ub-mode judges the code and its tests, whatever process produced them.
Do not use ub-mode as a quality certificate. A CRAP score under 10, no duplicates, and a 100% mutation score on a five-function demo say that those functions are simple, unduplicated, and tested at their boundaries. They do not say the discount rules are the right business rules, that the tax rate is correct, or that the module is secure. The gates are structural: they check shape and test strength, not meaning. A team that treats a green gate report as proof of correctness has replaced one kind of agent overconfidence with another.
Traps and limits, collected in one place
- Default coverage is unittest-shaped. A pytest-only project scores 0% under the default command. Generate your own LCOV report and pass
--use-existing-coverage, or replace the command with--coverage-command. Check the tool's log lines — it prints the exact command it runs — before believing a 0%. - Mutator needs its sibling. Run mutator from a checkout that sits next to the crapper checkout, or it cannot resolve function names. Clone all three tools together, as in Step 2.
- The first parser download can fail. A tree-sitter parser download failed once here on a proxy error and succeeded on retry. Retry before debugging.
- The skill's 0.8 and the tool's 0.82 differ. Quote the tool default when you set a threshold.
- Only the Python path was verified here. The other five languages follow the same design and are documented in the READMEs, but their coverage commands differ — JaCoCo for Java, go coverprofile for Go, cargo llvm-cov for Rust, LCOV for TypeScript and Clojure — and each brings its own toolchain requirements.
- Version 0.1.0 tools, one-week-old skill. Expect rough edges, changing defaults, and sparse issue history. The upside of adopting early is influence; the price is that you are also testing the tools.
- Line coverage is not branch coverage. Step 7's surviving coupon mutants are the permanent reminder: a covered line is not a checked decision.
The takeaway
Most agent skills try to make the agent smarter. ub-mode tries to make the agent accountable, by pairing a short set of engineering principles with three tools that return numbers. Install the skill file, install the three gates beside it, and run them on one module before you trust them on a codebase: complexity under 10 is the easy part, duplication is usually the embarrassing part, and mutation testing is the part that tells you whether your tests mean anything. In this verification, a suite that passed with 63.6% coverage on its main function turned out to catch barely a quarter of deliberate breaks in it — and six boundary tests later, it caught all of them. That is a useful thing for a reader to learn about their own tests, in an afternoon, for no money, without handing any code to a model. The agent still writes the code. Now it has to prove it.
Sources: the unclebob/ub-mode repository (README.md and SKILL.md, read October 29, 2026); the unclebob/crapper, dryer, and mutator repositories (READMEs, pyproject files, and command-line help, read October 29, 2026); the GitHub API for October 29, 2026 (ub-mode created October 25, 2026, 4,376 stars, 241 forks; crapper 139 stars, dryer 64 stars, mutator 81 stars); a trending tracker snapshot recording roughly 1,673 ub-mode stars in a single day during its first week; and the verification runs quoted in this guide (Python 3.12.3, October 29, 2026: crapper complexity and coverage tables, dryer duplicate report, mutator scan and run summaries, and the final all-green re-runs). The /ub-mode agent invocation and the uml-viewer companion are described from project documentation and were not executed here, because the verification environment has no coding-agent subscription and no Clojure toolchain was used. Star counts move daily; the figures above are dated snapshots, not live totals.