{
  "markdown": "# crapkit\n\n<!-- mcp-name: io.github.JeanFrancoisGagne/crapkit -->\n\n[![ci](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml/badge.svg)](https://github.com/JeanFrancoisGagne/crapkit/actions/workflows/ci.yml)\n[![PyPI](https://img.shields.io/pypi/v/crapkit)](https://pypi.org/project/crapkit/)\n[![Python](https://img.shields.io/pypi/pyversions/crapkit)](https://pypi.org/project/crapkit/)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/JeanFrancoisGagne/crapkit/blob/main/LICENSE)\n[![crapkit MCP server](https://glama.ai/mcp/servers/JeanFrancoisGagne/crapkit/badges/score.svg)](https://glama.ai/mcp/servers/JeanFrancoisGagne/crapkit)\n\n![crapkit init, coverage and worklist --top 5 on a small Python repo, then a shell heredoc adding 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](https://raw.githubusercontent.com/JeanFrancoisGagne/crapkit/main/docs/demo.gif)\n\ncrapkit scores every function in your repo on complexity times uncovered risk, ranks the\nworst ones by how often the file changes, and blocks commits that add more. It reads\nPython, TypeScript, TSX, JavaScript, Swift, Go, Rust, shell, PowerShell, C and C++,\nObjective-C, Vue, Java and Zig through [lizard](https://github.com/terryyin/lizard), and\njoins per-function branch coverage from the istanbul or coverage.py artifact your own test\ncommand already writes. Every read command speaks sorted-keys JSON on a pinned schema,\nbecause half the callers are coding agents.\n\n```\nCRAP = ccn^2 * (1 - cov)^3 + ccn\n```\n\nThe name is not ours: C.R.A.P. (Change Risk Anti-Patterns) was coined for crap4j by\nAlberto Savoia and Bob Evans in 2007.\n\n`ccn` is the smaller of standard and modified cyclomatic complexity, both read off one\nlizard pass. `cov` is branch coverage inside the function's span; with no branches it\nfalls back to statement coverage, and with no statements to invoked-or-not, so a\nhalf-executed straight-line function never reads as fully covered.\n\n**Above the ceiling, coverage cannot save you. Decompose.** At the default target of 6, a\nfunction at ccn 7 with 100% coverage still scores 7 and still fails the gate. The only\nmove that clears it is splitting the function.\n\n**Why 6 and not 30.** crap4j's conventional threshold of 30 is a CRAP score: it lets an\nuntested `ccn 5` through (25 + 5 = 30) and a fully covered `ccn 30` too. crapkit's default\nis a complexity ceiling, because coverage can at best collapse CRAP to `ccn`, and a\nfunction you cannot cover past `ccn 6` is one you decompose. Set `target = 30` in\n`crapkit.toml` if you want the crap4j number. A repo with existing debt does not need to:\n`ratchet seed` marks today's over-ceiling functions at today's score, the gate then judges\nonly the functions a change touches, and marks may only fall, so adoption never starts with\na wall of red. Next to crap4py, radon, xenon, wily and SonarQube:\n[docs/comparison.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/comparison.md).\n\ncrapkit scores **git-tracked files only**. Source you have not `git add`ed is invisible to\nit.\n\n---\n\n## The 60-second start\n\n```\npip install crapkit\ncd your-repo\ncrapkit init        # crapkit.toml and .gitignore lines, plus a live coverage lane when it\n                    # recognizes the runner and a scope speaks its language: pyproject.toml,\n                    # pytest.ini or setup.cfg for pytest; a test script or vitest/jest in\n                    # package.json for the JS side\n                    # without one: the lane comes commented out, init says to declare one,\n                    # and docs/lanes.md is how to fill it in\ncrapkit coverage    # runs the lane, joins coverage, stores a scored run\ncrapkit worklist    # the ranked risk map\ncrapkit ratchet seed && git add crapkit.toml crapkit-ratchet.tsv .gitignore\n```\n\n`coverage` scores, `worklist` ranks:\n\n```\n$ crapkit coverage\nrun 1 @ fae4db93108: 2 functions scored: 2 measured, 1 over ceiling 6, CRAP load 41.0, grade F\n-> next: crapkit worklist\n\n$ crapkit worklist\nworklist @ fae4db93108 (run 1, floor ccn>=5, churn 12mo) - 1 of 1 active (worklist_top 50), 0 dormant\n  risk     14.0  ccn  14  crap    38.5  cov  50%    1c/1a  calc/grade.py:7  classify( score , attempts , late , bonus )\n```\n\n`risk 14.0` is ccn times a churn weight of one: a one-commit repo has no spread of commits\nto weight, so each commit counts once and the ranking is complexity order until the\nhistory grows ([Risk](#risk-what-ranks-the-worklist)). `crap 38.5` and `cov 50%` are the\nscore and the coverage behind it.\n\n`ratchet seed` signs today's debt at today's score. From then on marks only ever fall, so\nthe repo can get better and never worse while you burn it down.\n\n**One thing stops most first runs: the coverage plugin.** `init` writes a lane that shells\nout to your own test runner, and the runner needs its coverage package installed:\n`pytest-cov` for pytest, `@vitest/coverage-v8` (pinned to your vitest major) for vitest.\nWithout it the lane produces no artifact and `coverage` exits 5 quoting the runner's own\nerror. For pytest, `init` probes the python its lane will run and prints the install\ncommand when `pytest_cov` is missing; `pip install \"crapkit[py]\"` pulls the plugin\nalongside crapkit when the two share a venv. On a Windows PATH holding only the `py`\nlauncher it writes `py`, not a `python3` the lane could never run, and when cmd.exe cannot\nstart the interpreter at all (exit 9009, the Store alias) it names that instead of guessing\nat pytest-cov. A repo that pins no lockfile and carries its own `.venv` gets that venv's\ninterpreter in the lane, when that interpreter can import pytest, rather than whichever\npython the shell answers with. The two quickstarts below walk a real repo end to end.\n\n**On Windows a lane command is read by cmd.exe**, the shell that will run it, not by sh.\nDouble quotes are the portable quoting. A single-quoted value is refused at config load\nwith exit 3, because cmd.exe would hand pytest five words and the lane would write no\nartifact:\n\n```\n# the lane in crapkit.toml\ncommand = \"python -m pytest -m 'not live and not perf' --cov=calc --cov-branch --cov-report=json:.crapkit/cov/py.json\"\n\n$ crapkit doctor\ncrapkit: lane 'py': positional argument 'live' 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 suite whose testpaths cannot be collected in one process needs one lane per testpath, each with full_suite = false and its own artifact\n```\n\nWrite it `-m \"not live and not perf\"`. Carets, `&&` and `|` segments, redirections and\nempty quoted arguments all read the way the shell reads them, so a chained lane\n(`cd tests && python -m pytest --cov ...`) is checked one segment at a time. `doctor` reads\na lane the same way, and FAILs one whose runner will not start.\n\n## Install\n\n```\npip install crapkit\n```\n\nThat is the release on [PyPI](https://pypi.org/project/crapkit/). For the unreleased tip\nof `main`, or from a local clone (run at the clone root):\n\n```\npip install git+https://github.com/JeanFrancoisGagne/crapkit.git\npip install .\n```\n\nEvery route pulls one dependency, `lizard>=1.24.0`, a normal PyPI wheel, so an offline\nmirror installs fine. Requires Python 3.11 or newer. The `pip install -e \".[dev]\"` under\n[Development](#development) is a different thing: it adds the test extra, for people\nchanging crapkit.\n\nScoring runs your own test command on your own machine and reads the artifact it writes.\nThere is no network call anywhere in crapkit, so no source, no score and no telemetry\nleaves the box\n([SECURITY.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/SECURITY.md)).\n\n```\n$ crapkit --version\ncrapkit 0.6.0\n```\n\n`python -m crapkit` works identically to the console script and is what to use from a\nsource checkout. Every subcommand accepts `--repo PATH` (default: the nearest `crapkit.toml`\nat or above the current directory, so a monorepo workspace finds the root's), and with it\nyou never have to `cd` into the repo you are scoring; [Subcommands](#subcommands) shows\nwhere the flag goes.\n\n## Upgrading from 0.4.4\n\n**Run `crapkit ratchet seed` first.** Shell cognitive complexity now nests, which is\nanalysis version 8, and marks measured under version 7 are not comparable. Until you\nre-seed, `verify` refuses at exit 3:\n\n```\n$ crapkit verify\ncrapkit: 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`\n```\n\nOnly shell and PowerShell cognitive numbers move. `ccn` does not, so a re-seed re-stamps\nthe file and leaves the marks where they were.\n\nFive more things change under you. Three of them need nothing from you:\n\n- **New cache files.** `.crapkit/coupling-cache-v1.json` joins `churn-cache-v2.json` and\n  `churn-log-v2.z`. A warm 0.4.4 churn cache is adopted once and its file removed, and\n  `.crapkit/` is already gitignored, so nothing new reaches your index.\n- **`trend` and `report` write.** Both read a per-run rollup table, filled once per run and\n  pruned with its run, instead of rescanning every scored row. A read-only `.crapkit/`\n  costs the speedup, never the command.\n- **Nested scopes may move files.** One predicate decides scope ownership now, and the\n  deepest declared path wins, so a repo whose `[[scope]]` paths nest inside each other can\n  see files change scope, rollup and ceiling on the next scan. Scopes that do not nest see\n  no change.\n\nThe other two put something in front of you:\n\n- **`mutate` keeps a worktree pool.** With `mutation_workers > 1` the worker worktrees now\n  live under `.crapkit/mutate-pool/` between runs and are re-prepared each run, which is\n  the setup cost gone (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare\n  them). The pool is not size-bounded and nothing sweeps it: `crapkit mutate --drop-pool`\n  removes it and exits. Single-worker runs are untouched.\n- **`doctor` WARNs on a lane with no `results_artifact`.** Every `coveragepy` or `istanbul`\n  lane written before 0.4.5 gets one, with the two lines that fix it. Coverage is\n  unaffected. What the lane cannot feed without a results file is the crashed-worker check\n  and the no-new-failures check (exit 8).\n\n### The exe lock on Windows\n\n`uv tool upgrade crapkit`, and `pip install -U` into a tool venv, fail with `os error 32`\n(\"The process cannot access the file because it is being used by another process\") while a\ncrapkit MCP server is live: an agent session spawns `crapkit.exe mcp`, which holds the\nlauncher, and Windows will not overwrite a running executable. The venv upgrades before\nthat copy fails, so `crapkit --version` already reports the new version and only the\nlauncher is stale. Quit the agent session and rerun the upgrade, or rename the locked exe\naside (Windows allows renaming a running one) and copy the new one in. Two lines in\ncmd.exe, where both `%` variables expand:\n\n```bat\nmove %USERPROFILE%\\.local\\bin\\crapkit.exe %USERPROFILE%\\.local\\bin\\crapkit.exe.old\ncopy %APPDATA%\\uv\\tools\\crapkit\\Scripts\\crapkit.exe %USERPROFILE%\\.local\\bin\\crapkit.exe\n```\n\nGit Bash has no `move` and passes `%APPDATA%` through as literal text, so that block\nfails there on its first line. Its form is `mv` and `cp` over `\"$USERPROFILE\"` and\n`\"$APPDATA\"`, which Git Bash sets to the same two directories.\n\n## The Claude Code plugin\n\n```\nclaude plugin marketplace add JeanFrancoisGagne/crapkit\nclaude plugin install crapkit@crapkit\n```\n\nTwo commands, installed once per user, and every repo on the machine gets it. The plugin\nships three skills, the read-only MCP server, and one advisory PostToolUse hook that names\nany function an edit pushed over its ceiling. Claude reaches two of the skills by itself,\n`crapkit` and `crapkit-recover`; the third you type, as `/crapkit:crapkit-onboard`, because\nwiring a repo up happens once and its description has no business in every turn's window.\nIt adds no files to your repo, and it needs the crapkit CLI on PATH.\n\nA repo with no `crapkit.toml` costs a silent sub-50 ms no-op per edit. Other agent\nruntimes have no marketplace: copy `plugin/skills/*` into their skills directory instead.\n\nThe hook registers on `Edit|Write`, which is every write that names a file. An agent that\nwrites its source through a shell heredoc names none, so a `Bash` event is judged off the\nworking tree instead. That half is yours to register, because it costs two\ngit spawns per shell call. Add a second PostToolUse entry to your own settings, same\ncommand, matcher `Bash`:\n\n```json\n{\n  \"hooks\": {\n    \"PostToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"crapkit claude-hook --protocol 1\", \"timeout\": 20 }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe cost is one `git rev-parse --show-toplevel` and one `git status --porcelain -z -uall`\nper shell call in any git repo, whether or not crapkit measures it: about 30 ms together\non crapkit's own checkout, and more on a bigger tree. What comes back is the dirty or\nuntracked `*.py` files written in the last 12 seconds, 25 at most, each judged the way an\nedit is. Python only, so a TypeScript or Go repo pays the two spawns and hears nothing.\n\n## Languages\n\n14 languages, two coverage parsers. Coverage joins where a parser exists; everything else\nscores on complexity alone.\n\n| Language | Files | Coverage |\n|---|---|---|\n| Python | `.py` | coverage.py |\n| TypeScript | `.ts` | istanbul |\n| TSX | `.tsx` | istanbul |\n| JavaScript | `.js` `.jsx` `.mjs` `.cjs` | istanbul |\n| Vue | `.vue` | istanbul, when your vitest run reports on `.vue` files |\n| Swift | `.swift` | none: cc-only |\n| Go | `.go` | none: cc-only |\n| Rust | `.rs` | none: cc-only |\n| shell | `.sh` `.bash` | none: cc-only |\n| PowerShell | `.ps1` `.psm1` | none: cc-only |\n| C and C++ | `.c` `.cc` `.cpp` `.cxx` `.h` `.hpp` | none: cc-only |\n| Objective-C | `.m` `.mm` | none: cc-only |\n| Java | `.java` | none: cc-only |\n| Zig | `.zig` | none: cc-only |\n\nA cc-only scope declares `coverage_optional = true`, scores `crap = ccn`, and needs no\nlane. Nothing about it is provisional: the ceiling still binds and the gate still refuses\na function over it. Add a coverage lane the day a parser exists and the same scope starts\njoining coverage.\n\n`crapkit init` writes that key itself, on every scope whose languages all lack a parser,\nand leaves it off any scope a lane could still measure. So the 60-second start above runs\nunchanged on a Go, Rust or shell repo: `crapkit coverage` scores it with no lane at all,\nand that run is the baseline `worklist`, `next-item`, `ratchet seed` and `verify` read.\n\nThree readers are crapkit's own. lizard ships none for shell or PowerShell, so crapkit\ncounts their functions itself. Its Rust reader scores a 7-arm `match` as ccn 2 (filed as\nlizard #494), so crapkit counts each non-wildcard arm like a C `case` and retires the\noverride the day upstream fixes it. The cognitive column charges that same block once,\nthe way Sonar charges a `switch`.\n\n## The gate\n\nFour surfaces ask the same question, ccn against the scope's ceiling, with four\ndifferent powers:\n\n| Surface | Fires | Power |\n|---|---|---|\n| `crapkit claude-hook` | after an agent's edit lands | **advisory.** Names the breach on stderr. Blocks nothing, because PostToolUse runs after the write |\n| `crapkit rescore FILE --gate` | when you ask, after the first coverage run | **preview.** The commit gate's verdict on demand, sub-second, before you stage. With no run behind it, exit 1 and `no snapshot` |\n| `crapkit hook-precommit` | `git commit` | **blocks.** The hook exits 6; git reports 1. Staged blobs only, so it costs the size of the commit and needs no coverage |\n| `crapkit verify` | before you push, and in CI | **the verdict.** Gate, ratchet, new test failures, diff coverage, against the trusted baseline |\n\nBoth hooks exempt a function the committed ratchet already carries a mark for, so touching\nsigned debt never refuses a commit. `verify` is what fails a mark that rises. Since 0.4.5\nits gate exempts a touched function whose fresh CRAP sits **at or under** its mark, the\nrule `rescore --gate` already applied; push it past the mark and the gate fires again. The\npre-commit hook still exempts on the mark's existence alone, on purpose: a staged blob has\nno coverage, so there is no fresh CRAP to compare against. It reports each exemption count\non stderr (`staged function(s) carry a ratchet mark and were not gated`), and says the same\nabout a staged file no `[[scope]]` claims, so a new top-level directory cannot go ungated\nin silence.\n\n**The crapkit root does not have to be the git top.** Since 0.4.5 every git spawn runs with\n`diff.relative=true` and `core.quotePath=false`, so a `crapkit.toml` in `packages/api`\ngates that package's own staged files and names them `app/m.py`, not\n`packages/api/app/m.py`, and a dirty non-ASCII path is a real row rather than an invisible\none. Before that a nested root matched staged paths against no scope, and a function at\ntwice the ceiling committed with a warning.\n\nGit runs hooks outside your shell's activated venv. Bare `python` must resolve to an\ninterpreter that has crapkit installed, or spell it out\n(`exec /path/to/venv/Scripts/python -m crapkit hook-precommit`).\n\n### Route 1: `.git/hooks/pre-commit` (local, not committed)\n\n```sh\ncat > .git/hooks/pre-commit <<'EOF'\n#!/bin/sh\nexec python -m crapkit hook-precommit\nEOF\nchmod +x .git/hooks/pre-commit\n```\n\nThe same file from PowerShell. `Out-File` and `>` write a byte-order mark (UTF-16 on\n5.1) in front of the shebang, and git then answers every commit with `cannot spawn\n.git/hooks/pre-commit` and lets it through; `Set-Content -Encoding ascii` does not. Git\nruns the hook with its own `sh`, so the interpreter is spelled with forward slashes and\nquoted, and no `chmod` is needed on Windows:\n\n```powershell\n$python = (Get-Command python).Source -replace '\\\\', '/'\nSet-Content -Path .git/hooks/pre-commit -Encoding ascii -NoNewline -Value \"#!/bin/sh`nexec '$python' -m crapkit hook-precommit`n\"\n```\n\n`crapkit doctor` warns when the hook file git would spawn starts with a byte-order mark.\n\n### Route 2: a committed hooks directory\n\nThe whole route, from a repo that has no `githooks/` yet:\n\n```sh\nmkdir -p githooks\ncat > githooks/pre-commit <<'EOF'\n#!/bin/sh\nexec python -m crapkit hook-precommit\nEOF\nchmod +x githooks/pre-commit\nprintf 'githooks/pre-commit text eol=lf\\n' >> .gitattributes\ngit add .gitattributes githooks/pre-commit\ngit update-index --chmod=+x githooks/pre-commit\ngit commit -m \"add crapkit gate hook\"\ngit config core.hooksPath githooks\n```\n\n**The `--chmod` goes between the `add` and the `commit`.** It writes the executable bit to\nthe index, so a commit that already happened does not carry it: run it after and `git\nls-tree HEAD` still says `100644`, which is a hook Unix checkouts silently skip. The\n`.gitattributes` line is the harder half of the same failure: under Windows' default\n`core.autocrlf` the hook checks out CRLF and `#!/bin/sh\\r` dies on Linux and macOS with a\nbad-interpreter error. `crapkit doctor` warns when a file under `core.hooksPath` is not\n`100755` in the index and prints the `update-index` line for it.\n\nGit will not read a hooks path out of a committed file, so that `git config` line belongs\nin your CONTRIBUTING setup steps. Every clone arms the gate with it.\n\n### Route 3: the pre-commit framework\n\ncrapkit ships a `.pre-commit-hooks.yaml` declaring `id: crapkit-gate`. In your\n`.pre-commit-config.yaml`:\n\n```yaml\nrepos:\n  - repo: https://github.com/JeanFrancoisGagne/crapkit\n    # crapkit's release step rewrites this line to the tag it just cut\n    rev: v0.6.0\n    hooks:\n      - id: crapkit-gate\n```\n\nThat file arms nothing on its own. The framework writes `.git/hooks/pre-commit` when you\ntell it to, and until then `git commit` runs no gate and says nothing:\n\n```sh\npip install pre-commit\npre-commit install\n```\n\n`pre-commit install` is the line every clone needs, the way Route 2 needs its\n`git config core.hooksPath` line.\n\n`rev` is a git ref pre-commit resolves against that remote. Pin a release tag, not a\nbranch: `pre-commit autoupdate` only moves between tags, and a moving `main` would change\nyour gate under you.\n\n### Route 4: CI\n\nA CI job runs on a fresh clone, which has no `.crapkit/` store, so bare `crapkit verify`\nexits 1. Running `coverage` first would make the PR's own tree the baseline, a gate that\ncan never fail. The portable baseline is the mechanism:\n\n```\n# on the default branch, after a passing verify: commit this file\ncrapkit verify --emit-baseline crapkit-baseline.tsv\n\n# in the PR job, against the committed baseline\ncrapkit verify --baseline-tsv crapkit-baseline.tsv --github\n```\n\n`--github` emits `::error file=...` annotations that land on the PR diff; `--sarif PATH`\nwrites SARIF 2.1.0 for code-scanning upload. Refresh the committed baseline whenever the\ndefault branch's verify passes.\n\nTwo things the job has to do before those lines run. **Install crapkit**, `pip install\ncrapkit`, and pin the version the way Route 3 pins `rev`: an unpinned install moves your\ngate on whatever day a release lands. **Fetch the whole history.** `actions/checkout`\nclones one commit by default, `verify` reads the diff against the baseline's commit out of\ngit, and a shallow clone does not have that commit:\n\n```\n$ crapkit verify --baseline-tsv crapkit-baseline.tsv\ncrapkit: baseline commit a74260f321f is not an ancestor of HEAD in this shallow clone, which does not hold it; set fetch-depth: 0 on the checkout or run git fetch --unshallow\n```\n\nThat is exit 4 on a `git clone --depth 1` of a repo whose baseline verifies at full depth.\nOn a full clone the same exit blames what it used to, a rebase or an amend that rewrote\nhistory, and asks for a fresh baseline instead.\nSet `fetch-depth: 0` on the checkout step, which is what crapkit's own\n[.github/workflows/ci.yml](https://github.com/JeanFrancoisGagne/crapkit/blob/main/.github/workflows/ci.yml) does.\n\nThe whole PR job, on GitHub Actions:\n\n```yaml\non: pull_request\njobs:\n  crapkit:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0        # verify needs the baseline's commit\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"\n      - run: pip install crapkit\n      - run: pip install -e \".[dev]\"   # your own test dependencies\n      - run: crapkit verify --baseline-tsv crapkit-baseline.tsv --github\n```\n\nThe second install is the one people leave out. `verify` reruns your lanes, so the job\nneeds whatever your test command needs: the coverage plugin, `npm ci`, a database, all of\nit. Without them the lane writes no artifact and `verify` exits 5 quoting the runner's own\nerror, which is a broken job and not a verdict.\n\n### What a refusal looks like\n\n```\n$ git commit -m \"add route\"\ncrapkit gate: 1 staged function(s) exceed the complexity ceiling of 6:\n  ccn   7  app/m.py:9  route( a , b , c , d )\ndecompose before committing (coverage cannot save a function above the target).\n```\n\nThat commit exited **1**, not 6. Git collapses any failed hook to 1, so 6 is a code you\nonly ever see by running the hook yourself: `crapkit hook-precommit` exits 6 on a\nviolation and 0 otherwise. The stderr block above is the same either way.\n\n`CRAPKIT_OVERRIDE_REASON` is not a bypass. Setting it routes the commit through the full\nthree-record audit: an alert line through `alert_command`, a ratchet entry staged into the\ncommit, and a row in the override log. All three land or nothing does, and an unset\n`alert_command` refuses the override outright. See\n[docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md#overrides-and-the-audit-trail).\n\n## The GitHub Action\n\n[action.yml](https://github.com/JeanFrancoisGagne/crapkit/blob/main/action.yml) at this repository's root is a composite action, so a reviewer\nsees crapkit's numbers on the pull request without installing anything. Four lines add it\nto a workflow, and every input has a default:\n\n```yaml\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - uses: JeanFrancoisGagne/crapkit@v0.6.0\n```\n\nThe whole job those four lines sit in:\n\n```yaml\non: pull_request\njobs:\n  crapkit:\n    runs-on: ubuntu-latest\n    permissions:\n      pull-requests: write             # the comment, and nothing else\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0               # the diff, and verify's baseline commit\n      - uses: actions/setup-python@v5\n        with:\n          python-version: \"3.12\"       # the interpreter the install below lands in\n      - run: pip install -e \".[dev]\"   # whatever your lanes need to run\n      - uses: JeanFrancoisGagne/crapkit@v0.6.0\n        with:\n          gate: \"false\"\n```\n\nThat `pip install` step is the one people leave out, and it is the same one Route 4 above\nnames: the action installs crapkit and nothing else, so your lanes still need whatever\nyour test command needs. Without it the lane writes no artifact and the comment says so.\n\n`fetch-depth: 0` is the other one. `actions/checkout` clones a single commit; the action\nreads the pull request's changed files out of git and `verify` reads the diff against the\nbaseline's commit. With a shallow clone the file list comes back empty and the comment\nranks the whole repository instead of the diff.\n\nThe action installs crapkit from `$GITHUB_ACTION_PATH`, which is its own checkout of the\nref you pinned in `uses:`. So a pin left at last month's tag scores your tree with last\nmonth's crapkit rather than with whatever released since, and pinning a tag is the whole\nversion policy; the snippets above name the current release.\n\n### What the comment looks like\n\nOne comment per pull request, edited in place on every push. A hidden\n`<!-- crapkit-action -->` line is how the next run finds it, so a fifteen-push branch\ncarries one comment and not fifteen. On a `push` event there is no pull request to carry\nit, and the same text goes to the job log instead.\n\nRendered from three saved payloads: a pull request that adds an untested `route()` (ccn 8)\nbeside a ratchet-marked `legacy_router()`, in a repository whose `diff_uncovered_max` is 3.\nThe payloads are under `tests/fixtures/action_comment/`, and the unit suite pins this block\nto their render:\n\n```markdown\n<!-- crapkit-action -->\n\n## crapkit\n\n4 functions in 2 files, 2 over ceiling 6, CRAP load 149.59, grade F.\n\n**verify failed, exit 6: complexity gate.**\n\n- gate: `app/calc.py:34` `route( a , b , c , d )` ccn 8, cov 0%, crap 72.0 -> decompose\n- uncovered lines in `app/calc.py`: 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45\n\nRun 3 against baseline 1, 1 changed file: 1 gate violation, 0 ratchet regressions, 0 new test failures, 11 uncovered changed lines.\n\n### Worklist: 1 changed file\n\n| File | Function | ccn | risk | remedy |\n|---|---|---:|---:|---|\n| `app/calc.py:34` | `route( a , b , c , d )` | 8 | 4.0 | decompose |\n| `app/calc.py:19` | `legacy_router( a , b , c , d , e )` | 8 | 4.0 | decompose (accepted debt) |\n```\n\nThe first line is the run `crapkit coverage` wrote: functions and files, how many sit over\nthe ceiling (`over ceiling 6`, or `over their ceilings (6; reports 12, util 4)` when scopes\nset their own), CRAP load and grade, with a failed lane's first error line appended as\n`; lane 'js' failed: ...`. When `coverage --json` died before a summary, the line quotes the\nerror object it printed instead: `` `crapkit coverage` exited 5: lane 'py' cannot import\npytest-cov; pip install pytest-cov. ``\n\nThe verdict opens with the exit code and the rule it stands for (`complexity gate`,\n`ratchet regressions`, `new test failures`, `diff-coverage ceiling N`), then one bullet per\nfinding: each gate violation with its function, ccn, coverage, CRAP and remedy; each\nratchet regression as recorded -> fresh; each new test failure by id; and the first twenty\nuncovered changed lines, one bullet per file, with a count of the rest. The counts line\ncloses it. A verify that passed is one line: `**verify passed.** Run 2 against baseline 1,\n7 changed files.`\n\nThe rows are the ranked worklist for the files the pull request changed, worst first,\n`top` of them, with the rows a finding names listed first. `risk` is ccn times churn\nweight, the number `crapkit worklist` ranks on, and `remedy` is the run's own verdict for\nthat function: `decompose`, `add-tests` or `ok`. `(accepted debt)` marks a function the\ncommitted ratchet carries a mark for, so an untouched `legacy_router` does not read like\nthe pull request's own new function. A pull request that touches no ranked function gets\nthe heading and no table.\n\nThe two file counts describe the same diff, counted twice. `39 changed files` is\n`git diff --name-only base.sha...HEAD`, the branch's own commits, and it is what the\ntable is filtered to. The count on the verdict line is what `verify` measured from the\nsame fork point. With `delta: \"false\"` the second one is 0, because there is nothing\nbehind the checkout to measure from.\n\n### The inputs\n\n| Input | Default | What it does |\n|---|---|---|\n| `gate` | `\"false\"` | `\"true\"` exits with `crapkit verify`'s own code, so a finding fails the check. On a pull request with `delta` on it also exits 1 when the base run was not made, because a verdict with no base run judged no changed function. Anything else exits 0 and the comment is the whole output |\n| `delta` | `\"true\"` | scores the pull request's base commit first, so the verdict covers the commits the pull request adds. Costs a second lane run; `\"false\"` scores the checkout alone, and the verdict then judges no changed function |\n| `top` | `\"5\"` | worklist rows rendered in the table |\n| `python-version` | `\"3.12\"` | the interpreter `actions/setup-python` installs crapkit into. Match it to the version your own setup-python step named, or the lanes run on an interpreter your dependencies never reached |\n\n`gate: \"false\"` is the default on purpose. A team adopts the action before it has decided\nwhich findings should stop a merge, and a check that fails on day one gets turned off on\nday two.\n\n### What the verdict line covers\n\nOn a pull request, the commits the pull request adds. The action scores the fork point\nfirst, then the checkout, then runs `crapkit verify --base <fork>`, which measures the\ndiff from there and takes the fork point's run as its baseline. So the gate judges the\nfunctions in the diff a reviewer is reading, and a repository that was already over its\nceiling before the branch started does not fail every pull request that touches it.\n\nThe fork point is `git merge-base` of `base.sha` and HEAD, not `base.sha` itself.\n`base.sha` is the base branch's tip when the event fired, so a base branch that moved\nafter the branch forked carries commits HEAD never saw, and a run there would be neither\nthe baseline verify wants nor a diff anyone is reviewing.\n\nThe base run happens in a detached worktree under `RUNNER_TEMP`, and its store is copied\nover the checkout's so both runs sit in one place. The cost is **two lane runs on a pull\nrequest**: your suite runs once at the fork point and once on the checkout. Set `delta:\n\"false\"` to skip the base run, and the verdict falls back to the checkout against its own\nrun, which reports the tree's own health and judges no changed function. The comment says\nso in place of `verify passed`:\n\n```markdown\n**verify judged no changed function:** the base run was not made (no base commit). Run 2 against baseline 2, 0 changed files.\n```\n\nThree things leave the base run unmade: a shallow clone that does not hold the fork\npoint, a fork point older than your `crapkit.toml`, and a lane that will not run against\nthat tree. The step logs `crapkit base scoring exited N` and writes the reason to\n`crapkit-base.reason` in the words the comment then quotes, `shallow clone does not hold\nthe fork point of <sha>; set fetch-depth: 0 on the checkout`, `no usable crapkit.toml at\nthe fork point <sha>: ...`, or `lane failed at the fork point <sha>: ...` with the lane's\nfirst error line. The verdict falls back the same way `delta: \"false\"` does, and the\nratchet still runs, so exit 7 there is a finding. What differs is the job's status. With\n`gate: \"true\"` on a pull request whose base run was attempted and not made, the exit step\nexits 1 and prints the reason, because `actions/checkout`'s default depth-1 clone would\notherwise turn every pull request into a green check that judged nothing. A `push` event\nand `delta: \"false\"` never attempt the base run, so they keep verify's own code; a `push`\nhas no base commit and no pull request to comment on.\n\nOne requirement the base run adds: the lane has to measure the tree it runs in. A lane\nthat reaches an installed copy of your package instead of the checkout will measure the\npull request's code while standing on the base commit, and the two runs then describe the\nsame tree. `crapkit verify` refuses a run whose artifact names files outside the tree\n(exit 5), which catches the loud version of this; a lane pinned to a path outside the\nworktree is the quiet one. Point the lane at the tree, or set `delta: \"false\"`.\n\n`--reuse-artifacts` is what keeps each of those runs to one pass of your suite. `coverage`\nran the lanes moments earlier on that tree, and verify parses those artifacts rather than\nrunning the whole suite a second time for the same numbers.\n\nWhich is why `crapkit coverage` has to exit 0 for there to be a verdict. When it exits\nanything else (a lane that failed, an artifact it refused), the action does not run\n`verify`: on a runner that keeps its workspace between jobs (`clean: false`), a\n`verify --reuse-artifacts` over a failed measurement read the artifact the dead lane had\nleft from an earlier run, passed over it, and made that run the trusted baseline. The\ncomment then carries coverage's failure in place of the verdict:\n\n```markdown\n**no verdict: `crapkit coverage` exited 5 (lane 'py' failed: lane 'py' wrote no artifact on its last attempt; the .crapkit/cov/py.json on disk predates it); verify did not run.**\n```\n\nThe parenthesis is the first line of the lane failure the summary carries. When every\nlane failed, `coverage` prints no summary at all and the lane errors are only in the job\nlog, and the line says so. With `gate: \"true\"` the job exits with coverage's code.\n\nThe other gate that judges a delta is the portable baseline in [Route 4](#route-4-ci):\ncommit `crapkit-baseline.tsv` on the default branch and run `crapkit verify --baseline-tsv\ncrapkit-baseline.tsv` in a step of your own. It needs no second lane run, and it needs\nsomeone to keep that file current.\n\nThe comment is posted with `gh api` and the job's own `GITHUB_TOKEN`, which needs\n`pull-requests: write`. Two things it cannot do: a pull request from a fork gets a\nread-only token, so the POST is a 403 there, and a self-hosted runner without the `gh` CLI\non PATH fails that step. Both leave the rendered text in the job log.\n\n## Subcommands\n\nEvery subcommand takes `--repo PATH`, and the flag goes **after** the subcommand. Without\nit the root is the nearest `crapkit.toml` at or above the current directory\n([ADR 0002](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/adr/0002-configuration-is-found-upward-nearest-wins.md)): from a\nmonorepo workspace `crapkit worklist` reads the root configuration that claims the\nworkspace, says `crapkit: using crapkit.toml at /repo` on stderr, and reads a relative path\nargument from where you stand. `claude-hook` is the one exception: it has no `--repo`,\nbecause it takes its root from the file named in the hook payload it reads.\n\n```\n$ crapkit worklist --repo /path/to/repo --scope util --top 1\nworklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 1 of 3 active (--top 1), 0 dormant\n  risk      5.4  ccn   5  crap    30.0  cov   0%    5c/1a  util/stats.py:1  bucket( value , low , high )\n```\n\nBefore it, argparse reads the path as the subcommand name and exits 2 without ever\nmentioning `--repo`:\n\n```\n$ crapkit --repo /path/to/repo worklist --top 1\ncrapkit: error: argument command: invalid choice: '/path/to/repo' (choose from 'inventory', 'coverage', ...)\n```\n\n`--json` prints one sorted-keys JSON object on stdout, always carrying a `schema` field.\n\n| Command | What it does |\n|---|---|\n| `init` | Sniffs tracked source into per-directory scopes, writes a self-validated starter `crapkit.toml` whose lanes report into `.crapkit/cov/`, and appends `.crapkit/` plus each runner's own droppings to `.gitignore`. Writes a live `[[lane]]` when it can detect the test runner, otherwise a commented template. Refuses to clobber an existing config. |\n| `doctor [--show-files] [--json] [--tune] [--plugin-root [PATH]]` | Checks the config still describes the repo: unknown keys (with the accepted spellings), zero-file scopes, tracked source no scope claims, scopes no lane covers, lane cwds and commands that no longer resolve, lizard importable, oversized files. It reads each lane command with the shell that will run it, so a quoted interpreter path is one word and a runner after `&&` is checked too, and it FAILs a lane whose runner does not resolve or that the shell cannot start, naming the word to change; a bare name is looked for on PATH and a runner spelled as a path is looked for under the directory the lane runs in, so `.venv/bin/python` answers the same from any directory you run `doctor` in; each distinct runner is probed once, not once per lane. It WARNs on a lane writing its artifact at the repo root, a `coveragepy` or `istanbul` lane with no `results_artifact` (the crashed-worker and no-new-failures checks are off for it, whichever runner the lane spells), a committed hook under `core.hooksPath` that is not executable in the index, a directory whose functions are all `untested` while its tests exist, and a scope a lane measures with no `[crapkit.scoped_tests]` template behind it, which is the loop's step 4 with nothing to run. `--tune` prints suggested parallelism knobs and writes nothing. `--plugin-root PATH` reads no repo at all: it checks an installed [plugin](https://github.com/JeanFrancoisGagne/crapkit/tree/main/plugin) against the `crapkit` on PATH (the bare name its hooks and MCP server spawn) on both version and hook `--protocol`, and FAILs when PATH carries no `crapkit` at all, one line per disagreement and silence when they agree; PATH is the plugin root or any directory above it, `~/.claude` included (only manifests named `crapkit` count, and the newest install wins), and with no PATH it looks in Claude Code's plugin cache. A root it found rather than one you typed is named first, as `crapkit doctor: checking PATH`. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#doctor---json). |\n| `inventory [--db PATH] [--export PATH] [--json]` | One lizard pass over every in-scope file into a SQLite snapshot run, cached by content hash. `--db` is the only way to point crapkit at a store outside `.crapkit/`, and only this command accepts it. |\n| `coverage [--lane NAME] [--reuse-artifacts] [--reuse-unchanged] [--export PATH] [--sarif PATH] [--github] [--json]` | Runs the lanes, joins branch coverage onto a fresh inventory, writes a scored run. A failed lane is recorded, not fatal: its scopes fall back to `no-lane` and the run is typed `partial`, so it can never serve as a baseline. See [docs/lanes.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md). |\n| `verify [--baseline ID \\| --base REF \\| --baseline-tsv PATH] [--emit-baseline PATH] [--override REASON] [--reuse-artifacts] [--reuse-unchanged] [--no-tighten] [--sarif PATH] [--github] [--json]` | The full verdict against the trusted baseline: gate on touched functions, ratchet, no new test failures, optional diff-coverage ceiling. The three baseline selectors are mutually exclusive; `--baseline ID` also bypasses the taint rule ([The trusted baseline](#the-trusted-baseline)), and `--baseline-tsv` reads a commit-stamped file so a fresh clone verifies with no store. `--no-tighten` passes the verdict without rewriting the ratchet. Findings a dirty tree produced are tagged `dirty` and counted apart. It reads each istanbul artifact once for coverage, dead lines and its digest, and skips the artifact walk on an empty diff; skipping the whole run on an unchanged tree was measured and rejected, because a key made of HEAD plus the dirty names cannot see a second edit to a file that was already dirty. |\n| `worklist [--top N] [--scope NAME] [--batches N] [--json]` | The risk map: every admitted function ranked by `ccn * churn weight`, floored by `worklist_floor`, with hot simple code and anything over its ceiling admitted past that floor. It ranks finished rows and `no-lane` rows too, marked `ok` and `no-lane`, so it never empties; `next-item` carries the stop condition. Every row carries the function's `crap` and `cov` off the ranked run and its `ratchet_mark` when the committed marks file signs for it, and the header counts the active rows the cap hid: `50 of 3980 active (worklist_top 50)`. `--scope NAME` (repeatable) is exact, not a substring; a name no `[[scope]]` declares is a configuration error, exit 3, naming the declared scopes. `--batches N` **adds** a `batches[]` view cutting the active list into at most N file-disjoint batches with co-changing files kept together, off the same cached pairs `coupling` reads; the normal keys stay. |\n| `next-item [--top N] [--exclude FRAG] [--scope NAME] [--claim]` | The actionable queue as JSON, with churn, budget estimates and uncovered lines. Same run and same admission floor as `worklist`, a different view of it: `no-lane` rows are skipped and counted in `skipped_no_lane`, and what is left is ranked by `crap` descending rather than by risk, so the item it hands out is often not the worklist's first row. `--exclude FRAG` (repeatable) skips items whose path or function name contains FRAG; `--scope NAME` (repeatable) is exact, not a substring, and a name no `[[scope]]` declares is a configuration error, exit 3, naming the declared scopes. `--claim` holds what it hands out so a second session skips it. `stale` is true when the ranked run's commit is not HEAD, the same field `worklist` carries. Every item carries a `handle`: the bare identifier, or `(anonymous)#N` for a function with no name, which is the name form that survives the edit the item asks for. |\n| `claims [list \\| release PATH NAME \\| release --all] [--json]` | The open claims, and the way to hand one back without waiting for a verify. `release` takes the bare identifier, the whole long name, or the `handle` the claim was taken under, which is the only one that picks out a single `(anonymous)` claim. |\n| `brief FILE NAME [--batch N] [--json]` | The start-editing packet for one function: its own `source` text, every function in the file, the scored row and the scope ceiling, the ratchet mark and what the gate will bind on, uncovered lines, duplication twins, file churn, coupling partners, the config's notes, and the literal commands for the rest of the loop. Plus `handle`, `remedy` and the same `est_splits` / `est_uncovered_paths` the queue prints, and a `commands.refresh` that writes a run (`refresh_writes_run`) rather than re-reading the stale one. `NAME` takes the bare identifier, the long name `next-item` printed, the function's start line, `(anonymous)#N` for a function printed `(anonymous)` counting the file's anonymous functions from the top, or `NAME#2` for the second of several functions a file gives one name to. `--batch N` drops the positionals and emits `packets[]` instead: the top N of the queue, built from one read of the store and one duplication pass over the snapshot for the whole batch (batch of 5: 11.8 s to 5.2 s, output byte-identical to five separate calls). |\n| `explain FILE NAME [--history] [--tests] [--json]` | A function's score across runs plus its mark. `NAME` resolves exact first: a function whose bare identifier or long name is exactly `NAME` wins, and only when nothing matches exactly does it fall back to a prefix match, so `route` explains `route` rather than every `route_*` beside it. It also takes the function's start line, the form `brief` takes, which is how you open one printed `(anonymous)`. `--history` adds the commits that touched it (`git log -L`), each carrying its message `body`, `--tests` the tests that covered it, which needs coverage.py contexts turned on ([recipe](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/lanes.md#test-attribution-for-explain---tests)). `--json` emits the same content as one `schema` 1 object. |\n| `rescore FILE ... [--gate] [--json]` | Fresh complexity for named files over the latest run's stale coverage, joined by name. Advisory: it writes no run. `--gate` applies the pre-commit hook's policy to the same selection the hook uses (functions the tree changed since HEAD), minus functions whose CRAP sits at or under their ratchet mark, and exits 6. A marked function past its mark is gated; the pre-commit hook exempts on the mark's existence instead, because a staged blob has no coverage to score. |\n| `ratchet seed \\| prune \\| merge \\| move \\| report [--enforce] [--json]` | The mark lifecycle: seed new debt, prune gone code (a mark whose file git renamed follows it), merge as a git driver, move re-paths marks, report reads burn-down from the file's own git history. See [docs/ratchet.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/ratchet.md). |\n| `runs [list \\| prune [--keep N]] [--json]` | Run history, and retention. `list` marks the run `verify` compares against today `baseline`, and prints `verdict=-` for a run that produces no verdict rather than one that failed. See [The trusted baseline](#the-trusted-baseline). `--keep` (default 5) is a floor on the newest trusted runs, not a cap: the digest pair, every passing verify baseline, every run an override names, and the newest non-hook run are kept too. `prune` VACUUMs afterwards. |\n| `overrides [--json]` | The override audit trail: who granted what, when, and why. |\n| `trend [--json]` | Totals per trusted run: functions, over-target count, CRAP load, average, per-scope rollup. It reads a per-run rollup table rather than rescanning every scored row, and fills that table for any run missing one, so it writes to the store (best effort: a read-only `.crapkit/` costs the speed, not the command). |\n| `digest [--alert]` | The delta between the two newest runs with identical lane sets. Silent when nothing changed. `--alert` pipes the body to `alert_command` on stdin. Plain lines, never JSON. |\n| `report [--out PATH]` | One self-contained HTML page written to `.crapkit/report.html` (or `--out PATH`, repo-relative, or an absolute path you name), with the path printed on stdout. It renders what `worklist --json` and `trend --json` already answer at their defaults: the ranked worklist capped at `worklist_top`, the per-scope grades off the newest run, the trend series, and a banner naming every stale lane. It measures nothing and opens no network connection. Every row carries the function's CRAP and coverage, and prints the `crapkit explain` call for the rest: dark lines, history, the mark. It reads the same per-run rollups `trend` does, and writes them on the same terms. |\n| `duplication [--min-lines N] [--similarity F] [--top N] [--json]` | Near-duplicate functions by normalized line shingles with containment scoring. Defaults: `--min-lines 8`, `--similarity 0.8`, `--top 50`. `--top` truncates the list. A function and a function nested inside it never pair: their spans nest, they score 1.0 by construction, and nobody can deduplicate a factory from its own closure. |\n| `coupling [--min-support N] [--min-confidence F] [--top N] [--json]` | File pairs that keep landing in the same commits. Defaults: `--min-support 5` shared commits, `--min-confidence 0.5` max-direction ratio, `--top 50`. Bulk commits never couple pairs, and a young repo returns nothing at the default support. The ranked pairs are cached in `.crapkit/coupling-cache-v1.json`, keyed on HEAD, the churn window, today's UTC date, the path format and a digest of the tracked set, and shared with `brief` and `worklist --batches` (warm: 1.05 s to 0.11 s on a 72k-commit repo). The date is part of that key, so the first run after midnight UTC rebuilds the pairs on an unchanged HEAD. `--top` reads the cache, because it truncates that same order; `--min-support` or `--min-confidence` off their defaults ask a wider question than the file answers, so they bypass it and recompute. |\n| `mutate [--files F ...] [--max-mutants N] [--drop-pool] [--json]` | Diff-scoped mutation testing: flips comparisons, boundary shifts, boolean connectives and boolean literals on changed lines, runs `mutation_command` per mutant, lists survivors. `--files` replaces diff scope with the whole file. Both lists pass through the scored corpus first, the same predicate `coverage` uses (scopes, excludes, the test-file cut, `max_file_bytes`): a test file, an excluded path, a file over `max_file_bytes` or a file no scope claims is named on stderr and never mutated, `--json` lists it under `outside_corpus`, and when nothing is left stdout says `nothing to mutate` at exit 0 without starting the suite. `--max-mutants` (default 100) caps the run and the cap warning goes to stderr only, so `mutants` in `--json` is the capped count. Shell and PowerShell files are refused by name on stderr rather than mutated: `<` and `>` are redirections there, not comparisons. With `mutation_workers > 1` the worker worktrees are kept at `.crapkit/mutate-pool/` and re-prepared per run (30.6 s to build four on a 31,459-file repo, 0.46 s to re-prepare them); `--drop-pool` removes them and exits. |\n| `test-scoped FILE ...` | Runs each owning scope's `[crapkit.scoped_tests]` template on the files (quoted, longest-prefix scope wins). A template with no `{files}` runs as written, which is how a scope whose tests live outside its own paths runs its whole suite. Exit code only; a nonzero runner exits 1. |\n| `hook-precommit` | The cc-only gate on staged blobs. No coverage, no snapshot, no repo-wide cache. Exit 6 on a violation. |\n| `claude-hook [--protocol N]` | Reads one Claude Code PostToolUse payload from stdin and judges the file it edited: ccn against the scope ceiling, on functions the edit changed, minus functions a ratchet mark already covers. Advisory only: the edit has landed, and `hook-precommit` stays the enforcement point. Exit 2 and an advisory on stderr is the only thing it ever says, one block per judged file (a head line, one line per breaching function, a closing line): no `crapkit.toml` above the edited file, an unscoped file, mid-rebase or mid-merge, a `--protocol` other than 1, source that parses to no functions, or any internal failure all exit 0 in silence. The root is the first `crapkit.toml` above the edited file; the walk stops at a `.git` entry, so a worktree never borrows its parent's config. A `Bash` event names no file, so it judges the working tree instead: the dirty or untracked `*.py` files touched in the last 12 seconds, 25 at most, each through the same ladder, and silence for a clean tree or a cwd outside any repo. That half fires only where you register a `Bash` matcher ([The Claude Code plugin](#the-claude-code-plugin)). It opens no snapshot and writes nothing. |\n| `watch [--interval SECONDS] [--cycles N]` | Rescores tracked files as they change (mtime polling, default 2s, subprocess-isolated so a half-saved syntax error never kills the watcher). `--cycles N` polls exactly N times and exits 0; without it the loop runs until ctrl-c. |\n| `help [TOPIC]` | The help git, npm and docker answer to. With no TOPIC it prints the command list; with one it prints that subcommand's own help, the same page as `crapkit TOPIC --help`. A TOPIC that names no subcommand exits 3. |\n| `mcp` | A dependency-free stdio MCP server (newline JSON-RPC 2.0) exposing twelve read-only tools named `verb_noun`, each with a title and a documented output schema. Every tool shells to the CLI's own `--json` surface, so the MCP view cannot drift from what the CLI reports. Answering from a kept in-process store was benchmarked and rejected: a packet's `source` would go stale behind the edit it describes. See [docs/agent-json.md](https://github.com/JeanFrancoisGagne/crapkit/blob/main/docs/agent-json.md#mcp-server). |\n\n## Reading the output\n\n### Flags: why a coverage number is missing\n\n| Flag | Meaning | Scored |\n|---|---|---|\n| `measured` | A lane artifact spoke about this function. | Real `cov`. |\n| `untested` | A lane covers the scope, but its artifact is silent on this function, which normally means no test imports the file. | `cov = 0`. A testing gap, and `uncovered_lines` comes back `null` because no artifact can name lines it never saw. |\n| `no-lane` | No lane's `scopes` list names this function's scope. | `cov = 0`. A tooling gap, not a testing gap. `next-item` never hands one out and counts them in `skipped_no_lane`; `worklist` ranks them and marks the row `no-lane`, because a wiring gap is a risk you have to see. |\n| `cc-only` | The scope sets `coverage_optional = true`, so no coverage number can exist. | `crap = ccn`, and `remedy` can only be `ok` or `decompose`. `uncovered_lines` comes back `null` with a note naming that setting. |\n\nThe coverage summary counts all four as `measured` / `untested` / `no_lane` / `cc_only`.\n\n### Remedy: what to do about it\n\n| Remedy | Condition | Action |\n|---|---|---|\n| `decompose` | `ccn > ceiling` | Split it. No amount of coverage clears this. |\n| `add-tests` | `ccn <= ceiling` and `crap > ceiling` | Cover the branches. |\n| `ok` | `crap <= ceiling` | Nothing. |\n\n### Grade and CRAP load\n\nThe grade is the share of functions over their ceiling: `A+` at exactly zero, `A` under\n2%, `B` under 5%, `C` under 10%, `D` under 20%, `F` at 20% or more. `crap_load` beside it\nis the plain sum of every function's CRAP score, so it moves when a function gets better\neven if the letter does not.\n\n### Risk: what ranks the worklist\n\n`risk = ccn * churn weight`. The weight is a time-weighted sum over the file's commits in\nthe churn window: each commit contributes a logistic weight rising to 0.5 for the newest\ncommit in the log and falling to near zero for the oldest, so five edits last month\noutrank fifty from two years ago. The window anchors on the newest commit, never on the\nwall clock, so a fixed tree ranks identically forever.\n\nAge is not the input, position in the log is. A log whose commits all share one timestamp\nhas no range to weight against, so each commit counts once: a one-commit repo weighs every\nfile 1.0, ranks by ccn, and promotes nothing under the floor, because a top 10% of equal\nweights would be every file. Commits minutes apart already rank. This repo was eight\ncommits old, all made the same day:\n\n```\n$ crapkit worklist --scope util\nworklist @ a7c5c85ac37 (run 1, floor ccn>=5, churn 12mo) - 3 of 3 active (worklist_top 50), 0 dormant\n  risk      5.4  ccn   5  crap    30.0  cov   0%    5c/1a  util/stats.py:1  bucket( value , low , high )\n  risk      4.5  ccn   9  crap    90.0  cov   0%    1c/1a  util/curve.py:1  curve( scores , mode , floor , ceiling , skip_none )\n  risk      4.3  ccn   4  crap     4.2  cov  75%    5c/1a  util/stats.py:13  spread( values , cap )  ok\n```\n\n`bucket` at ccn 5 outranks `curve` at ccn 9 because five commits touched it and one\ntouched `curve`. That is the whole point of weighting by churn. `spread` carries the `ok`\nmarker: already at or under its ceiling, listed anyway, and `next-item` would not hand it\nout.\n\nThe list splits in two: **active** (files with commits in the window) and **dormant**\n(zero churn, kept out of the queue but counted). Two rules reach under the\n`worklist_floor`. A file whose churn weight sits in the top 10% is promoted down to ccn 3,\nwhich is why `spread` appears above at ccn 4. And a function over its ceiling is admitted\nwhatever its ccn, so the floor can never hold back debt.\n\n### The trusted baseline\n\nEvery `verify` measures the working tree against one earlier run, the **trusted\nbaseline**. `crapkit runs list` marks which one that is today.\n\n**Which runs qualify.** A `coverage` run, or a `verify` that passed. A failed `verify`\nnever qualifies, and neither does a `partial` run (a lane failed, so some scope fell back\nto `no-lane`) nor a `hook` override record, which carries no scored rows at all. In `runs\nlist`, `verdict=-` marks a run that produces no verdict rather than one that failed: only\n`verify` renders a verdict. Four readers ask this one question and get this one answer: the\nbaseline pick here, `ratchet seed`, `prune`, and the tighten damping that compares a mark\nagainst the same commit's previous run. A mark can no longer be signed off a run `verify`\nrefused.\n\n**What advances it.** Any qualifying run. `coverage` writes one wherever HEAD is, so a\ndashboard cron advances the baseline exactly as CI does. A passing `verify` advances it\nand tightens the ratchet on the way.\n\n**The taint rule.** A failed `verify` recorded findings against a tree. Until some\n`verify` passes, runs made after that failure do not become the baseline: choosing one\nwould move the comparison point past the findings, the flagged function would stop\ncounting as touched, and nothing would look at it again. `verify` says which run it\nrefused and falls back to the newest run in front of the failure.\n\n```\n$ crapkit runs list\nrun   1 @ 88012a148f6 2026-08-23T09:27:46Z coverage  verdict=-      lanes=py  baseline\nrun   2 @ 803bdde8556 2026-08-23T09:27:53Z verify    verdict=FAILED lanes=py\nrun   3 @ 803bdde8556 2026-08-23T09:28:02Z coverage  verdict=-      lanes=py\n\n$ crapkit verify\nwarning: run 3 is not the baseline: verify run 2 FAILED with 1 finding(s) and no passing verify has cleared it since — measuring against run 1 @ 88012a148f6 instead, so those findings stay visible. Fix them, or pass `--baseline 3` to accept the newer run deliberately.\nverify FAILED @ d89068de7f3 vs baseline 88012a148f6 (2 changed files)\n  GATE  crap     72.0  ccn   8 cov 0%  calc/legacy.py:7  legacy_router( a , b , c , d , e )  -> decompose\n  findings: 1 committed / 0 dirty (uncommitted edits and untracked files)\n```\n\nRun 3 is a `coverage` run somebody took on the tree run 2 refused, and it scores the same\nccn-8 function. Without the rule it would have become the baseline, `legacy_router` would\nhave stopped being a touched function, and that gate line would never print again.\n\n**The escape, twice.** Fix the findings and let a `verify` pass, which clears the taint\nfor good. Or accept the newer run on purpose with `verify --baseline 3`: an explicit id\nbypasses the rule, and the run history records which run the verdict used. Nothing here\ntouches a repo that has never run `verify`: with no failure to protect, `coverage` alone\nalways advances the baseline.\n\n**When the id you pass cannot serve.** A `--baseline ID` naming a real run that is not a\ncandidate says which run it is, why, and which ones can:\n\n```\n$ crapkit verify --baseline 3\ncrapkit: run 3 is an inventory run (no coverage was measured) and cannot serve as a baseline; trusted runs: 1, 2; pass `--baseline 2` for the newest\n```\n\n## Exit codes\n\n| Code | Meaning |\n|---|---|\n| 0 | OK. For `verify` and `hook-precommit`: the gate passed. |\n| 1 | **Overloaded.** Three unrelated things, listed below the table. |\n| 2 | Usage error from argparse: unknown flag, missing positional. Raised before crapkit's own error handling. |\n| 3 | Config error: `crapkit.toml` missing or unparseable, an unknown language or parser, a lane command the shell that runs it reads as a narrowed suite, a ratchet metr",
  "bytes": 60000,
  "sha": "e77c6d536c419b74a14ffa2dc3a50701f467c5969344ce59e82fa6bcff7a9f98",
  "repo_slug": "jeanfrancoisgagne/crapkit",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_jeanfrancoisgagne_crapkit_d3ab95b0/readme"
}