The eval harness
Skills are prompts, and a prompt can get worse without anyone noticing. The eval harness catches that. It keeps
recorded runs of each skill as test cases, checks them on every pull request without calling a model, and on a
schedule runs the skills again on real models and has a judge from another model family score the results. A
promotion to rolling or main is refused when the scores fall below the bar or below main's baseline.
Tier 1 is cheap and runs everywhere; tier 2 costs money, so it runs where the key is and gates promotions.
Cases
A case is a directory, evals/cases/<skill>/<name>/, with a case.toml. It names the skill, the input given to it,
and the artifacts a recorded run produced under output/. There are 48 cases today, for the stub, grill, slice,
plan, ready, brief, retro, decide, proof, and inspect skills. Nineteen of them have a [tier2] section
and a rubric, so tier 2 runs them again. The 13 inspect cases are seeded-defect pull requests, scored by precision
and recall against their planted defects, with no judge.
A case can also carry:
schemas: artifacts that must validate against a JSON Schema.ready: artifacts holding a work item that must pass Ready validation.calls: the recorded run'saayucalls, replayed against a fake GitHub that serves the case'sgithub/files.injection: the input carries a prompt injection, so no recorded call may write.scope: scope guards, regular expressions for requirements the input never stated. When a judge once caught a skill inventing a requirement, the case keeps that defect as a deterministic rule, checked before any judge.brief: abriefcase, whose draft must passaayu brief's deterministic checks (structure, citations, the register's lint).
Tier 1: deterministic, every pull request
Tier1 in internal/evals/tier1.go runs these checks on the recorded run: artifacts exist, schemas validate, work
items pass Ready, scope guards find nothing, brief drafts pass their checks, recorded calls exit 0, and an injection
case writes nothing. TestTier1 runs every case inside go test, so the required build job in ci.yml runs tier 1
on every pull request. No model is called.
Tier 2: model-judged, on a schedule
Tier 2 runs each [tier2] case k times (5 today) on the skill's real model: the registry lane of the verb the skill
is, or stands in for, on the direct host (Claude Code). The pinned judge, gpt-6.1-sol, then scores each run from 1 to
5 against the case's rubric. A run passes at a score of 4 or more.
Why a judge from another family: a model grading its own vendor's output tends to forgive the same mistakes. The
harness calls models.CheckFamilies with the skill model and the judge, and refuses to start when they match. The
judge's calls go through the model gateway on the review lane, and must return JSON that validates
against a small schema (score, reason), with one retry.
Deterministic checks still come first. A reply of the wrong shape, one that fails Ready, or one a scope guard catches
scores 1 without asking the judge. A brief draft that fails aayu brief's checks gets the findings once to revise,
as it would in a host; if it still fails, it scores 1.
A run that cannot be completed (the judge's reply never validates, a reply stops at its token cap, the spend cap is
reached) is an error. Errors are kept out of the scores but counted: more than one errored run in a case fails it,
so a broken endpoint cannot pass quietly.
Scores, groups, and the bar
For each case the harness records two numbers over its completed runs:
- pass^k: 1 when every completed run passed, else 0. A case with one weak run in five has pass^5 of 0.
- mean rubric score: the average score.
For a suite, pass^k is the mean of the cases' pass^k, and the mean is over every completed run.
Cases run in groups, defined by [[groups]] in evals/eval.toml. A case belongs to the first group with a prefix
of its ID. Each group runs as its own job with its own spend cap (max_usd), so one group cannot use up another's
money. The groups today are brief-plain (the eli5 and ste brief cases), brief (engineer-ideate and
regulatory-close), personas (the retro and decide cases), review (the inspect cases), conversations (six
grill conversations), and skills (every other case).
Compare in internal/evals/tier2.go applies the bar to one group's results:
- Always enforced: the mean of at least 3.5 for every case and for the suite; at most one errored run per case;
and, against
main's baseline, a suite mean no more than 0.3 lower. - pass^k is a suite bar. A pass^k of at least 0.8 is enforced for the group as a whole, and a case's pass^k may not
fall below its own baseline. A case's pass^k below 0.8 is printed as an advisory and does not fail. Pull request 154
made this change, because one off run in five failed whole promotions while the suite stood at pass^5 0.80 and mean
4.80. The comment in
compareandTestCompareSuitePassKhold it. pass_k = "advisory"(thebrief,brief-plain, andreviewgroups): pass^k findings, at the suite and against the baseline, are printed as advisories. These groups pass on the mean bar, the error limit, and the drop rule. Pull request 143 added this;TestGroupPassKPolicycovers it.gate = "advisory"(brief-plain,personas,review, andconversations): results are computed and printed, but never block a promotion. Pull request 153 added this;TestGroupGatePolicyandTestCompareAdvisoryGatecover it.
How the gates run
evals.ymlruns tier 2, one matrix job per group, in theai-gatewayenvironment, which onlydev,rolling, andmainmay use. It runs nightly, on dispatch, on pushes torollingandmain, and on pushes todevthat touch skills, the harness, or the model gateway. The two brief groups skip pushes todev, because they cost the most. The advisorypersonasandconversationsgroups run only on dispatch and on pushes todevthat change their own files, andreviewonly on dispatch. Each group writesevals/results/<sha>-<group>.jsonand uploads it as the artifacteval-results-<sha>-<group>. Its baseline is the results file formain's commit and the same group: a committed file underevals/results/if one exists, else that commit's artifact.evals-gateinci.ymlruns when a promotion candidate is gated (gate=rollingorgate=stable). For each gating group (eval -groups -gating), it fetches the promoted commit's results andmain's, runseval -compare, and fails when any group's results are missing or fail.- The evolve gate.
aayu evolvechanges a skill in answer to accepted Feedback. Its pull request must first add a case that names the Feedback and reproduces it: the old skill's recorded run (in thereproducedirectory) must fail tier 1, and the new run inoutput/must pass. Thebuildjob runseval -evolve-gateon every pull request and refuses an evolve pull request (anevolve/branch or anAayudha-Evolve:trailer) without such a case.
Key data and file formats
A case with scope guards, from evals/cases/stub/csv-export/case.toml (trimmed):
skill = "stub"
description = "Draft a Ready Story for a CSV export request."
input = "input.txt"
artifacts = ["output/issue.json"]
schemas = [{ file = "output/issue.json", schema = "evals/schemas/workitem.json" }]
ready = ["output/issue.json"]
…
scope = [
{ pattern = '(?i)content[- ]?type|\btext/csv\b|\bmime\b', why = "a content type the request does not state" },
…
]
…
[tier2]
rubric = "rubric.md"
verb = "grill" # the stub drafts a Story as `grill story` does, so it runs on grill's lane (spec)
The bar and the groups, from evals/eval.toml (comments trimmed):
judge = "gpt-6.1-sol" # openai; the stub skill runs on grill's spec lane, claude-opus-5-5 (anthropic) today
k = 5 # runs per case
pass_score = 4 # a run passes at this rubric score (1-5) or above
min_pass_k = 0.8
min_mean = 3.5
max_drop = 0.3
…
[[groups]]
name = "brief-plain"
prefixes = ["brief/eli5-", "brief/ste-"]
max_usd = 4.0 # run 39012708292 (70559ea): 1.70 USD for 9 of 10 runs, …
pass_k = "advisory"
gate = "advisory"
[[groups]]
name = "brief"
prefixes = ["brief/"]
max_usd = 3.0 # engineer-ideate and regulatory-close (the plain registers moved to brief-plain, D-7.3h); …
…
pass_k = "advisory"
[[groups]]
name = "skills" # every other case
max_usd = 4.0 # run 39017055049 (dae0662): 1.97 USD
The pass^k computation, from Tier2 in internal/evals/tier2.go:
var done []int
cr.PassK = 1
for _, r := range cr.Runs {
if r.Status == RunError {
cr.Errors++
continue
}
done = append(done, r.Score)
if !r.Pass {
cr.PassK = 0
}
}
if len(done) == 0 {
cr.PassK = 0
}
cr.Mean = mean(done)
…
for _, cr := range res.Cases {
res.PassK += cr.PassK / float64(len(res.Cases))
}
And where a group's pass^k policy is applied, from compare in the same file:
passK := &reasons
if g, ok := cfg.Group(cur.Group); ok && g.PassK == PassKAdvisory {
passK = &advisory
}
The brief group's honest history
The brief skill writes one-page briefs in four registers: eli5, ste (Simplified Technical English), engineer,
and regulatory. Its original bar was the full one, pass^5 of at least 0.8 for every register. It failed that bar in
five consecutive repair rounds (eval runs 38200697472, 38202244333, 38207049608, 38216409539, and 38222172014; the
build record also lists a sixth charged run, 38210971285). The last scored suite pass^5 0.50 and mean 4.45: the
regulatory and engineer registers passed 5 of 5 runs, ste 4 of 5, and eli5 3 of 5. Every deterministic check
held on every judged run; what remained were judgement calls no rule decides.
Rather than hide the shortfall, the team recorded it and changed only the brief group's gate: it passes on the mean,
per register and for the suite, with pass^k reported as advisory (pull request 143). The next rolling candidate then
failed eli5 and ste even on the mean, mostly where the rubric asks for everyday restatement and then marks it down
for fact fidelity. So pull request 153 moved those two registers into brief-plain, which is reported but does not
gate. The plain-language rubric conflicts are tracked as an open issue, Feedback 152, for evolve to fix. The other
gating group, skills, keeps the full bar, which since pull request 154 applies pass^k per group at the suite level
rather than to each case on its own. The design rows are in the build plan; search it for
"advisory".
Retro, evolve, and the coverage ratchet
Three later additions sit around the harness. Two shape how skills learn from delivery: retro measures the flow of work, and evolve changes a skill only on repeated evidence. The third holds this repository's own test coverage.
Flow, reported and not gated
aayu retro reviews the stories delivered in a window and files what it finds as Feedback issues. Every retro report
also has a "Flow (reported, not gated)" section after the estimate table, and retro --json carries the same numbers
as flow. They come from data aayu already keeps (issue timelines, the merge gate's runs, build records and
dismissals on the ledger, merged pull requests), with no model:
- Time per stage, as P50 and P90 hours: queue (Ready to the first checkpoint), build (to the merge gate going green), human wait (gate green to ship), and total (Ready to ship). A stage counts only stories with both ends.
- Touches per story: comments and reviews by a person on the story or its pull request between Ready and ship,
plus each later
status:ready. Bots, comments with an aayudha marker, and ship's approving review do not count. - inspect advice acted on: the share of the finding IDs inspect posted that are gone from its latest inspection. A finding a person dismissed, or one still open at merge, does not count as acted on.
- Escape rate: stories with a Bug opened after the merge and within
[autonomy] correction_days, whose body links the story or its pull request, and which that pull request does not itself fix. - Change mix: the Conventional Commit type of every pull request title merged into
devin the window.
Nothing gates on these numbers. No finding, Feedback issue, config key, or status line comes from them. They are a
baseline, and the retro skill tells the agent not to change a story, a split, or a review to move one.
The flow object of retro --json, from internal/audit/flow.go:
type Flow struct {
Stories []StoryFlow `json:"stories"`
Stages []Stage `json:"stages"`
Touches Stage `json:"touches"`
Inspect InspectFlow `json:"inspect"`
Escaped int `json:"escaped"`
Mix []TypeCount `json:"change_mix"`
Merged int `json:"merged_prs"`
Days int `json:"escape_days"` // [autonomy] correction_days
}
Honest status: the first dry run on this repository (1 to 16 October 2026, 46 stories and Bugs) showed a human wait P50 of 0.0 hours, because in solo mode the agent ships as soon as the gate is green. Only 6 stories had a build record, so the queue and build stages cover 6 stories. inspect had recorded one posted finding.
evolve's rules
aayu evolve turns accepted retro Feedback about a skill into a skill change, through the eval-first gate above.
Three rules keep it from chasing one-off noise:
- Two Feedback issues. It proposes a change only when at least two accepted Feedback issues (labelled
needs:grill) name the same skill in their titles (EvolveMinFeedback= 2). With fewer it exits 4 and writes nothing, and the pull request it opens cites every issue. Run without--feedback, it lists which skills are ready and which are still waiting. - Once a month per skill. It refuses when an
evolve/<skill>-…pull request was opened in the last 30 days (EvolveCooldownDays), in any state. - Rewrite, not append. The eval-first gate refuses a skill diff that adds lines and removes none: "the skill diff
only appends". A fix rewrites the section the Feedback is about rather than adding a rule beside it. Because the
gate runs in the
buildjob, CI enforces this rule, not only the verb.
Retro checks that a fix held. Each report lists the evolve pull requests merged into dev in the 30 days before its
cursor (RecurrenceDays). If a retro Feedback issue opened after the merge, or a finding of this window, names the
same skill, the problem came back. Retro then files "evolve did not hold: skill <s> (…) came back" with the
aayu install --pin rollback. That title names the skill, so it counts as evidence for the next evolve.
Fix layer first: only prose goes to evolve
A skill rewrite is the third layer of the fix, not the first (ETHOS 10). Before evolve sees it, each Feedback issue is classified by the highest layer that can fix its correction for good, with the reason:
| Layer | The fix | What aayu files |
|---|---|---|
codebase |
a type, an invariant, or a boundary makes the mistake impossible | a Story whose Completion Goal names that proof in backticks |
check |
a lint rule, a test, a CI check, or a verify floor catches it |
a Story whose Completion Goal names the rule or test |
prose |
guidance in a skill or AGENTS.md, for what needs judgement |
nothing: evolve's eval-first rewrite |
review |
a change to what the environment surfaces for a person | a Story about the proof comment, the review plan, or the risk line |
aayu retro --classify <feedback> --layer <layer> --reason "…" records it: a fix:<layer> label on the Feedback issue
and one replace-in-place comment holding the reason. For prose and review it also needs --not-catchable, why no
type, invariant, lint rule, or test can catch it. For the other three layers it needs --criterion, the Story's
Completion Goal criterion, and files the Story once. The Story carries the same fix:<layer> label, so fix work is
filterable by layer. The layer is a model judgement; aayu checks only that the record is complete. Running the
command again reclassifies, and the Story's label follows (a Story loses it when the correction moves to prose).
Unclassified Feedback does not wait for evolve to refuse it. aayu doctor's feedback-layers row, the home screen,
the status line, and the session-start hook list every open Feedback issue without a fix:<layer> label, with the
command that classifies it. The hook reads the last home screen and makes no network call, so it is as fresh as the
last aayu or aayu next.
evolve then refuses (exit 4) a Feedback issue that is unclassified, a review correction, or a codebase or check
correction whose classification gives no --not-catchable reason, and only the Feedback it accepts counts toward
the two-issue rule. Every retro report ends with a "Fix layer of Feedback" table that counts every Feedback issue by
its label, unclassified included, with the fix Stories that carry each layer's label and those shipped by the
cursor.
The evolve loop in loops/evolve.toml runs monthly, on the 2nd at 10:00 in [time] tz. It only lists the skills
that have the evidence; it changes no skill itself.
The coverage ratchet (this repository only)
A ratchet is a floor that can only move up. This repository keeps one for its own test coverage. It is not a product
feature: no verb, config key, or init template brings it to a repository aayudha sets up, and it uses no model.
ratchets.toml at the repository root holds the floor (comments trimmed):
[coverage]
floor = 63.4
margin = 0.5
The required build job in ci.yml measures the statement coverage of go test ./... on every pull request and
push, then runs internal/ratchet. Check in internal/ratchet/ratchet.go applies three rules:
- Coverage below the floor minus the margin (the measurement noise the floor tolerates) fails.
- On a pull request, coverage at the floor plus twice the margin or more fails until the pull request tightens the
floor with
-update. A push todevchecks only rule 1, so two pull requests that each raised coverage a little do not turndevred; the next pull request tightens. - A floor lower than the base's fails, unless
waiver = <n>names a new floor-waiver Decision that a person accepted (aayu decide --waiver coverage <story-or-bug>, then--accept waive). The job reads it with its own read token. A waiver the base already named does not count again.
The tightening never loosens, from Tighten in the same file:
func Tighten(cur Ratchet, measured float64) float64 {
return math.Max(cur.Floor, math.Floor((measured-cur.Margin)*10)/10)
}
Invariants
- Every recorded run passes tier 1 on every pull request, without a model call. Enforced by
TestTier1ininternal/evals/evals_test.go, run by thebuildjob in.github/workflows/ci.yml. - The judge's family differs from the skill model's, and from its fallback's. Enforced in
Tier2,TestTier2RefusesSameFamilyJudge. - Deterministic checks beat the judge: a wrong shape, a Ready failure, or a scope guard hit scores 1 first. Enforced
in
internal/evals/tier2.go,TestTier2ScopeGuardAndOpenQuestionsandTestTier2ReadyFloor. - More than one errored run in a case fails its group, and a spend cap turns the remaining runs into recorded errors.
Enforced by
MaxErrorsandTier2,TestTier2SpendCap. - pass^k is enforced per group at the suite level, never per case; the per-case mean, the error limit, and the
baselines stay enforced. Enforced in
compare,TestCompareSuitePassK. - Only
brief,brief-plain, andreviewrelax pass^k, and onlybrief-plain,personas,review, andconversationsdo not gate; an unknown group gates. Enforced byConfig.Gatesandcompare,TestGroups,TestGroupPassKPolicy,TestGroupGatePolicy,TestCompareAdvisoryGate. - An evolve pull request adds a reproducing case before any skill diff. Enforced by
EvolveGateininternal/evals/evolve.go,TestEvolveGateCIandTestEvolveGateAfterTestMergeRecomputed. - An evolve skill diff that only appends lines is refused, beside the same change as a rewrite, which passes.
Enforced by
EvolveGateininternal/evals/evolve.go,TestEvolveGateCIininternal/evals/cmd/eval/main_test.go. aayu evolvechanges a skill only on at least two accepted Feedback issues that name it, and at most once in 30 days. Enforced byrunEvolveininternal/cli/evolve.go(EvolveMinFeedback,EvolveCooldownDays),TestEvolveVerbininternal/evals/evolve_test.go.- Retro reports when a problem an evolve pull request fixed came back within 30 days, and files it under the skill's
name. Enforced by
recurrencesininternal/audit/retro.go,TestRetroRecurrenceininternal/audit/audit_test.go. - Retro's flow numbers are computed from recorded data, with no model, and survive a snapshot round trip, so
retro --replayreproduces them; they file nothing. Computed byflowininternal/audit/flow.go,TestRetroFlowininternal/audit/audit_test.go. - This repository's coverage floor only tightens: coverage below the floor fails, a stale floor fails a pull
request, and a lower floor needs a new floor waiver a person accepted. Enforced by
Check,Tighten, andAcceptedWaiverininternal/ratchet/ratchet.go,TestCheck,TestTotal, andTestAcceptedWaiverininternal/ratchet/ratchet_test.go, run by thebuildjob in.github/workflows/ci.yml.
Where it lives in the code
- internal/evals/evals.go: cases, groups, and
eval.tomlloading. - internal/evals/tier1.go: the deterministic checks.
- internal/evals/tier2.go: runs, the judge, pass^k, and
Compare. - internal/evals/evolve.go: the eval-first gate.
- internal/evals/cmd/eval/main.go: the
evalcommand CI runs. - evals/: cases, schemas,
eval.toml, and results. - internal/audit/flow.go and internal/audit/retro.go: retro's flow section and the recurrence check.
- internal/cli/evolve.go and loops/evolve.toml: evolve's evidence rule, monthly cap, and loop.
- internal/ratchet/ratchet.go and ratchets.toml: the coverage ratchet.
- .github/workflows/evals.yml and .github/workflows/ci.yml.
Honest status: tier 2 needs the
ai-gatewaykey, so it never runs on pull requests, and a fork cannot run it. The two results files committed underevals/results/are from earlier runs ondev, so the baseline comes frommain's workflow artifact. No evolve case exists inevals/cases/yet, so the evolve gate has only run in tests and on pull requests that were not evolve pull requests. Theeli5andsteregisters do not gate any promotion until Feedback 152 is resolved.
Read next
- The model gateway and lanes: how the judge and the skill models are chosen and paid for.
- Releases: where
evals-gatesits in a promotion.