Skip to main content
ckdn — deterministic check runner and log digester for AI-assisted development loops

Deterministic check runner and log digester for AI-assisted development loops

Let agents move fast.
Keep verification explicit, bounded, and machine-readable.

PyPI CI MIT license Python ≥ 3.11 core: stdlib only Security policy digest schema v2


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:

  1. owns the true process exit code,
  2. archives the full log as evidence,
  3. emits a bounded, machine-readable digest — the only thing the agent is supposed to read.
ckdn pipeline: agent → ckdn run coverage → subprocess (owns exit code) → .agent-runs artifacts; agent reads digest.json

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:

  1. Context bloat — a full coverage run with term-missing is thousands of lines. The agent burns context on noise and misses the signal.
  2. False green — text-based interpretation invites the worst failure mode: a collection error produces no FAILED lines, 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 → loud error / 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:

  • statuspass iff 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, or 1 if that member’s own rc was 0 (gate failure / mismatch).
  • fail_fast = true (default) stops at the first non-green member; members not reached are listed as "skipped". With fail_fast = false all 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). Subprocesses and relative runs_dir paths resolve from the invocation working directory (--cwd or CKDN_CWD), not from the config file's parent — so a config copied to /tmp can drive checks in a git worktree. 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

[check.format]
command = "uv run ruff format --check ."
parser = "reformat"

[check.pre_commit]
command = "uv run pre-commit run --all-files"
parser = "pre_commit"

[check.lock]
command = "uv lock --check"
parser = "generic"

[check.style]
members = ["format", "ruff"]     # format + lint atomics

[check.hooks]
members = ["pre_commit"]           # full hook suite

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.

Command policy (default workspace): before any subprocess starts, path-like argv tokens must resolve inside the invocation cwd (--cwd / CKDN_CWD). /etc/passwd, .. escapes, and paths under /etc, /proc, ~/.ssh, etc. are rejected. MCP extra_args are subject to the same rules. Set command_policy = "allowlist" to require configured command prefixes (uv run , uvx , …, or custom [run.command_allowlist].prefixes). Use command_policy = "off" only for exotic workflows. CI: ckdn lock-config then ckdn verify-config --locked catches tampered commands without running them.

CLI

Global flags (on commands that load config): --config PATH, --cwd DIR (working directory for subprocesses and relative runs_dir; else CKDN_CWD).

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
ckdn verify-config [--locked] validate command policy (+ optional ckdn.lock.toml)
ckdn lock-config [-o path] write command SHA-256 lock file for CI

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
pre_commit pre-commit run terminal text pre-commit run (use --all-files for full-repo parity); per-hook findings on failure
generic exit code only

Guards (loud failure, never silent green): count / clean-marker cross-checks on text parsers; missing reports with rc ≠ 0error; 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

Four layers, increasing strength:

  1. Standing rule (CLAUDE.md / equivalent) — run only via ckdn run <check> or MCP run_check / run_group; read the digest; pass is the only green; never edit .agent-runs/ or weaken checks to go green. Template: examples/claude/CLAUDE.md.
  2. Skillexamples/claude/skills/verified-fix-loop/SKILL.md (copy into the agent’s skills dir). Bounded fix loop, digest-only reading, forbidden moves, MCP tool mapping, and cwd for worktrees.
  3. Hooks / CIckdn run passes red exit codes through, so it drops into the same slots as the raw tool, with digests as a side effect. Use ckdn lock-config + ckdn verify-config --locked in CI for command governance (not exposed as MCP tools).
  4. MCP (optional) — ckdn[mcp] / ckdn-mcp when the client should call ckdn over the protocol instead of shelling out (see below).

Division of labor: constitution → procedure → instrumentation → enforcement. Digests never contain instructions to the agent (prompt- injection surface and policy fork).

Working directory: subprocesses and relative .agent-runs/ resolve from cwd, not from where ckdn.toml lives. CLI: --cwd / CKDN_CWD. MCP: per-call cwd on every config-using tool, or CKDN_CWD / ckdn-mcp --cwd as server defaults. When the config file is outside the project tree (worktree, Glass slice, temp config), pass the project root as cwd on every run — otherwise tools execute in the wrong directory.

