Skill tests

Three layers test a skill: smoke, eval and A/B, each answering one question.

The skill (producer) declares its own smoke and eval; the runners (consumers) read the declarations and name no skill. Chain position: Skill map.

The three layers

AspectSmokeEvalA/B
QuestionRuns end to end under its own constraints?How good is one configuration's output on a real task?What did one change do?
Declared insmoke block in <leaf>.cueeval/definition.json in the skill dirvariant descriptor at invocation
Entry pointmake skill-smoke SKILL=<leaf> TOKEN=<label>/skillroutine-eval <skill> token: <label>/skillroutine-ab <skill> variant: <descriptor>
Resultverdict and coverage in the artifacts cacheevals/<skill>/<date>-<hash6>/evals/<skill>/ab-<date>-<hash6>/

/<skill> --help prints under Tests the smoke command with its required token label and whether an eval is declared. It reads the deployed help-test.md, rendered only with the session-helpers component (the skillhelp hook's).

Smoke: make skill-smoke SKILL=<leaf> TOKEN=<label>

Run record

runs/<label>/result.json is every layer's run record; A/B selects records by its fields.

FieldHolds
session, model, cost, end state, check exitEvery run.
layersmoke or eval
skill, skillVersionThe skill and the deployed version the run loaded.
modenightly or interactive
smokeSmoke runs: verdict, cause, coverage.
scoresEval runs with a scorer.

The session's heartbeat rows sit beside it in runtime.json.

Note

The workflow layer has no behaviour test: make workflow-gen-check (an audit unit) proves block freshness and the shape rules of every committed stage file. Only smoke and eval runs exercise a script's logic.

Smoke layer

Checks the skill runs end to end under its own constraints, on a dummy input, in minutes. Never a quality judgement.

Declaration

The smoke block in <leaf>.cue: a skip reason or a run. Every skill declares it; make skills-check fails without. The runner loads the model and resolves the defaults in memory; no file is rendered.

FieldMeaningDefault
skipWhy the skill has no smoke run.
invokeText after /<leaf>. {token} becomes the run's TOKEN label, so no label is checked in.
modenightly or interactive (Modes).nightly
fixturetree and patch under the skill's smoke/ dir.
expectAnchors, or a check command.
envRepo-scoped values the run sets, by env key: every required repo-scoped variable of the skill, model-checked.
limitsBudget and timeout.3 USD, 15 minutes
modelRun model.sonnet
effortRun effort.low

smoke/ is never deployed.

Runner

The consumer, skilleval, reads only the declaration and names no skill. It runs on the eval's machinery: provision, headless arm, heartbeat capture, cleanup.

CommandRuns
make skill-smoke SKILL=<leaf> TOKEN=<label>skilleval smoke
make skill-smoke-verdict SKILL=<leaf> SESSION=<id>skilleval smoke-verdict

Deployed copy first

A smoke runs only the deployed skill, so the runner first checks it came from the checkout's source. Deploy writes source <sha256> into ~/.claude/skills/<leaf>/.smine-deployed: the hash of every skill-dir file but eval/ and smoke/, projection blocks stripped. The runner hashes the checkout's skill dir alike (skills.IsDeployedCurrent); a different or missing stamp refuses with exit 3.

Order

Change, deploy, smoke. The check hashes sources only, reading no install or repo values, so a smoke runs from any checkout.

No machine values

A nightly smoke never sees the machine's repo-scoped values. Before the session starts, the runner:

ValueIn the smoke
An optional variable the block leaves outUnset; its items drop as anywhere.
Install-scoped valuesThe deployed skill's, filled at deploy.
An interactive smokeRuns in the person's own session, reading its values.
context/context.jsonRegenerated in each arm worktree at provisioning, as in session worktrees.

Modes

ModeRuns
nightly (default)One headless claude -p "/<leaf> <invoke>" in a throwaway worktree at the checkout's HEAD: declared model and effort, limits.usd budget, limits.minutes timeout, billed to the stored token. Fixture patch as the overlay, fixture tree copied onto the root, never over an existing file. Cleanup removes the arm worktree and every isolation worktree of a workflow the session launched (.claude/worktrees/<run id>-<n>), with branches.
interactiveThe runner prints the prompt; a person runs it in a session; smoke-verdict judges that session from heartbeat's rows.
Stores

A nightly session never touches the live proposal and backlog stores; the plans archive is fenced (no read, no write, whatever grant the skill holds). The store root is store/ in the throwaway worktree (SMINE_STORE_ROOT), as make rules ARGS="store-root" and store-check answer there.

Verdict

Mechanical; a failure names its first cause.

VerdictWhenPasses when
Terminalexpect.check, or no expectationWall time under limits.minutes, cost under limits.usd; a session exists, exit status 0, terminal reason completed, result success, no error flag; the check, run in the worktree after the session, exits 0.
Heartbeatexpect.anchorsTerminal rules; the session's run row of the skill exists, not killed, no failed check, every expected anchor; coverage minimums. A missing row's cause tells heartbeat not answering from no enforced run of the skill opening.
Interactivesmoke-verdictNo stream, so no limits or session rules: the run row exists and has ended, then the heartbeat row rules and coverage minimums.

Coverage

Heartbeat smokes only; zero for a terminal-verdict smoke. Two ratios, each over its total in the deployed enforce.json: declared anchors hit, declared checks passed at least once. expect.minAnchors (hits over all declared anchors) and expect.minChecks are minimum ratios; unset means 0.

Record and cache

Each run writes one record per skill and deployed version to ~/.cache/claude-routine/artifacts/smoke/<leaf>/<version>/, never under ~/.claude/smine, never committed; the previous same-version record stays beside it as <version>.<n>. A run executes in smoke/.staging/ and moves into place after cleanup.

FileHolds
run.jsonThe manifest, with the token label.
runs/A/result.jsonThe run record.
runs/A/runtime.jsonHeartbeat's rows.
stream, trace, diffNightly runs only; an interactive verdict writes just the three files above.
smoke.coverage fieldCounts
anchorsDeclaredAnchors in the deployed enforce.json.
anchorsExpectedexpect.anchors ids the run was judged against.
anchorsExpectedHit, anchorsHitExpected anchors hit; declared anchors hit.
checksDeclared, checksPassedDeclared checks; checks passed at least once.

The printed and badged summary; the second form is for a record without an expectation:

anchors <expected hit>/<expected> (<declared>), checks <passed>/<declared>
anchors <hit> (<declared>), …

The config server shows the newest record on the skill card and skill head, dim and marked stale when its version is not the skill's. The Tests tab shows it as a card: expected anchors hit or missed, checks, cost, session, record dir, run line.

Fixtures

PathHolds
smoke/tree/Files the runner copies onto the worktree root.
smoke/*.patchOne patch of new-file changes, applied uncommitted. Go code goes only here: a .go file under smoke/tree/ would join the module's build. It never edits a tracked file, so it applies at any head.
smoke/tree/store/Seeded store of a skill working on the stores (proposals/<kind>.json, backlog/backlog.json); without it the store root is empty.

Output and exit codes

smoke <leaf> <version>: pass
smoke <leaf> <version>: fail — <cause>
coverage: anchors <expected hit>/<expected> (<declared>), checks <passed>/<declared>

The coverage line follows for a heartbeat smoke. A skip prints its reason.

ExitMeans
0Pass, skip, or interactive instructions.
1Fail or harness error.
2Usage.
3The deployed copy differs.

Eval layer

Measures how good one skill configuration's output is on a real task, with time, tokens and cost.

Declaration

eval/definition.json in the skill dir, schema skills/skillroutine/skillroutine-eval/reference/definition.schema.json (4.0, gate ACDSL-JSON-005).

PartDeclares
InvocationFree text, the skill's args as data, the args a run may set:.
EnvironmentRegistry repo, range or checkout commit, overlay.
LimitsBudgets and prerequisites.
Configurationmodel (default opus), effort (default high), replicas (default 1).
Check and referenceOptional check argv and reference commit.
ScoringThe scoring contract, below.

Running

/skillroutine-eval <skill> [replicas: <n>] token: <label> [golden: produce|test] [set: <name>=<value>]
  1. Init freezes the run

    go run ./cmd/skilleval init composes the prompt and freezes the run from the definition: prerequisites, the golden kind's file, token label, runs × armUsd against the window.

  2. The skill runs the replicas

    Provisions, launches the replicas, spawns the adherence judge (plus golden judges on a test run).

  3. Cleanup and report

    Cleans up, folds, reports. The skill interprets nothing in the definition.

Scoring

Exactly one scoring contract per skill.

ContractKeysResult
Goldenfindings, goldens, scoringThe golden path, unchanged: candidates, golden judge, review score and minutes.
Scorerscorer.commandAn argv run in each run's worktree after the check; stdout is one JSON object {"metrics": {"<name>": <number>}}, names [a-z][a-z0-9_]*. Values land in result.json scores and in eval.json as score_<name>. A failing scorer leaves scores absent, its cause in runs/<label>/scorer.log.

Every run also gets generic measures: mechanical verdict (smoke_pass), check_pass, wall and active time, output tokens, cost, permission denials, heartbeat check failures, adherence-judge step misses. Wall is the arm stream's own span summed per process, excluding a resumed run's session-limit pause; active time is peek's main-loop figure. The eval's notes give the definition's open args at the run prompt's values.

Output

Record dir evals/<skill>/<date>-<hash6>[-<n>]/: the hash is the checkout commit, -<n> a same-day repeat.

WrittenFiles
Alwaysrun.json and its run.json.sha256 digest, SKILL.md (the deployed skill the runs loaded), overlay.patch, provision.json, eval.json (2.12, validated), report.md, interpretation.md (bullets only, at most 8 of 160 runes, gated)
Golden test rungolden.json, scoring.json
Produce runseed.json
With a reference commitreference.patch
Per run, runs/<label>/result.json (the run record: layer: eval, skill, skillVersion, mode: nightly, scores), stream.jsonl, trace.md, diff.patch, check.log, scorer.log, runtime.json, adherence.json, artifacts/ (what the skill left in its artifact dir)
Per run, golden pathcandidates.json, judge.json

Cleanup salvages worktrees into the arm's <worktree>-out/ beside the worktree, never into the record, and removes it once every worktree is gone. The prebuild log lives there too.

Tests tab

The config server renders the records at /scripts/skills/{origin}/{name}/tests (?run=<dir> features a dir, ?focus=<run id> one run), top to bottom:

#BlockShows
1Smoke cardThe newest smoke record's version (stale when not the skill's), verdict and cause, a hit-or-missed chip per expected anchor, checks, cost, session, record dir, and make skill-smoke SKILL=<leaf> TOKEN=<label> with the run's token label.
2Config lineThe eval run's configuration.
3HeroReview score, lost minutes and the minutes bar: the headline run's escaped, false-positive and found minutes from golden.perRun[].severities, oracle and null as ticks.
4Breakdown tablePer severity: price, the golden's entries, the run's found, missed and false-positive counts and minutes, oracle, lost; the score spelled out from the totals.
5Interpretation, Result, heatmapInterpretation bullets, Result table, entry heatmap by topic.
6Artifacts tableWhat each file is.
7Golden cardsOne per definition golden; production contributions are keyed by their dir, leave the history, get their own selector group.
8History, definitionRun history, then the test definition.

Accounts

token: <label> names an OAuth token stored from the config server in ~/.config/claude-routine/tokens/<label>. Only the label travels; the arm process exports the value as CLAUDE_CODE_OAUTH_TOKEN. Init refuses an unknown or missing label, listing the stored ones. Reports name token <label>.

Limits

A/B layer

What one change did: the difference between two record sets of a lower layer.

/skillroutine-ab <skill> [layer: eval|smoke] variant: <descriptor> [replicas: <n>] token: <label>

Variant descriptor

Names exactly one factor against the definition's baseline. Two changes, none or an unknown factor is refused (ab-init exit 72).

DescriptorLayerHow it runs
model:<name>, effort:<level>evalSide by side: both sides in one run dir, equal replicas, shuffled blind labels.
no-<mcp server>evalSide by side. Void unless the baseline shows the server connected.
overlay:<repo-relative patch>evalSide by side; the variant applies the definition's overlay plus the patch.
version:<a>..<b>eval, smokeRuns nothing. Heartbeat and the gate read the one deployed copy, so versions compare sequentially: run at <a>, deploy, run at <b>, then A/B. The smoke layer's only descriptor, as its cache keeps one record per deployed version (exit 73 otherwise).

Side by side, the blind compare judge ranks the outputs against the definition's reference commit when one exists.

Record selection

ComparisonRecords
Side by sideThe A/B dir's own runs, by configuration key.
Version, eval layerEvery evals/<skill>/<date>-<hash>/ run with layer: eval, the skill, and skillVersion <a> or <b>, measured by that dir's eval.json. Unfolded dirs are noted and skipped.
Version, smoke layer~/.cache/claude-routine/artifacts/smoke/<skill>/<version>/runs/A/result.json, measured by its verdict, cost, tokens, denials, wall time and coverage ratios.

Output

Record dir evals/<skill>/ab-<date>-<hash6>/.

FileHolds
ab.jsonDescriptor, layer, both record sets with measures, and per-measure differences: means, standard deviations, n, Δ mean, Welch's t, df, significance at alpha 0.05.
report.md, interpretation.mdThe report and its interpretation.
run.json, eval.json, compare.json, stats.json, runs/Added by side-by-side runs.

Statistics come from go run ./cmd/evalstats -ab <ab.json>; fewer than two records per side gives df 0 and no significance.

Limits

An IDE comparison (no-goland) needs a definition whose invocation opts into the server: the descriptor varies the runner, never the invocation.