The Handbook · self-contained · reads offline from a clone

crapkit

A deterministic change-risk gate for Python, TypeScript, TSX, JavaScript, Vue, Go, Rust, shell, PowerShell, Swift, C and C++, Objective-C, Java and Zig. It scores every function on complexity times uncovered risk, ranks the worst by how often their files change, hands a coding agent everything it needs to fix one, and blocks commits that add more.

CRAP = ccn2 × (1 − cov)3 + ccn
A terminal session: crapkit init writes a config, crapkit coverage scores eight functions, crapkit worklist --top 5 ranks them, a shell heredoc appends a function at ccn 7, the per-edit advisory reports it and exits 2, and the commit gate refuses the staged file with exit 6.
Ninety seconds, one small Python repo, no editing. Every frame is captured output: tools/demo/generate.py builds the fixture repo, runs these five commands and renders what they printed.

Yes, the name is real and it is not ours: C.R.A.P. (Change Risk Anti-Patterns) was coined for crap4j by Alberto Savoia and Bob Evans in 2007. An honest score deserved an honest name. We just built the kit.

01Why crapkit exists

You need to know which function will bite you next. One number answers that.

Complex functions break more. Untested functions break silently. CRAP multiplies the two: cyclomatic complexity squared, times the cube of what your tests never touch. A simple covered function scores near its complexity. A complex uncovered one explodes. The worst crapkit has scored in the field was an anonymous handler at ccn 220 with no tests, CRAP 48,620.

Plenty of tools report complexity. Plenty report coverage. crapkit earns its place with what happens after the arithmetic.

crapkit one store, one score a developer mid-feature: rescore --gate a coding agent on a debt item: the packet CI on a pull request: verify --baseline-tsv a human reading the weekly digest commits that add no new debt debt burned down worst-first
Four callers, one deterministic store. Everything below explains the pipeline between them.

02The score

Two inputs, and the second one has an exponent on it. That is the whole design.

ccn is cyclomatic complexity, the count of independent paths through a function. crapkit takes the smaller of lizard's standard and modified counts, where modified treats a whole switch or match as one decision. Comprehension for and if, ternaries, and and/or all count.

cov is branch coverage inside the function's span. With no branches it falls back to statement coverage, and with no statements to invoked-or-not. A half-executed straight-line function never reads as fully covered.

0 40 80 120 160 1 6 9 12 ccn → CRAP → target 6 cov 0% cov 30% cov 60% cov ~100% (= ccn)
The cube is the story. Coverage collapses the curve toward the + ccn floor, but never below it. At 100% coverage, CRAP equals ccn.

The ceiling theorem

Full coverage still leaves CRAP = ccn, so a target is secretly two rules. At the default target of 6, a function at ccn 7 with 100% coverage scores 7 and fails. Above the ceiling, coverage cannot save you and the only clearing move is decomposition. Below it, the target prescribes how much coverage each complexity owes.

minimum branch coverage to pass at target 6 ccn 1–20% (the +ccn floor already passes) ccn 331% ccn 450% ccn 566% ccn 6100%
The coverage ladder: what each complexity owes in tests before it clears a ceiling of 6. One step past the top rung, no amount pays the bill.
the repo grade from every scored run's totals A+ zero debt A B C D F 💩 past F, the name takes over crapkit's own repo grades A+, zero functions over target, and its author commits through its own gate
Every scored run prints a grade.

A Sonar-spec cognitive complexity column rides along in the brief and next-item payloads as a second opinion on readability, measured in every language crapkit scans. Running everywhere is not the same as modelling everything: a construct the reader for that language does not model reads 0, so a low cognitive number beside a high ccn is a question, not an all-clear. It reports and never gates. The gate is min-ccn only, so the rule a commit is judged by has exactly one definition.

03What it reads

Two questions before you install: can crapkit score my repo, and will coverage ever join the score.

Fourteen languages, two coverage parsers. The left column is the label a crapkit.toml spells in languages = [...]. A file joins a scope only when its path prefix and its extension both match.

LanguageExtensionsCoverage
python.pycoveragepy
typescript.tsistanbul
tsx.tsxistanbul
javascript.js .jsx .mjs .cjsistanbul
vue.vueistanbul, when your own vitest run reports on .vue files
go.gocc-only
rust.rscc-only
shell.sh .bashcc-only
powershell.ps1 .psm1cc-only
swift.swiftcc-only
cpp.c .cc .cpp .cxx .h .hppcc-only
objectivec.m .mmcc-only
java.javacc-only
zig.zigcc-only
kotlin.kt .ktsnot admitted, blocked upstream

cc-only is a real answer, not a gap

crapkit ships two artifact parsers and no more, so most of that table scores crap = ccn under coverage_optional = true, with no lane to run. That costs less than it sounds, because the ceiling is a ccn ceiling (§02). Coverage cannot rescue a function above target in any language. What a cc-only scope loses is the ability to earn a pass below the ceiling with tests, and the dark-lines detail in the packet. What it keeps is the whole gate: a ccn-9 function still fails, still ranks, still gets a packet.

crapkit init writes coverage_optional = true on every scope whose languages all sit in that cc-only half, and leaves it off any scope one parser can still reach. A repo made entirely of those scopes therefore declares no [[lane]] at all, and crapkit coverage runs it anyway: the scored run it writes is the baseline worklist, next-item, rescore, ratchet seed and verify all read. Exit 3 survives for the case it was written for — a scope with neither a lane nor the key — and the message names that scope.

Write a coverage lane the day the language gets one. Nothing else in the config changes.

Three readers crapkit owns or corrects

