Writing PRDs

The PRD grammar, criteria and requirements, the lints that check them, and the scaffold marker.

zurdo v1.36.0

A Zurdo PRD is markdown with a strict grammar. If it parses, Zurdo can run every acceptance criterion itself.

Caution

Important If you remember three things: use a real em-dash (—, U+2014) in task headings, leave no blank line under the heading, and give every criterion at least one hint.

The grammar in one example

  1. Title. The file starts with # PRD: and a name.
  2. Task heading. ## Task: task-auth, an em-dash, then the title. Ids match ^task-[a-z0-9-]+$ and are unique in the PRD.
  3. Metadata. Directly under the heading, no blank lines. Effort and Depends-on are required.
  4. Requirements. Optional, and before Description when present.
  5. Description. Required. Sent verbatim to the agent.
  6. Acceptance Criteria. Run by Zurdo, never by the agent. Each needs at least one hint.
# PRD: <free-form title>

## Task: task-1 — <task title>
**Effort**: medium
**Depends-on**: []
**Max-Attempts**: 5
**Skills**: rust-style
**Agent-timeout**: 30m
**Frozen**: docs/adr/*.md
**Coverage**: 80% — reused from the service's existing suite

### Requirements
- req-tests-green: The workspace test suite passes.

### Description
Free-form prose. Passed verbatim to the executor agent.

### Acceptance Criteria
- [ ] cargo tests pass [shell: cargo test --workspace] [proves:req-tests-green]
- [ ] /health returns 200 [http: GET http://localhost:8080/health -> 200]
- [ ] design review signed off [manual]

The rules that trip people up

zurdo validate
$ zurdo validate bad/hyphen.md
bad/hyphen.md:3: task heading uses hyphen-minus (U+002D) where an em-dash (U+2014) is required
$ zurdo validate bad/blank-line.md
bad/blank-line.md:3: blank line between task heading and metadata block
$ zurdo validate bad/unknown-key.md
bad/unknown-key.md:6: unknown metadata key `Priority`
$ zurdo validate bad/no-hint.md
bad/no-hint.md:11: criterion has no hints in task `task-1`
Each one exits 2, so you find out before a run starts.
RuleGoodBad
The heading separator is an em-dash## Task: task-1 — Greettask-1 - Greet, task-1 – Greet
Metadata sits directly under the heading**Effort**: low on the next linea blank line between them
Only the eight metadata keys below**Effort**: low**Priority**: high
Every criterion has at least one hint- [ ] tests pass [shell: cargo test]- [ ] the greeter works
Effort is a key of the active [effort_map.<provider>]low, medium, high in the default configa level your map doesn't define

On macOS, type the em-dash with Option+Shift+-. Turn off any editor setting that swaps dashes in .md files.

KeyRequired?Notes
EffortyesA key in the active executor's effort map.
Depends-onyes[task-1, task-2], or []. Satisfied once each dependency is passed or passed-pending-review.
Max-AttemptsnoA positive integer.
Agent-timeoutno30s, 15m, 1h. A bare integer fails.
SkillsnoBare skill names. Zurdo never installs them; it warns at pre-flight when one is missing.
FrozennoGlobs the agent must not modify, joined with [verification] protected_paths. Touching one fails the iteration.
CategorynoGroups tasks in reports. Older PRDs use it; new ones don't need it.
Coverageno<N>% — <reason> overrides the coverage gate's threshold for this task; exempt — <reason> skips the gate entirely. The reason is mandatory; a malformed value or a repeated key is a parse error.

Criteria and requirements

A criterion is a checkbox line ending in one or more hints. Zurdo never reads or writes the checkbox; the hints are what run. Several hints on one line must all pass.

### Requirements lets you say what a task must achieve, and [proves:<id>] marks which criterion shows it:

### Requirements
- req-auth-1: Tokens expire after 24 hours
- req-auth-2: Expired tokens are rejected with HTTP 401

### Acceptance Criteria
- [ ] token expiry is enforced [shell: cargo test auth::token_expiry] [proves:req-auth-1]
- [ ] expired token rejected [http: GET http://localhost:8080/protected -> 401] [proves:req-auth-2]
  • Ids match ^req-[a-z0-9-]+$. A [proves:] naming an undeclared id is an error.
  • A declared requirement nothing proves is an uncovered-requirement warning.
  • [proves:] is not a hint. A criterion carrying only [proves:req-a] still fails with criterion has no hints.

Pre-authored tests

If the agent writes both the feature and its test, it controls both sides of the check. Write the test first, commit it with the PRD, and mark it ignored so CI stays green:

### Description
The tests marked `#[ignore = "pre-authored: task-03"]` in `tests/preauthored_task_03.rs`
are evidence. Do not delete them or weaken their assertions. The only edit this task
may make to that file is removing the `#[ignore …]` attribute.

### Acceptance Criteria
- [ ] login rejects a missing password [shell: cargo test -- --include-ignored login_rejects_missing_password]
- [ ] no pre-authored test is still ignored [no-grep: #\x5bignore in tests/preauthored_task_03.rs]
  • Use --include-ignored, never --ignored: once the attribute is gone, --ignored matches nothing and the criterion fails as a zero-test run.
  • Stub every function the test calls (todo!()), so it compiles.
  • Run the hint yourself before committing. It must fail on an assertion, not on a compile error.
  • In Go, tag the file //go:build preauthored and pass -count=1.

Validate early, analyze before you spend

zurdo validate prds/feature.md           # free, instant: grammar, dependency graph, lints
zurdo validate --strict prds/feature.md  # promotable lint warnings become errors (exit 2)
zurdo analyze prds/feature.md            # adds lesson obligations and an LLM critique
zurdo validate prds/docs.md
prds/docs.md:15: warning: task `task-1` shell hint `true` is a no-op and proves nothing about its criterion
prds/docs.md:16: warning: task `task-1` grep pattern `hello` already matches `main.rs` in the current working tree — the criterion may not prove the change
prds/docs.md:9: warning: task `task-1` requirement `req-ci` is not proven by any criterion
OK
With --strict, these warnings become errors and the command exits 2.

Zurdo has twenty-two lint families. zurdo validate emits twenty-one; unaddressed-lesson comes from zurdo analyze only. --strict promotes nine of the twenty-two to errors:

Promoted by --strictWarn only
grep-target, vacuous-shell, grep-tautology, frozen-overlap, doc-echo, uncovered-requirement, unreviewed-scaffold, hint-path, shell-tautologyskill-resolution, discarded-evidence, cached-verification, unaddressed-lesson, frozen-suggest, http-missing-server, stale-artifact, already-holds, no-grep-vacuous, existence-only, http-race, grep-should-be-symbol, assertion-free-test

What each family means for your criteria is on the Hints reference. How each one decides is on Internals.

The scaffold marker

zurdo scaffold drafts a PRD, its .trail.md, and its pre-authored tests from a design document, or from one --section of it. The draft opens with this line:

<!-- zurdo:scaffold unreviewed — run the zurdo-prd-author skill before zurdo run -->

While it's there, validate reports unreviewed-scaffold, and validate --strict exits 2. Run the zurdo-prd-author skill on the draft: it reviews the PRD and removes the marker only when it reaches ✓ READY TO RUN. Don't delete the line by hand. The walkthrough is on Your first PRD.

Authoring with the bundled skills

  • zurdo-prd-author drafts criteria first, derives tasks from them, and pressure-tests every hint. It writes a <prd>.trail.md recording why, and any new lessons.
  • zurdo-design-author comes first when the work is too big for one PRD.
  • zurdo-prd-review compares a run's diff with what each task meant, and turns a gap into a follow-up PRD.

zurdo init installs the eight bundled skills.