Hints reference

The seven core hint types and the three structural hints, how to choose one, and how to keep a criterion honest.

zurdo v1.36.0

A hint is the machine-checkable part of an acceptance criterion. After every agent iteration, Zurdo runs each hint itself and decides pass or fail. The agent's opinion is never consulted.

Picking a hint

What are you proving? Behavior shell http Exists file-exists grep Is gone file-absent no-grep Structure symbol references callers A human manual What are you proving? Behavior shell http Exists file-exists grep Is gone file-absent no-grep Structure symbol references callers A human manual
You want to prove…UseNot
The code behaves: tests pass, a build succeeds[shell:]a grep for the function name
A running service answers[http:][shell: curl …]
A file was created or deleted[file-exists:], [file-absent:][shell: ! test -e …]
A file contains, or no longer contains, some text[grep:], [no-grep:][shell: ! grep …]
A definition exists, is referenced, or is called[symbol:], [references:], [callers:]a grep that also matches a comment
Only a person can judge it[manual]a hint that always passes

The seven core hint types

HintPasses whenExample
[shell: <cmd>]The command exits 0 in the repo root, and no test runner in its output reports zero tests, and a cargo test, go test or pytest it runs printed its report line[shell: cargo test --workspace]
[http: <method> <url> -> <status>]The response status matches — a redirect is not followed, so -> <status> is the named URL's own[http: GET http://localhost:8080/health -> 200]
… contains "<text>"The status matches and the body contains the literal, case-sensitive text[http: GET http://localhost:8080/health -> 200 contains "ok"]
… followUp to ten redirects are chased, and the compared status is the final hop's[http: GET http://localhost:8080/health -> 200 follow]
[file-exists: <path>]A file exists at the repo-relative path[file-exists: target/debug/zurdo]
… non-emptyThe path is a regular file of at least one byte, following a symlink[file-exists: target/debug/zurdo non-empty]
[file-absent: <path>]No file exists at the path[file-absent: config/legacy.toml]
[grep: <pattern> in <file>]The regex matches in that one file[grep: Health check in docs/runbook.md]
… count:<N> / … min:<N>Exactly, or at least, N lines of the file match[grep: ^## in docs/commands.md count:6]
[no-grep: <pattern> in <file>]The file is readable and the regex does not match. A missing file fails.[no-grep: TODO in src/main.rs]
[manual]Never machine-checked. A person signs it off.[manual]

Hints go at the end of the criterion line, and several hints on one line combine with AND:

- [ ] the build succeeds and emits the binary [shell: cargo build] [file-exists: target/debug/zurdo]
- [ ] design review signed off [manual]

Targets stay inside the repository

A [file-exists:], [file-absent:], [grep:] or [no-grep:] target is resolved relative to the repo root, and it must stay there. An absolute path, a .. component, and any path into .zurdo/ or .git/ (matched case-insensitively) are rejected lexically; a path that resolves outside the root through a symlink fails without the file being read, with the failure reason hint_path_escapes. .zurdo/ and .git/ are never valid, even though they sit inside the root: the agent can write .zurdo/, so it is the cheapest place to plant evidence. A lexically escaping target is a parse error, so zurdo validate rejects it before a run; one that escapes through a symlink fails at run time with hint_path_escapes. The hint-path lint flags the one target the parser cannot see, the PRD's own path.

When one of these hints passes, its result in prd.json records an evidence entry for each file it read: the path and the git blob id of the bytes it read (what git hash-object prints). A reviewer can compare that against git ls-tree of the merged commit.

  • [grep:] and [no-grep:] take one file, never a directory. Patterns are Rust regex in multi-line mode, so ^ and $ anchor at line boundaries as in command-line grep.
  • [manual] beside automated hints: the automated hints still gate. A criterion with only [manual] can bring its task no further than passed-pending-review. A task whose criteria are all [manual] reaches it at pre-flight without calling the agent.
  • Modifiers are not hints. timeout:, min-tests:, count:, min:, contains "…", follow and [proves:] share the bracket grammar but run no check of their own.
  • [http:] says what it read. By default a redirect is not followed, so -> <status> is the named URL's own response. A trailing follow modifier opts back in: up to ten redirects are followed, the compared status is the final hop's, a result that followed one carries redirected: true in prd.json, and the run prints a warning: redirect followed line naming both URLs; it fails nothing. The verifier reads at most http_read_limit bytes of the body (65536 by default). A contains against a larger body fails with http_body_too_large rather than as a miss, because none of the body was read; a status-only hint is unaffected and is decided by its status alone. A red result records why: http_status_mismatch, http_body_mismatch, http_transport, timeout or http_body_too_large.
  • HEAD has no body. A [http: HEAD <url> -> <status> contains "…"] hint is a parse error — there is nothing for contains to match. Use GET, or drop the contains clause.

Timeouts

HintTime limit
shell:, http:[timeouts] criterion_seconds (default 300s)
shell: ending in timeout:<N> (s, m, h)That value, for this hint only
File and grep hintsNone
- [ ] the integration suite passes [shell: cargo test --test integration timeout:10m]

Only [shell:] accepts timeout:. On [http:] it's a parse error; raise criterion_seconds instead.

Requiring a minimum test count

A green cargo test --test suite says at least one test ran. It does not say the tests are still there — delete four of five and the survivor still exits 0. A [shell:] hint can set a floor with min-tests:<N>:

- [ ] the parser rejects every malformed form [shell: cargo test --test preauthored_task_2 min-tests:6]

Below N, the hint fails with too_few_tests and the diagnostic states ran <n>, min <N> with a per-source breakdown. Zurdo counts passed + failed on every libtest test result: line, the top-level --- PASS: / --- FAIL: lines of go test -v, and every pytest summary's counts; output it cannot count contributes zero.

  • Use it where you know the number — above all on a pre-authored test file, whose #[test] or func Test count you wrote yourself. Guessing a floor higher than the suite runs fails every attempt.
  • Give go test a -v. Without it Go prints nothing per test, so the count is always zero and any floor fails. The diagnostic says so when it sees that shape.
  • min-tests: combines with timeout: in either order, at most one of each. <N> must be an integer of 1 or more — min-tests:0, a negative and a non-integer are parse errors, not ignored tokens.

Counting the lines a [grep:] matches

"All six subcommands are documented" asserted as one [grep: ^## in docs/commands.md] is green when one of the six is. A trailing count:<N> or min:<N> makes the hint count instead:

- [ ] every subcommand has a section [grep: ^## in docs/commands.md count:6]
- [ ] at least the six core subcommands are documented [grep: ^## in docs/commands.md min:6]

count:<N> passes only when exactly N lines match; min:<N> when at least N do. A miss fails with grep_count_mismatch, and the diagnostic states matched <n> line(s), expected exactly <N> — or expected at least <N>.

  • The unit is lines, not matches, the way grep -c counts. A line carrying the pattern three times counts once, so count: over a pattern that can repeat on one line counts the lines that carry it.
  • Use count: for a closed roster — six subcommands, four exit codes, the rows of a table you are freezing — and min: when more may legitimately follow.
  • <N> must be an integer of 1 or more. count:0, min:0, a negative, a non-integer, and both modifiers on one hint are parse errors, not ignored tokens. count:0 is spelled [no-grep:], which is what the error says, and neither modifier applies to a [no-grep:] hint.
  • The modifier never reaches the path. It counts only as the last whitespace-separated token of the file part, and never as the whole of it, so [grep: x in count:3] still names a file called count:3. A passing counted grep records the bare path and the blob of the bytes it counted.
  • The lints read the count. grep-tautology and already-holds judge a counted grep by its count against the untouched tree, so a count the tree does not yet meet is not flagged as already holding — there is work to do.

Requiring bytes from [file-exists:]

[file-exists: target/debug/zurdo] passes on a zero-byte file — touch is as good as a real build. A trailing non-empty raises the bar to "a regular file with at least one byte":

- [ ] the binary is built [file-exists: target/debug/zurdo non-empty]

A symlink is judged on what it resolves to, so a symlink to a non-empty file still passes. A present target that fails the check — not a regular file, or a regular file of zero bytes — fails with file_empty, and the diagnostic names the path and says which. A missing target still fails as missing, never as file_empty. A pass hashes the bytes the check read, exactly as an unmodified pass does.

  • The modifier never reaches the path. It is stripped only as the last whitespace-separated token and never as the whole of it, so [file-exists: non-empty] still names a file called non-empty.
  • [file-absent:] rejects it. A count of absence has nothing to require bytes of, so a trailing non-empty there is a parse error rather than silently becoming part of the path.
  • already-holds judges the modifier. It flags a non-empty hint as already holding only when the untouched tree already has a non-empty regular file there. existence-only is unaffected: a criterion whose only proof is [file-exists: <path> non-empty] still flags, because echo x > <path> satisfies non-empty as cheaply as touch satisfies the bare form.

A [shell:] hint that runs no tests fails

cargo test <filter that matches nothing> exits 0 without running anything. So Zurdo fails a [shell:] hint whose output shows a test runner reporting zero tests, whatever the exit code.

zurdo run prds/empty.md
─── task-login: Reject a missing password ─── effort=low, deps=[]
  → iteration 1 of 1 (max-attempts=1, agent-timeout=30m 00s)
  ✓ agent completed: exit=0, 56ms
    ✗ shell: cargo test login_rejects_missing_password (57ms)
      stdout tail:
      running 0 tests
      test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
  ✗ task-login: failed in 1 iterations (56ms)
cargo said "ok" and exited 0, but ran no tests, so the criterion fails as empty_test_run.

What this asks of you:

  • Name tests that exist, or that the task writes, or that you pre-authored.
  • Preserve stdout and the test runner's exit status. Avoid both > /dev/null and pipelines ending in grep -q on cargo test or go test: they hide runner evidence, and a pipeline can mask test failures. If you need a sentinel, use a wrapper that preserves the output and explicitly propagates the runner's exit status.
  • Pass -count=1 to go test, so a cached result can't stand in for this tree. The cached-verification lint flags its absence.

Which runners and output shapes count as zero tests is on Internals.

A runner that reports nothing fails

A runner can also be silenced outright. A .cargo/config.toml with runner = "true", or a pytest.ini with addopts = -p no:terminal, exits 0 and prints nothing, so there is no zero to read. Zurdo therefore reads the command too. When it runs cargo test, go test, pytest or python -m pytest, that runner must print its report line somewhere in the output: libtest's running N tests, an ok / FAIL / ? package line, or pytest's N passed … in Ns summary. If the line is missing, the hint fails with runner_silent, and the diagnostic names the runner. A quiet flag such as pytest -qq hides the line too.

  • The runner is found in any &&, || or ; item, behind NAME=value, env or timeout <N>.
  • Never held to it: an item that pipes or redirects, a mode that runs nothing (cargo --no-run and -- --list; go -c, -list, -json; pytest --co, --collect-only), and any other runner (cargo nextest, npm test, jest, vitest).
  • A pytest run in which only skipped or deselected tests ran counts as empty, so it fails with empty_test_run.

A [shell:] hint runs in a clean environment

A [shell:] criterion, and the run-end completion_command, don't inherit Zurdo's environment. It is cleared and rebuilt from a fixed allowlist: PATH, HOME, USER, SHELL, TERM, LANG, TMPDIR, CARGO_HOME, RUSTUP_HOME, RUSTUP_TOOLCHAIN, GOPATH, GOMODCACHE, GOCACHE, GOFLAGS, and every LC_* variable. A CARGO_TARGET_DIR, RUSTFLAGS or NODE_OPTIONS in your shell therefore can't make a PRD pass on your machine and fail on someone else's. If a criterion needs another variable, list it in env_passthrough. The coverage gate and the agent itself keep Zurdo's environment.

Criteria that prove nothing

A criterion that's already true before the agent starts, or can't fail, proves nothing about the run. The lints catch the common shapes before you spend tokens:

zurdo validate
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:17: warning: task `task-1` criterion's only proof greps `README.md` for `Usage section`, a phrase the criterion itself names — the agent satisfies it by writing the phrase
neg.md:14: warning: task `task-1` shell hint `go test ./...` invokes `go test` with no cache-defeating flag; a replayed result can pass a criterion the current tree does not satisfy — add `-count=1`
Warnings don't fail validation unless you pass --strict.
FamilyFires whenWhat to do
vacuous-shellA [shell:] can't fail (true, a bare echo)Run the thing that proves the work
grep-tautologyA [grep:] already matches before any workPin text the change introduces; escape regex characters the warning names
doc-echoThe only proof greps a doc for a phrase the criterion namesPin a constant the change adds, or add a hint that checks behaviour
grep-targetA grep targets a directory, or its regex doesn't compilePoint it at one file; fix the pattern
frozen-overlapA hint's evidence path is frozen and doesn't already holdUnfreeze the path, or change the evidence
uncovered-requirementA declared requirement has no [proves:]Add one, or drop the requirement
discarded-evidenceA test run's stdout is discardedDrop the redirect
cached-verificationgo test runs without -count=NAdd -count=1
skill-resolutionA **Skills** name resolves to no skillInstall it where your provider looks
unaddressed-lessonA lesson obligation applies and no criterion meets it (analyze only)Add the check the lesson asks for
unreviewed-scaffoldThe PRD still carries the scaffold markerRun zurdo-prd-author on it
hint-pathA target is the PRD's own pathPoint it at the file the task changes
frozen-suggestA [shell:] command runs a script, Makefile, or nested toolchain file no **Frozen** glob protectsAdd the globs it names, or leave them out if the task is meant to edit them
http-missing-serverA localhost or 127.0.0.1 [http:] hint's task carries no [shell:] hint that could start the serverAdd the [shell:] that starts the server, or confirm it's started out of band
shell-tautologyA compound [shell:] discards the runner's exit status: a trailing || true or ; exit 0, a test runner in a non-final pipeline stage, or a final grep matching any lineRun the runner last, and let its exit status be the verdict
stale-artifactA command runs a path under target/, dist/, or build/ with no build step before itBuild in the same command, or run the build tool instead of the binary
already-holdsEvery hint on the criterion already passes on the untouched treePin something the change introduces
no-grep-vacuousA [no-grep:] names a file nothing in the task or its dependencies createsAdd the hint that creates or checks the file, or fix the path
existence-onlyThe only hint is [file-exists:] on a path that doesn't exist yetAdd a hint that checks the file's content
http-raceA localhost [http:] follows a backgrounded [shell:] with no readiness stepAdd a sleep, wait, curl --retry, or readiness loop
grep-should-be-symbolA definition-shaped [grep:] in a Rust, Go, or Python fileUse the [symbol:] the warning spells out
assertion-free-testA test the [shell:] runner command runs makes no call that could assertGive the test an assertion

zurdo validate --strict promotes nine of the twenty-two to errors; the Writing PRDs page lists which. If the PRD's work has already shipped, every grep reads as a tautology against HEAD, so check with zurdo validate --authoring-state.

Prefer absence hints to shell negation. [no-grep:] fails when the file can't be read, so a typo can't pass. A misplaced ! in [shell: ! grep -q TODO src/main.rs] silently flips the exit code, and the criterion always passes.

Structural hints

Three hint types check facts about named code symbols against the Lumen index. They need [lumen] enabled = true.

[symbol: <kind> <qualified-name> in <file>]
[references: <kind> <qname> in <file> within <kind> <qname> in <file>]
[callers: <kind> <qname> in <file> within <kind> <qname> in <file>]
- [ ] limiter type is defined [symbol: struct RateLimiter in src/middleware/rate_limit.rs]
- [ ] app wires the limiter [callers: method RateLimiter::layer in src/middleware/rate_limit.rs within function build_router in src/app.rs]
  • Eleven kinds: function, method, type, class, struct, enum, interface, trait, module, constant, variable. Case-sensitive.
  • Names use :: in every language (Config::load). Paths are exact and repo-relative.
  • within is required on [references:] and [callers:], and rejected on [symbol:].
  • Ambiguity fails. Qualify the name until it resolves to one symbol. zurdo lumen query --name <name> prints the exact kind and name to use.

What each one proves is on Structural verification.

Writing hints that hold up

  • Check behaviour, not side details. [file-exists: README.md] passes on almost any repo.
  • Keep criteria independent. If a hint needs a running server, say so in the Description.
  • Prefer small checks. [shell: cargo test auth::token_expiry] tells you what broke; one ./check-everything.sh doesn't.
  • Use [manual] honestly. It adds a human obligation to the record. It never makes a task pass on its own.