Kotlin is measured correctly and still refused. lizard's KotlinReader records only brace-bodied functions, so fun sign(x: Int) = if (x > 0) 1 else if (x < 0) -1 else 0 yields no function at all and its two decisions land nowhere. A language that can hide arbitrary branching from the gate is worse than one crapkit declines, so the label waits on upstream.

Three quirks worth knowing before you read a number

04How it works

One pass of analysis, your own test commands for coverage, one SQLite store, many views.

git ls-files tracked source only crapkit.toml scopes · excludes · lanes · notes complexity inventory one lizard pass · std+modified ccn + cognitive · content-hash cached coverage lanes your own test commands run artifacts: istanbul · coverage.py timeouts · retries · flake retest the join cov per function span .crapkit/ crap.sqlite runs · identities claims · overrides the views, every one deterministic, every one JSON-able worklist · next-item · brief · explain · trend · digest · doctor · verify · report · mcp git log churn window, cached deflated churn weights rank the views
The pipeline. crapkit never guesses at your test setup. The lanes run the commands your repo already has, and crapkit reads the artifacts they leave.

05The dials

Every threshold in this handbook is a default, not a constant. These are the keys people change first.

[crapkit]
target = 6                  # THE dial: the CRAP ceiling, and with it the ccn ceiling
churn_window_months = 12    # how far back "hot" looks
worklist_floor = 5          # admission floor for the risk map (never hides over-ceiling debt)
worklist_top = 50           # rows the worklist prints, and rows the report page renders
diff_uncovered_max = 40     # optional: verify exit 9 when a change leaves more dark lines
max_parallel_lanes = 3      # coverage lanes at once (default 1, so this one is a raise)
notes = ["…"]               # repo traps, delivered inside every packet

[[scope]]
name = "legacy"
target = 10                 # a scope can carry its own ceiling…
coverage_optional = true    # …or opt out of coverage entirely (crap = ccn, cc-only)

[[lane]]
timeout_seconds = 2400      # per-lane watchdog
no_progress_seconds = 300   # or when its log stops growing
retries = 1                 # and how often a flaky lane gets another shot

Per-scope targets are how a repo holds new code to 6 while a quarantined legacy tree ratchets down from 10. Every key, with type, default and effect, is in docs/configuration.md. doctor tells you when the file and the repo have drifted apart, and doctor --tune proposes numbers that fit what it found.

06The two gates

Two hooks fire on their own. Only one of them can stop you, and knowing which is the difference between reading a note and losing a commit.

The advisory runs after every edit and annotates. The commit gate runs at git commit and blocks. They read the same ceiling map and subtract the same ratchet marks, so they cannot disagree about a function.

per edit · crapkit claude-hook · ADVISORY never blocks fires on Edit and Write in every repo on the machine, and on Bash where you register that matcher the plugin ships Edit|Write only · the Bash half is a second entry in your own settings, *.py only reads the file on disk plus git diff HEAD for that one path · never opens the store · writes nothing a breach: three lines on stderr and exit 2 · the model reads them and keeps working no crapkit.toml · file in no scope · mid-rebase · bad input · any internal error → exit 0, silent silent silent ccn 9 named silent edit edit edit edit git commit one session → at commit · crapkit hook-precommit · THE GATE this one blocks reads the staged blobs in one git cat-file --batch · cc-only, no store, no coverage, no network a breach: the hook exits 6, git reports 1, the commit does not happen · decompose, or sign for the debt exempt: any function the committed ratchet already marks, counted in one stderr line costs the size of the commit, not the size of the repo crapkit verify fails a mark that rises
Two hooks, one ceiling map, one set of ratchet marks. That agreement is why a session of green advisories cannot end at a refused commit.

Why the advisory is deliberately toothless

PostToolUse runs after the write. The edit is already on disk, so a hook that claimed to block would be lying. It says the opposite outright: the head line ends (the edit landed; nothing was blocked).

Silence everywhere else is the other half of the design. This hook is installed once per user and then fires on every edit that user makes, anywhere. 47.5% of the edits it was measured against land in repos with no crapkit.toml. A hook that spoke there would be unbearable within a day, so an unmeasured repo costs one stat and an exit 0.

What changed in 0.4.0

Before this release the two disagreed. The advisory pardoned ratchet-marked functions and the commit gate did not, so touching signed debt produced a wall: green advisories all session, then a refused commit. The commit gate now exempts marked functions too, on mark existence, and prints one line saying how many. crapkit verify keeps the numeric check and is what fails a mark that actually rises.

Existence, not the numeric rule, because the gate judges staged blobs. A blob carries no coverage, so there is no CRAP to compare against a mark. The only question the gate can answer is the one it asks: did the repo already sign for this function?

What changed in 0.4.5

The third reader joined them. crapkit verify now reads a mark the way rescore --gate always did: a touched function whose fresh CRAP sits at or under its mark is exempt, not a gate violation. Before this, an edit inside signed debt passed the advisory, passed the commit gate, and then met exit 6 at merge, which was the last place the three readers could still disagree about one function.

Exit 7 is untouched. A mark that rises is still a ratchet regression, and it is still reported on functions the diff never named, which is where coverage rot shows up.

What changed in 0.4.7

The advisory now answers Bash events too. A Bash payload carries the command and no file path, so source written through a shell heredoc or python - <<'PY' named no file for the hook to judge, and every breach written that way landed unadvised. It reads the working tree instead: the *.py files git reports dirty or untracked, whose mtime falls inside a 12-second window, at most 25 of them, each through the same per-file ladder an Edit takes. Exit 2 still means the one thing it has always meant. A clean tree, a file that went stale, a command run outside any git repo: silence, as before.

The window is what stops the first dirty file from drawing the same advisory again on every later ls, and the cap is there because PostToolUse waits this process out, so a large dirty tree would be a stall rather than a reason to judge all of it. Only Python is judged. Every other language stays the commit gate's business, which is what keeps a check that runs on every shell call cheap enough to run.

The shipped plugin still registers Edit|Write only, so nothing above fires until you ask for it. A Bash matcher is a second entry in your own settings hooks, same command, and it costs one git rev-parse and one git status per shell call in any git repo, measured or not. Add it when your harness writes source through the shell; skip it when every write arrives as an Edit.

The protocol check also moved to the front of the ladder. It used to run after the walk for a crapkit.toml and the mid-rebase test; it now runs before the event is even shaped, so a payload asking for a protocol this CLI does not answer costs the read of stdin and nothing else. Same silences, and it matters more than it used to: the working-tree fallback is the first thing this hook does that spawns git, and a wrong protocol never reaches it.

07The rest of the ladder

One rule, checked at five distances from the keyboard: every touched function at or under its scope's ceiling.

Touched means the diff intersects the function's span. The two automatic checks are §06. The other three you invoke.

WhereCommandBlocks?Sees coverage?What it is for
after every editcrapkit claude-hooknonoAutomatic, via the plugin. Names a function the edit pushed over its ceiling.
on demand, mid-editrescore FILE --gatenonoSub-second preview of the commit gate's verdict, before you stage anything. It reads the newest run's coverage, so it needs one: exit 1 until the first coverage.
at git commitcrapkit hook-precommitexit 6, git says 1noThe enforcement point. Staged blobs, in memory, no store needed.
before mergecrapkit verify --base REF6 7 8 9yesThe full verdict: gate, ratchet, new test failures, dark diff lines.
in CIcrapkit verify --baseline-tsv FILE6 7 8 9yesA fresh clone judged against a committed baseline. Add --sarif PATH or --github.

A refusal is design feedback, not a threshold to widen. The attended escape is an audited override: a reason, a snapshot record, and auto-staged ratchet debt. All three land or none do.

Exit codes are the API

ExitMeaningThe move
0Verdict cleanProceed. For verify, this is the green that ends a loop iteration.
1Overloaded: no data yet, a doctor FAIL, or a debt-policy breachRead the message. It names the command that creates the missing run, or the FAIL line.
2Usage errorargparse refused. Check the flag spelling and order.
3Config gapA scope without a template, a metric-stamp mismatch. Fix crapkit.toml or restamp.
4Git errorNot a repository, or a baseline commit rewritten out of history by a force-push.
5Tool error: lizard missing, a lane produced no artifact (or one measuring a different tree, or one measuring this tree in absolute paths) or timed out past retries, an alert command failedFor the artifact cases: fix the lane, not the code. The refusal names the lane log in full. Seven root causes, triaged in docs/lanes.md.
6Gate: touched function over its ceilingDecompose until every piece fits.
7Ratchet regressionA standing mark got worse. On a function you never edited, suspect test rot first.
8New test failures vs baselineThe flake retest already ran if configured. What remains is real.
9Changed lines nobody coveredOnly in repos that set diff_uncovered_max.

verify reports the first of 6, 7, 8, 9 that fires, in that order, so a CI script branches on one code per run.

08The ratchet

How a brownfield repo turns the gate on today instead of after the cleanup.

crapkit ratchet seed writes every standing over-target score into crapkit-ratchet.tsv, a committed, human-readable file of pardons. A mark pardons debt at or under its recorded score. The function may stay bad; it may not get worse. Improve it and the mark tightens at the next green verify. Debt can only shrink. That is the ratchet.

mark stands path · long_name · crap score improves → next green verify tightens the mark fixed to the ceiling → mark repaid, row gone code deleted or renamed → ratchet prune follows git merging branches? crapkit ratchet merge registered as a git merge driver: changed-side-wins, min on conflict, never by hand
A mark's life. Every transition is a command, so the file needs no manual editing. Hand-resolving its merge conflicts is the one classic mistake the tooling exists to prevent.

A mark does three jobs at once. It pardons the commit gate (§06), it silences the per-edit advisory, and it sets the high-water line verify refuses to let rise. Marks carry a metric-version stamp: if a future crapkit changes how ccn is counted, verify refuses to compare across metrics instead of silently re-judging old pardons with new math. Optional policy knobs add expiring debt and repayment quotas for teams that want the screw to turn on a schedule.

0.4.5 is one of those bumps

Shell cognitive complexity now nests: fi, done and esac close the level if, a loop keyword or case opened, so a 4-deep shell if reads 10 like every other language instead of 4. That is analysis version 8, so the first command you run after the upgrade refuses the marks the old version signed:

$ crapkit verify
crapkit: ratchet marks were recorded under [crapkit-analysis=7 lizard=1.24.0] but this run measures [crapkit-analysis=8 lizard=1.24.0] — CRAP scores are not comparable across metric versions; re-baseline with `crapkit ratchet seed`

Do what it says. Re-seeding brings the same marks back: only shell's cognitive column moved, ccn is untouched in every language, and no mark, gate or CRAP score reads the cognitive column at all (§02).

Tightening is damped, because a tighten claims the code improved and one commit measured twice cannot have. Some suites measure the same bytes two ways: a coverage attribution that races a subprocess reported one function at CRAP 20.0 on one run of a commit and 72.0 on the next. Tighten on the lucky half of that and the unlucky half fails the mark, so the gate becomes a coin flip on an unchanged tree and the marks file churns 20 to 72 and back in commits. So verify compares each marked function against the same commit's previous trusted run — the same word ratchet seed uses, so a failed verify and a partial run are invisible to both — and holds any mark whose score moved by more than tighten_max_jump (default 2x), naming the function and both values on stderr. Real work moves a score by less than a measurement race does, so a stable improvement still tightens in full; verify --no-tighten holds the whole file when you want the verdict without the rewrite.

09The trusted baseline

Every verdict compares against one earlier run. Which one is a rule, not a guess.

Qualifying runs are coverage runs and passing verifies. A failed verify never qualifies, and it taints what follows. Runs taken after a failure are skipped by default baseline selection until some verify passes, so nobody retires findings by quietly re-running coverage on the refused tree.

run 19 coverage ✓ run 20 verify FAILED run 21 coverage (tainted) run 22… still tainted verify keeps comparing against run 19, so the findings stay visible escapes: fix the findings and let a verify pass · or accept a newer run with verify --baseline N
The taint rule. Without it, a coverage run on the refused tree becomes the new baseline, the flagged function stops counting as touched, and the finding evaporates unexamined.

runs list marks the row verify compares against today, and warns when a moved HEAD makes the snapshot stale. For a repo with no store at all, such as a CI clone, verify --emit-baseline on the main branch writes a commit-stamped TSV that verify --baseline-tsv reads in the pull request.

One rule, every reader

Two families read that timeline, and since 0.4.5 each has one answer. The views, worklist, next-item, rescore and brief, take the newest trusted run and stop there: a view compares nothing, so it has nothing to launder. The judges, verify, ratchet seed and ratchet prune, add the taint rule on top of that pick. Before, worklist could rank one snapshot while next-item handed out work from another, and ratchet seed could sign marks off a run verify had just refused, which is exactly how a failed verify's findings stop counting.

Naming a run that cannot serve

--baseline ID is the deliberate escape, and it can name a run that exists and still cannot be measured against. That used to print the empty-store line, which sent people off to run coverage for a store they already had. It now names the run, the reason, and the runs that can:

$ crapkit verify --baseline 2
crapkit: run 2 is an inventory run (no coverage was measured) and cannot serve as a baseline; trusted runs: 1; pass `--baseline 1` for the newest

Four reasons reach that line: a failed verify, a hook run, a partial run (a lane subset, or a lane that failed), and an inventory run, which measured complexity and no coverage.

10Lanes: how coverage arrives

crapkit runs no test framework of its own. A lane is your command, its artifact, and a parser.

[[lane]]
name             = "unit"
command          = "npx vitest run --coverage --coverage.reportsDirectory=.crapkit/cov/unit --reporter=default --reporter=junit --outputFile=.crapkit/cov/unit/junit.xml"
artifact         = ".crapkit/cov/unit/coverage-final.json"
results_artifact = ".crapkit/cov/unit/junit.xml"
parser           = "istanbul"          # or "coveragepy"
scopes           = ["src"]

One gap costs a python lane its numbers with nothing said, and it is not crapkit's to fix: pytest-cov 7.0.0 dropped its own subprocess measurement, so a suite that drives a CLI through subprocess.run reads every entry point at 0% and the lane still succeeds. crapkit's own suite is such a suite. pytest-cov 7 and subprocess coverage has the measurements and the two config lines that restore it.

Zero overlap has three readings

An artifact can parse, measure hundreds of files, and touch none of the paths its lane's scopes declare. Joining it would score every function in those scopes untested: a confident grade F assembled out of a tooling mistake, which is worse than a missing artifact because it looks like an answer. Since 0.4.7 the measured paths say which of three things happened, judged against the repo root.

What the artifact reportsReadingVerdict
paths resolving outside the rootanother tree. A venv whose editable install points at a second checkout is the quiet way inexit 5
absolute paths resolving under the rootthis tree, spelled absolutely. The join is on root-relative paths, so it matches nothing eitherexit 5
in-tree relative paths that miss every scopea suite that imports none of the scoped source yet, which should score untestedwarning, scores on

The middle row is the one 0.4.7 added, and it gets its own fix, because the advice for the first row is wrong for it: the environment is right, and path_prefix only ever prepends. A coveragepy lane is told to set relative_files; an istanbul lane is told to point its reporter's own cwd or root option at this checkout.

crapkit: lane 'py' FAILED: lane 'py' measured 2 file(s), none of them under the paths its scopes declare (src), and 2 of them written as absolute paths that DO sit under this checkout — .crapkit/cov/py.json measured this tree and spelled it absolutely, and the join is on root-relative paths, so it still matches nothing and every function in those scopes would score untested; it reports paths like /repo/src/faro/core.py, /repo/src/faro/util.py. Make the runner write relative paths: `relative_files = true` under `[tool.coverage.run]` in pyproject.toml, or `[run] relative_files = true` in .coveragerc, then rerun the lane

Nothing is rebased and no path is guessed at. A ../ climb is always another tree, because the runner's working directory is recorded nowhere in the artifact, and a mixed artifact is another tree as well: a path from somewhere else can only have come from somewhere else. One failed lane leaves the run partial; exit 5 arrives when every lane failed.

How the guard reads your command

A lane command runs under a shell, so both lane lints read it with the shell that will run it: sh on POSIX, cmd.exe on Windows. That is a character walk over the quotes, and on Windows over ^ escapes as well, not a whitespace split. So a quote that opens mid-token is one argument, --cov-report=json:".crapkit/cov/unit dir/coverage.json" included, and -k ^"not slow^" keeps its value in one piece.

