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.
- It ranks by consequence. Debt in a file that changes weekly outranks identical debt in a file nobody has touched in a year. The risk map orders by
ccn × churn weight, not by score alone. - It gates the future, never the past. A brownfield repo turns the gate on the day it installs. Standing debt goes into a committed ratchet and is pardoned; only touched functions are judged. A repo-wide day-one gate fails forever and gets switched off.
- It briefs the fixer. One command emits a start-editing packet: the function's source, its siblings, the ceiling it is judged by, the lane that measures it, the commands to run, and the repo's written-down traps.
- It is deterministic. Same tree, same config, same bytes out. Every read command emits JSON with a pinned
schema, because half the callers are coding agents.
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.
+ 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.
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.
| Language | Extensions | Coverage |
|---|---|---|
| python | .py | coveragepy |
| typescript | .ts | istanbul |
| tsx | .tsx | istanbul |
| javascript | .js .jsx .mjs .cjs | istanbul |
| vue | .vue | istanbul, when your own vitest run reports on .vue files |
| go | .go | cc-only |
| rust | .rs | cc-only |
| shell | .sh .bash | cc-only |
| powershell | .ps1 .psm1 | cc-only |
| swift | .swift | cc-only |
| cpp | .c .cc .cpp .cxx .h .hpp | cc-only |
| objectivec | .m .mm | cc-only |
| java | .java | cc-only |
| zig | .zig | cc-only |
| kotlin | .kt .kts | not 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.
Reader corrections and extensions
- JavaScript and TypeScript keep comma-separated expression arrows as separate functions, including callbacks on the same line. In TypeScript, an unparenthesized arrow body with an unmatched
<before a comma is refused because that comma can belong to a type argument or an expression. Parentheses or a block around the body resolve the ambiguity. - Rust runs on a corrected reader. lizard 1.24.0 lists
matchas a keyword and counts no arm (lizard #494), so a dispatch table reads as trivial: a five-arm match that crapkit scores ccn 5 scores 2 upstream. crapkit counts one point per non-wildcard arm, exactly like a Ccase. A test pins the stock reader's wrong answer, so the module retires itself the day upstream lands the fix. The cognitive column charges the same block once, +1 plus the nesting it sits in with the arms free, the way Sonar charges aswitch: seven ways through, one decision to read. - shell gets a reader lizard ships none of, counting
caseper arm rather than once per block. It reports functions only: top-level script code belongs to lizard's*global*pseudo-function, exactly like Python module level, so a script that is one long top-level sequence reports nothing. That is the answer, not a parse failure. - PowerShell gets a reader too: one point per non-default arm of a
switch, because a twelve-arm dispatcher should not score the same 2 as a one-arm one.defaultis free, the same rule the Rust reader applies to a wildcard, so the same switch with and without one scores alike: it is the fall-through, not a decision. Source bytes decode utf-8 first and cp1252 second, by content rather than by the machine's locale. Windows PowerShell writes cp1252 by default, and left toio.openthe same file scored one way on Windows and another on Linux.
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
- C and C++ share one label,
cpp, C included: lizard resolves all six suffixes to one reader, and.his the header both dialects share. A&&before the opening brace declares an rvalue reference rather than deciding anything, and costs no cognitive complexity; the same rule coversobjectivec, since.mmis Objective-C++. Both arms of an#ifdeffork are textually present, so a platform shim defines one function twice in one file. Each arm takes its own ratchet key, the first as written and later ones suffixed#2,#3in file order, so both are marked and both are gated; crapkit prints one stderr line naming any file that happens in. - Vue scores the
<script>block. Template directives (v-if,v-for) are HTML attributes to lizard and contribute nothing, so a component that branches in its template reports only what its methods do. - Zig counts a
switch'selseprong as one more case, so a Zig switch reads one point above its hand count. Inflation only. It can cost a refactor that was not needed; it never hides one that was.
04How it works
One pass of analysis, your own test commands for coverage, one SQLite store, many views.
- Scopes carve the repo into named territories (
src,ui,py), each with its languages, an optional per-scope target, and free-textnotes: the written-down operational traps that ride into every packet. A file belongs to exactly one scope, and since 0.4.5 one predicate decides which, for scoring, test-scoped routing, lane reuse and the packet alike: the deepest declared path wins. Three parts of the code used to answer that differently, so if your scope paths nest (srcandsrc/api), some files change owner on the next scan and take their per-scope rollup and ceiling with them. Scope paths that do not nest see no change at all. - The inventory is one lizard tokenization per file, content-hash cached, producing both ccn variants and cognitive complexity for every function.
- Lanes are your repo's coverage commands with timeouts, retries, and provenance stamps. Coverage exits 5 on lane errors. When other lanes succeeded, their partial result remains available for diagnosis, with the failed lane's scopes marked
no-lane. A partial run cannot become a trusted baseline. - The store keeps scored runs compactly: function identity is written once and referenced per run, alongside claims, overrides, and run history.
runs pruneis the retention knob. - Churn comes from the git log over a configurable window, cached deflated and refreshed incrementally, so the expensive walk happens rarely.
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.
Resource limits apply across active work: analysis pools share a worker budget and start no more workers than runnable chunks. Lane logs retain up to 16 MiB in each of two files by default. The development runner retains ten recent runs for up to seven days, preserving active and caller-managed evidence. crapkit doctor --json reports the effective settings; crapkit clean --dry-run --json previews eligible cleanup. See resource use and cleanup for configuration and process lifetime.
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.
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.
| Where | Command | Blocks? | Sees coverage? | What it is for |
|---|---|---|---|---|
| after every edit | crapkit claude-hook | no | no | Automatic, via the plugin. Names a function the edit pushed over its ceiling. |
| on demand, mid-edit | rescore FILE --gate | no | no | 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 commit | crapkit hook-precommit | exit 6, git says 1 | no | The enforcement point. Staged blobs, in memory, no store needed. |
| before merge | crapkit verify --base REF | 6 7 8 9 | yes | The full verdict: gate, ratchet, new test failures, dark diff lines. |
| in CI | crapkit verify --baseline-tsv FILE | 6 7 8 9 | yes | A 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
| Exit | Meaning | The move |
|---|---|---|
| 0 | Verdict clean | Proceed. For verify, this is the green that ends a loop iteration. |
| 1 | Overloaded: no data yet, a doctor FAIL, or a debt-policy breach | Read the message. It names the command that creates the missing run, or the FAIL line. |
| 2 | Usage error | argparse refused. Check the flag spelling and order. |
| 3 | Config gap | A scope without a template, a metric-stamp mismatch. Fix crapkit.toml or restamp. |
| 4 | Git error | Not a repository, or a baseline commit rewritten out of history by a force-push. |
| 5 | Tool 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 failed | For 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. |
| 6 | Gate: touched function over its ceiling | Decompose until every piece fits. |
| 7 | Ratchet regression | A standing mark got worse. On a function you never edited, suspect test rot first. |
| 8 | New test failures vs baseline | The flake retest already ran if configured. What remains is real. |
| 9 | Changed lines nobody covered | Only 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.
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.
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"]
- Provenance: every artifact is stamped with its producing commit and duration. Automatic
--reuse-unchangedrequires the same clean HEAD and unchanged lane settings, configuration bytes, inherited environment and coverage/JUnit bytes. Any tracked or untracked edit or new commit reruns the lane. Ignored external inputs, dependencies and services still require a fresh measurement when they change. Explicit--reuse-artifactsreads saved files and warns about stale source coverage. - The results file:
results_artifactnames the JUnit XML written by the lane command. It supplies the failed test IDs for verify's no-new-failures check (exit 8). Measurement also refuses reported worker crashes, collection or session errors, and declared test counts that do not match the reported cases. Without this file, those checks cannot run;doctorwarns, andinitwrites the reporter flag and key together for supported runners. - Resilience: per-lane
timeout_seconds,no_progress_secondsandretriesbound the run. The progress deadline measures how long the lane log has stopped growing. A configuredretest_commandreruns newly failing tests; only an explicit pass in fresh JUnit evidence clears a failure. Lanes run in parallel up tomax_parallel_lanes, longest first. Each command owns its descendants through a Windows Job or a POSIX process group. Completion, timeout, interruption and caller death stop the owned processes before releasing their resources. POSIX commands must keep their inherited group; a daemon that explicitly callssetsidleaves that ownership. An untimed command has no deadline. Logs stream during execution, and a refusal reports the cause from the final attempt. - Honest failure: a lane that produces no artifact marks its scopes
no-laneand the runpartial. The queue counts what it cannot see instead of pretending. The seven classic causes are triaged in docs/lanes.md. An artifact that parses and still reaches none of its scopes is a different refusal, below. - The test template: a
[crapkit.scoped_tests]entry per scope says how to run that scope's tests in isolation. It is whatcrapkit test-scopedexecutes, what the packet'scommands.scoped_testscarries, and whatdoctorwarns about when a laned scope has none. - Housekeeping: managed run state and logs live under
.crapkit/; lane commands write to their configured destinations.doctorwarns about artifacts in the repo root, unclaimed files, and scopes with no test template, and fails a lane whose runner does not resolve on PATH or will not start. - Where the root sits:
crapkit.tomlcan sit below the Git root.crapkit coverage --repo packages/apiscores that package; runningcrapkit coverageinside it finds the same config by walking up. Scoring, changed files, lane reuse, mutation targets and ratchet renames use paths relative to the project root. Git paths retain their literal whitespace and Unicode. See docs/lanes.md for nested projects.
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 reports | Reading | Verdict |
|---|---|---|
| paths resolving outside the root | another tree. A venv whose editable install points at a second checkout is the quiet way in | exit 5 |
| absolute paths resolving under the root | this tree, spelled absolutely. The join is on root-relative paths, so it matches nothing either | exit 5 |
| in-tree relative paths that miss every scope | a suite that imports none of the scoped source yet, which should score untested | warning, 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. A failed lane makes coverage exit 5. Results from lanes that succeeded remain available as a partial run.
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.
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.6 ccn 5 crap 5.1 cov 83% 35c/2a src/crapkit/cli/admin.py:166 _package_json( root : Path ) ok
risk 5.6 ................ ccn x churn weight - what ranks this map
ccn 5 ................... min(standard, modified) ccn - what the gate judges
crap 5.1 cov 83% ....... the score and the coverage behind it; `-` on an inventory-only run
35c/2a .................. 35 commits, 2 authors in the window
path:166 ................ file and the line the function opens on
_package_json( root : Path ) the long name - paste it straight into `crapkit brief`
ok ...................... at or under its ceiling; `no-lane` marks a scope no lane measures
--json carries the same row plus ccn_std, the recency weight the risk is made of, and ratchet_mark, the committed mark on the function or null. The header counts the active rows against their total, 50 of 3980 active (worklist_top 50) (--top N when the flag set the cap), so a capped list never reads as the whole repo.
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
One function's source, score, test commands and project notes, in one response.
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.
- Batch mode:
crapkit brief --batch N --jsonemits the top N queue items as N packets from one process. One store open, one churn load, one source read per file, and since 0.4.5 one shingling pass over the snapshot for the whole batch instead of one per packet: a batch of 5 on a 31k-file repo went from 11.8 s to 5.2 s, byte-identical output. An orchestrator dealing work to parallel agents pays the fixed costs once. - Run the supplied commands as written. For ordinary paths,
commands.gateiscrapkit rescore PATH --gateandcommands.scoped_testsiscrapkit test-scoped PATH. Paths that a Windows shell would expand use an encoded PowerShell wrapper. That preserves the path when the command is pasted into cmd.exe or PowerShell. Thecrapkitconsole script must be on PATH. - Five name forms: the long name the worklist prints, the bare name, the start line, the ordinal handle
(anonymous)#Nmeaning the Nth anonymous function in the file by start order, or the twin selectorNAME#2for one of several functions a file gives one name to. Both ordinal forms survive edits that shift lines above them, which a start line does not;claimstakes the handle. The bare name is the long name's leading token, which in Rust and Go is all there is before the parameters:route cmd : & Cmdisroute. Matching is exact first and falls back to a substring search only when no function answers to the name, soroutenever also returnsroute_chain. Since 0.4.5explainresolves a start line the same way, socrapkit explain app/grade.py 1answers where it used to want a name. - Honesty flags:
stalesays the snapshot predates HEAD.uncovered_lines: nullplus a note names the lane that abstained, never conflated with "nothing to cover".refresh_writes_runwarns that refreshing writes a run.
13Built for agents
The CLI, packets and MCP tools support a coding agent's edit, test and verify loop.
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 exposes discovery, briefing and gate checks over stdio. It does not run coverage lanes or claim items. Tools can populate caches or initialize and migrate local state. In an unmeasured directory it returns setup guidance.
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 commands for investigating test strength, duplication, change history and configuration.
mutate: are the tests real?
Diff-scoped mutation testing. Flips comparisons and boolean logic on the lines you changed in files the scored corpus holds (never a test), 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.
Every mutation worker uses a kept worktree, including the default of one. See mutation worktrees for preparation, concurrent runs and cleanup. Reclaim the checkouts with:
$ 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). pip install crapkit installs the latest release from PyPI. 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
Windows can refuse to replace crapkit.exe while an MCP session uses it, reporting os error 32. Close the agent sessions using crapkit, then rerun the upgrade with the installer you used:
uv tool upgrade crapkit # for a uv tool install
python -m pip install --upgrade crapkit # in the environment holding a pip install
Use one of these commands, then restart the agent so its MCP server runs the updated package. A partially completed upgrade can already report the new package version while its launcher remains locked; the successful installer run is the check that the upgrade finished.
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 user-scope 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.
Codex: install the three skills and MCP server
codex plugin marketplace add https://github.com/JeanFrancoisGagne/crapkit.git
codex plugin add crapkit@crapkit
Codex uses its own plugin manager with the same repository marketplace. Use its three skills and MCP server in Codex. The advisory hook instructions here configure Claude Code's PostToolUse event.
Update installed plugins after the CLI
claude plugin marketplace update crapkit
claude plugin update crapkit@crapkit --scope user
codex plugin marketplace upgrade crapkit
codex plugin add crapkit@crapkit
codex plugin list --marketplace crapkit --json
Refresh the marketplace before updating the installed copy. Restart existing Claude Code sessions to apply its plugin update, and start fresh MCP sessions after a CLI upgrade. crapkit doctor --plugin-root PATH checks installed files against the CLI on PATH; it does not reload a running session. Pass the installed Codex plugin directory explicitly because doctor's default cache is Claude Code's. The plugin upgrade guide explains the installed path and Windows launcher locks.
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
$ 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 of 4 active (worklist_top 50), 0 dormant
risk 7.0 ccn 7 crap - cov - 1c/1a api/grade.py:1 grade( n , total )
risk 6.0 ccn 6 crap - cov - 1c/1a ui/panel.ts:1 label ( kind , hot )
risk 5.0 ccn 5 crap - cov - 1c/1a infra/main.rs:1 route kind : u8
risk 5.0 ccn 5 crap - cov - 1c/1a 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.
- Grades by scope at the top: functions, over-target count, CRAP load, and a colored grade chip per scope. This is the slide.
- The worklist, ranked, at
worklist_toprows (default 50), each row with its CRAP and coverage off the ranked run. Each row also prints thecrapkit explain PATH NAMEcall that opens it: dark lines, history and the mark stay one command away. - The trend: CRAP load per scored run as one polyline, down is better, plus the numbers underneath.
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 for | Because |
|---|---|---|
| about to edit an unfamiliar file | brief PATH NAME --json | Source, siblings, churn, coupled files, the ceiling and the repo's traps, before the file is even open. |
| wondering if the hook will refuse your commit | rescore FILE --gate | The same verdict the hook gives before you stage. |
| deciding where new code should live | worklist · coupling · duplication | Hot fragile files are a design input; a near-twin means the helper already exists. |
| writing tests for a change | uncovered_lines · est_uncovered_paths | The packet names the dark branches; the estimate sizes the work. |
| wondering if a past refactor held | explain PATH NAME | The score's trajectory across runs, plus regrowth when a split quietly un-split itself. |
| back from lunch on a shared repo | digest | The delta between the last two runs. Silent when nothing changed, which is the healthy reading. |
| already in a file anyway | file_functions in any brief | The boy-scout check: a sibling one point over its ceiling is cheapest to fix while context is hot. |
| watching scores while you edit | watch | Rescores 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.
entries of its own batch and let it call brief PATH NAME per item.- Doctor's warnings are the first work items. Debt in a scope no lane measures cannot be burned down, only wired up. On one large consumer, wiring six extra lanes took measured functions from 95k to 113k of 141k before a single function was edited.
- The stop signal is the termination rule (§11), never an empty-looking worklist. A finished repo is a worklist full of
okmarkers. - Value is front-loaded, so stopping early is rational. The risk map is worst-first by
ccn × churnand the hand-out queue by CRAP, and the top of it is where incidents live. "Fix the worst slice and gate the rest forever" is the same campaign ended earlier, and the ratchet holds whatever line you reach. - The summit items are redesigns. A whole command handler at extreme ccn is not a split, it is an architecture session. The packet's
est_splitssaying dozens is the tell. - Proof it composes: crapkit's own repo runs this campaign against its own gate, with the config, the marks and the hook all committed (
crapkit.toml,crapkit-ratchet.tsv,git-hooks/pre-commit). Measured on the 0.4.5 tree: 1,291 functions, all measured, 0 over the ceiling of 6, CRAP load 3,605.78, grade A+. The marks file is a header and one standing mark oncli/admin.py, a pardon the repo has since paid back. - 0.4.5 made the repeated commands cheap, which is most of what a campaign spends its day on. Measured on a 31,459-file consumer, 152k functions, 41,544 marks and 72,653 commits: warm
coupling1.05 s to 0.11 s,worklist --batchesdown 62%, a batch of five briefs 11.8 s to 5.2 s,doctor7.5 s to 1.4 s across 14 lanes over 2 runners,trend4.58 s to 0.04 s warm,reportdown 76%,verify25.5 s to 18.9 s, andmutate's worker setup 30.6 s to 0.46 s. These are historical measurements from that consumer, not timing guarantees. Measure your own repo;verifyincludes the cost of its test lanes. - Four ideas were measured and thrown away, so nobody spends a week rebuilding them: skipping
verifyon an unchanged tree (the cache key cannot see a second edit to an already-dirty file), serving MCP tool calls from a kept process (a stalesourcebreaks the packet contract), parallel git date slices for the churn walk, and a faster JSON decoder.
18Design principles
- Deterministic or wrong. Same tree, same config, same bytes out. Randomness, wall-clock dependence and unstable orderings are treated as defects.
- The gate binds the future. Standing debt is pardoned and ratcheted; only touched functions are judged. A gate a team turns off is worse than no gate.
- Above the ceiling, decompose. Coverage cannot save a function past its target. The design pressure is the point, and crapkit's own source is held to its own rule.
- One enforcement point. Advisories annotate; the commit gate blocks. Two things that can refuse your work are two rules to keep in your head.
- Versioned JSON. Outputs declare
schema: 1, and contract tests pin the fields callers depend on. Consumers should check the schema and tolerate added fields. - Your test commands, not ours. Lanes run what the repo already runs and crapkit reads artifacts. There is no embedded test framework to fight.
- Failure is data. A failed lane, a stale artifact, an abstaining scope: each is counted and named, never papered over with a zero that reads as health.
- Docs are contracts. Tests diff this page and the README against the argument parser, and the shipped skills against the CLI, both directions. Documentation that can drift, will, so it is not allowed to.
- Dependency-light. Python stdlib plus lizard. The MCP server, the parsers, the store: no framework underneath to version-chase.