Internals

How zurdo keeps state, protects evidence, detects stalls, matches lessons, lints criteria and indexes code.

zurdo v1.36.0

What happens under the commands, briefly. Nothing here is needed to write or run a PRD. The full specs are in the tool's repository, linked at the end.

State directory layout

Each PRD's state lives at .zurdo/<slug>/ under the repo root, never beside the PRD. The slug is the PRD's basename plus the first four hex digits of the SHA-1 of its repo-relative path. zurdo state where <prd> prints it.

.zurdo/
├── config.toml
├── lumen/                  # structural index, shared by every PRD
├── pyxis/index.json        # retrieval index, when enabled
├── reason/usage.json       # lesson use counts, shared
└── <slug>/
    ├── prd.json            # the run's state: statuses, criteria results, tokens, cost
    ├── policy.json         # the canonical policy document hashed into prd.json
    ├── progress.log        # append-only JSONL event stream
    ├── lock                # pid and start time of the running process
    ├── baseline            # run-start snapshot and each task's baseline tree
    ├── config-at-run-start.toml # the config the coverage gate reads
    ├── run-diff.patch      # everything the run changed
    ├── review-log.jsonl    # [manual] sign-off chain
    ├── heal-log.jsonl      # accepted heals
    ├── iterations/         # <task>-<n>.prompt / .out / .err for every attempt
    ├── analyze-iterations/ # the analyze --fix trail
    ├── reason/             # this run's diagnosis blocks
    ├── reports/            # zurdo report output
    └── .archive/<ts>/      # state kept by --reset
lessons/                    # the lesson library: repo root, committed

prd.json is written atomically and is what report, verify, review and heal read. Add .zurdo/ to .gitignore; Zurdo reminds you but never edits the file. Don't ignore lessons/.

--reset archives only the slug's directory. The Lumen index, the lessons and usage.json belong to the repository and survive it.

Evidence integrity

Zurdo records where each verdict's evidence came from, not only the verdict.

  • Baseline capture. Before the first task, the working tree is snapshotted into a scratch git index (GIT_INDEX_FILE), untracked files included. Your .git/index and reflog are untouched.
  • Per-task baselines. Each task's baseline is taken at its first attempt and reused on retries, so no task is charged for an earlier task's edits.
  • Tree-to-tree comparison. Both sides go through the same git add -A pipeline, so an untracked file counts only once it changes.
  • Frozen paths. A change to a **Frozen** or protected_paths glob, or to one of the five toolchain defaults, fails the attempt, and the retry prompt says to revert it. A second leg hashes every regular file the globs match, so a gitignored frozen file is enforced too. It never walks .git/ or .zurdo/, and it stands down with one warning when a task's globs match more than 20,000 files.
  • Evidence blobs. A passing [file-exists:], [file-absent:], [grep:] or [no-grep:] records the git blob id of the bytes it read, so the result can be compared against the merged tree.
  • The verification window. Once an iteration's last criterion has run, every file a passing local hint recorded in evidence is re-read and its blob compared with the one the hint saw. A file that changed in between (say, rewritten by a process the agent left running) demotes the result to evidence_changed_during_verification and fails the iteration.
  • Toolchain provenance. At run start Zurdo resolves sh and every command word of the [shell:] hints on PATH, and records each one's path and SHA-256 as toolchain in prd.json and in a verifier_context event. Before each verification pass it re-hashes them; one that moved prints warning: toolchain changed and marks that pass's results toolchain_changed. A rustup update looks the same as a planted shim, so this only warns.
  • Folded frozen writes. When a criterion itself writes into a frozen, gitignored path, the write is still not charged to the next attempt, but it is no longer silent: a warning, a frozen_folded event, and a frozen_folded list on the iteration name the paths.
  • Flaky criteria. Each iteration records verified_tree, the git tree its criteria ran against. A criterion that got two verdicts on one tree is listed as flaky on its task, in the report and in the run-end summary. A flaky green still counts.
  • Witness coverage. A passed task records witness: which of its changed files some hint reads, and which none does. An unwitnessed change isn't wrong (refactors touch helpers), but it is where to look first.
  • Pre-flight provenance. A criterion already green before the run is flagged as proving nothing about this run. A task that passes with zero attempts gets a run-end warning.
  • Evidence-modified warnings. If a file a hint reads changed since the baseline, Zurdo warns and carries on, because often the task is to edit that file.

