Skip to content

Add per-skill eval scaffolding: contract tests, trigger evals, output cases - #17

Merged
lucasrodes merged 9 commits into
mainfrom
evaluate-skills
Aug 18, 2026
Merged

Add per-skill eval scaffolding: contract tests, trigger evals, output cases#17
lucasrodes merged 9 commits into
mainfrom
evaluate-skills

Conversation

@lucasrodes

@lucasrodes lucasrodes commented Aug 4, 2026

Copy link
Copy Markdown
Member

Sets up evaluation for the four skills. The structure is the thing to review; I kept it uncommitted to any particular runner or grading service, so a layer can be dropped or a tool swapped without unpicking the rest.

Approach

Three layers, cheapest and most deterministic first. They answer different questions, and only the third needs a model grading output.

Layer Question Model? Cost Where
1. Contract tests Do the endpoints and response shapes documented in SKILL.md still match reality? no seconds evals/skills/<skill>/contract.sh
2. Trigger evals Does the skill fire when it should, and not steal a sibling's job? yes (routing only) minutes evals/skills/<skill>/triggers.json
3. Output evals Is the output good, and better than no skill at all? yes + a human tens of minutes evals/skills/<skill>/evals.json

Layer 1 comes first because it targets how these skills will actually break: they are thin documentation over live public HTTP, so they rot when the API changes, not when the prose gets worse. Layer 2 is next because all four skills describe overlapping subject matter — misrouting between them is a likelier defect than a bad answer.

The layer 3 file format matches the evals.json convention anthropics/skills' skill-creator expects, so its tooling can drive our cases later without a migration. Placement deliberately differs from getsentry/skills and openai/skills, which put evals inside the skill directory — see the distribution note below.

Layout

Makefile                            # entry points — what you actually run
evals/
├── README.md                       # the playbook: how to run each layer, how to iterate
├── lib/assert.sh                   # fetch / jq_true / has_keys / csv_header / skill_md_contains …
├── run-contract-tests.sh           # layer 1
├── run-trigger-eval.py             # layer 2 (uv script, stdlib only)
├── results/                        # gitignored
└── skills/<skill>/                 # mirrors skills/, one directory per skill
    ├── contract.sh
    ├── triggers.json               # 10 queries, 5 positive + 5 near-miss
    ├── evals.json                  # 3 cases: happy path / specific guidance / edge
    └── fixtures/                   # joining-data only, for now

skills/<skill>/SKILL.md             # the skill, and nothing else

