A brief success criterion and a plan's Verify, Regression, End-to-end and Verification lines are each one command the criteria runner accepts, written as the line's first backticked span; anything more is bash tests/<name>.sh, and each must be able to go red (the runner reads the exit code only). - skills/writing-criteria/SKILL.md (S1 form), with Runs:/Refused:/Never red: examples that a pin runs through the criteria runner - brief-reviewer and plan-critic preload writing-criteria and reviewing-adversarially (S3 + S4); the copied runner rules leave them, the two templates and /trekplan, which names the skill where the plan is written and where its three reviewers start - eval cases 3 fire / 2 quiet / 1 effect; tests/lib/skills-criteria-prose-pins - review-630 @~1 pin follows the text into the skill; trekplan-stats line_ref re-measured Baseline without the skill: 24 of 27 written checks refused by the runner, and 0 of 3 reviewers saw that git status/git log can never go red. With it: 0 of 27 refused, 3 of 3 reviewers flagged both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.4 KiB
| name | description |
|---|---|
| writing-criteria | Criteria discipline for the checks Voyage runs itself: each brief success criterion, each plan Verify, Regression and End-to-end line and each Verification bullet is one command the criteria runner accepts, written as its first backticked span (a known test runner, plain words, no pipe, no &&, no shell), and a check that needs more becomes a script declared as bash tests/<name>.sh. Each criterion must be able to go red: a command that exits 0 whatever the code does is no criterion. Use when writing or reviewing success criteria, a Verify, Regression or End-to-end line or a Verification section, or when a validator reports a criterion as not runnable (BRIEF_SC_NOT_RUNNABLE, PLAN_STEP_VERIFY_NOT_RUNNABLE). |
Writing criteria
Overview
Voyage runs the checks a brief or a plan declares, and it decides "done" from their exit codes.
lib/verification/criteria-runner.mjs runs each one with no shell, as its words, and only when it is a
known test-runner invocation. Anything else is NOT RUN: never executed, never green. In /trekexecute
Phase 7 an unrunnable plan criterion stops the run; in /trekreview an unrunnable success criterion is
an absent measurement. A criterion that runs but cannot fail measures nothing either. This skill is the
one place that says how to write both right.
When to use
- Writing a brief's
## Success Criteria. - Writing a plan step's
Verify:,Regression:orEnd-to-end:line, or the plan's## Verification. - Reviewing them:
brief-reviewerandplan-criticpreload this skill. - A validator reports
BRIEF_SC_NOT_RUNNABLE,PLAN_VERIFICATION_NOT_RUNNABLE,PLAN_STEP_VERIFY_NOT_RUNNABLE,PLAN_STEP_REGRESSION_NOT_RUNNABLEorPLAN_END_TO_END_NOT_RUNNABLE. - Not for: writing the test itself and seeing it fail (that is
voyage:test-first), or starting the app to look at a change by hand (Claude Code's ownrun).
Process
-
One criterion, one command. No
&&,;,|,>,<,$(or backtick inside it. Two things to check are two criteria, or one script (step 5). A line that chains the suite, a setup and three checks is refused whole, and then none of it is measured. -
The command is the first backticked span on the line. The runner reads the first span as the command. Name the feature in plain words, not in backticks before the command:
- shelf count prints the number of books: `bash tests/count.sh` exits 0. A line that opens with the feature in backticks (`shelf count` prints ...) declaresshelf countas its command. -
Use a runner the runner knows. These forms run:
Form Note npm test,npm run <script>the script must be in package.jsonwhen the check runs; a plan may add itnode --test <files>a glob only on Node 21 and later; elsewhere name the files vitest,jest,pytest,python -m pytest,uv run pytestwith a short list of read-only options cargo test,go test,make testnothing may follow make testbash tests/<name>.sh,/bin/bash tests/<name>.sha script under tests/; the second pins the system bashgit status,log,diff,show,ls-filesread-only only; git grepis not on the list -
Plain words. No shell reads the line, so a word that means something in a shell is refused: a quote, a backslash,
$, a brace, a leading~, a word starting with@(a git revision such as@~1is the one exception), a path that climbs out with..or starts at/, an environment prefix (CI=1 npm test), and any option not on the runner's read-only list. A trailing# commentis dropped, unless it holds a quote,$, backtick or backslash; then the line is refused. -
A check that needs more is a script. The product's own binary, a temporary folder, an environment variable, comparing output,
jq,grep,curl: put it in a script undertests/that exits non-zero with a message when the check fails (set -eu, an explicitexit 1), and declarebash tests/<name>.sh. The script goes in theFiles:of the step that builds the behaviour. This is how anEnd-to-end:line runs the product the way a user does. -
It must be able to go red. Before writing a criterion, name the wrong result that would make it fail. The runner decides on the exit code alone: the
→ expected:text after the command is for the reader and is never compared. So a command that exits 0 whatever the code does is no criterion:git status --porcelain"expected: no output" exits 0 on a dirty tree too,git logandgit diff --statreport and exit 0, andnpm testalone proves no new behaviour unless a test for that behaviour is in it. A check on output goes in a script that compares it and exits 1. A step'sVerify:is ONE command, the step's own new test, seen red before the step; aVerify:that runs a test file which already passes can never be red. The suite the step must not break goes inRegression:. -
Check it before handing it on.
node ${CLAUDE_PLUGIN_ROOT}/lib/validators/plan-validator.mjs --strict --json <plan>andlib/validators/brief-validator.mjsname every unrunnable criterion. A refused criterion is rewritten (steps 1 to 5), never kept with a note.
Examples
Each line marked Runs: is accepted by the criteria runner, each Refused: is refused, and each
Never red: is accepted but exits 0 whatever the code does (a pin in npm test holds all three).
- Runs:
npm test - Runs:
npm run typecheck - Runs:
node --test tests/count.test.mjs - Runs:
bash tests/count.sh - Runs:
/bin/bash tests/e2e.sh - Runs:
pytest -q tests/test_count.py - Runs:
cargo test --workspace - Refused:
npm test && npm run lint(two commands: write two criteria) - Refused:
node bin/shelf.mjs count(the product's binary is no test runner:bash tests/count.sh) - Refused:
node bin/shelf.mjs list --json | jq length(a pipe: move the check into a script) - Refused:
SHELF_FILE=tmp/s.json node --test tests/cli.test.mjs(an environment prefix: set it in the test) - Refused:
node -e "process.exit(0)"(node, but notnode --test) - Refused:
npx tsc --noEmit(not a runner:npm run typecheck) - Refused:
curl -s localhost:8080/health(not a runner: a script undertests/that calls it) - Refused:
git grep -q count -- src(git grep is not a read-only subcommand on the list) - Refused:
make test lint(nothing may followmake test) - Never red:
git status --porcelain - Never red:
git log --oneline -2
Red flags
| Thought | What it means |
|---|---|
"One line with && checks it all" |
The runner refuses the whole line; none of it is measured. Two criteria, or one script. |
| "Name the feature in backticks, then the command" | The first backticked span is the command. The feature goes in plain words. |
"A user runs node bin/app.mjs, so that is the check" |
It is still no test runner. A script under tests/ runs it. |
"A quick node -e one-liner checks the JSON" |
Refused. The same code in a test file runs as node --test. |
"npm test exits 0, so the feature is done" |
It exited 0 before the change too, unless a test for the feature is in it. |
"git status --porcelain → expected: no output fails on a dirty tree" |
The runner reads the exit code, never the expected text. It exits 0 either way: it can never go red. |
"Verify: runs the step's test plus a CLI check" |
Verify: is one command, the step's new test. The rest is a script or Regression:. |
| "NOT RUN is not a failure, so it can stay" | NOT RUN is never green: an unrunnable plan criterion stops /trekexecute Phase 7. |
Enforced by
lib/verification/criteria-runner.mjs: deterministic. It runs only what its allowlist accepts, with no shell, and reports everything else NOT RUN, never green.lib/validators/plan-validator.mjsandlib/validators/brief-validator.mjs: the same allowlist when the plan or brief is written (PLAN_*_NOT_RUNNABLEare errors in a plan,BRIEF_SC_NOT_RUNNABLEa warning in a brief).- prose only: that a criterion can go red and that it tests what the brief asks for.
brief-reviewerandplan-criticjudge that by reading; no check runs it.
Credits
- Voyage's own content; nothing is borrowed. The rules restate
lib/verification/criteria-runner.mjs. The red flags come from Voyage's own baseline runs without this skill (docs/skills-authoring.md).