Primary user-facing guide for the rf-skill-eval harness: what it is,
how to set it up, how to run evaluations locally, and how CI uses the
same machinery on every PR.
- Overview
- Prerequisites
- One-time Setup
- Running Evaluations
- Understanding Reports
- Adding a New Task
- CI Reference
- Model Policy
- OpenRouter tier
- Cost Expectations
- Related Documents
The skill evaluation harness answers a single question: does this skill actually improve Claude Code's behavior on Robot Framework tasks? It does this by driving headless Claude Code sessions against a pinned task bank, capturing session telemetry, and grading the output with a deterministic rubric.
Key properties:
- Reproducible. Same inputs → same scorecard, bit-for-bit. See ADR-004.
- Paired arms. Every task can run with the plugin (
treatment) and without it (baseline), N replicates each, so reports show whether a skill actually helps (treatment − baseline deltas). - Tiered. PR runs are narrow, treatment-only and gated against a stored baseline (fast, cheap). Weekly runs execute everything in both arms. See ADR-005.
- Subscription-billed by default. CI uses a long-lived OAuth token
tied to the maintainer's Claude Pro/Max subscription, falling back
to a per-token
ANTHROPIC_API_KEYfor fork PRs and overflow runs. - Locally reproducible.
uv run rf-skill-eval runon a fresh clone reproduces what CI does. No "works in CI, broken locally" drift.
For the full architecture, see
ddd-design.md and the ADRs under
architecture/adr/.
Install these once per machine:
| Tool | Minimum version | Notes |
|---|---|---|
uv |
0.4 | Python toolchain manager |
| Node.js | 22 | Required by Claude Code CLI (>= 22) |
| Claude Code CLI | latest | npm i -g @anthropic-ai/claude-code |
| Git | 2.40 | Worktrees for fixture provisioning |
| Python | 3.12 | Installed automatically by uv sync |
robotframework>=7.0, robotframework-browser, and other Python
dependencies are installed into the eval/ project environment by
uv sync. No global pip installs are required.
Verify prerequisites:
uv --version
node --version
claude --version
git --versionThese steps run once per contributor on a clean clone.
claude setup-tokenThis triggers an OAuth flow in your browser. On success it prints a
token of the form sk-ant-oat01-... valid for roughly one year.
Copy it.
Alternative path: claude /install-github-app and follow the wizard's
"Create a long-lived token with your Claude subscription" option.
cp .env.example .envEdit .env and paste your OAuth token:
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-your-token-here
CLAUDE_MODEL_DEFAULT=claude-haiku-5-5
RF_SKILL_EVAL_LOG_LEVEL=INFO.env is gitignored. Do not commit it.
If you do not have a Claude subscription and want to pay per token,
comment out CLAUDE_CODE_OAUTH_TOKEN and uncomment
ANTHROPIC_API_KEY instead. See faq.md for
details.
To run on the cheap OpenRouter model instead, set OPENROUTER_API_KEY; see
OpenRouter tier.
uv syncThis creates .venv/ inside eval/, installs the harness plus all
runtime dependencies (pydantic, polars, typer, robotframework, scipy,
etc.), and pins everything to eval/uv.lock.
Browser-library fixtures need Chromium. Run:
uv run rfbrowser initThis fetches Chromium and its system dependencies. It can take a few minutes on a cold cache. See local-testing.md for troubleshooting.
uv run rf-skill-eval doctordoctor checks:
uvversion and lockfile integrity- Python version (3.12)
- Claude Code CLI presence and version
- OAuth token presence and expiry (warns if < 30 days)
robotframeworkimportabilityrfbrowserChromium install- Fixture submodule health
A green doctor means you are ready to run evaluations.
Every command below that starts Claude sessions accepts
--max-cost-usd. Use it. When the reported cost reaches the cap, no further session starts, the remaining runs are recordedincomplete(reasonbudget) and the command exits non-zero.
uv run rf-skill-eval validate-tasks eval/tasks # schema, model ids, skills, fixtures, gating
uv run rf-skill-eval coverage # every shipped skill has a narrow task + trigger set
uv run rf-skill-eval doctor --tasks-dir eval/tasks --strict # grader tools / libraries present
uv run pytest tests/eval -q # harness unit tests (no API calls)scripts/eval-smoke.shRuns eval/tasks/narrow/narrow-libdoc-search-01.yaml once with
claude-haiku-5-5 in the treatment arm, grades it and prints the
report.
scripts/eval-local.shLint + unit tests, then the narrow tier in the treatment arm with --runs 3
(and optionally the realistic tier), then a report.
Write run output outside the repository (e.g.
--output /tmp/rf-eval/...). A workspace under the checkout makes Claude Code treat this repo as the project: in CI the agent read and edited the repo's ownpyproject.toml, and trigger rates dropped. The runner logs a warning when this happens; CI writes to$RUNNER_TEMP/eval-runs. Theeval/runs/...paths below are shorthand.
uv run rf-skill-eval run \
--task eval/tasks/narrow/narrow-libdoc-search-01.yaml \
--arm treatment \
--max-cost-usd 1 \
--output eval/runs/manual-$(date +%s)--arm—treatment(the plugin as shipped: skills, hooks, subagents) orbaseline(no plugin parts; everything else identical).--profile controlstill works as a deprecated alias ofbaseline.--model— override the task's model (see Model Policy).
uv run rf-skill-eval run-batch \
--tasks-dir eval/tasks/narrow \
--arms treatment,baseline \
--runs 3 \
--max-cost-usd 5 \
--output eval/runs/batch-$(date +%Y%m%d)--arms— defaulttreatmentonly.--runs N— replicates per task × arm (default 3), each in a fresh workspace.--skills rf-browser,plugin/--tier narrow— select tasks (plugin= bundle canaries).- Runs are graded inline.
score-batchre-grades an existing directory. - A pre-dispatch estimate (tasks × arms × runs × historical mean cost from
eval/baselines/) is printed; the batch is refused when it exceeds 1.5× the cap. bench --task … --runs Nis an alias for a single-task batch that prints replicate statistics.
uv run rf-skill-eval report --runs-dir eval/runs/batch-20260927 --output report.md
uv run rf-skill-eval report --runs-dir eval/runs/pr --baseline eval/baselines/narrow.json --format json --output report.jsonPer task and arm: pass rate (runs whose gate result is pass / runs
attempted), outcome pass rate, incomplete runs, mean input/output tokens,
turns, duration and cost (stdev in JSON), a flaky flag (0 < pass rate < 1),
and the treatment − baseline delta computed from outcome checks only
("unavailable" when an arm has no runs). When the baseline arm was not run,
--baseline supplies its numbers from the stored file. Skipped/errored checks
are listed with reasons; process checks (e.g. "the skill was invoked") are
shown for the treatment arm only; adversarial tasks are marked non-gating.
uv run rf-skill-eval trigger --skills rf-browser,rf-selenium --split validation --max-cost-usd 2Runs each query of eval/triggers/<skill>.yaml (default 3 times) with the
plugin staged but hooks off, --max-turns 3, and only the tools
Skill,Read,Glob,Grep available (claude --tools; in bypass mode
--allowedTools alone only pre-approves). Each session starts in a fresh copy
of eval/fixtures/sut-trigger/, a neutral Robot Framework project that imports
no test library. Reports TP/FP/TN/FN, precision, recall and accuracy per skill
for train and validation (and holdout, when selected) separately, plus the
other skills loaded for failing queries. See
eval/triggers/README.md.
| Option | Meaning |
|---|---|
--split train,validation,holdout2 |
Comma list of train, validation, holdout, holdout<N>; a holdout name runs only that split's post-tuning queries, each reported in its own column. |
--concurrency 1|2 |
Queries run at once (default 1; max 2, the ADR-002 OAuth cap). |
--variant-root <dir> |
Stage <dir>/plugins/rf-agentskills instead of the shipped plugin. |
--listing-budget <chars> |
Set SLASH_COMMAND_TOOL_CHAR_BUDGET (skill-listing budget) for the sessions; default: not set, Claude Code's own budget. |
--output <dir> |
Also the resume key: re-running with the same directory skips finished queries. |
Resume. Every finished query is appended to <output>/outcomes.jsonl,
keyed by (variant id, model, listing budget, skill, query id, split). Re-run
the same command with the same --output after a crash or a budget stop: only
the missing or incomplete queries run, and trigger-results.json aggregates
all of them. Keep --output outside the repository tree (see the
contamination warning).
Listing budget and description visibility. Claude Code lists skill
descriptions only while they fit context window × 4 × 1% characters (about
8000 for Haiku 4.5; bundled skills first, then the others alphabetically;
the rest appear by name only). By default the harness leaves that budget
alone, which is what gates. To measure the 1M-context condition:
uv run rf-skill-eval trigger --split validation --listing-budget 40000 \
--variant-root /tmp/variants/compact --concurrency 2 --max-cost-usd 7 \
--output /tmp/evalruns/compact-val- The budget is recorded as
listing_budgetin the results (null= default) and is part of the resume key, so default and40000batches can share one--outputwithout mixing: the default batch writestrigger-results.jsonandtrigger-report.md, a budget batchtrigger-results-budget-<N>.jsonandtrigger-report-budget-<N>.md(baseline updateonly readstrigger-results.json). - If
SLASH_COMMAND_TOOL_CHAR_BUDGETis already exported and--listing-budgetis not given, the exported value is used and recorded (a note is printed); unset it to measure the default. - Each run's
skill_listingattachment insession.jsonlis parsed. Every outcome carriesvisible_descriptions(one list per run: the rf-* skills listed with their description), and the results carry avisibilitymap per skill:own_visible/own_listed(sessions of the skill's own queries) andall_visible/all_listed(every session). The report adds a "Description visibility" table, e.g.| rf-requests | 0/24 | 0/288 |means the model saw only the namerf-requests.
Run isolation. Task runs (profiles with write-capable tools) snapshot the
repository before and after the session. Files that appear outside the run's
workspace are listed in <run>/workspace_violations.json and the run gets an
isolation-violation: … error, which makes its gate result incomplete (not
a pass). Nothing is deleted: the file may belong to a developer or another
tool working in the repository at the same time. Trigger sessions have no
write-capable tool and skip the snapshot.
Description variants. Measure candidate descriptions without editing
skills/:
uv run python scripts/build-description-variant.py \
--candidates openspec/changes/tune-skill-descriptions/candidates/rf-requests.yaml \
--out /tmp/variants/rf-requests-it1 # prints the variant id
uv run rf-skill-eval trigger --skills rf-requests --split train \
--variant-root /tmp/variants/rf-requests-it1 --concurrency 2 --max-cost-usd 2 \
--output /tmp/evalruns/rf-requests-it1The variant id is a hash of every staged description (an empty candidates
file gives the shipped id); trigger-results.json records variant_id and
variant_root.
# after a weekly-equivalent run (both arms, N=3, all tiers, triggers):
uv run rf-skill-eval baseline update --from eval/runs/full --output-dir eval/baselines
# compare a PR run with the stored baseline:
uv run rf-skill-eval gate --runs-dir eval/runs/pr --baseline eval/baselines/narrow.json
uv run rf-skill-eval gate --trigger-results eval/runs/triggers/trigger-results.jsongate exits 0 (pass), 1 (fail: pass rate below the baseline — Fisher exact
p < 0.05 when the baseline has ≥ 6 runs, otherwise a drop > 1/N — mean input
tokens above --token-budget (default +30 %; the PR job uses +75 %), incomplete runs, trigger validation recall or precision
significantly below the baseline — Fisher exact p < 0.05 on run-level loads,
or more than one query for baselines without run counts — a shipped skill
listed by name only in a skill listing, cost over --max-cost-usd) or 3 (rebaseline-needed only: the task
definition, fixture, model or turn-limit factor changed, or no baseline entry exists — never a
pass). Without --baseline, gate and baseline update use the runs' model's
folder: <evals>/baselines/ for Claude, <evals>/baselines/openrouter/<model name>/
for an OpenRouter model. Baseline files are promoted through a reviewed PR
(eval/baselines/README.md).
Each grader check yields passed, failed, skipped (could not be
evaluated — with a reason) or error (grader defect). Skipped/errored checks
never count as passes. A run's gate result is pass, fail or
incomplete; CI treats incomplete as a failure. Gating checks are those
whose type equals the task's primary_metric, or that set gating: true.
The Mann-Whitney / Cliff's δ / SHIP-ITERATE-HOLD model in ADR-004 remains the long-term target; with N=3 it is underpowered, so reports show raw rates and deltas and the gate uses the rules above (see the ADR-004 amendments).
- Pick a tier:
narrow/(one skill),realistic/(multi-step) oradversarial/(tempts a failure mode; never gates). - Copy an existing task as a template and edit it (schema:
eval/tasks/README.md). uv run rf-skill-eval validate-tasks eval/tasks && uv run rf-skill-eval coverage.- Run it once:
uv run rf-skill-eval run --task <file> --max-cost-usd 1.
A new skill needs at least one narrow task and a trigger set, or
coverage fails and names it.
Workflow: .github/workflows/skill-evaluation.yml (tiers and caps: ADR-005
amendment).
| Trigger | Jobs | Scope | Arms | N | Model | Cap |
|---|---|---|---|---|---|---|
pull_request |
preflight → pr-eval |
narrow tasks of changed skills + plugin canaries (all narrow tasks when the harness changed); validation-split trigger evals when a SKILL.md description changed |
treatment | 3 | deepseek/deepseek-v4.1-flash when OPENROUTER_API_KEY is set, else claude-haiku-5-5 |
OpenRouter: PR_NARROW_CAP_USD_OPENROUTER=2, PR_TRIGGER_CAP_USD_OPENROUTER=2; Claude: PR_NARROW_CAP_USD=10, PR_TRIGGER_CAP_USD=20 |
schedule (Sun 04:00 UTC) |
preflight → full-eval |
all tiers + all trigger sets | treatment, baseline | 3 | DeepSeek when its key is set, else tier defaults | OpenRouter: WEEKLY_CAP_USD_OPENROUTER=3 per invocation; Claude: 12 + 12 + 8 + 8 = $40 |
workflow_dispatch |
preflight → full-eval |
inputs provider, tiers, arms, runs, model, allow_opus, max_cost_usd, triggers |
input | input | provider: claude + model, or DeepSeek |
max_cost_usd per invocation |
| all | harness-tests |
ruff, mypy, pytest tests/eval (no API calls) |
– | – | – | – |
- Preflight picks the provider once: OpenRouter when
OPENROUTER_API_KEYis set, otherwise Claude; a manual dispatch withprovideroverrides it. Each eval job gets only that provider's credentials. - The PR job gates against the provider's baselines
(
plugins/<plugin>/evals/baselines/narrow.jsonfor Claude,plugins/<plugin>/evals/baselines/openrouter/deepseek-v4.1-flash/narrow.jsonfor OpenRouter) and posts one PR comment (report + gate result + trigger report, and the OpenRouter spend). - The weekly job uploads the report and a
candidate-baseline-<run>artifact; a maintainer promotes it with a PR. The PR gate becomes a required check only after two weekly baselines have been recorded. - Without credentials (fork PRs) the eval jobs are skipped and a
not-runjob writes "not run: no credentials" to the summary — never a passing result.
# Weekly-equivalent run now:
gh workflow run skill-evaluation.yml
# Only the narrow tier, treatment arm, 5 replicates:
gh workflow run skill-evaluation.yml -f tiers=narrow -f arms=treatment -f runs=5 -f max_cost_usd=10
# Opus check (manual only; needs the opt-in and a cap):
gh workflow run skill-evaluation.yml -f tiers=narrow -f model=claude-opus-5-5 -f allow_opus=true -f max_cost_usd=20
# Force API-key auth:
gh workflow run skill-evaluation.yml -f use_api_key=true
# A Claude run while the OpenRouter key is set (before a release):
gh workflow run skill-evaluation.yml -f provider=claude -f tiers=narrow,realistic -f model=claude-sonnet-5-5
# Record the OpenRouter baselines (N=6):
gh workflow run skill-evaluation.yml -f provider=openrouter -f runs=6
# Re-record one plugin only:
gh workflow run skill-evaluation.yml -f provider=openrouter -f runs=6 -f plugins=rf-desktop| Model | Default use | Why |
|---|---|---|
claude-haiku-5-5 |
narrow tier, trigger evals, PRs, smoke, local | Cheap, fast, low variance |
claude-sonnet-5-5 |
realistic and adversarial tiers | Higher fidelity for multi-step tasks |
claude-opus-5-5 |
manual runs only, explicit opt-in | Checks a skill does not hurt the strongest model |
- Task YAML and trigger sets may declare Haiku or Sonnet only.
- Opus requires
--model claude-opus-5-5 --allow-opus --max-cost-usd <cap>; without either flag the harness refuses to start and names the missing one. It never runs on PRs or the weekly schedule (≈5× Sonnet per token). - Retired ids (
claude-haiku-4-5,claude-sonnet-4-6) are rejected with a migration hint. - OpenRouter models (
deepseek/deepseek-v4.1-flash) are run time only; a task YAML that declares one fails to load. See OpenRouter tier.
The harness can run every session on DeepSeek V4.1 Flash through OpenRouter,
so the evals run from an OpenRouter key instead of a Claude subscription. CI
uses it by default when the OPENROUTER_API_KEY secret is set. A spike run of
the rf-core narrow tier (both arms, N=3, 72 sessions) cost $0.84.
export OPENROUTER_API_KEY=sk-or-v1-... # or put it in .env
uv run rf-skill-eval doctor --provider openrouter
uv run rf-skill-eval run-batch --tier narrow --arms treatment --runs 3 \
--model deepseek/deepseek-v4.1-flash --max-turns-factor 1.5 --max-cost-usd 2 \
--output eval/runs/or
uv run rf-skill-eval gate --runs-dir eval/runs/or # uses baselines/openrouter/deepseek-v4.1-flash/- Provider. An OpenRouter model id selects the OpenRouter provider;
--provider openrouter|anthropiconly states it, and a mismatch is refused. The runner points Claude Code athttps://openrouter.ai/api, maps every Claude model alias and the subagent model to the chosen model, and doesn't passCLAUDE_CODE_OAUTH_TOKENorANTHROPIC_API_KEYto the session. A run whose usage names any other model is recorded as incomplete (provider_model). - Allowed models. Only
deepseek/deepseek-v4.1-flash. Adding one is a code change: an entry inOPENROUTER_MODELS, a price insrc/rf_skill_eval/domain/pricing.py, and its own baselines. - Cost. Claude Code prices every session as Claude (the spike reported
$29.66). For OpenRouter runs the harness prices the token counts at
OpenRouter's list prices instead; reports label that figure as an estimate.
List prices are an upper bound (the spike priced at $1.33 against $0.84
charged).
--max-cost-usduses the estimate. CI also records the key's real spend before and after each job; the key's credit limit is the hard cap. - Turn limits. DeepSeek takes more turns than Haiku: 25 of the 72 spike
sessions stopped at the limit.
--max-turns-factor 1.5scales each task'smax_turns(rounded up); the factor is stored with the runs and the baselines, and a different factor meansrebaseline-needed. Reports show turn-limit stops per task and arm.OPENROUTER_TURNS_FACTORSin the workflow can override the factor per plugin. None does now: rf-desktop at 2.0 used the extra turns and passed no more often. - What it tells you. Results say how the skills work with DeepSeek, not
with Claude. Run a Claude check before a release (see RELEASING.md).
Anthropic doesn't support non-Claude models in Claude Code, which prints
unrecognized_modelon each session; a Claude Code release could break this path, and the CI smoke step would catch it.
Very rough per-task estimates (Haiku unless noted). Real costs vary with prompt length and turn count.
| Scope | Tasks | Arms | Replicates | Est. cost (API key) | Subscription impact |
|---|---|---|---|---|---|
| Smoke | 1 | 1 | 1 | ~$0.005 | ~1 min of 5-hr window |
| Local narrow | ~12 | 1 | 3 | ~$0.25 | ~15 min of 5-hr window |
| PR narrow (changed-skills) | ~4–8 | 1 | 3 | ~$0.30–0.60 | ~10 min of 5-hr window |
| Weekly (all tiers) | ~25 | 2 | 3 | ~$6 (Sonnet: ~$30) | ~2 hours of 5-hr window |
On the OAuth/subscription path, cost is flat as long as you stay
inside the 5-hour rolling window. On the ANTHROPIC_API_KEY fallback,
every token is billed. See faq.md for when
to prefer which.
- local-testing.md — pre-push checklist and troubleshooting.
- faq.md — common questions, rate limits, rotation.
- architecture/ddd-design.md — bounded contexts and module layout.
- architecture/adr/ADR-004-scoring-model.md — scoring rubric and gate.
- architecture/adr/ADR-005-ci-integration.md — workflow, auth, secrets.
- architecture/adr/ADR-006-result-persistence.md — how historical results are stored.
eval/tasks/README.md— task schema reference and tier definitions.