ckdn (checkdown): deterministic check runner and log digester for AI-assisted development loops
Project description
Deterministic check runner and log digester for AI-assisted development loops
Let agents move fast.
Keep verification explicit, bounded, and machine-readable.
ckdn (short for checkdown) sits between a coding agent and your project’s verification tools. The agent never reads a 10 000-line pytest log and never decides from prose whether a run “looks green”.
Every check goes through one orchestrator that:
- owns the true process exit code,
- archives the full log as evidence,
- emits a bounded, machine-readable digest — the only thing the agent is supposed to read.
Runtime: Python ≥ 3.11, stdlib only for the core CLI (zero third-party
dependencies). The optional MCP server is an extra
(ckdn[mcp]).
Why
Letting an agent interpret raw tool output fails in two directions:
- Context bloat — a full coverage run with
term-missingis thousands of lines. The agent burns context on noise and misses the signal. - False green — text-based interpretation invites the worst failure
mode: a collection error produces no
FAILEDlines, a regex finds nothing, and the run is reported clean.
ckdn’s answer is a strict status model from both the exit code and a format-aware parser. The two must agree before anything is called green. ckdn may downgrade green; it never upgrades red.
Install
uv tool install ckdn # global CLI
# or as a project dev dependency:
uv add --dev ckdn
The core has zero dependencies. The MCP transport is an optional extra — see MCP.
Quick start
cd your-project
ckdn init # writes starter ckdn.toml
# edit commands / parsers / aliases to match the project
echo '.agent-runs/' >> .gitignore
ckdn checks # list configured checks
ckdn run ruff # one atomic check
ckdn run lint # alias → members (e.g. ruff, pylint)
ckdn show # pretty-print latest digest
ckdn list # recent runs
Status model
Every run reconciles exit code (rc) against the parser into exactly one
status. pass is the only green state.
| rc | parser | status | meaning |
|---|---|---|---|
| 0 | confident, no findings, gates ok | pass |
green |
| 0 | gate failed (e.g. coverage < fail_under) |
fail |
tool happy, policy not |
| ≠ 0 | findings extracted | fail |
normal red + evidence |
| ≠ 0 | no findings, evidence expected | error |
infra / collection — fix the run |
| ≠ 0 | could not interpret output | error |
same, with log tail |
| 0 | findings anyway / unreadable | parse_mismatch |
green untrusted |
Invariants (enforced by ckdn.reconcile, covered by contract tests):
- Text never upgrades a nonzero exit code to green.
- A zero exit code never survives contradicting evidence.
- A confused parser sets
parser_ok=false→ louderror/parse_mismatch, never a silent clean.
Exit-code contract. ckdn run exits with the original command’s code
(clamped 1–255), so it drops into any hook or CI slot where the raw
command used to be. One extra rule: rc == 0 with a non-green status
exits 1.
Digests (ckdn.digest/2)
Stdout and on-disk digest.json are compact and sparse: absent
keys mean empty / 0 / false. Always present: schema, check,
status, rc, run_dir.
Green pass (intentionally tiny):
{
"schema": "ckdn.digest/2",
"check": "ruff",
"status": "pass",
"rc": 0,
"run_dir": ".agent-runs/20260707T101500Z-ruff"
}
Failure keeps the evidence — bounded findings with locations and
snippets, gates, notes, explicit truncation counters (shown indented here
for readability; ckdn show does the same for stored digests):
Failure digest — full shape (findings, summary, artifacts)
{
"schema": "ckdn.digest/2",
"check": "pytest",
"status": "fail",
"status_reason": "exit code 1 with 1 finding(s)",
"rc": 1,
"summary": {
"counts": {
"tests": 214,
"failures": 1,
"skipped": 2
}
},
"findings_total": 1,
"findings": [
{
"id": "tests.test_digest::test_sparse_keys",
"kind": "test_failure",
"message": "assert 'notes' not in digest",
"location": "tests/test_digest.py:41",
"detail": [
"E AssertionError: assert 'notes' not in digest"
]
}
],
"run_dir": ".agent-runs/20260707T101500Z-pytest",
"artifacts": [
"full.log",
"junit.xml",
"meta.json"
]
}
On error / parse_mismatch the digest additionally carries a bounded
log_tail.
digest.json is deterministic (no timestamps / durations — those live in
meta.json). Digests carry facts only; policy belongs in a skill or
CLAUDE.md, not in the data file.
Aliases and aggregates (ckdn.aggregate/1)
An alias groups atomic checks: ckdn run lint runs each member in config
order. Every member gets its own run directory and digest — the
aggregate on stdout is a routing document, not a replacement for member
evidence:
Aggregate — ckdn.aggregate/1 example
{
"schema": "ckdn.aggregate/1",
"alias": "lint",
"status": "fail",
"rc": 1,
"members": [
{
"check": "ruff",
"status": "fail",
"rc": 1,
"run_dir": ".agent-runs/20260707T101500Z-ruff"
},
{
"check": "pylint",
"status": "skipped"
}
]
}
The aggregate contract:
status—passiff every member passed; otherwise the first non-green member’s status.rc(also the process exit code) — follows the same pass-through rule as atomic runs: the first non-green member’s exit code, or1if that member’s ownrcwas0(gate failure / mismatch).fail_fast = true(default) stops at the first non-green member; members not reached are listed as"skipped". Withfail_fast = falseall members run and every entry carries a real status.- Extra args after
--are rejected on aliases — pass them to the atomic check (ckdn run ruff -- -x).
Read the aggregate to decide which member digest to open
(ckdn show <run-dir>), then work from that digest.
Configuration
ckdn.toml at the project root (ckdn init; override with --config).
Excerpt of the starter (the full catalogue is written by ckdn init):
ckdn.toml — starter excerpt (atomics + aliases)
[run]
runs_dir = ".agent-runs"
keep = 20
top = 20
max_snippet_lines = 12
log_tail_lines = 40
[check.pytest]
command = "uv run pytest -q --junitxml {run_dir}/junit.xml"
parser = "pytest"
[check.coverage]
command = "uv run pytest -q --junitxml {run_dir}/junit.xml --cov=src --cov-report=term-missing --cov-report=xml:{run_dir}/coverage.xml"
parser = "coverage"
fail_under = 96.0
[check.ty]
command = "uv run ty check"
parser = "ty"
[check.mypy]
command = "uv run mypy src --output json"
parser = "mypy"
format = "json"
[check.types]
members = ["ty", "mypy"] # alias → atomic members in order
[check.ruff]
command = "uv run ruff check --output-format json --output-file {run_dir}/ruff.json ."
parser = "ruff"
[check.lint]
members = ["ruff"] # add pylint / bandit / … when enabled
# fail_fast = true # default; false runs all members
Atomic check: command + parser (required), optional timeout
in seconds (a timeout yields rc=124 and a non-green status). Any other
key is passed to the parser as an option (fail_under,
score_fail_under, fail_levels, …).
Alias: members = ["atomic", …] only (optional fail_fast). No
nesting.
Commands are tokenized with shlex and run without a shell — no
pipes, no redirects, no &&. Deliberate: a shell pipeline is exactly
where exit codes get laundered (cmd | tee reports tee’s status). If a
check needs shell features, wrap them in a script and point command at
it. {run_dir} is substituted in commands and artifact paths — point
machine-readable reports into the run directory.
CLI
| Command | Purpose |
|---|---|
ckdn run <check> [--quiet] [-- extra…] |
run atomic check or alias; compact digest / aggregate on stdout |
ckdn show [run-dir] |
pretty-print a stored digest (latest default) |
ckdn list [-n N] |
recent runs |
ckdn checks |
configured checks (atomics + aliases) |
ckdn gc [--keep N] |
prune old run directories |
ckdn init |
write starter ckdn.toml |
Alias stdout is only the aggregate; member digests stay under
.agent-runs/ for ckdn show.
Run directory
.agent-runs/
20260707T101500Z-ruff/
full.log # interleaved stdout+stderr
ruff.json # tool artifact via {run_dir}
meta.json # argv, rc, timestamps, duration, log sha256
digest.json # deterministic facts for the reader
latest -> 20260707T101500Z-ruff
.agent-runs/ is evidence: do not edit it; keep it out of version control.
Built-in parsers
Prefer machine-readable artifacts over terminal text.
| parser | reads | command must include |
|---|---|---|
pytest |
JUnit XML | --junitxml {run_dir}/junit.xml |
coverage |
coverage XML (+ JUnit if present) | --cov-report=xml:{run_dir}/coverage.xml |
ruff |
JSON file | --output-format json --output-file {run_dir}/ruff.json |
ty |
terminal text | — (drift guards) |
mypy |
text, or NDJSON with format = "json" |
--output json (mypy ≥ 1.11) for NDJSON |
pyright |
JSON in log | --outputjson |
reformat |
black / ruff-format text | --check (no --diff) |
pip_audit |
JSON file | -f json -o {run_dir}/pip-audit.json |
bandit |
JSON file | -f json -o {run_dir}/bandit.json |
pylint |
json2 (pylint ≥ 3.0) | --output-format=json2:{run_dir}/pylint.json |
sarif |
SARIF file | whatever flag writes SARIF to {run_dir}/report.sarif (semgrep --sarif-output, gitleaks --report-format sarif --report-path, trivy --format sarif -o); artifact option report |
generic |
exit code only | — |
Guards (loud failure, never silent green): count / clean-marker
cross-checks on text parsers; missing reports with rc ≠ 0 → error;
parser_ok=false on format drift.
Policy gates in ckdn config: fail_under (coverage),
score_fail_under (pylint), fail_levels (SARIF). Filter severity
tool-side where possible (bandit --severity-level) — a parser must
never hide findings the exit code knows about.
Not supported on purpose: flake8 / isort / pydocstyle / pyupgrade (use ruff); vulture (overlaps CodeClone’s structural dead-code analysis); safety (use pip-audit); mutmut-style mutation as a loop-time check.
Agent integration
Three layers, increasing strength:
- Standing rule (
CLAUDE.md/ equivalent) — run only viackdn run <check>; read the digest;passis the only green; never edit.agent-runs/or weaken checks to go green. - Skill —
examples/claude/skills/verified-fix-loop/SKILL.md(copy into the agent’s skills dir). Bounded fix loop, digest-only reading, forbidden moves. - Hooks / CI —
ckdn runpasses red exit codes through, so it drops into the same slots as the raw tool, with digests as a side effect.
Division of labor: constitution → procedure → instrumentation → enforcement. Digests never contain instructions to the agent (prompt- injection surface and policy fork).
MCP (optional)
The fourth integration path: when an agent should call ckdn over MCP instead of shelling out, install the FastMCP transport:
uv tool install 'ckdn[mcp]'
ckdn-mcp speaks stdio only. Config resolution: --config →
$CKDN_CONFIG → ./ckdn.toml (cwd). Every client shares the schema
{ command, args, env }; only the file name and format differ.
Claude Code — .mcp.json (project-scoped, committed)
claude mcp add --scope project ckdn -- ckdn-mcp
or commit a .mcp.json at the repo root (Claude Code expands ${VAR}):
{
"mcpServers": {
"ckdn": {
"command": "ckdn-mcp",
"args": [],
"env": {
"CKDN_CONFIG": "${CKDN_CONFIG:-ckdn.toml}"
}
}
}
}
Cursor — .cursor/mcp.json (or global ~/.cursor/mcp.json)
{
"mcpServers": {
"ckdn": {
"command": "ckdn-mcp",
"args": [],
"env": {
"CKDN_CONFIG": "/absolute/path/to/ckdn.toml"
}
}
}
}
Claude Desktop — claude_desktop_config.json
Settings → Developer → Edit Config, same schema:
{
"mcpServers": {
"ckdn": {
"command": "ckdn-mcp",
"args": [],
"env": {
"CKDN_CONFIG": "/absolute/path/to/ckdn.toml"
}
}
}
}
ChatGPT Codex — ~/.codex/config.toml (TOML, not JSON)
[mcp_servers.ckdn]
command = "ckdn-mcp"
args = []
env = { CKDN_CONFIG = "/absolute/path/to/ckdn.toml" }
Tools (thin adapter over the same application layer as the CLI):
| Tool | Purpose |
|---|---|
list_checks |
Configured atomic checks + aliases |
run_check |
Run one atomic check → {digest, exit_code} |
run_group |
Run one alias → {aggregate, exit_code} |
get_digest |
Load stored ckdn.digest/2 (latest or by run id) |
list_runs |
Recent run summaries |
get_evidence |
Bounded findings / artifact line slices (never auto-dumps full.log) |
Trust rules:
- Only checks from
ckdn.toml— no arbitrary shell. fail/error/parse_mismatchare normal structured results, not MCP tool failures.- MCP
isErroris reserved for impossible tool calls (missing config, unknown check, path escape). runis a run id (single directory name), never a path; refs that escape.agent-runs/areisError, not silent reads.exit_codein tool results is a convenience mirror of the digest’src; the digest is the source of truth.- Core CLI remains stdlib-only; FastMCP is the optional extra.
Custom parsers
A parser reports facts; it never decides the final status.
from ckdn.parsers.base import Finding, ParseContext, ParseResult
class MyToolParser:
name = "mytool"
def parse(self, ctx: ParseContext) -> ParseResult:
report = ctx.artifact("report", "mytool.json")
if not report.exists():
return ParseResult(
parser_ok=False,
notes=[f"report not found: {report}"],
)
return ParseResult(findings=[...], summary={"count": 0})
Rules: prefer {run_dir} artifacts; if parsing text, add a
self-consistency guard; findings = failure evidence only; bound
everything; return parser_ok=False instead of raising on bad output.
Registration today: edit _REGISTRY in ckdn/parsers/__init__.py
(fork-and-own; no entry-point plugin API yet).
Design principles
- Exit-code-first — parsing only makes the verdict stricter.
- Agree-or-alarm —
passrequires exit code and parser to agree. - Reports over regexes — JUnit / coverage XML / JSON where possible.
- Facts ≠ policy — digests vs skills / project rules.
- Determinism where it pays — digest vs meta split.
- No shell — exit codes are not laundered through pipelines.
- Stdlib only — the guard of dependency behavior brings none of its own.
Non-goals (for now)
- Parallel member execution and global
ckdn run --all(named aliases cover lint/types groups; full-suite sequencing stays with the caller) - Watch mode, TUI, HTML dashboards
- Windows symlink handling beyond the
LATESTmarker fallback - Pluggable parser entry points
Development
uv sync --extra dev
uv run pytest
uv run ruff check src tests
uv run mypy src
Entry point: ckdn → ckdn.cli:main.
Contract tests pin the status-model invariants; parser tests pin fact extraction and loud-failure guards.
License & community
- Copyright (c) 2026 Den Rozhnovskiy <rozhnovskiydenis@gmail.com>
- License: MIT (
SPDX-License-Identifier: MIT) - Changelog: CHANGELOG.md
- Security: SECURITY.md
- Contributing: CONTRIBUTING.md
- Code of Conduct: CODE_OF_CONDUCT.md
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ckdn-1.0.0.tar.gz.
File metadata
- Download URL: ckdn-1.0.0.tar.gz
- Upload date:
- Size: 196.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eeed315254b9c46af514047b67e3d8024f543de96c4e736d4fde4ae472b90fd2
|
|
| MD5 |
4bd9eed8579ed712bb232cbb37c325de
|
|
| BLAKE2b-256 |
1eede926c468899c474ad579fe0e6565549d8e39f1f2a1ca47507356ee6c5a07
|
Provenance
The following attestation bundles were made for ckdn-1.0.0.tar.gz:
Publisher:
publish.yml on orenlab/ckdn
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ckdn-1.0.0.tar.gz -
Subject digest:
eeed315254b9c46af514047b67e3d8024f543de96c4e736d4fde4ae472b90fd2 - Sigstore transparency entry: 2138889847
- Sigstore integration time:
-
Permalink:
orenlab/ckdn@d8d78010f0d2921833177b53900e21844622d84a -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/orenlab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d8d78010f0d2921833177b53900e21844622d84a -
Trigger Event:
release
-
Statement type:
File details
Details for the file ckdn-1.0.0-py3-none-any.whl.
File metadata
- Download URL: ckdn-1.0.0-py3-none-any.whl
- Upload date:
- Size: 62.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
975a8f4cc87cd6b2d8f51a0f9de8e103c9a45581d0a6ab9ce93f978ece361e57
|
|
| MD5 |
31d5c384109bab2380774fcf018ffcdb
|
|
| BLAKE2b-256 |
775e8efb320af86557c3bf4d8683bd10bfa1a93bdfeb4e6a5eb5cdc6e0130a3c
|
Provenance
The following attestation bundles were made for ckdn-1.0.0-py3-none-any.whl:
Publisher:
publish.yml on orenlab/ckdn
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ckdn-1.0.0-py3-none-any.whl -
Subject digest:
975a8f4cc87cd6b2d8f51a0f9de8e103c9a45581d0a6ab9ce93f978ece361e57 - Sigstore transparency entry: 2138889870
- Sigstore integration time:
-
Permalink:
orenlab/ckdn@d8d78010f0d2921833177b53900e21844622d84a -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/orenlab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d8d78010f0d2921833177b53900e21844622d84a -
Trigger Event:
release
-
Statement type: