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
| You want to prove… | Use | Not |
|---|---|---|
| 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
| Hint | Passes when | Example |
|---|---|---|
[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"] |
… follow | Up 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-empty | The 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 Rustregexin multi-line mode, so^and$anchor at line boundaries as in command-linegrep.[manual]beside automated hints: the automated hints still gate. A criterion with only[manual]can bring its task no further thanpassed-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 "…",followand[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 trailingfollowmodifier opts back in: up to ten redirects are followed, the compared status is the final hop's, a result that followed one carriesredirected: trueinprd.json, and the run prints awarning: redirect followedline naming both URLs; it fails nothing. The verifier reads at mosthttp_read_limitbytes of the body (65536 by default). Acontainsagainst a larger body fails withhttp_body_too_largerather 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,timeoutorhttp_body_too_large.HEADhas no body. A[http: HEAD <url> -> <status> contains "…"]hint is a parse error — there is nothing forcontainsto match. UseGET, or drop thecontainsclause.
Timeouts
| Hint | Time 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 hints | None |
- [ ] 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]orfunc Testcount you wrote yourself. Guessing a floor higher than the suite runs fails every attempt. - Give
go testa-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 withtimeout: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 -ccounts. A line carrying the pattern three times counts once, socount: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 — andmin: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:0is 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 calledcount:3. A passing counted grep records the bare path and the blob of the bytes it counted. - The lints read the count.
grep-tautologyandalready-holdsjudge 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 callednon-empty. [file-absent:]rejects it. A count of absence has nothing to require bytes of, so a trailingnon-emptythere is a parse error rather than silently becoming part of the path.already-holdsjudges the modifier. It flags anon-emptyhint as already holding only when the untouched tree already has a non-empty regular file there.existence-onlyis unaffected: a criterion whose only proof is[file-exists: <path> non-empty]still flags, becauseecho x > <path>satisfiesnon-emptyas cheaply astouchsatisfies 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.
─── 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)
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/nulland pipelines ending ingrep -qoncargo testorgo 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=1togo test, so a cached result can't stand in for this tree. Thecached-verificationlint 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, behindNAME=value,envortimeout <N>. - Never held to it: an item that pipes or redirects, a mode that runs nothing (cargo
--no-runand-- --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:
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`
| Family | Fires when | What to do |
|---|---|---|
vacuous-shell | A [shell:] can't fail (true, a bare echo) | Run the thing that proves the work |
grep-tautology | A [grep:] already matches before any work | Pin text the change introduces; escape regex characters the warning names |
doc-echo | The only proof greps a doc for a phrase the criterion names | Pin a constant the change adds, or add a hint that checks behaviour |
grep-target | A grep targets a directory, or its regex doesn't compile | Point it at one file; fix the pattern |
frozen-overlap | A hint's evidence path is frozen and doesn't already hold | Unfreeze the path, or change the evidence |
uncovered-requirement | A declared requirement has no [proves:] | Add one, or drop the requirement |
discarded-evidence | A test run's stdout is discarded | Drop the redirect |
cached-verification | go test runs without -count=N | Add -count=1 |
skill-resolution | A **Skills** name resolves to no skill | Install it where your provider looks |
unaddressed-lesson | A lesson obligation applies and no criterion meets it (analyze only) | Add the check the lesson asks for |
unreviewed-scaffold | The PRD still carries the scaffold marker | Run zurdo-prd-author on it |
hint-path | A target is the PRD's own path | Point it at the file the task changes |
frozen-suggest | A [shell:] command runs a script, Makefile, or nested toolchain file no **Frozen** glob protects | Add the globs it names, or leave them out if the task is meant to edit them |
http-missing-server | A localhost or 127.0.0.1 [http:] hint's task carries no [shell:] hint that could start the server | Add the [shell:] that starts the server, or confirm it's started out of band |
shell-tautology | A 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 line | Run the runner last, and let its exit status be the verdict |
stale-artifact | A command runs a path under target/, dist/, or build/ with no build step before it | Build in the same command, or run the build tool instead of the binary |
already-holds | Every hint on the criterion already passes on the untouched tree | Pin something the change introduces |
no-grep-vacuous | A [no-grep:] names a file nothing in the task or its dependencies creates | Add the hint that creates or checks the file, or fix the path |
existence-only | The only hint is [file-exists:] on a path that doesn't exist yet | Add a hint that checks the file's content |
http-race | A localhost [http:] follows a backgrounded [shell:] with no readiness step | Add a sleep, wait, curl --retry, or readiness loop |
grep-should-be-symbol | A definition-shaped [grep:] in a Rust, Go, or Python file | Use the [symbol:] the warning spells out |
assertion-free-test | A test the [shell:] runner command runs makes no call that could assert | Give 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. withinis 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.shdoesn't. - Use
[manual]honestly. It adds a human obligation to the record. It never makes a task pass on its own.