Apart from the verification window, which fails the iteration, these signals don't change statuses or exit codes. The guard is tamper-evident, not tamper-proof: the baseline sits under .zurdo/, which the agent can write. The backstop is that Zurdo re-runs every criterion itself.

Outside a git repository, capture and frozen-path enforcement each degrade to a warning.

The policy identifier

At run start, Zurdo computes a canonical JSON document representing the verdict-changing parts of the effective configuration: protected paths and their defaults, coverage thresholds and sources, the environment allowlist, http_read_limit, criterion_seconds, the clean-room backend and image, the standing rules, and every [verification] entry. This document is hashed with SHA-256 and recorded as policy on prd.json; the document itself is written beside the run state as .zurdo/<slug>/policy.json so that two runs' configurations can be diffed via sha256sum policy.json. A resumed run preserves the run-start policy and prints warning: policy changed since run start to stderr if today's configuration would hash differently. The policy identifier answers which rules were in force for a verdict, and a paired comparison between runs is valid only when they share the same policy.

Standing rules

[[verification.standing]] entries define repository-wide invariants checked on every task automatically, rather than copied by hand onto each criterion that could violate them. Each rule is an automated hint ([grep:], [no-grep:], [file-exists:], [file-absent:], [shell:], [symbol:], [references:], [callers:]) paired with a why explaining the rule. [manual] hints are refused.

A standing rule is evaluated after every task's own criteria, on every attempt, on every iteration. A red standing rule fails the iteration even when every task criterion passed, costing an attempt and recording one standing_failures entry per broken rule as standing rule broken: [<hint>] — <why>. The next attempt's prompt opens with a # Standing Rules section naming each broken rule and stating that standing rules hold for every task. When the clean room is enabled, the standing set is sent alongside the task's own criteria, so a rule that goes red in the room is a clean_room_mismatch.

A standing set that is red at pre-flight refuses the run with exit code 3 before any agent is invoked, naming the failing hint and its why. Pre-flight evaluates the set once; a rule already red means the assertion is aspirational rather than descriptive of the current tree, and every task would fail on it, so the run cannot start until the tree satisfies the rule. The time limit for evaluating the standing set is verification.standing_budget_seconds (default 30 seconds); if the batch exceeds this budget at pre-flight or on any iteration, the run fails with exit code 3 naming the budget and the measured time.

Rollback points and git refs

Each task's baseline tree—the state of the working tree when that task began—is captured and stored as a git tree object. Its object id is recorded as rollback_point on the task's record in prd.json, and a git ref refs/zurdo/<slug>/<task-id> is created pointing to it, so the tree survives a git gc even though the task's iteration branches are temporary. A task that passed all its criteria at pre-flight (with zero attempts) carries no rollback_point because it never entered the iteration loop. The --reset flag deletes every refs/zurdo/<slug>/* ref for the archived run. Git failures in recording or updating refs only warn and never fail the run.

Resume, locks, and recovery

zurdo run is crash-safe: re-running the same command picks up where it stopped.

SituationWhat Zurdo doesExit
Another process holds lockRefuses and names the pid; a dead pid's lock is taken over3
State existsAsks: resume, reset or abort. Non-TTY, --resume or --no-prompt resume—
The PRD changed since the last parseRefuses until --reset; edits made by zurdo heal are accepted4
Ctrl-C onceFinishes the iteration, prints the summary—
Ctrl-C twiceKills at once; the dropped iteration doesn't count as an attempt—

A resumed run captures a fresh baseline, so its run-diff.patch covers only the work since the resume. The patch's # header says which baseline it used. zurdo review holds the lock for its whole session.

Stall detection

Every failing iteration gets a failure fingerprint: a digest of what failed, including the criterion's index and any frozen-path violation. When stall_attempts consecutive attempts (default 2) share one, the task is stalled.

A stall fires before the attempt budget runs out. It appears in the run output, as task_stalled in progress.log, and under Fingerprint Stalls in zurdo report. With [reason] enabled, it also triggers a diagnosis.

What the reasoner actually reads

Zurdo assembles the evidence; the model never reads the raw log.

  • A projection, not a tail. The bundle holds the agent's final text, a numbered tool-call log and any run-level error. Zurdo classifies events against the provider's vocabulary and drops bookkeeping frames.
  • A 12 KB window that keeps both ends. Content over budget keeps the head and tail, with an elision marker between. A post-mortem's two projections get 6 KB each.
  • Uncited confidence is clamped. A confidence: high block that cites no tool-call step is lowered to medium and marked confidence_clamped, not rejected.

The library is source, not state

Lessons are lessons/lesson-<hash8>.md files at the repo root: YAML front matter plus a Markdown body, reviewed in pull requests like code.

  • The hash covers only match and the body, so identical lessons collapse into one file.
  • Use counts live in .zurdo/reason/usage.json, so a run never edits a tracked lesson.
  • Above max_lessons, the least-used lessons are evicted first, the oldest among ties.
  • A file with an unknown front-matter key is skipped, named in zurdo reason status, and warned about at pre-flight.
  • Retire a lesson with git rm.

Matching — deterministic, no embeddings

A lesson declares up to four components: hint types, failure reasons, a command head and path prefixes. Each one that overlaps the task scores a point, and two points surface the lesson. So a lesson must declare at least two.

  • Prospective matching uses a task's declared surface before anything fails: the first prompt, analyze, heal and zurdo reason match.
  • Reactive matching uses an actual failure's components, for the retry prompt.

The warn-lint families

Thirteen deterministic families catch criteria that can't fail, can't pass, or can't be trusted. validate reports twelve; unaddressed-lesson needs the lesson library, so only analyze reports it.

FamilyFires when--strict
skill-resolutionA skill the PRD names can't be foundnever: skills are user-managed
grep-targetA grep pattern targets a directory or doesn't compileyes
vacuous-shellA [shell:] payload can't failyes
grep-tautologyA [grep:] already matches before any workyes
frozen-overlapA hint's evidence path is frozen and doesn't already holdyes
doc-echoThe only proof greps a doc for the criterion's own wordsyes
uncovered-requirementA req-* no criterion [proves:]yes
unreviewed-scaffoldThe PRD still opens with the zurdo scaffold markeryes
hint-pathA [grep:], [no-grep:], [file-exists:], or [file-absent:] target that is the PRD's own pathyes
discarded-evidenceA test-runner hint throws stdout awaynot yet: awaiting field data
cached-verificationgo test runs without -count=Nnot yet: awaiting field data
unaddressed-lessonA lesson's requires obligation goes unmetnever: obligations are per repository
frozen-suggestA task's [shell:] commands run files no frozen glob protectsnever: a suggestion, not a defect

Separately, a [shell:] hint whose output shows a test runner ran zero tests fails whatever its exit code. Zurdo reads libtest's running 0 tests, go test's [no tests to run], and a pytest summary with no passed or failed count, in the full, untruncated output. A cargo test, go test or pytest the command runs that prints no report line at all fails as runner_silent (details).

What resolves, per language

Lumen indexes Rust, Python, Go, TypeScript and JavaScript, TSX and JSX included. Anything an adapter doesn't map isn't indexed, and a hint naming it fails "not found" rather than being guessed.

LanguageCross-file binding through
Rustuse trees, nested modules, pub use re-exports
Pythonrelative imports
Gopackage imports, through the go.mod module path
TS / JSrelative imports and tsconfig.json paths aliases

Hints may name eleven kinds. enum-variant, field, macro and alias are recorded for zurdo lumen query only. Qualified names use :: in every language, as in Config::load.

The Vela watcher

Vela is an optional daemon that publishes fresh index generations as files change, so a run finds a warm index. It never changes a verdict: structural checks repair the index in-process either way.

One daemon runs per repository, arbitrated by a PID lock under .zurdo/vela/. It exits after idle_minutes idle and logs to .zurdo/vela/vela.log. A failed refresh keeps the previous generation.

The model probe

Before a run, Zurdo spawns the executor CLI once per distinct model the PRD needs. An unknown or plan-gated model fails pre-flight with exit 4 before any task starts.

zurdo doctor probes every effort-map entry plus the analyzer model, writing nothing. zurdo init --check-models probes too, but always exits 0. --skip-model-check and doctor --skip-probes turn the probe off.

The vocabulary canary

Zurdo reads each CLI's structured event stream through one shared vocabulary descriptor, used by both the adapters and the step summaries. When a provider changes its event shapes, the executor keeps working but analyzer surfaces fail.

The canary warns when a stream parses yet yields no assistant text. It runs over the probe's captured output, so it spawns nothing. A separate usage-gap check warns when a stream yields text but no token counts. Both are advisory.

Full specs

The full specs, design records and changelog stay with the tool.