A retry loop that keeps replaying the same failure is burning tokens, not converging. The reason subsystem (v1.3–v1.6, extended in v1.11.0) closes that gap in three moves: it detects when a task is stalled, it diagnoses the stall with one LLM call and decides whether retrying is even worth it, and it remembers — distilling recoveries into lessons that future runs in the same repository get told about before they trip over the same quirk. When a task dies anyway, a terminal post-mortem explains why to you.
The whole subsystem is opt-in and off by default. With [reason] enabled = false (the default), runs behave exactly as they did before the feature existed — except stall detection, which is deterministic, free, and always on.
The lifecycle at a glance
flowchart TD
FAIL["Iteration fails"] --> FP["Failure fingerprint<br/>(deterministic, free, always on)"]
FP -->|"fingerprint changed —<br/>the agent is making progress"| RETRY["Normal retry"]
FP -->|"same fingerprint<br/>stall_attempts times"| STALL["Stall detected<br/>(task_stalled event)"]
STALL -->|"[reason] disabled"| RETRY
STALL -->|"[reason] enabled,<br/>attempts + budget remain"| DIAG["One reasoner call →<br/>diagnosis block"]
DIAG --> V{"Verdict"}
V -->|retry_with_guidance| INJ["Next prompt carries a<br/># Diagnosis section"]
V -->|suggest_heal| ROUTE["Loop continues; a heal routing<br/>is recorded for run end"]
V -->|halt_task| HALT["Task stops immediately →<br/>failed, attributed in the report"]
INJ --> OUT{"A later attempt<br/>passes?"}
ROUTE --> OUT
OUT -->|"no"| EXHAUST["Budget exhausted →<br/>failed"]
OUT -->|"yes — a recovery"| EXTRACT["Lesson extracted<br/>(one reasoner call)"]
EXTRACT --> LIB[("Lesson library<br/>.zurdo/reason/library/<br/>repo-scoped, cross-PRD")]
LIB -->|"prospective match<br/>(before anything fails)"| FUT1["Future runs: first prompts,<br/>zurdo analyze, zurdo heal,<br/>authoring skills"]
LIB -->|"reactive match<br/>(after a failure)"| FUT2["Future runs:<br/>retry prompts"]
Everything the reasoner produces is advisory or subtractive — it can guide the agent, stop spending, or route a criterion to zurdo heal, but no verdict can ever mark a criterion passed, relax a hint, or edit the PRD. Verification stays the exclusive grader.
Stall detection (always on)
Every failing iteration gets a failure fingerprint — a deterministic digest of what failed. When stall_attempts consecutive attempts (default 2, minimum 2) share the same fingerprint, the task is stalled: the agent is repeating itself, not converging. Detection is free, needs no LLM, and runs regardless of [reason] enabled.
A stall surfaces the moment it trips: a task_stalled line in the progress stream and progress.log, and a ## Fingerprint Stalls section in zurdo report. (This is distinct from the older report field for tasks that exhausted their budget — a fingerprint stall fires before exhaustion, while there is still time to act.)
Since v1.8.0 the fingerprint also incorporates frozen-path violations and the criterion index, so an iteration that fails by touching a frozen path is distinguishable from one that fails a criterion, and two failures at different criteria no longer collide. That changed every fingerprint value: stall history recorded by an earlier zurdo isn’t recognized as equal by a newer one. Existing runs proceed normally; they simply start their stall counting over (pass --reset if you’d rather start clean).
Diagnosis blocks
With [reason] enabled = true, a detected stall with attempts remaining triggers one single-shot LLM call to the reasoner role ([roles.reasoner], falling back to [roles.analyzer]). The call reads the stalled attempts’ evidence and produces a diagnosis block: a structurally-verified artifact carrying a hypothesis about why the loop is stuck, guidance for the next attempt, a verdict, and a confidence (low / medium / high).
Costs are bounded on two axes: max_diagnoses_per_task (default 2) and max_reasoner_calls_per_run (default 20, shared with lesson extraction). A diagnosis never fires on a task’s final attempt — its guidance would have no prompt to land in.
Fail-open, everywhere. A reasoner spawn failure, timeout, unparseable reply, or exhausted budget never fails the task — the iteration proceeds exactly as if the subsystem were disabled. The reason subsystem can stop zurdo from wasting money; it can never be the reason a run breaks.
Verdicts
Every accepted diagnosis block carries exactly one verdict from a closed set:
| Verdict | What zurdo does |
|---|---|
retry_with_guidance |
The loop continues on its unchanged budget; the next prompt opens a # Diagnosis section carrying the reasoner’s guidance (capped by guidance_max_bytes). Explicitly advisory — the agent may apply or ignore it. |
halt_task |
Zurdo stops attempting the task immediately, even with attempts left — the verdict can spend the budget down, never up. The task records the same failed status as budget exhaustion, dependents go blocked-by-dependency, and the run continues on other tasks. Never silent: the close-out line reads halted by reasoner diagnosis (attempt N): <hypothesis> and the report gains a ## Halt Attributions section. |
suggest_heal |
The reasoner believes the hint is misaimed, not the code. Inside the loop this behaves like retry_with_guidance; at run end the routing surfaces as a --heal <task> criterion <n> summary line and a ## Heal Routings report section. Zurdo never runs heal itself — that stays your call. |
Deliberately absent from the enum: anything that marks a criterion passed, skips it, or weakens a hint. There is no verdict that makes work look done.
Post-mortems
A diagnosis speaks to the agent. A post-mortem (v1.11.0) speaks to you — the first reason block whose audience is human.
It fires under one narrow condition: a stall fingerprint repeating after an accepted retry_with_guidance block for that same fingerprint. That is the strongest available evidence that the accepted hypothesis was wrong, and the moment the most evidence exists. The task has spent its budget; there is nothing left to guide.
- Its verdict space is restricted to
{halt_task, suggest_heal}. A post-mortem carryingretry_with_guidancefails verification and is discarded with a reason — advisory guidance with no subsequent prompt is waste. Asuggest_healpost-mortem turns a dead run into a concretezurdo healnext action. - Nothing in the runner branches on it. It is persisted (
kind: post_mortemunder.zurdo/<slug>/reason/), priced into the reasoner tally, and rendered from the block store intozurdo report’s## Diagnosestable. The run’s outcome is unchanged by it. - Its evidence bundle sees what the first diagnosis could not: a never-dropped prior-hypothesis section (the earlier block’s hypothesis, guidance, verdict, confidence, citation status, and the observed non-effect) plus two narrative projections — the pre-guidance and post-guidance attempts — so the reasoner can check directly whether the agent even followed the guidance.
Cost: one extra reasoner call per persistently-failed task, drawn from the existing per-task allowance of 2 that shipped defaults previously left unreachable. No new config key.
What the reasoner actually reads
The evidence bundle handed to a diagnosis or post-mortem call is assembled by zurdo, never by the model. Two v1.11.0 changes made it far more informative:
A structured projection, not a raw stream tail. The narrative section used to carry a tail-only slice of the provider’s raw event stream. It now renders three parts — the executor’s final assistant text, a numbered tool-call log, and the run-level error when the stream carried one — built by classifying each parsed event against the per-provider event vocabulary and dropping bookkeeping frames. The old window was pathological on exactly the runs where diagnosis matters most: on one 348 KB transcript the reasoner saw 1.18% of it, and the single largest item in that window was the terminal result envelope — at 40% of the entire budget, almost entirely cost telemetry. An unparseable stream (unknown provider, non-JSON output, a crashed CLI) still falls back to a raw tail.
Its own budget, truncated from both ends. The narrative window (12 KB) is decoupled from the executor prompt’s 4 KB truncation, which stays small deliberately — that prompt is rebuilt every iteration and token economy there is the point. Over-budget projections keep the head and the tail with a [… n bytes elided …] seam between them: over a tool-call log the head is where an agent picks its paths. A post-mortem’s two projections split this budget at 6 KB each rather than doubling it.
Uncited high confidence is clamped. Evidence references can point at a criterion, a path, or (new) {"step": N} — the Nth line of the numbered tool-call log. A block claiming confidence: high while citing no such step is downgraded to medium at verification time, with confidence_clamped: true recorded on the persisted block rather than silently rewritten. Clamped rather than rejected: an uncited block can still carry correct guidance, and rejecting it would burn a retry that might have worked.
Reason-block schema 2 The bump accommodates the post_mortem kind and the step evidence-ref shape. Schema mismatches are verification failures, not migrations — blocks persisted under .zurdo/<slug>/reason/ by an older zurdo are not upgraded in place.
Out-of-tree path references
An agent that reaches outside the repository — editing ~/.claude/skills/, writing to $HOME — produces work that is invisible to the diff, unauditable, and often the real reason a criterion won’t go green. Since v1.11.0 every iteration’s captured provider stream is scanned at capture time for absolute-path-shaped tokens (structured file_path / path / changes[].path fields and command tokens, with a leading ~ or $HOME resolved) that fall outside the repo root.
Findings are deduped by resolved path, kept in first-seen order, and capped at 20 with an explicit overflow marker. They surface on four places:
| Surface | What appears |
|---|---|
prd.json |
out_of_tree_refs on the attempt — omitted entirely when empty, so existing state round-trips unchanged |
progress.log |
An out_of_tree_refs event on attempts that found something; a clean attempt appends nothing |
zurdo report |
An ## Out-of-Tree References section, one row per attempt that named such a path |
zurdo review |
A task-scoped out-of-tree references: block, drawn once per task ahead of the selected criterion’s detail |
Plus the reasoner’s evidence bundle, where it is a never-dropped section — visible to a diagnosis or post-mortem even when the bundle is over its byte cap and every optional section has been dropped.
The pass is advisory and never gating: it does not distinguish a read from a write, it never fails an iteration or a run, and the findings are deliberately excluded from the failure fingerprint so they cannot perturb stall detection or diagnosis triggering.
The authoring rule that prevents it An executor resolves bare dotfile paths against $HOME, not the repository root. When a task edits dotfiles, spell the path out in full (<repo-root>/.claude/skills/) and pair it with an in-tree criterion that gate-checks the edit actually landed in the repo. The bundled zurdo-prd-author skill teaches this rule directly.
Lessons
Extraction — only recoveries teach
When a task that stalled later passes — a recovery — one reasoner call compares the stalled attempt’s evidence with the recovering attempt’s diff and distills one rule a future run could apply first (e.g. “tests in tests/ need the daemon started via make dev-up before cargo test passes”). An ordinary first-attempt pass extracts nothing: no struggle, no lesson. Extraction is gated on extract_lessons (default true), charged to max_reasoner_calls_per_run, and fail-open — an extraction error never touches the task’s passed status.
The library — repository-scoped memory
Lessons live at .zurdo/reason/library/, one JSON file per lesson, written atomically. The library is repository-scoped: lessons transfer across every PRD in the repo, never across repos, and stay private by default (.zurdo/ is gitignored). Duplicates are collapsed by content; on overflow past max_lessons (default 200), the lowest-uses lessons are evicted first, oldest first among ties.
Per-run diagnosis blocks live separately under .zurdo/<slug>/reason/. zurdo run --reset archives the slug’s blocks but leaves the library untouched — only zurdo reason clear deletes it.
Matching — deterministic, no embeddings
Each lesson stores a match surface built by zurdo (never authored by the model) from up to four feature types: the failing criteria’s hint types (shell, grep, …), typed failure reasons, the shell command head (cargo, npm, …), and directory prefixes from evidence paths and **Frozen** globs. A candidate scores one point per overlapping feature and needs at least 2 to surface — one coincidence is never enough.
Matching runs in two modes:
- Prospective — against a task’s declared surface, before anything fails. Powers first-iteration injection,
zurdo analyze,zurdo heal, andzurdo reason match. - Reactive — against an actual failure’s components. Powers retry-prompt injection.
Where lessons appear
| Surface | Section rendered | Counts as a “use”? |
|---|---|---|
| Executor prompts during a run | # Lessons From Previous Runs (top max_lessons_injected, default 2) |
Yes — increments uses, stamps last_matched_at |
zurdo analyze |
Per-task == Lessons == (every match, both full and --static-only passes) |
No |
zurdo heal propose prompt |
=== LESSONS FROM PREVIOUS RUNS === |
No |
zurdo reason match <prd> (preview CLI) |
Per-task match listing | No |
zurdo-prd-author (pressure-test phase) and zurdo-hint-debugger (failure analysis) |
Lessons from previous runs |
No |
Only real executor-prompt injection stamps usage stats — previews and authoring reads can never shield a never-consumed lesson from eviction. Every injected lesson is attributed to its originating task and PRD, and the prompt section opens with a fixed advisory framing: lessons inform the agent; they never override the task.
The zurdo reason CLI
| Command | What it does |
|---|---|
zurdo reason match <prd> |
Preview, per task, every library lesson whose prospective match clears the threshold — with each match’s originating PRD, task, and recovered attempt. Read-only; works even with [reason] disabled. Invalid PRDs are rejected exactly as zurdo validate would. |
zurdo reason status |
The library’s lesson count (grouped by match key), plus each .zurdo/<slug>/’s persisted diagnosis-block count. Read-only; torn files are skipped, not fatal. |
zurdo reason clear |
Delete the lesson library (only — per-run diagnosis blocks are untouched). Confirms on a TTY; non-interactive use requires --yes. |
Per-run diagnosis blocks aren’t browsed through zurdo reason — they surface in zurdo report and the progress log.
Configuration
The reasoner role and the [reason] table are accepted in .zurdo/config.toml but not seeded by zurdo init — add them by hand to opt in:
[roles.reasoner] # optional; falls back to [roles.analyzer]
provider = "anthropic"
model = "claude-sonnet-4-6"
[reason]
enabled = true # master switch; default false
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch for diagnosis calls, lesson extraction, and lesson injection. |
stall_attempts |
2 |
Consecutive same-fingerprint attempts that define a stall (minimum 2). Detection itself is always on. |
max_diagnoses_per_task |
2 |
Reasoner-call budget per task — a terminal post-mortem draws from the same allowance. |
max_reasoner_calls_per_run |
20 |
Run-wide cap on all reasoner calls (diagnosis + extraction). |
guidance_max_bytes |
4096 |
Size cap on the guidance carried into the next prompt. |
extract_lessons |
true |
Distill a lesson on every stall→pass recovery. |
max_lessons_injected |
2 |
Top-k lessons injected per executor prompt; 0 disables injection. |
max_lessons |
200 |
Library cap; overflow evicts lowest-uses first, oldest first among ties. |
enabled = true with neither [roles.reasoner] nor [roles.analyzer] configured is a config-load error, raised before any PRD is read.
Reading the results
zurdo report gains seven sections, each omitted entirely when empty: ## Diagnoses (every persisted reason block — diagnoses and post-mortems alike — with model, token usage, and accepted-or-discarded outcome), ## Fingerprint Stalls, ## Halt Attributions, ## Heal Routings, ## Out-of-Tree References, ## Lessons Extracted, and ## Lessons Injected.
In progress.log, stalls land as task_stalled events and every diagnosis call as a diagnosis_outcome event (accepted or discarded, with verdict, confidence, and token counts when accepted). Post-mortems deliberately emit no diagnosis_outcome — that event keeps meaning what it has always meant, a diagnosis whose guidance an attempt is about to carry — and are read from the block store instead.
Note Reasoner calls are billed LLM calls, visible in the report’s token accounting as a separate reasoner tally. The defaults (2 diagnoses per task, 20 calls per run) keep the worst case small relative to the executor spend they exist to prevent.
Next: Commands