Write values in double quotes. They are the one form both shells read the same way. To cmd.exe a single quote is an ordinary character, so -m 'not slow' arrives as two words: -m takes the first, and the second lands as a positional filter that narrows the run to nothing. The lane then writes no artifact. The guard refuses it at config load and names the shell it read with:

command = "python -m pytest -m 'not slow' --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json"

$ crapkit doctor
crapkit: lane 'slow': positional argument 'slow'' narrows a full-suite coverage run; drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), or set full_suite = false deliberately (cmd.exe does not treat ' as a quote: write the value in double quotes)

A chained command is read one argv per &&, ||, & or |, and every segment that runs the runner is checked. A second, narrower run after the operator is still a narrowed coverage run:

command = "python -m pytest --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json --junitxml=.crapkit/cov/slow/junit.xml && python -m pytest tests/smoke --cov=app --cov-report=json:.crapkit/cov/slow/coverage.json"

$ crapkit doctor
crapkit: lane 'slow': positional argument 'tests/smoke' narrows a full-suite coverage run; drop it, attach it to the flag it belongs to (-n8, --numprocesses=8), or set full_suite = false deliberately

Redirections (> nul, 2>&1) belong to the shell and are never positionals, an empty quoted argument stays empty instead of shifting the next path onto the flag, and words break on space, tab and line endings only, so a non-breaking space pasted out of a wiki no longer splits a value. doctor reads a lane the same way, which is how it knows a quoted interpreter path is one word and that the runner after && is a runner. It also probes each distinct runner once instead of once per lane, and FAILs a lane whose first word will not start rather than calling the repo clean.

The results-file WARN reads like this, hint included:

$ crapkit doctor
WARN lane 'unit' declares no results_artifact: the crashed-worker check and the no-new-failures check (exit 8) cannot run for it; add --junitxml=.crapkit/cov/junit-unit.xml to the command and results_artifact = ".crapkit/cov/junit-unit.xml" to the lane

11The queue: two views of one run

Two commands rank the same run differently on purpose. Pick the wrong one and you get the wrong answer.

the newest trusted run every function, every score worklist: the risk map ranked by risk = ccn × churn weight shows finished rows (marked ok) shows unmeasured rows (marked no-lane) never empties: a done repo is all ok markers for: humans and planners deciding where risk lives next-item: the actionable queue ranked by crap descending drops ok and no-lane rows carries budget estimates and dark lines empty: true is the stop signal (with two riders) for: the session asking “what do I fix next”
The same run through two lenses. The worklist has no stop condition on purpose; next-item carries the only one.

One run, and since 0.4.5 that is enforced rather than assumed: both views ask the same question the baseline rule answers (§09) and get the newest trusted run. They could pick differently before, so a worklist could rank one snapshot while next-item dealt work from another, and the two disagreed about what was left.

Reading a worklist row

risk      5.4  ccn   6 (  6 std)    5c/1a w   0.90  src/crapkit/cli/reports.py:62  cmd_digest( args )

  risk 5.4 .......... ccn x churn weight - what ranks this map
  ccn 6 (6 std) ..... min(standard, modified) ccn, standard in parentheses
  5c/1a w 0.90 ...... 5 commits, 1 author in the window, recency weight 0.90
  path:62 ........... file and the line the function opens on
  cmd_digest( args )  the long name - paste it straight into `crapkit brief`

The weight favors recent activity: five commits last month outrank one commit each of five months. A function over its ceiling is admitted whatever its ccn, so the admission floor (worklist_floor, default ccn 5) trims the list without ever hiding debt. Hot-but-simple code appears too, which is the map doing its job; the ok marker says no action is owed.

The termination rule

Three conditions from next-item, all at once: empty: true, no items skipped because another session claims them, and no no_lane_over_target count in the reasons. Debt a wiring gap keeps unreachable is still debt. One flag alone is not done.

12The packet

The first half hour of any refactor is reading. brief deletes it.

Ask about one function and the reply is a start-editing packet: everything a session would otherwise assemble with an editor, a grep and three git commands. The fields group into five questions.

crapkit brief PATH NAME --json what am I looking at source · params · handle scored (16 metrics) · remedy est_splits · est_uncovered_paths uncovered_lines (+note) what surrounds it file_functions · file_totals duplication_twins coupling (is_test flagged) churn what I'm judged by gate_rule: ceiling · binds ratchet_mark · mark_age_days target (= the ceiling) · stale versions what to run commands.gate commands.scoped_tests commands.verify · refresh lane (command · artifact · cwd) what this repo knows that you don't notes.repo · notes.scope: the written-down traps attempts · regrowth: has someone tried this before, did it hold 29 top-level fields (additive-only, so this number only grows) · schema pinned at 1 · full reference in docs/agent-json.md
The packet, grouped by the question each field answers. Nothing here requires opening the file.

13Built for agents

Half of crapkit's callers are language models. That is a design constraint here, not an afterthought.

1 · take the packet next-item → brief --json 2 · do the work decompose / test at the packet's seams 3 · gate the edit commands.gate (exit 6 → step 2) 4 · run the scope's tests commands.scoped_tests (= crapkit test-scoped) 5 · verify, then commit commands.verify → green → commit
The loop from AGENTS.md. The commands in steps 3 to 5 arrive inside the packet with paths filled in. Retyping them is how a lane flag silently drops.

Claims

Parallel sessions coordinate through next-item --claim: a claimed item is skipped by everyone else, released on green verify or by hand. Handles survive line shifts.

Batches

