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/indexand 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 -Apipeline, so an untracked file counts only once it changes. - Frozen paths. A change to a
**Frozen**orprotected_pathsglob, 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
evidenceis 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 toevidence_changed_during_verificationand fails the iteration. - Toolchain provenance. At run start Zurdo resolves
shand every command word of the[shell:]hints onPATH, and records each one's path and SHA-256 astoolchaininprd.jsonand in averifier_contextevent. Before each verification pass it re-hashes them; one that moved printswarning: toolchain changedand marks that pass's resultstoolchain_changed. Arustup updatelooks 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_foldedevent, and afrozen_foldedlist 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 asflakyon 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.
| Situation | What Zurdo does | Exit |
|---|---|---|
Another process holds lock | Refuses and names the pid; a dead pid's lock is taken over | 3 |
| State exists | Asks: resume, reset or abort. Non-TTY, --resume or --no-prompt resume | — |
| The PRD changed since the last parse | Refuses until --reset; edits made by zurdo heal are accepted | 4 |
| Ctrl-C once | Finishes the iteration, prints the summary | — |
| Ctrl-C twice | Kills 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: highblock that cites no tool-call step is lowered to medium and markedconfidence_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
matchand 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,healandzurdo 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.
| Family | Fires when | --strict |
|---|---|---|
skill-resolution | A skill the PRD names can't be found | never: skills are user-managed |
grep-target | A grep pattern targets a directory or doesn't compile | yes |
vacuous-shell | A [shell:] payload can't fail | yes |
grep-tautology | A [grep:] already matches before any work | yes |
frozen-overlap | A hint's evidence path is frozen and doesn't already hold | yes |
doc-echo | The only proof greps a doc for the criterion's own words | yes |
uncovered-requirement | A req-* no criterion [proves:] | yes |
unreviewed-scaffold | The PRD still opens with the zurdo scaffold marker | yes |
hint-path | A [grep:], [no-grep:], [file-exists:], or [file-absent:] target that is the PRD's own path | yes |
discarded-evidence | A test-runner hint throws stdout away | not yet: awaiting field data |
cached-verification | go test runs without -count=N | not yet: awaiting field data |
unaddressed-lesson | A lesson's requires obligation goes unmet | never: obligations are per repository |
frozen-suggest | A task's [shell:] commands run files no frozen glob protects | never: 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.
| Language | Cross-file binding through |
|---|---|
| Rust | use trees, nested modules, pub use re-exports |
| Python | relative imports |
| Go | package imports, through the go.mod module path |
| TS / JS | relative 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.