Everything directly under evals/ is harness; everything under evals/skills/ is per-skill input, named exactly as the skill it tests. The runners glob evals/skills/*/, so they cannot mistake lib/ or results/ for a skill.

Two rules, both now enforced by make validate rather than trusted:

  • Commit inputs, never outputs. Cases and fixtures are source; everything a run produces goes to the gitignored evals/results/.
  • No SKILL.md references any eval file. Otherwise every user who triggers a skill pays context for test prose.

Evals are outside skills/ on purpose

Worth reviewing this bit specifically, because it reverses my first instinct.

A skill directory is the unit of distribution and is copied recursively into users' projects. The skills CLI the README recommends for Codex/Gemini/Cursor excludes only metadata.json, .git, __pycache__ and __pypackages__ — no ignore file, no opt-out. So anything under skills/<skill>/ ships to everyone who runs npx skills add owid/skills.

Skill activation was never the risk: progressive disclosure loads only name+description at startup, and nothing references the eval files. The risk is an agent's own filesystem exploration of a project that has these skills installed. Fixture CSVs are realistic by design, so once they land in .agents/skills/ they are indistinguishable from the user's own data. Simulated both layouts:

evals inside skills/          evals outside skills/
find . -name '*.csv'   3 hits, 2 ours     ->   1 hit, the user's own
grep -ril "per capita" 7 hits, 5 evals    ->   2 hits, both real SKILL.md prose
installed footprint    108K               ->   32K

Keeping evals in a sibling directory removes the possibility rather than mitigating it — the installer never sees them. Directory names still mirror the skills, so the locality cost is one hop.

Running it

make                        # list targets
make validate               # spec conformance, manifest, registration, no-eval-references
make lint                   # shellcheck for the shell, ruff for the Python
make test                   # contract tests (layer 1) — this is what CI runs
make test SKILL=<name>      # one skill
make triggers SKILL=<name>  # trigger evals (layer 2) — costs tokens

Five targets. Layer 3 gets none: it has no runner yet, and a catch-all "run all evals" would quietly spend tokens on layer 2.

make validate runs four checks, none of which subsumes another:

Check Tool Why separately
Spec conformance, per skill skills-ref (the validator the spec recommends) Agent-agnostic — covers Codex/Gemini/Cursor users
Marketplace manifest claude plugin validate Claude-specific, outside the spec
Marketplace registration ours Neither validator knows marketplace.json exists
No SKILL.md references evals ours The only path by which evals could reach an agent's context

Verified against four breakage classes: frontmatter name mismatch, a deleted required field, an unregistered skill, and a SKILL.md that references an eval file. Note skills-ref's published executable is agentskills, not skills-ref as the spec page shows; it is pinned >=0.1.1,<0.2 since it is pre-1.0.

CI has two jobs. validate is fast and should always be green; contract-tests runs on PRs and nightly at 06:00 UTC. That separates "someone broke the repo" from "the API moved".

Layer 2 loads all four skills together via --plugin-dir and records which skill fired, giving four outcomes: correct, miss, misroute, false_positive. triggers.json keeps the {query, should_trigger} shape skill-creator uses, plus an optional expected_skill for "a sibling should win this one":

{
  "query": "grab the csv behind https://ourworldindata.org/grapher/life-expectancy for the USA",
  "should_trigger": false,
  "expected_skill": "fetch-chart-data",
  "note": "url already known — discovery is not needed"
}

What the contract tests found

104 checks pass, 7 fail. All seven are real documentation drift, not broken tests. They are left failing on purpose so the first run reports the drift instead of freezing it in. #18 fixes all seven and takes the suite to 120/0.

Skill Finding
search-charts objectID documented as required on every hit — returned on 0 of 200 sampled.
search-charts explorerType documented as required on SearchExplorerViewHit — never returned.
search-charts chartConfigId documented as required on SearchMultiDimViewHit — never returned.
search-charts The tab→URL table omits StackedDiscreteBar and Dumbbell, both of which the API returns. An agent can't build a ?tab= URL for either.
fetch-chart-data descriptionKey typed string[] but is a single markdown bulleted string.
fetch-chart-data Header documented as Entity,Code,Year, but the recommended useColumnShortNames=true returns lowercase entity,code,year. The recommendation contradicts the documented structure.
owid-catalog search(..., sort_by="relevance") raises TypeError — no such parameter in owid-catalog 1.1.0. The documented example is broken code.

Two more are encoded as passing checks with a note, because they're traps rather than contradictions:

  • A nonsense query returns five irrelevant hits, not zero. search-charts says "If you don't get any results, try slightly different terms" — but nbHits == 0 essentially never happens, so that guidance never fires and an agent can present unrelated charts as matches.
  • csvType=filtered also applies the chart's own default entity selection. population.csv?csvType=filtered&time=2020 returns 7 rows — continents and World, no countries. joining-data reasonably assumes country rows are present.

And one in joining-data's prose a contract test can't assert: it tells agents to read $.columns.[0].timespan, but columns is an object keyed by column name, so that path is not valid jq. For population-with-un-projections it matters — the first column is estimates (1950–2023) and the projection to 2100 is a second column, so reading one column's timespan gives the wrong answer for exactly the recent/future-year case the skill recommends that chart for.

A harness bug worth knowing about

The failure list initially drifted between runs with no code changing, which turned out to be a bug in the tests rather than flakiness in the API.

q=energy+mix returned mostly explorerView hits one hour and only multiDimView hits the next. The explorerType assertion was [... | select(.type == "explorerView") | has("explorerType")] | all, and jq's all is true for an empty array — so with no explorerView hits in the sample it passed vacuously, silently retiring one of the findings this PR exists to demonstrate.

Same class of bug twice more: the tab-table check sits behind a [[ -s "$FILE" ]] guard that falls through with no output at all (renaming a variable made it stop running entirely, and nothing reported that); and contract_check.py gated four indicator checks on the semantic search succeeding, so a transient 502 from search.owid.io dropped all four from the summary.

Fixed by sampling a query that returns all three record types, asserting each type's shape only when that type is present, and skip-ing loudly with a reason otherwise. Fixing it surfaced two findings the fragile version was hiding: the chartConfigId drift, and Dumbbell missing from the tab table alongside StackedDiscreteBar.

The invariant is written down in evals/README.md: a check that did not run must never look like a check that passed. It applies to layer 2 as well — assert shapes, not populations.

Verification

  • Layer 1: 104 passed / 7 failed against the live API, reproduced identically in CI.
  • make lint is clean: shellcheck (with --external-sources) and ruff, both via uvx.
  • A contract.sh still works run directly, without the runner's environment.
  • Layer 2: detection validated against a real claude -p transcript — 2 hits in 80 lines, first was search-charts (the expected skill), no spurious matches from the available-skills listing. Fixed two bugs found doing this: claude -p blocking on stdin, and a fallback regex loose enough to match the skills listing itself.

Not done

  • Layer 3 has no runner. Cases are definitions only. The with-skill/without-skill baseline and the grading/aggregation step are unimplemented.
  • Trigger sets are 10 queries each, not the 20 Anthropic suggests. One query in evals/skills/joining-data/triggers.json is flagged in its note as genuinely ambiguous and wants a human call.
  • No SKILL.md content was changed here — that's Fix the seven documentation drifts the contract tests found #18.
  • Layer 2 costs real tokens (10 queries × 3 runs × 4 skills = 120 claude -p invocations), so it is deliberately not wired into CI.

Open questions

  1. Are three layers too many for four skills, or is layer 1 alone enough for now?
  2. Should a nightly contract-test failure notify anywhere, or is a red badge enough? A red run usually means OWID shipped an API change, not that someone broke this repo.
  3. Bash is the weakest part of the design. A pytest + jsonschema rewrite would let the TypeScript types in each SKILL.md be checked structurally instead of via hand-transcribed jq assertions, and would express "expected to fail, tell me if it stops failing" as xfail(strict=True) — which is exactly what the seven intentional failures want, and would let CI be green. Worth doing now, or after the suite outgrows four skills?

🤖 Generated with Claude Code

… cases

Sets up evaluation for the four skills in three layers, cheapest and most
deterministic first. Test inputs live in skills/<skill>/evals/ next to the skill
they test; the shared harness and playbook live in evals/.

- Layer 1 (contract tests, no model): checks that the OWID endpoints and
  response shapes each SKILL.md documents still match what the API returns.
  Runs on every PR and nightly. This is the failure mode most likely to bite —
  these skills are thin documentation over live public endpoints, so they rot
  when the API changes, not when the prose gets worse.
- Layer 2 (trigger evals): loads all four skills at once and records which one
  fired, so a sibling stealing another's traffic shows up as a misroute rather
  than a pass.
- Layer 3 (output evals): case definitions only, no runner yet.

Commit inputs, never outputs: test cases and fixtures are source, everything a
run produces goes to the gitignored evals/results/. No SKILL.md references any
eval file, so none of this reaches a user's context budget.

Layer 1 currently reports 103 passing checks and 6 failures. All six are real
documentation drift, listed in the PR description. The checks are deliberately
left failing rather than rewritten to assert current behaviour, so the first run
reports the drift instead of freezing it in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lucasrodes and others added 2 commits August 18, 2026 13:24
Review feedback on #17: the eval layers were only reachable by remembering two
script paths and their flags. A Makefile at the root makes the entry points
discoverable without moving where anything lives — per-skill definitions stay in
skills/<skill>/evals/, the shared harness stays in evals/.

Four targets, deliberately few:

    make            list the targets
    make validate   plugin manifest, skill frontmatter and registration
    make test       contract tests (layer 1)
    make triggers   trigger evals (layer 2)

SKILL=<name> narrows test and triggers to one skill. Layer 3 gets no target on
purpose: it has no runner yet, and a catch-all "run all evals" would quietly
spend tokens on layer 2.

make validate also covers the two manual checks AGENTS.md used to ask for:
`claude plugin validate` passes even when a skill's frontmatter name does not
match its directory and when the skill is missing from marketplace.json, so both
are now asserted directly. It degrades to a warning when the claude CLI is
absent, since the skills support other agents too.

CI now calls `make test` rather than the script directly, so the documented
command is the tested one. Recipes avoid .RECIPEPREFIX, which needs GNU make
3.82+ — macOS ships 3.81.

Docs: the root README gains a short Development block pointing at AGENTS.md and
evals/README.md; both of those now reference the make targets instead of raw
script paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The search-charts failure list drifted between runs without any code changing,
which exposed a harness bug rather than a documentation one.

`q=energy+mix` returned mostly explorerView hits one hour and only multiDimView
hits the next. The explorerType assertion was written as

    [.results[] | select(.type == "explorerView") | has("explorerType")] | all

and jq's `all` is true of an empty array, so with no explorerView hits in the
sample it passed vacuously — silently retiring one of the findings this PR exists
to demonstrate. Only the "at least one explorerView hit" guard failed, which read
as a flaky test rather than as a real drift going undetected.

Same class of bug in two more places:

- The tab-table check is wrapped in `[[ -s "$FILE" ]]`, which falls through with
  no output at all if an earlier request failed. Renaming a variable made it stop
  running entirely and nothing reported it.
- contract_check.py gates four indicator checks on the semantic search
  succeeding. A transient 502 from search.owid.io dropped all four from the
  summary rather than reporting them.

So: sample a query that returns all three record types, assert each type's
documented shape only when that type is present, and `skip` with a reason
otherwise. `skip` now propagates from contract_check.py too, and the indicator
search retries, since that service 502s often enough to make the nightly run
flaky instead of informative.

This surfaces two findings the fragile version was hiding: multiDimView omits the
documented chartConfigId, and the tab table is missing Dumbbell as well as
StackedDiscreteBar.

Contract tests: 103 passed, 7 failed — all seven genuine drift, and now
reproducible rather than dependent on what the search index served that hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lucasrodes

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c50563ddb5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread evals/run-trigger-eval.py Outdated
try:
results[i].append(done.result())
except Exception as exc: # a crashed run is a failed run, not a crashed suite
results[i].append({"fired": [], "transcript": f"RUNNER ERROR: {exc}"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat failed Claude runs as evaluation failures

When a claude invocation exits nonzero, times out, or raises before emitting a Skill tool call, this handler records the run as fired: [] without preserving an error state. For a should_trigger: false case without expected_skill, classify() then marks that run correct, so authentication, configuration, or CLI failures can silently inflate negative-case accuracy instead of invalidating the evaluation.

Useful? React with 👍 / 👎.

Comment thread evals/run-trigger-eval.py Outdated
Comment on lines +288 to +289
"misroutes": sum(r["outcome"] == "misroute" for r in positives),
"misses": sum(r["outcome"] == "miss" for r in positives),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count failures from sibling-routing cases in the summary

Queries with should_trigger: false and expected_skill can be classified as miss or misroute, but these counters only examine positives, excluding every such sibling-routing case. As a result, the report can show reduced accuracy and rows labeled as failures while still claiming zero misses and zero misroutes, obscuring the main failure mode this runner is intended to diagnose.

Useful? React with 👍 / 👎.

Comment on lines +105 to +106
if os.environ.get("SKIP_SLOW"):
emit("NOTE", "skipped semantic indicator search (SKIP_SLOW=1)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record skipped indicator checks as skips

With the documented SKIP_SLOW=1 make test invocation, this branch emits only a note, so the semantic-search check and its four dependent checks disappear from the totals and the suite can report zero skipped checks. This makes a deliberately reduced contract-test run look like a complete pass; emit explicit SKIP records for the omitted checks as the failure branch below already does.

Useful? React with 👍 / 👎.

lucasrodes and others added 6 commits August 18, 2026 15:12
Replaces a hand-rolled frontmatter check with `skills-ref`, the reference
validator the Agent Skills spec recommends, and runs it in CI.

Three checks now, none of which subsumes another:

- spec conformance, per skill, via `skills-ref`. Agent-agnostic, which matters
  because these skills are also read by Codex, Gemini CLI and Cursor, whereas
  `claude plugin validate` only inspects the marketplace manifest.
- the marketplace manifest, via `claude plugin validate`.
- marketplace registration, which neither of the above knows about. An
  unregistered skill is installable by neither route.

The hand-rolled check only compared the frontmatter `name` to the directory name.
skills-ref covers that plus the rest of the spec: it catches a missing
`description`, invalid characters, and the length and hyphen rules, none of which
were checked before.

Two gotchas worth recording. The published package's executable is `agentskills`,
not `skills-ref` as the spec page shows, so `uvx --from skills-ref agentskills`
is the working invocation. And it is pinned to 0.1.x: skills-ref is pre-1.0,
where a minor bump may change behaviour, but floating within 0.1.x still picks up
spec fixes, which is the point of using it.

CI gains a `validate` job — fast, and green regardless of what the OWID API is
doing, so it separates "someone broke the repo" from "the API moved". The
workflow is renamed Checks accordingly, since it no longer runs only the contract
tests. The claude CLI is absent on the runner, so that one check skips there with
a message rather than failing.

Verified against three breakage classes: frontmatter name mismatch, a removed
required field, and an unregistered skill. All three fail the target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A skill directory is the unit of distribution, and the cross-agent installer the
README recommends copies it recursively with no ignore mechanism: vercel-labs/skills
excludes only metadata.json, .git, __pycache__ and __pypackages__. So everything
under skills/<skill>/ landed in users' projects via

    npx skills add owid/skills            # into the current project

That was 15 eval files, including fixture CSVs that are realistic by design. Once
they sit in someone's .agents/skills/, a project-wide `find . -name '*.csv'` or a
grep returns them next to that person's real data. Simulated before and after:

    before: find . -name '*.csv'   -> 3 hits, 2 of them our fixtures
    after :                        -> 1 hit, the user's own file
    before: grep -ril "per capita" -> 7 hits, 5 of them eval files
    after :                        -> 2 hits, both genuine SKILL.md prose
    installed footprint            -> 108K to 32K

Skill activation was never the risk — progressive disclosure means only name and
description load at startup, and nothing referenced the eval files. The risk was
an agent's own filesystem exploration of a project that happened to have these
skills installed.

So skills/<skill>/ is now exactly SKILL.md, and test inputs live in a sibling
evals/<skill>/. This removes the possibility rather than mitigating it: the
installer never sees them. Directory names still mirror the skills, so the
locality cost is one hop.

Also makes the no-reference rule a check instead of a convention. A SKILL.md that
mentions an eval file is the only remaining way this content could reach an
agent's context, so `make validate` now fails on it — verified by adding such a
reference and watching it fail.

Contract tests unchanged at 103 passed, 7 failed. Verified that a contract.sh
still works when run directly, without the runner's environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
evals/ mixed harness files with per-skill directories, so there was no way to tell
at a glance which entries corresponded to a skill. Per-skill inputs now live in
evals/skills/<skill-name>/, mirroring the top-level skills/ directory:

    evals/README.md, lib/, run-*.sh, results/   the harness
    evals/skills/<skill-name>/                  that skill's inputs

This also makes the runners' globbing structurally correct rather than
incidentally correct. `evals/*/contract.sh` only skipped lib/ and results/
because neither happens to contain a contract.sh; `evals/skills/*/contract.sh`
cannot match the harness at all.

Contract tests unchanged at 103 passed, 7 failed. Verified that a contract.sh
still resolves its own paths when run directly, without the runner's environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract tests were hard to read, and the cause was not code quality —
shellcheck is clean — but a vocabulary too small for what they assert. assert.sh
offered eight assertions, only one CSV-aware and none collection-aware, so every
check the vocabulary could not express fell back to raw shell or raw jq:

    11 x  bash -c with nested quote escaping
    13 x  hand-rolled [ ... ] | all  /  any | not
    12 x  inline jq filters over 60 characters

All three are now zero, zero, and four. What replaced them:

- all_match / none_match take the selector and the predicate as separate
  arguments rather than one fused filter. Quoting stays sane, and the helper can
  insist the selector matched something before judging the predicate. That makes
  the vacuous-pass bug fixed earlier impossible by construction rather than by
  discipline: `[] | all` is true in jq, so a fused filter silently passes when
  the sample holds none of the thing being described. They skip loudly instead.

- CSV columns are addressed by name, resolved case-insensitively, with a
  quote-aware splitter. Compare:

      before: ok "country filter is respected" \
                  bash -c "[ \"\$(tail -n +2 '$CSV' | cut -d, -f2 | sort -u | tr '\n' ' ')\" = 'GBR USA ' ]"
      after:  csv_column_set "the country filter is respected" "$CSV" code "GBR USA"

  Naming rather than indexing also fixes two latent bugs. Case-insensitive
  matching means one line works for both documented header forms, where
  useColumnShortNames=true lowercases the first three. And the quote-aware
  splitter gets `"Bonaire, Sint Eustatius and Saba"` right, where cut -d, silently
  returns the wrong field.

- The 150-character capture() filters in joining-data are now one named, commented
  jq filter; jq allows comments, so it explains itself.

- The 15-line sed/comm tab-table block became skill_md_table_covers, since
  "does a markdown table in SKILL.md cover every value the API returns" is
  generic doc-drift checking.

owid-catalog is left alone: it delegates to Python and already read cleanly.

Adds `make lint` — shellcheck for the shell, ruff for the Python, both via uvx so
nothing new to install. Both are clean. shellcheck needed --external-sources to
follow the dynamic source of assert.sh; ruff wanted Callable from collections.abc
and flagged one stale noqa.

The seven contract-test findings are unchanged. Passing checks go 103 -> 104: the
CSV rewrite added one precondition that the entity, code and year columns resolve
at all, so a header change fails once and clearly instead of cascading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ccesses

Three issues raised by the Codex reviewer on #17. All three were real; each is
reproducible and each is now covered.

1. A failed `claude` invocation was indistinguishable from "no skill fired".
   run_once returned fired: [] whether the model declined to route or the CLI
   never ran, so classify() scored every `should_trigger: false` case without an
   `expected_skill` as correct. Simulated with a `claude` stub that exits 1:

       before: accuracy 20%, two negative cases reported as passing
       after : every query reports "! error (claude exited 1: not authenticated)",
               a warning that the numbers are invalid, and exit status 1

   A run may now only vote if it observed a routing decision or exited cleanly.
   Failed runs are reported with their cause, the trigger rate is computed over
   usable runs only, and a query with no usable run is an `error` outcome.

2. The summary counted misses and misroutes over `should_trigger: true` rows
   only, so a sibling-routing case that missed or misrouted lowered accuracy
   while the breakdown still read "0 miss, 0 misroute" — hiding precisely the
   failure mode this runner exists to catch. The same stub run showed 8 failing
   rows summarised as 5 misses. Outcomes are now counted across all rows; recall
   stays scoped to the positives, where it is defined.

3. `SKIP_SLOW=1` emitted a bare note, so the indicator gate and its four
   dependent checks vanished from the totals and a deliberately reduced run
   reported zero skipped checks. This broke the invariant this repo documents —
   a check that did not run must never look like a check that passed — in the
   one place it was easiest to miss. The gate and its dependents are now named
   once and reported by all three paths, so the arithmetic balances:

       SKIP_SLOW=1  ->  20 passed,  0 failed,  5 skipped
       full run     ->  24 passed,  1 failed,  0 skipped   (25 checks either way)

Contract tests unchanged at 104 passed, 7 failed. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-tunable

Two problems surfaced by the first real `make triggers` run: it exited 1 on a
perfectly informative result, and it cost about 40% of a five-hour session window.

Exit status was `accuracy < 1.0`, which makes the command permanently red — 100%
routing accuracy is not a realistic bar, and a command that always fails is a
command people stop reading. Trigger accuracy is a measurement, not a gate, so
the runner now exits non-zero only when runs actually failed (the numbers are
untrustworthy) or when you opt into a floor with --min-accuracy. Contract tests
remain the gate; this is instrumentation.

Cost is queries x runs x skills full `claude -p` sessions — 120 for the default
invocation, each carrying a whole system prompt, and a query that fires nothing
lets the model answer it in full before the process exits. Knobs added, largest
lever first:

- --effort, defaulting to low. Routing is decided before the model does any work,
  so thinking tokens are pure waste here. Raise it for a fidelity run.
- RUNS= and MODEL= and EFFORT= pass through from make, so the cheap iteration loop
  is `make triggers SKILL=owid-catalog RUNS=1` rather than a hand-written command.
- --max-budget-usd, a hard per-run cap (honoured with --print, which -p gives us).
  A cut-off run reports as an error, not as a negative, so it cannot quietly
  become a passing result.

The README now states the cost arithmetic and the caveat that a cheaper MODEL
measures that model's routing, not the routing your users get — fine for iterating
on wording, not for a recorded result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lucasrodes
lucasrodes merged commit 9749864 into main Aug 18, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant