Skip to main content

ckdn (checkdown): deterministic check runner and log digester for AI-assisted development loops

Project description

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). 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 ≠ 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

Three layers, increasing strength:

  1. Standing rule (CLAUDE.md / equivalent) — run only via ckdn run <check>; read the digest; pass is the only green; never edit .agent-runs/ or weaken checks to go green.
  2. Skillexamples/claude/skills/verified-fix-loop/SKILL.md (copy into the agent’s skills dir). Bounded fix loop, digest-only reading, forbidden moves.
  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.

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 Desktopclaude_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_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.
  • 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

Project details


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.0.0.tar.gz (196.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.0.0-py3-none-any.whl (62.7 kB view details)

Uploaded Python 3

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

Hashes for ckdn-1.0.0.tar.gz
Algorithm Hash digest
SHA256 eeed315254b9c46af514047b67e3d8024f543de96c4e736d4fde4ae472b90fd2
MD5 4bd9eed8579ed712bb232cbb37c325de
BLAKE2b-256 1eede926c468899c474ad579fe0e6565549d8e39f1f2a1ca47507356ee6c5a07

See more details on using hashes here.

Provenance

The following attestation bundles were made for ckdn-1.0.0.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.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

Hashes for ckdn-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 975a8f4cc87cd6b2d8f51a0f9de8e103c9a45581d0a6ab9ce93f978ece361e57
MD5 31d5c384109bab2380774fcf018ffcdb
BLAKE2b-256 775e8efb320af86557c3bf4d8683bd10bfa1a93bdfeb4e6a5eb5cdc6e0130a3c

See more details on using hashes here.

Provenance

The following attestation bundles were made for ckdn-1.0.0-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.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page