MCP (optional)

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 (process cwd). Working directory: --cwd$CKDN_CWD → process cwd. Subprocesses and relative runs_dir anchor on cwd, not the config file parent — pass cwd on every tool call when config and project root differ.

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}",
        "CKDN_CWD": "${CKDN_CWD:-}"
      }
    }
  }
}

Set CKDN_CWD when the MCP server process cwd is not the project root (e.g. monorepo subfolder). For worktree slices, prefer per-call cwd on each tool instead of a fixed env default.

Cursor.cursor/mcp.json (or global ~/.cursor/mcp.json)
{
  "mcpServers": {
    "ckdn": {
      "command": "ckdn-mcp",
      "args": [],
      "env": {
        "CKDN_CONFIG": "/absolute/path/to/ckdn.toml",
        "CKDN_CWD": "/absolute/path/to/project-root"
      }
    }
  }
}

Omit CKDN_CWD when the server already starts in the project root.

Claude Desktopclaude_desktop_config.json

Settings → Developer → Edit Config, same schema:

{
  "mcpServers": {
    "ckdn": {
      "command": "ckdn-mcp",
      "args": [],
      "env": {
        "CKDN_CONFIG": "/absolute/path/to/ckdn.toml",
        "CKDN_CWD": "/absolute/path/to/project-root"
      }
    }
  }
}
ChatGPT Codex~/.codex/config.toml (TOML, not JSON)
[mcp_servers.ckdn]
command = "ckdn-mcp"
args = []
env = { CKDN_CONFIG = "/absolute/path/to/ckdn.toml", CKDN_CWD = "/absolute/path/to/project-root" }
Worktree / temp config — per-call cwd

When ckdn.toml lives outside the project tree, pass project root as cwd on every MCP tool (same as CLI --cwd):

{
  "check": "tests",
  "config": "/tmp/ckdn.toml",
  "cwd": "/path/to/worktree"
}

Server instructions and tool descriptions document this; agents that only read JSON Schema still see optional cwd on all config-using tools.

Tools (thin adapter over the same application layer as the CLI). All config-using tools accept optional config and cwd:

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_mismatch are normal structured results, not MCP tool failures.
  • MCP isError is reserved for impossible tool calls (missing config, unknown check, path escape).
  • run is a run id (single directory name), never a path; refs that escape .agent-runs/ are isError, not silent reads.
  • exit_code in tool results is a convenience mirror of the digest’s rc; the digest is the source of truth.
  • lock-config / verify-config are CLI/CI governance — not MCP tools.
  • 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-alarmpass requires 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 LATEST marker 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: ckdnckdn.cli:main.

Contract tests pin the status-model invariants; parser tests pin fact extraction and loud-failure guards.

License & community

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ckdn-1.1.1.tar.gz (218.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ckdn-1.1.1-py3-none-any.whl (73.0 kB view details)

Uploaded Python 3

File details

Details for the file ckdn-1.1.1.tar.gz.

File metadata

  • Download URL: ckdn-1.1.1.tar.gz
  • Upload date:
  • Size: 218.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ckdn-1.1.1.tar.gz
Algorithm Hash digest
SHA256 d3bd24bd9ac7818dddba09f2c01a9d60af6a2e2216d5d661f4fafdea3cca2b7e
MD5 ea2e0250643ba17651daa6789195b56d
BLAKE2b-256 33ecc9d42792b4b37f9b766a8c7f307d1c87971f13ee2bd7b27019240c42c028

See more details on using hashes here.

Provenance

The following attestation bundles were made for ckdn-1.1.1.tar.gz:

Publisher: publish.yml on orenlab/ckdn

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ckdn-1.1.1-py3-none-any.whl.

File metadata

  • Download URL: ckdn-1.1.1-py3-none-any.whl
  • Upload date:
  • Size: 73.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ckdn-1.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a77e25c31d3ad286c867b3bdc5f4d95ca333e6799beb91906c1df9c6b0ff816a
MD5 c4d71ca6dfd02fd8b89e1f437a5d0a7b
BLAKE2b-256 aea7be69a9a559b45b6688b18c8832f9ca96a95211c0b2a3767e0755bb116b29

See more details on using hashes here.

Provenance

The following attestation bundles were made for ckdn-1.1.1-py3-none-any.whl:

Publisher: publish.yml on orenlab/ckdn

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

1.3.2

2 files

1.3.1

2 files

1.3.0

2 files

1.2.0

2 files

This release

1.1.1 This release

2 files

1.1.0

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page