Grader reference
Scope: every grader kind, its options, and what it emits. How the judged grader decides is
How the judge decides. Source of truth: src/graders/.
A deterministic grader decides its question from the trace alone: no model call, no cost, and the same answer every time. Prefer one over the judge whenever it can answer the same question.
The grader vocabulary is an open enum
Section titled “The grader vocabulary is an open enum”grader accepts any kebab-case name. Nothing in the schema branches on the value, so the
schema does not police it. The grader registry does, at run time. That is deliberate: adding a
grader never needs a new schema version.
The accepted cost is that a name the registry does not know passes validation. It fails during the run instead, naming every kind it does know:
ERROR fix-bug › no-shell unknown grader kind "ai"; the grader registry knows command, cost, file-access, json-output, regex, skill-invoked, tool-usage, turn-count, "ai", and "human"ai is the most likely one you will hit: it was the pre-1.0 spelling of ai.
| Kind | Decided by | Cost |
|---|---|---|
ai (default) |
The ensemble judge | An inference call per run |
human |
A person, per session | None |
command |
An executable you name, over the trace | Whatever it costs to run |
tool-usage, skill-invoked, file-access, turn-count, cost, regex, json-output |
The trace alone | None |
Shared behavior
Section titled “Shared behavior”Options are validated before grading. Every option-configured grader validates its own options,
so a malformed eval produces an error outcome that names the offending option:
ERROR fix-bug › forbidden-tool tool-usage: options.expect must be one of: used, not-usedValidation happens twice, at run time and again when fill proposes an eval. That second pass is
why a grader that cannot validate its options can never be proposed. That is exactly how command
is kept out of fill.
Every eval grades the window its artifact governed. A skill’s eval is not a statement about the whole session. It is a statement about the turns that ran under that skill. The window is derived from the artifact’s type; there is nothing to declare and no option to set.
| Artifact type | Window |
|---|---|
| skill | From that skill’s invocation until the next skill or slash command takes over, or to the end of the session |
| slash command | The same rule, from each /name injection until the next injection takes over. A Skill tool call can close a command’s window but never opens one |
| agent | Only that subagent_type’s own branch, and anything it delegated inside it |
| project rules | The whole session, since CLAUDE.md governs everything |
So a Bash call that happened before a skill was invoked does not count against that skill’s
expect: not-used. An Edit the main session made does not count against an agent that only
read. Nothing outside the window is evidence about the artifact.
tool-usage, file-access, skill-invoked, regex, and turn-count are windowed, and so is the
transcript sent to the judge for an ai eval. That also makes a judged skill eval markedly
cheaper than judging the whole session. cost and json-output are session-level by nature; see
their sections.
An empty window is skipped, never passed. A skill that resolved but was never invoked governed
nothing, and so did an agent that was spawned but recorded no turns. There is no evidence either
way, so every eval on it reports skipped with the reason spelled out:
SKIP doc-writer › doc-writer-wrote-docs agent "doc-writer" was spawned but recorded no subagent turns (no inline sidechain records and no sidecar transcript), so it governed no turnsThat applies to judged and human evals too. An empty window never reaches the judge, because a
judge shown the parent session would answer about work the artifact had no part in.
| Skip reason | Cause |
|---|---|
skill "<name>" was never invoked in this trace, so it governed no turns |
the skill resolved but the session never invoked it |
slash command "/<name>" was never invoked in this trace, so it governed no turns |
the command file resolved but the session never ran it |
agent "<name>" was spawned but recorded no subagent turns (…), so it governed no turns |
the spawn is in the trace but its transcript is not |
agent "<name>" was never spawned in this trace, so it governed no turns |
no spawn for that subagent_type |
Conditional triggers
Section titled “Conditional triggers”when makes an eval arm only for the sessions it is about. That is what lets you check the
artifact that should have been used and wasn’t.
Every windowed grader takes it, meaning tool-usage, skill-invoked, file-access, regex,
and turn-count. The predicate is evaluated over the artifact’s window, and those are the
graders that have one. cost and json-output grade the whole session by nature, so they reject
when rather than accept an option they could not honour:
ERROR CLAUDE.md › budget cost: options.when is not supported by the cost grader, which grades the whole session rather than a windowYou cannot hang an eval on a skill that never loaded, because nothing resolves it and so nothing
plans it. That is the structural trick. But CLAUDE.md resolves for every session, and skill-invoked is allowed
there. Declare the check on the rules file, and let when keep it quiet the rest of the time.
# in CLAUDE.mdmetadata: evals: - id: docs-work-uses-the-writing-skill assertion: A session that edits documentation invokes the writing skill. grader: skill-invoked options: skill: writing-toolkit:technical-writer expect: used when: file-access: "docs/**"Three outcomes, and the first one is the point.
| Trigger | Grader’s own answer | Outcome |
|---|---|---|
| not matched | none | skipped, with the unmet condition named |
| matched | assertion unmet | fail |
| matched | assertion met | pass |
An unmatched trigger is never a pass. A check that never armed has not been satisfied, and calling it one would manufacture green from an eval that did not run:
SKIP CLAUDE.md › docs-work-uses-the-writing-skill trigger not met (whole session): no file matching "docs/**" was accessedConditions
Every one is a pure trace fact, evaluated over the artifact’s window. All listed conditions must hold.
| Condition | Type | Matches when |
|---|---|---|
file-access |
glob | Any file the window touched matches the glob. |
tool-used |
string | That tool was called in the window. |
prompt-matches |
regex | Any user prompt in the window matches. |
turn-count-above |
integer | The window’s turn count is strictly greater. |
The glob understands ** (spans directories, and matches the directory itself), * (within one
path segment), and ? (one character). It anchors at a path-segment boundary, so docs/** matches
/anywhere/docs/guide.md but not /anywhere/docsite/guide.md. You write the path as it reads in
the repository, not as it reads on the machine that recorded the trace. It is the same matcher
file-access uses for its path, so a pattern means one thing everywhere.
An unknown condition is an error, not a typo you get away with. A silently ignored file-acess
would leave the eval armed on every session, which is the exact failure when exists to prevent:
ERROR CLAUDE.md › docs-work-uses-the-writing-skill skill-invoked: options.when.file-acess is not a known condition; expected one of: file-access, tool-used, prompt-matches, turn-count-aboveA when: {} with no conditions is rejected for the same reason. It arms always while reading as a
scoped check.
Only error severity fails an eval. A finding at warning or info is reported and the eval
passes. Severity is set per eval; see the
evals schema.
Shared validation messages
Section titled “Shared validation messages”| Message | Cause |
|---|---|
options.<key> is required |
a required option is absent |
options.<key> must be one of: a, b |
an enum option has an unrecognized value |
options.<key> must be a finite number |
a numeric option is not a number, or is NaN/infinite |
options.<key> must be a whole number |
a numeric option needs an integer |
options.<key> must be at least <n> |
a numeric option is below its floor |
options.<key> must be a boolean |
a boolean option is not one |
options.<max> must be greater than or equal to options.<min> |
inverted bounds |
at least one of options.a or options.b is required |
a grader needs at least one bound and got none |
tool-usage
Section titled “tool-usage”Whether a tool was used, avoided, or used within count bounds.
| Option | Type | Default | Description |
|---|---|---|---|
tool |
string | required | Tool name, matched exactly: Bash, Read, Write, Edit, Agent, Skill, an mcp__* name. |
expect |
used | not-used |
used |
Direction of the assertion. |
min |
integer ≥ 0 | none | Minimum call count. |
max |
integer ≥ 0 | none | Maximum call count. |
includeSidechains |
boolean | false |
Count subagent calls that happened inside another artifact’s window. Not needed on an agent definition. |
when |
object | none | Arm the check only for sessions that match. See Conditional triggers. |
- id: forbidden-tool assertion: The session never ran shell commands; this skill is edit-only. grader: tool-usage options: { tool: Bash, expect: not-used }Sidechains. Inside a skill’s or a project-rules window, only main-chain calls count by default.
Work the session handed to a subagent is that subagent’s, not the skill’s. includeSidechains: true opts into counting it anyway.
On an agent definition the option does not apply. An agent’s window is its branch, and every
call inside a branch is a sidechain call. Excluding them would empty the window and make the
eval structurally unable to fail. { tool: Edit, expect: not-used } on
.claude/agents/reviewer.md would have passed however much the reviewer edited. The branch’s calls
are the subject there, and they are counted whatever the option says. fill still sets it on agent
proposals, which is harmless and remains valid.
Failure messages
| Message |
|---|
tool <tool> was used <n> time(s) but must not be |
tool <tool> was never used |
tool <tool> was used <n> time(s); at least <min> required |
tool <tool> was used <n> time(s); at most <max> allowed |
Validation. tool required; expect must be a valid enum value; min/max must be
non-negative integers with min ≤ max. Two contradictions are caught up front, so you never get an
unsatisfiable check:
options.min is unsatisfiable with expect: not-usedoptions.max of 0 is unsatisfiable with expect: used
skill-invoked
Section titled “skill-invoked”Whether a named skill was invoked. Detects both Skill tool calls and /command injections.
| Option | Type | Default | Description |
|---|---|---|---|
skill |
string | required | Skill name as it appears in the trace, including any plugin:skill prefix. |
expect |
used | not-used |
used |
Direction of the assertion. |
when |
object | none | Arm the check only for sessions that match. See Conditional triggers. |
Failure messages
| Message |
|---|
skill <skill> was never invoked |
skill <skill> was invoked but must not be |
file-access
Section titled “file-access”Whether a file was read, written, or edited.
| Option | Type | Default | Description |
|---|---|---|---|
path |
string | required | A repository-relative path, matched at a segment boundary. Not an absolute path. |
op |
read | write | edit |
any op | Restrict to one operation. |
expect |
accessed | not-accessed |
accessed |
Direction of the assertion. |
when |
object | none | Arm the check only for sessions that match. See Conditional triggers. |
Matching is a trailing match anchored at a path segment, separator-normalized, and
case-insensitive, so an eval written on one platform works on another. src/app.ts matches
C:\work\demo\src\app.ts and /home/you/demo/src/app.ts alike.
Anchored, not a bare suffix. db/migrations matches .../db/migrations and does not match
.../legacydb/migrations, which is a different directory. It is exactly the matcher
when uses, so **, *, and ? work here too. A path of src/**
covers the tree.
- id: touches-migrations assertion: The session must not edit database migrations. grader: file-access options: { path: db/migrations, op: edit, expect: not-accessed }Failure messages
| Message |
|---|
file <path> was never accessed (op: <op>) |
file <path> was accessed (op: <op>) but must not be |
Validation. path required; op and expect must be valid enum values. fill additionally
rejects an absolute path: options.path must be a repository-relative suffix, not "<path>".
turn-count
Section titled “turn-count”Whether the conversation stayed within bounds. A turn is a non-sidechain user prompt.
| Option | Type | Default | Description |
|---|---|---|---|
min |
integer ≥ 0 | none | Minimum turns. |
max |
integer ≥ 0 | none | Maximum turns. |
when |
object | none | Arm the check only for sessions that match. See Conditional triggers. |
At least one of min or max is required.
Failure messages
| Message |
|---|
<n> turn(s); at most <max> allowed |
<n> turn(s); at least <min> required |
turn-count is windowed like the other counting graders. A bound declared on a skill measures
the prompts that arrived while that skill was governing. One declared on an agent measures that
agent’s own brief and follow-ups, rather than the nested subagents it spawned.
Whether the session stayed within a spending or token budget. Session-level by nature, and
deliberately not windowed. Usage is reported per assistant message for the whole context. Charging
a slice of it to whichever artifact happened to be governing would be an invented number.
A cost eval therefore grades the session no matter which artifact declared it. That includes one
whose window is empty, where a session that blew its budget still fails rather than skipping.
| Option | Type | Default | Description |
|---|---|---|---|
maxUsd |
number ≥ 0 | none | Maximum session cost in USD. |
maxTokens |
number ≥ 0 | none | Maximum input + output tokens. |
At least one is required. Each budget is evaluated independently, so an unavailable one does not suppress a checkable one.
Failure messages
| Message |
|---|
session cost $<x.xxxx>; at most $<maxUsd> allowed |
session used <n> tokens; at most <maxTokens> allowed |
Skips when it cannot tell. Claude Code session files do not carry cost data; only claude -p
stream-json transcripts do. When no configured budget is checkable, the eval is skipped with the
reason spelled out:
trace carries insufficient cost data: no cost data (maxUsd not checkable)That is deliberate. A budget check that cannot see the numbers reports honestly instead of passing by default.
Whether a pattern appears in the session’s text.
| Option | Type | Default | Description |
|---|---|---|---|
pattern |
string | required | A JavaScript regular expression source. |
flags |
string | none | Regular expression flags, e.g. i, m. |
on |
assistant | user | all |
assistant |
Which text to search. |
expect |
match | no-match |
match |
Direction of the assertion. |
when |
object | none | Arm the check only for sessions that match. See Conditional triggers. |
Failure messages
| Message |
|---|
pattern /<pattern>/ never matched <on> text |
pattern /<pattern>/ matched <on> text but must not |
Validation. flags is checked on its own first, then the pattern is compiled with those
flags. An invalid flag is therefore reported as a flag problem instead of a confusing pattern error:
options.flags must be a stringoptions.flags is invalid: <detail>options.pattern is not a valid regular expression: <detail>
json-output
Section titled “json-output”Whether the session’s final assistant message is JSON matching a schema.
| Option | Type | Default | Description |
|---|---|---|---|
schema |
object | required | A JSON Schema the output must validate against. |
- id: emits-report assertion: The session's final message is a report object. grader: json-output options: schema: type: object required: [status, findings] properties: status: { enum: [ok, failed] } findings: { type: array }Failure messages
| Message |
|---|
session produced no assistant text to validate |
final assistant output is not valid JSON |
| one finding per schema violation, from the validator |
Validation. The option is shape-checked up front
(options.schema is required and must be a JSON Schema object), and the schema is compiled at grade
time. A schema that fails to compile becomes an options error instead of a crash.
Also session-level, and not windowed. It looks at the last assistant message of the entire session. An eval declared on one of several artifacts still measures the same final output.
The default. The assertion goes to the ensemble judge, which reads the rendered trace and votes.
The transcript it reads is the artifact’s window, not the whole session (see
Shared behavior). The digest names the scope it was cut to, and an artifact that
governed nothing is skipped before any request is made.
Takes no options; it is steered by assertion, evidence, and examples instead. An eval may
name provider: to be judged by a specific configured provider rather than the run’s default.
A provider name the run cannot construct is that eval’s own error, never a silent fall back to the
default model.
Skipped entirely under --deterministic-only.
Queues the eval for a person. It always reports needs-review, never pass or fail. The one
exception is an artifact that governed no turn in this trace. There is nothing to review, so the
eval is skipped with the reason given.
Unlike the page-side vocabulary this shares its shape with, a human verdict is judged per
session and never cached. Every trace is new, so there is nothing a previous verdict could still
be true about. It costs nothing and needs no provider, so it reports in --deterministic-only runs
too.
Whether a needs-review outcome fails the run is a config decision. See failOnNeedsReview in the
configuration reference.
command
Section titled “command”Runs an executable over the trace and grades by exit code.
| Field | Type | Default | Description |
|---|---|---|---|
command |
array of strings | none | argv. {trace} is replaced in every element with the trace’s path. |
success-exit-codes |
array of integers | [0] |
Exit codes that count as a pass. |
timeout-ms |
integer | 30000 |
How long the command may run. |
generated-assertion-hash |
string | none | sha256 of assertion when the command was generated. Never legal without command. |
These live on the eval entry, not in options, and the schema enforces that they only appear
alongside grader: command.
- id: no-force-push assertion: The session never force-pushed. grader: command command: ["node", "tracevals/no-force-push.mjs", "{trace}"] timeout-ms: 15000The command runs with its working directory set to the project root artifact resolution walked from. A repo-relative script path therefore resolves the way you wrote it.
Four states are an error rather than a verdict, because none of them can honestly be called a
pass:
| State | Message |
|---|---|
grader: command with no command |
command-graded eval has no \command`…` |
generated-assertion-hash no longer matches the assertion |
assertion has changed since the command was generated… |
| The executable cannot be spawned | could not run command: … |
| The command overruns its timeout | command timed out after <n>ms: … |
The hash is the second half of a generation contract. A tool that writes a check script from an assertion also writes the hash, so a later edit to the assertion is detectable. moose-tracevals does not generate check scripts. It reports the mismatch and leaves the regeneration to you.
fill never proposes a command grader. Writing an executable into an artifact is a decision a human
makes.
Turning execution off
Section titled “Turning execution off”Pass --no-commands, or set the config key, to evaluate a trace without running anything its
project declares:
tracevals: graders: command: enabled: falseThe eval reports skipped, with the reason stated, never pass:
SKIP fix-bug › no-force-push command execution is disabled (--no-commands / graders.command.enabled: false)An unrun check has not been satisfied, so it never counts as clean. Nothing else about the run changes. The verdicts and the exit code are the same as a run without the flag.
The default stays on. An eval that declares a command is asking for that command to run. A tool that silently refused would make the vocabulary’s own worked example look broken. What is new is the lever for the person taking the risk (ADR 01019, which amends 01011 rather than reversing it).
For a run that both executes nothing and sends nothing, combine it with --deterministic-only:
moose-tracevals run session.jsonl --deterministic-only --no-commandsCustom graders
Section titled “Custom graders”The registry accepts new kinds at run time via registerGrader(). A grader must implement both
grade() and validateOptions(): one that cannot validate its own options can never be proposed by
fill. The built-in graders above are the reference implementations.