crapkit worklist --batches N cuts the queue into file-disjoint batches, co-changing files kept together. A fleet plan with no overlap by construction.

MCP server

crapkit mcp is a dependency-free stdio server exposing the read side as tools. Register it globally; in an unmeasured directory it answers with guidance instead of dying.

The plugin

plugin/ is the agent surface as one installable artifact: three skills (the loop, the failure router, the onboarding walk), the MCP server, and the advisory hook. Contract tests pin their text and the hook's handlers to the CLI so they cannot drift.

Notes

Repo and scope notes in crapkit.toml ride into every packet. The channel for hard-won operational traps, delivered at the moment of use.

Pinned JSON

Every read command emits JSON under --json, with next-item always and digest alone staying plain lines by design. Payloads carry schema: 1 and change additively only.

14The analysis cabinet

Six read-only commands that answer a question the score cannot.

mutate: are the tests real?

Diff-scoped mutation testing. Flips comparisons and boolean logic on the lines you changed, runs the suite per mutant, lists survivors. The audit for coverage that moved the number without testing anything.

duplication: does this already exist?

Near-duplicate function detection by shingle similarity, containment-aware so an enclosing function is not reported as its own twin.

coupling: what changes together?

Files that co-change across the churn window, with support and confidence. The blast-radius map for a refactor. Cached since 0.4.5, so a warm run is a file read.

trend: is it getting better?

Totals per scored run: over-target count, CRAP load, grade. The burn-down chart's data, read since 0.4.5 off a per-run rollup instead of every row.

doctor: does the config still fit?

Every drift between crapkit.toml and reality, as WARN or FAIL. Run it after any rename.

overrides: who granted what?

The audit trail behind every gate override: who, when, why, and the debt it staged.

What 0.4.5 caches, and where it lives

These are the commands people run over and over, so three of them stopped redoing work. coupling, brief and worklist --batches share a ranked-pairs file, .crapkit/coupling-cache-v1.json, beside the churn caches. Its key is HEAD, the churn window, the UTC date, the path format and a digest of the tracked set, so it cannot answer for a tree it did not see; --min-support or --min-confidence off their defaults bypass it rather than poison it, and --top reads it. On crapkit's own repo, 337 tracked files over 220 commits:

