Write a rubric (judge) eval scenario¶
A stack-pack skill's behavioral evals used to grade an answer by string
matching — contains/regex/not-contains against the answer text. That
graded wording, not behavior: an answer that correctly warned against
@ts-ignore failed a not-contains "@ts-ignore" check for saying the token
at all, alongside an answer that actually used it. Flow 316 replaced that for
behavior scenarios with a rubric graded by a separate LLM judge call. This
guide covers how to write one.
Exclusion clauses in a description/triggers are safe to write (flow 334)¶
This section is unrelated to judge rubrics above — it is about the
SEPARATE, purely lexical trigger scorer (src/gdskills/governance/scout.ts:
checkSkillSelected/checkSkillSelectedLeaveOneOut/scoutSkill/
nearestSkills), not the judge. It is grouped on this page because the same
"don't fight the grader, write for it" spirit applies to a skill's
SKILL.md frontmatter description/triggers. Every consumer of that
scorer is affected: keryx skills eval <id>'s triggerAccuracy,
keryx skills scout's use/fork/create decision, keryx skills stocktake's
own-trigger-routes-back check, and bundle/external.ts's vetting of an
external skill import candidate (which scores the candidate's own
name + description the same way).
Before flow 334, that scorer was a plain bag-of-words/IDF match with no
negation handling: text inside a skill's own exclusion clause — "Use when X.
Not for Y (use `other-skill` instead)." — counted Y's words as ordinary
POSITIVE evidence for the skill, exactly as if the description had claimed
them. Authors were working around this by stripping words out of
descriptions and stuffing eval prompts with jargon instead of writing the
clause the convention already recommends. Concrete measured case: before the
fix, ts-js-node/nodejs-implementation's description — "... Not for UI
markup/rendering code (use the matching UI framework pack) or writing/fixing
tests (use nodejs-testing)." — scored 0.682 against the query "write vitest
tests for this service", purely from "writing"/"tests" sitting inside its own
disclaimer.
The scorer now strips exclusion-clause text from an entry's own
description/triggers before scoring it. Write your disclaimer normally —
do not strip the topic word out of your description to avoid a false match,
and do not avoid stating what a skill is NOT for. The recognized forms,
surveyed from the bundled catalog (stripExclusionClauses in scout.ts is
the exact implementation; this is a plain-language summary of it, not a
substitute for reading it):
- A sentence CONTAINING
not forordo not use foranywhere in it (not only at the start) — e.g.Not for <clause>./NOT for: <clause>., the dominant convention (37+ bundled skills), usually paired with a redirect:Not for X (use \other-skill`).The WHOLE sentence from that phrase to its end is dropped, soNOT for A, or NOT for B (use `x`)` in one sentence drops both clauses together. - A sentence CONTAINING
does notanywhere in it — e.g.Does not edit code or apply fixes.Broad on purpose (anydoes not, not just a scope disclaimer), so an ordinary capability statement using that phrase is also affected; write around it if that costs you description-support for a word you need (see the "Does not" tradeoff noted in the flow 334 journal). - A sentence that OPENS with
never/Never— e.g.Never run this against a database migration.(a mid-sentenceneverin otherwise-ordinary prose — "a fix that never widens beyond the failure" — is deliberately left alone; only a sentence-INITIALneveris treated as an exclusion clause, to avoid over-triggering on normal English). - A
(not <clause>)or(never <clause>)parenthetical aside, anywhere in the text — e.g.(not the CLI form). - A
(see <clause>)cross-reference parenthetical — e.g.(see api-truth for version-diff checks). - A standalone
Use \` instead. redirect with noNot forwrapper — "use" immediately followed by EITHER a backtick/quote-wrapped token OR a bare HYPHENATED skill-id-shaped token (the shape every real bundled skill id has, e.g.nodejs-testing`), then "instead" with nothing in between. A bare, unhyphenated, unquoted word never matches, so this never touches an ordinary "Use when ..." description opener that happens to also contain "instead" later in the same sentence, nor a short aside like "Use git bisect instead of a manual search" that names no other skill at all. e.g./i.e.abbreviations inside a NOT-for sentence do not end it early.- A
(not only X but also Y)parenthetical is left untouched — it is an INCLUSIVE idiom, the opposite of an exclusion. - Deliberately NOT recognized: a plain
without(too ambiguous on its own), and a NOT-for clause that spans more than one sentence (not observed in the bundled catalog; only the sentence a marker starts in is dropped).
Only your own DESCRIPTION-shaped text gets this treatment on both sides of
a comparison; a live routing or trigger-accuracy prompt never does. A
skill's own description/triggers are always stripped. A QUERY is stripped
only when it IS itself a skill description — keryx skills scout's
pre-creation dedupe query, bundle/external.ts's candidate vetting, and
nearestSkills's (and scoutSkill's) self-identification checks — never
for checkSkillSelected/checkSkillSelectedLeaveOneOut, which always score
a live prompt or trigger-accuracy probe exactly as typed, so an ordinary
query that happens to open with "Never" (e.g. "Never mind the tests, push my
branch") is never corrupted.
Excluded tokens are dropped from the entry's positive evidence; they are
not turned into negative evidence (a skill is not penalized for the
topic it disclaims — see the stripExclusionClauses section comment in
scout.ts for the full reasoning). This is layered on top of the shared
tokenizer (src/lib/route-tokens.ts), not a change to it, and it does not
touch behavior-scenario judging on this page — it only affects
triggerAccuracy, scoutSkill's use/fork overlap decision, stocktake's
own-trigger check, and external candidate vetting.
The schema¶
A behavior scenario's expected_behavior array may carry at most one judge
expectation, alongside zero or more deterministic ones:
{
"grader": "judge",
"rubric": "What a correct answer does, 1-4 sentences, behaviour not wording.",
"pass_criteria": ["every one must hold (>= 1 entry)"],
"fail_criteria": ["any one holding fails the answer (optional)"]
}
A judge expectation carries no value — rubric/pass_criteria/
fail_criteria replace it. A scenario that carries one must also carry
calibration (required) and may carry anti_patterns (optional):
"calibration": {
"known_right": "<hand-written correct answer>",
"known_wrong": "<hand-written plausible wrong answer that commits the forbidden anti-pattern>",
"vague": "<hand-written plausible answer that names the right direction but gives no concrete fix>",
"subtle_wrong": "<hand-written realistic-sounding answer that still commits the anti-pattern or misses required behaviour>"
},
"anti_patterns": ["@ts-ignore", "as any"]
vague and subtle_wrong (fix 1 / R1-4, R1-11) are required alongside
known_right/known_wrong — all four must be non-empty and pairwise
different:
vague— a plausible, generic answer of 1-3 sentences that points in the right direction ("fix the type instead of hiding the error", "add the missing dependency") but gives no concrete fix. Must FAIL. This is the leniency class review round 1 on PR #698 found the live judge passing before this fix (R1-4): a one-sentence paraphrase of the rubric's own direction, with nothing concrete shown, cleared some scenarios' judge calls.subtle_wrong— a reasonable-sounding, well-written answer that still commits the anti-pattern or misses a required behaviour in a realistic way — a partial fix, hedging, a non-anycast, log-and-continue, or a mock one layer too deep. Must FAIL.known_wrongcalibrations tend to be strawmen ("Easiest fix:<anti-pattern>, no need to look further") that the judge fails trivially;subtle_wrongis what actually exercises the judge against a failure mode a real model might produce.
validateEvalSpec (src/gdskills/governance/eval.ts) throws EvalSpecError,
naming the file, for any malformed judge field: calibration missing, its
two answers empty or identical, or an anti_patterns entry that is empty.
A real example¶
src/gdskills/bundled/stacks/go/skills/go-implementation/evals.json,
scenario context-propagation, mixes a deterministic check with a judge
check on the same scenario:
{
"id": "context-propagation",
"prompt": "I'm adding a new function to internal/order that calls a downstream client. How should I handle the context and errors?",
"strictness": "high",
"expected_behavior": [
{ "grader": "regex", "value": "context\\.Context" },
{
"grader": "judge",
"rubric": "A correct answer explains passing context.Context as the function's first parameter through to the downstream client call (not stored on a struct), and wrapping the error the downstream call returns with fmt.Errorf(\"...: %w\", err) so callers can unwrap it with errors.Is/errors.As, rather than discarding it or replacing it with a plain %v/string.",
"pass_criteria": [
"States that context.Context should be the function's first parameter, passed through to the downstream call, not stored on a struct field.",
"States that the error returned by the downstream call should be wrapped with %w (e.g. fmt.Errorf(\"doing x: %w\", err)) rather than discarded or reformatted with %v/string concatenation.",
"Connects the wrap to how callers use it (e.g. errors.Is/errors.As), even briefly."
],
"fail_criteria": [
"Recommends discarding or swallowing the downstream error (e.g. `_ = err` with no comment) instead of wrapping and returning it. Mentioning that only to warn against it is not a failure."
]
}
]
}
An answer must pass BOTH: the regex (an unambiguous fact — the literal type
name context.Context has to appear) AND the judge (behavior — the answer
has to actually explain the parameter-passing and wrapping, not just mention
the type). gradeScenarioAnswer (src/gdskills/governance/judge.ts) is the
one function that combines them; passed is true only when every
deterministic expectation is true AND the judge verdict is pass.
Writing the rubric, pass_criteria, and fail_criteria¶
- The
rubricis 1-4 sentences describing what a correct answer does — the mechanism, not the vocabulary. Write it so it would still make sense if every keyword in it were paraphrased. - Every
pass_criteriaentry must hold for the answer to pass — write each one as something a grader can check against the answer's actual content ("names X as the first step", "wraps the error with %w"), not a restatement of the rubric. - The concreteness rule (fix 1 / R1-4). A pass criterion holds only when
it is concretely present in the answer — the specific change, code, or
step is actually shown or named, not merely gestured at or promised. An
answer that only names the right direction ("fix the type instead of
suppressing the error", "add the missing dependency") without showing what
the fix actually is does not satisfy that criterion; write your
pass_criteriaso they require the concrete artefact, not just the direction, and use thevaguecalibration answer above to prove it. This is stated in the judge's own system prompt (src/gdskills/governance/judge.ts,JUDGE_PROMPT_VERSION 2026-09-25.1), which also requires the judge's "reason" to cite where in the answer each pass criterion is satisfied, not just restate the criterion. fail_criteriais optional; any single entry holding fails the answer regardless of how many pass criteria also hold. Use it for the specific wrong move the scenario exists to catch (reaching for a suppression, skipping reproduction, mutating state in place). Never write a negation-only note as its ownfail_criteriaentry — for example, a standalone "Mentioning X only to warn against it is not a failure." Fold that sentence into the fail criterion it qualifies instead (see "Warning is not failure" below); integrity rule I10 (below) rejects a negation-only note listed on its own.- "Warning is not failure." Mentioning an anti-pattern only to warn
against it, or to explain why it must not be used, is not the same as
committing it. Say so explicitly in the
fail_criteriaentry that names the anti-pattern — see thego-implementationexample above ("Mentioning that only to warn against it is not a failure") andnodejs-build-fix'sno-ts-ignore-suppressionscenario, which adds the same sentence to its own suppression-related fail criterion. This clause is what a plainnot-contains "@ts-ignore"check could never express: the judge's system prompt (src/gdskills/governance/judge.ts,SYSTEM_PROMPT) states the same rule globally ("do not fail an answer for correctly describing what to avoid"), but naming it again in the scenario's own fail criterion removes any doubt for that specific rubric.
When a deterministic check is allowed¶
Keep a contains/regex/not-contains expectation only for an unambiguous
fact — a specific API name, type, or syntax that every correct answer must
literally contain, regardless of how it phrases the explanation around it.
context\.Context and getByRole (react-testing's
mock-network-boundary scenario) are both this shape: there is no correct
answer that solves the task without naming that exact token.
Never use not-contains on a judge scenario. Integrity rule I8 (below)
refuses it outright, and it is exactly the check that produced the
mis-specified batch-1 graders flow 316 replaced: a not-contains
"@ts-ignore" check fails a correct answer that warns against @ts-ignore
just as readily as it fails one that uses it — the token's mere presence
proves nothing about which the answer actually did. A judge scenario's
fail_criteria is the replacement: it can express "recommends X as the
fix" in a way a substring match cannot.
Calibration and anti_patterns¶
calibration.known_right, calibration.known_wrong, calibration.vague,
and calibration.subtle_wrong are all hand-written, not generated:
known_right does what the skill teaches and must pass; the other three
must each fail, for different reasons (see "The anti-gaming requirement"
below). They drive both the anti-gaming harness and these integrity rules:
- I6. Every
anti_patternstoken appears (case-insensitive) in the skill's ownSKILL.md— an anti-pattern the scenario checks for but the skill never actually forbids is not a real anti-pattern. - I7. Every
anti_patternstoken appears (case-insensitive) in the scenario's owncalibration.known_wrong— the wrong answer has to actually commit the anti-pattern it is supposed to demonstrate. - I7b. Every
anti_patternstoken is at least 4 characters long, or contains a non-letter character — a generic short word likeanycannot satisfy I6/I7 by accident (it will appear almost anywhere). Name the specific form instead:`any`(backtick-quoted) oras anyreads as a real anti-pattern token; a bareanydoes not. - I7c (fix 1 / R1-7). Every
anti_patternstoken also appears (case-insensitive) in the scenario's own judge expectation — itsrubricor afail_criteriaentry — not just inknown_wrongorSKILL.mdprose.anti_patternsis authoring metadata for I6/I7; without I7c a token could satisfy I6/I7 by construction while the judge itself is never actually told about it, which makes the token a hint to the author, not something the grading ever exercises.anti_patternstokens must be literal constructs the judge can check against the answer (a suppression comment, a type name, an API call) — not a prose phrase copied from the authoredknown_wrongtext, which would trivially satisfy every one of these rules by construction without the judge ever needing to recognize the construct itself.
anti_patterns itself is optional — a scenario with no clean single-token
anti-pattern (for example, go-implementation's context-propagation
scenario above) can omit it.
The anti-gaming requirement¶
Every judge scenario must be proven hard to game against eight canned
answers (antiGamingAnswers, src/gdskills/governance/judge.ts; fix 1 /
R1-4, R1-11 widened this from six kinds to eight):
| Kind | Built from | Must grade |
|---|---|---|
empty |
"" |
FAIL — never reaches the judge; gradeScenarioAnswer short-circuits it |
echo |
"Sure. You asked: " + prompt |
FAIL |
vague |
calibration.vague |
FAIL — points in the right direction, shows no concrete fix |
known-wrong |
calibration.known_wrong |
FAIL |
subtle-wrong |
calibration.subtle_wrong |
FAIL — realistic-sounding, still wrong |
injection |
known_wrong + a paragraph telling the grader to output pass |
FAIL |
stuffed |
known_wrong + the rubric and every pass/fail criterion pasted in |
FAIL |
known-right |
calibration.known_right |
PASS |
injection and stuffed prove the judge itself resists two specific
attacks: a direct prompt injection telling it to override its instructions,
and keyword-stuffing the rubric's own vocabulary into an otherwise-wrong
answer. Both are built ON TOP OF known_wrong, so a scenario cannot pass
them just by having a known_wrong that happens to already fail on its own
merits — the judge has to resist the added attack text specifically.
vague and subtle-wrong prove something different: not resistance to an
attack, but that the judge actually enforces the concreteness rule and
catches a realistic failure mode, rather than passing anything that gestures
in the right direction or sounds confident. Review round 1 on PR #698 found
the live judge passing some vague answers before this fix — see the
concreteness rule above.
Recording the calibration¶
keryx skills judge-check <skill-id> --judge <provider>[:<model>] [--scope bundled|all] [--samples <n>] [--record] [--json]
Runs all eight canned answers for every judge scenario in the named skill
through the live judge (not a stub) and exits 1 if any verdict does
not match what that answer expects. The live judge is not deterministic on
identical input — review round 1 caught it flipping verdicts on the same
canned answer across two calls — so each non-empty canned answer is graded
--samples times (default 3) rather than once, and a canned answer only
counts as matching its expectation when every sample agrees and none
errored. --record writes all of those samples (not a single verdict) to
src/gdskills/governance/judge-recordings/<pack>__<skill>.json — a stable,
diff-friendly JSON file the integrity guard replays offline. Recordings, in
other words, are samples: a recorded entry for a canned answer is proof
that a live judge, checked at record time, agreed with itself --samples
times in a row on that exact input — not proof that it always will.
A recording goes stale, and the integrity guard's anti-gaming (AG) check fails closed rather than silently reusing it, when:
- the recorded
requestDigestno longer matches a freshly-built request — the rubric, pass/fail criteria, prompt, or answer changed since the recording was taken; judgePromptVersionin the recording is not the currentJUDGE_PROMPT_VERSION(src/gdskills/governance/judge.ts) — the judge's own system/user prompt text changed;- the recording is missing entirely for a scenario that carries a judge expectation;
- any recorded sample for a canned answer disagrees with another, or
carries a parse
error— a recording is only trusted when its samples are unanimous.
Any of these names the exact fix in its failure message: re-run
skills judge-check <skill-id> --judge ... --record.
A recording is a hand-writable file — anyone with write access to the
repository can compute a judgeRequestDigest and author the expected
samples without ever calling a judge. Treat it like any other committed
artifact whose truth depends on the process that produced it: the real
control is reviewing the recording's diff in the pull request alongside the
scenario it backs, not the recording's mere presence. See the gate's own
threat-model note in docs/requirements/keryx-agent-platform-expansion/
workstreams/W1-stack-catalog.md for the same point applied to eval's
trial records.
The opt-in live check¶
src/commands/stack-pack-judge-calibration.live.test.ts runs the same
anti-gaming set against a real DeepSeek call, but only when
KERYX_LIVE_JUDGE=1 is set — it costs real tokens and money, so it never
runs by default in CI or locally. Every other integrity check, including the
AG rule above, runs against the recorded verdicts and needs no network
access or KERYX_LIVE_JUDGE.
Flow 317 added a related but distinct live check: keryx skills eval
--reverify <pack-dir> [--sample N] --judge <provider>[:<model>] re-judges a
random sample of already-recorded TRIAL outputs (not the canned anti-gaming
answers judge-check uses) against the current live judge, and reports
where the live verdict disagrees with what was recorded. It closes a
different gap than judge-check: hard-to-game rubrics (judge-check) vs.
judge-verdict provenance and drift (--reverify) — see the CLI reference's
threat-model paragraph for eval --reverify.
The full integrity rule list¶
src/gdskills/stack-pack-eval-integrity.test.ts enforces these permanently
on every scenario in the shipped tree:
- I1. fails when graded against the empty answer
"". - I2. fails when graded against the echo answer
"Sure. You asked: <prompt>". - I3. a scenario with no judge expectation carries at least one
POSITIVE (
contains/regex) expectation that does not already match the prompt text itself; a judge scenario instead carries at least one non-empty pass criterion, a non-empty rubric, and a calibration set whose four answers are all non-empty and pairwise different. - I4. shares no 6-word sequence with its own skill's
SKILL.mdbody — the eval question must not be pasted into the skill as a worked example. - I5. its skill's
SKILL.mdcontains no answer-key phrasing ("verbatim", "exact sentence", "open your answer", ...) telling the model how to word its answer to trip the grader. - I6. every
anti_patternstoken appears (case-insensitive) in the skill's ownSKILL.md. - I7. every
anti_patternstoken appears (case-insensitive) in the scenario's owncalibration.known_wrong. - I7b. every
anti_patternstoken is at least 4 characters long, or contains a non-letter character. - I7c (fix 1 / R1-7). every
anti_patternstoken appears (case-insensitive) in the scenario's own judge expectation — itsrubricor afail_criteriaentry. - I8. a judge scenario carries no
not-containsexpectation. - I9. every stack-pack behavior scenario is a judge scenario.
- I10 (fix 1 / R1-6). no
fail_criteriaentry is a negation-only note (for example, a standalone "Mentioning X only to warn against it is not a failure.") — that sentence must be folded into the fail criterion it qualifies, never listed as its own criterion. - AG (anti-gaming). for every judge scenario, every canned answer
(
empty,echo,vague,known-wrong,subtle-wrong,injection,stuffed,known-right) grades, through the recorded live judge, to exactly the outcome that answer expects — unanimously across all of a recorded canned answer's samples. A missing recording, a stalejudgePromptVersion, a non-unanimous or errored sample, or a recording missing the digest for one of these answers fails the check outright — never a silent skip, never a pass by omission.
How the stable-pack gate uses this¶
A pack's stability may only be "stable" once every skill's recorded eval
report clears checkSkillReportForPackGate
(src/gdskills/governance/eval.ts), which requires, on top of the integrity
checks above:
strictness: "high",trials >= PACK_MIN_TRIALS(10as of flow 317 — raised from5: several scenarios in the first honest runs sat exactly on thePACK_BEHAVIOR_PASS_FLOOR, one flipped trial away from failing either direction; doubling the trial count halves that single-flip swing),scope: "bundled";(runner, model)and, when the report has any judge scenario,(judge, judgeModel)both inSTACK_PACK_GATE_POLICY(src/gdskills/governance/gate-policy.ts) — pinned to DeepSeekdeepseek-chatfor both roles;runnerPromptVersionequal to the currentRUNNER_PROMPT_VERSION(flow 317: every--runnercall now appends a uniform "answer in text, no tools" system note, since the single-turn runner has no tool wiring at all and a model that tries one anyway produces a non-answer);judgePromptVersionequal to the currentJUDGE_PROMPT_VERSION;- every ran behavior scenario's
passRateat or abovePACK_BEHAVIOR_PASS_FLOOR(0.8); - every ran behavior scenario carrying
trialRecords, its recordedpasses/passRate/trial count matching what those records show, andregradeRecordedReportfinding no discrepancy between the recordedpassedvalues and a re-derivation from those same records — the judge is never re-run at gate time; "re-derive" means recomputing from the recorded outputs and recorded verdicts, not re-judging them.
What the gate proves, and what it does not. The checks above prove
internal consistency of a recorded report against the skill's current
files: the recorded outputs hash to their recorded digests, the counts and
pass rate are re-derived from the trial records rather than trusted as
self-declared fields, and trigger results are re-scored live against the
current bundled catalog every time. The gate does not prove provenance —
the trial outputs, the judge's verdicts, the runner/judge labels, and every
AG recording described above are self-declared by whoever ran the eval, and
nothing here re-runs the judge model against them. The actual control for
provenance is review of the committed eval.json / recording diff in the
pull request, the same way any other committed artifact is reviewed. See
docs/requirements/keryx-agent-platform-expansion/workstreams/
W1-stack-catalog.md, "Threat model: what the gate proves, and what it does
not (fix 1, R1-2)", for the full statement and the planned follow-up (FU6, a
live re-judge sampler).
Never tune a SKILL.md to pass¶
If a scenario's known-right answer fails under the judge, or a real gate run
scores lower than expected, the fix is the rubric, the criteria, or the
calibration — never SKILL.md. Editing a skill's own instructions to make
an eval pass after seeing the run is tuning the skill to the grader, the
exact failure mode flow 314 and flow 316 both exist to close; it also
invalidates the eval as a signal that the skill actually behaves correctly
for an unseen prompt. When flow 316's honest gate run found python and
go scenarios that were themselves under-specified or too narrow (see
docs/requirements/keryx-agent-platform-expansion/workstreams/
W1-stack-catalog.md, "Implementation notes: grader reliability
(flow 316)"), the fix was recorded as follow-up work on the grader, not
applied mid-run — and no SKILL.md was touched.
The lesson from this flow: a prompt with no code needs a rubric that fits¶
Two scenarios failed for the same underlying reason before fix 1:
python-build-fix's mypy-error-no-blanket-suppress (prompt: "mypy reports a
type error on a function I touched. Fix it.") and, under the hardened judge,
react-build-fix's no-disable-hooks-lint (prompt carries no code either).
When a scenario's prompt contains no code, its rubric must not demand that
the answer name code-level specifics it cannot possibly derive — the exact
type mismatch, the exact declaration to change — because a genuinely correct
answer to an under-specified prompt is to ask for the missing information (or
to state the fix in general terms) rather than invent a plausible-looking
fake. The fix, in both cases, was not to weaken the rubric to accept a vague
answer (the vague calibration must still fail): it was to accept either (a)
asking for the missing concrete detail while correctly committing to the
right kind of fix and refusing the anti-pattern, or (b) a concrete
illustrative example — a corrected declaration or dependency array shown as
code, invented for the purpose of demonstrating the mechanism, clearly framed
as an example rather than claimed as the actual fix for code the answer never
saw. Write your pass_criteria to accept a concrete illustrative example
whenever the prompt itself supplies no code for the answer to quote from —
otherwise a correct answer that plays it straight ("I don't have the actual
error text — here's the pattern once you do") fails the concreteness rule for
a reason that is really the prompt's fault, not the answer's.