$ ls -1 .crapkit/*.json .crapkit/*.z
.crapkit/cache.json
.crapkit/churn-cache-v2.json
.crapkit/churn-log-v2.json
.crapkit/churn-log-v2.z
.crapkit/coupling-cache-v1.json
.crapkit/stat-stamps.json

$ crapkit coupling --top 5      # cold, caches deleted: 0.53 s
$ crapkit coupling --top 5      # warm: 0.09 s

trend and report read a rollup row written once per run and pruned with that run, instead of rescanning every scored row the store holds. Both now write to the store on a best-effort basis, which is new for two commands that used to be pure readers.

mutate with mutation_workers above 1 keeps its worker worktrees at .crapkit/mutate-pool/ and re-prepares them each run rather than adding and removing them. Single-worker runs are untouched. The pool is not size-bounded, so when you want the disk back:

$ crapkit mutate --drop-pool
removed 2 pooled worktrees from …\.crapkit\mutate-pool

15Install

One base install everyone shares. Then pick the surfaces you want, in any order.

The base: the CLI, then one repo

pip install crapkit                 # the PyPI release; git+https://github.com/JeanFrancoisGagne/crapkit.git for main
cd your-repo
crapkit init        # sniffs tracked source into scopes, writes live lanes it can prove
crapkit doctor      # config against reality: unknown keys, unclaimed files, lane runners that will not start
crapkit coverage    # runs the lanes, joins coverage, writes the first scored run and its grade

Python 3.11 or newer, one runtime dependency (lizard). Plain pip install crapkit works once the PyPI release lands. python -m crapkit is identical to the console script and is what to use from a source checkout.

Read the doctor line narrowly. It resolves the first word of every command segment on PATH (the pieces &&, ||, & and | cut the line into) and starts the command's own first word. That is the whole of what it knows about a lane. A lane spelled npm run test is checked as far as npm, never the script behind it. So doctor can print doctor: no problems found and the next line of the block, crapkit coverage, can still exit 5 on that same lane. The reading rules in full are in docs/lanes.md.

The one dependency crapkit cannot install for you

The python lane init writes runs pytest --cov, and those flags come from pytest-cov, a package of your suite's interpreter. A pipx or uv-tool install of crapkit shares no venv with that suite, so no dependency of crapkit's could ever guarantee it. init probes the python its lane will run and prints the fix when the import fails: install pytest-cov where the suite runs, or, when crapkit lives in that same environment, pip install "crapkit[py]".

The double quotes are not decoration. cmd.exe passes ' through as an ordinary character, so the single-quoted form reaches pip as a requirement it rejects; double quotes are the one spelling cmd, PowerShell, bash and zsh all read the same way, and the bare form still breaks zsh's globbing.

Two Windows cases get their own notes since 0.4.5. On a PATH carrying only the py launcher, the lane is written py -m pytest: the chain is python, then python3, then py, and py is last because it exists on no other operating system. And when cmd.exe cannot start the interpreter at all, which is what the Windows Store python.exe alias does with no Store app behind it (exit 9009, "Python was not found"), init says that instead of talking about pytest-cov, and doctor FAILs the lane rather than calling the repo clean. Nothing ran, so nothing about pytest-cov was learned.

Upgrading on Windows

uv tool upgrade crapkit, and pip install -U into a tool venv, fail with os error 32 ("The process cannot access the file because it is being used by another process") while a crapkit MCP server is live: an agent session spawns crapkit.exe mcp, which holds the launcher, and Windows will not overwrite a running executable. The venv upgrades before that copy fails, so crapkit --version already reports the new version and the launcher is the only stale piece. Quit the agent session and rerun the upgrade, or rename the locked exe aside (Windows allows renaming a running one) and copy the new one in; the .old file goes at the next reboot.

mv ~/.local/bin/crapkit.exe ~/.local/bin/crapkit.exe.old
cp %APPDATA%/uv/tools/crapkit/Scripts/crapkit.exe ~/.local/bin/crapkit.exe

Claude Code: two commands, once per user, zero files per repo

claude plugin marketplace add JeanFrancoisGagne/crapkit
claude plugin install crapkit@crapkit

That installs three skills, the MCP server, and the per-edit advisory hook as one artifact whose version tracks the CLI's. It is a machine-wide install and it changes nothing inside any repo. A repo with a crapkit.toml gets the full ladder; a repo without one gets a silent no-op per edit.

A CLI installed from a clone wants its plugin from that same clone. claude plugin marketplace add takes a path as well as a GitHub slug, and the clone root already carries the .claude-plugin/marketplace.json that names ./plugin as its source, so the same pair of commands installs the plugin sitting beside the CLI:

claude plugin marketplace add /path/to/your/crapkit-clone
claude plugin install crapkit@crapkit

Take that route whenever the CLI came from pip install . or from the git tip. A CLI from the tip and a plugin from the marketplace are the pair that drift, and the next paragraph is how you find out. Both marketplaces answer to the name crapkit, since it is the same file either way, so drop the GitHub one with claude plugin marketplace remove crapkit before adding the clone.

A runtime with no plugin marketplace copies plugin/skills/* into its own skills directory and gets the skills alone. crapkit doctor --plugin-root PATH compares an installed plugin against the crapkit on PATH on two things, its version and the hook protocol it asks for, and prints one line per disagreement at exit 1. Agreement prints nothing at all, at exit 0.

Since 0.4.5 you do not have to know the path. Pass the plugin root, or any directory above it, ~/.claude included, and the newest crapkit install under it wins; pass no path at all and it reads Claude Code's own plugin cache. A root it picked for you is named first, as crapkit doctor: checking PATH, so a wrong guess is visible rather than silent. A root you typed exactly is checked in silence.

Enforcement: seed the ratchet, then arm the hook

crapkit ratchet seed        # pardons standing debt into crapkit-ratchet.tsv
git add crapkit-ratchet.tsv && git commit -m "seed the crapkit ratchet"

printf '#!/bin/sh\nexec python -m crapkit hook-precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

From here every commit is judged, new debt only. Seed before arming, or the first commit on a brownfield repo refuses everything. The plugin is optional and the hook is not: the advisory annotates, this blocks.

Two things bite here. Git runs hooks outside your shell's activated venv, so bare python must resolve to an interpreter that has crapkit installed, or write the absolute path. And a hook in .git/hooks/ is not committed, so teammates get nothing. The README's gate section has the committed-hooks-directory route, the pre-commit-framework route, and CI.

Then, whenever

crapkit worklist            # the risk map: what is worst, and how hot it runs
crapkit report              # renders .crapkit/report.html for people who will not open a terminal

Walked-through quickstarts for Python and TypeScript, with real transcripts, live in the README. The judgment calls, meaning scope granularity, exclude versus lane, and the first-verify hazard, are in docs/adoption.md.

16Six ways people run it

The same store, six different days. Find yours.

1 · Solo, mid-feature

You are writing code. crapkit stays out of the way until a function crosses the line, and then it tells you twice: once as a note you can ignore, once as a commit that stops.

… you edit auth/session.ts, the advisory says nothing …
… you edit it again, and now it has something to say:

crapkit advisory: 1 function(s) over ceiling 6 in auth/session.ts (the edit landed; nothing was blocked)
  ccn 9  auth/session.ts:41  refreshToken ( req , store )
the commit gate enforces this; decompose there or mark the debt

$ crapkit rescore auth/session.ts --gate      # the same verdict, on demand, sub-second
$ crapkit brief auth/session.ts refreshToken  # the packet, if you want the seams handed to you
… split it, then …
$ git commit -m "refresh tokens through a store adapter"   # gate runs, exit 0, commit lands

The advisory and the gate agree by construction (§06). If you skip the advisory entirely, nothing changes: the gate is the enforcement point either way.

2 · An agent burning down debt

You hand a coding session the queue and walk away. The loop is §13. These are the commands it runs.

$ crapkit next-item --claim            # worst by crap, claimed so no sibling session takes it
$ crapkit brief PATH NAME --json       # source, siblings, ceiling, lane, commands, repo traps
… decompose or cover, at the seams the packet named …
$ crapkit rescore PATH --gate          # exit 6 sends you back to the edit
$ crapkit test-scoped PATH             # just this scope's tests, from its template
$ crapkit verify                       # the full verdict; green releases the claim

Stop when next-item reports empty: true and nothing is skipped as claimed and no no_lane_over_target count appears (§11). An empty-looking worklist is not the stop signal.

3 · Day one on a polyglot repo

A repo with Python, TypeScript, Rust and shell in it. init sniffs all four; the two with coverage tooling get live lanes, the other two get scored anyway.

$ crapkit init
wrote crapkit.toml with 4 scope(s): api, infra, ops, ui
detected 2 lane(s) from this repo's own files: py, js — next: run `crapkit coverage`
added to .gitignore: .crapkit/, .coverage, __pycache__/

$ crapkit doctor
ok   every tracked source file belongs to a scope
FAIL scope 'infra' is in no lane's scopes list — its functions can only score no-lane (declare a lane, or coverage_optional = true)
FAIL scope 'ops' is in no lane's scopes list — its functions can only score no-lane (declare a lane, or coverage_optional = true)
doctor: 2 problem(s)

$ crapkit inventory                    # complexity alone, no lanes needed
run 1 @ 8da79a04d51: 4 functions in 4 files (0 cached)

$ crapkit worklist
worklist @ 8da79a04d51 (run 1, floor ccn>=5, churn 12mo) — 4 active, 0 dormant
  risk      0.0  ccn   7 (  7 std)    1c/1a w   0.00  api/grade.py:1  grade( n , total )
  risk      0.0  ccn   6 (  6 std)    1c/1a w   0.00  ui/panel.ts:1  label ( kind , hot )
  risk      0.0  ccn   5 (  5 std)    1c/1a w   0.00  infra/main.rs:1  route kind : u8
  risk      0.0  ccn   5 (  5 std)    1c/1a w   0.00  ops/deploy.sh:1  release()

Read those two FAILs as a fork, not an error: declare a lane for the scope, or set coverage_optional = true and accept cc-only. Both are correct answers; leaving it undecided is not, because no-lane debt is unreachable debt.

Note the Rust row. Stock lizard scores that five-arm match ccn 2, which is under the ceiling and invisible. crapkit's corrected reader scores it 5 (§03).

4 · CI on a pull request

A CI job runs on a fresh clone, so it has no .crapkit/ store and bare crapkit verify exits 1. Running coverage first would make the pull request's own tree the baseline, which is a gate that can never fail. The portable baseline is the mechanism.

# on the default branch, after a passing verify: commit this file
$ crapkit verify --emit-baseline crapkit-baseline.tsv

# in the PR job, against the committed baseline
$ crapkit verify --baseline-tsv crapkit-baseline.tsv --github

--github emits annotations that land on the pull request diff; --sarif PATH writes SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the default branch's verify passes. Branch on the exit code: 6 is complexity, 7 is a ratchet regression, 8 is new test failures, 9 is uncovered changed lines.

5 · Showing someone who will not open a terminal

$ crapkit report        # writes .crapkit/report.html, prints the path it wrote

One self-contained page, three sections, rendered from the same payloads the JSON commands print. It cannot rank a different function first than worklist just did.

A banner across the top names every lane whose artifact no longer describes the tree. Read the banner before you read the grades.

6 · The whole codebase

That is a campaign, and it has its own section: §17.

All six, below the git top

Nothing above assumes crapkit.toml sits at the git root. A package one or more directories down is the ordinary monorepo shape, and it needs no mode and no flag beyond --repo. 0.4.4 fixed the scoring half of it; the commit gate was still reading git diff --cached from the git top, so under a nested root the staged paths matched no scope, and a function at twice the ceiling committed with a warning. 0.4.5 fixed that, and the nine siblings that joined the same two path shapes. The gate now judges what you staged, named the way the rest of the run names it:

$ git add packages/api/app/route.py
$ cd packages/api
$ python -m crapkit hook-precommit          # what the git hook runs
crapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:
  ccn  10  app/route.py:1  route( kind , hot , live , admin )
decompose before committing (coverage cannot save a function above the target).

A file staged above the crapkit root is outside that diff by design, and the gate no longer names it.

Everyday lookups

You are…Reach forBecause
about to edit an unfamiliar filebrief PATH NAME --jsonSource, siblings, churn, coupled files, the ceiling and the repo's traps, before the file is even open.
wondering if the hook will refuse your commitrescore FILE --gateThe same verdict the hook gives, sub-second, before you stage.
deciding where new code should liveworklist · coupling · duplicationHot fragile files are a design input; a near-twin means the helper already exists.
writing tests for a changeuncovered_lines · est_uncovered_pathsThe packet names the dark branches; the estimate sizes the work.
wondering if a past refactor heldexplain PATH NAMEThe score's trajectory across runs, plus regrowth when a split quietly un-split itself.
back from lunch on a shared repodigestThe delta between the last two runs. Silent when nothing changed, which is the healthy reading.
already in a file anywayfile_functions in any briefThe boy-scout check: a sibling one point over its ceiling is cheapest to fix while context is hot.
watching scores while you editwatchRescores tracked files as they change, in a terminal pane.

17The campaign: fixing a whole codebase

Everything above composes into one flow when the goal is the whole repo. Four phases, and the gate goes on first.

phase 0 · wire it, then stop the bleeding init → doctor → coverage → ratchet seed → commit the TSV → install the hook standing debt pardoned · new debt blocked · the hole stops deepening while you dig phase 1 · size the mountain, cut the map worklist --batches N · brief --batch M --json N file-disjoint batches, one per agent, co-changing files kept together · M packets off the top of the CRAP queue, dealt by one process phase 2 · the loop, solo or as a fleet claim → packet → decompose or cover → gate → scoped tests → green verify → commit every green verify tightens the ratchet, so progress is monotone; regrowth flags a split that un-split; mutate audits a batch closed by tests alone phase 3 · steady state the termination rule ends the campaign · digest is the watchtower · the gate holds the line
Phase 0's ordering is the part teams get backwards. Seed and gate before fixing anything, so the cleanup outruns an inflow you already shut off. In phase 1, N and M are unrelated numbers: N is agents, M is packets off the top of the CRAP queue. Two packets can fall in one batch and leave another batch with none, so hand each agent the entries of its own batch and let it call brief PATH NAME per item.

18Design principles