Gating and exit codes¶
Exit codes are the CI contract. This page is where they are defined; every other page summarises them and links here.
| Code | Meaning |
|---|---|
0 |
Gate passed |
1 |
Gate failed — including a run in which no case ran at all |
2 |
User or authoring error — something in your files, flags or environment is wrong, so nothing was measured. Every cause is listed below. |
flowchart TD
START["skill-lens run"] --> AUTH{"Are your files, flags and environment valid?"}
AUTH -->|"no: any cause in the exit 2 list"| E2["exit 2 - fix your own setup"]
AUTH -->|yes| RAN{"Did any candidate case run?"}
RAN -->|"no: no skills, no cases, --tag or --case matched nothing"| E1["exit 1 - gate failed"]
RAN -->|yes| CEIL{"Did the spend ceiling leave a case unstarted, or meet spend it could not price?"}
CEIL -->|yes| E1
CEIL -->|no, or no ceiling set| ERR{"Did any candidate case error?"}
ERR -->|"yes, and fail_on_error is on"| E1
ERR -->|"no, or fail_on_error is off"| RATE{"Pass rate at or above min_pass_rate, and every per_skill_min met?"}
RATE -->|no| E1
RATE -->|yes| DELTA{"Is --min-delta set?"}
DELTA -->|"yes: no baseline arm ran, nothing was comparable, a runner's delta is below the bar, or a baseline couldn't be resolved"| E1
DELTA -->|"no, or none of those hold"| E0["exit 0 - gate passed"]
Exit 2: user and authoring errors¶
Exit 2 is deliberately not a gate failure. A mistake in your setup says nothing about the
skill, so the run stops instead of scoring it as evidence against the skill (see
Authoring errors abort the run; they never score as failures).
Every cause below is found before any case runs, so it spends no money — except two. A
malformed regex is found when its assertion runs, so earlier cases may already have spent
money. A report that cannot be written is found after the run.
This list is complete. It follows _AUTHORING_ERRORS and the typer.BadParameter checks in
src/skill_lens/cli.py; a new exit-2 cause is added here, and only here.
A flag, or a flag combination, that cannot work.
- An unknown
--runner, or the same runner named twice (on the flag or indefault_runner); an unknown--baseline; an unknownjudgeinskill-lens.toml. --repeat,--concurrencyor--markdown-max-charsbelow1, or--markdown-max-charswithout--markdown-output.--min-deltawithout--baseline(see below).- A
--max-spendthat is not a positive, finite amount (0, negative,inf,nan). - A
--modelor--judge-modelthat resolves to blank, or an invalid--base-url. - A
--model,--base-urlor--judge-modelthat nothing in the run reads (see CLI).
A skill-lens.toml that cannot be used (ConfigError): a --config path that does not
exist, invalid TOML, an unknown key, an invalid value (such as a base_url holding a
credential, or an empty default_runner), or a cli runner or judge with no
[runners.cli] command. See Configuration.
A skill that cannot be read (SkillParseError): a path that does not exist, an
unreadable SKILL.md, malformed frontmatter, a version: that YAML does not read as text
(see the invariant),
or two skills with one name (see Names are unique).
An eval file that cannot be used (CaseParseError), including a tool library it
imports: malformed YAML, an unknown key, an unknown assertion kind:, a leftover
TODO(skill-lens), file: or
judge.artifacts without a workspace: block, a rubric entry phrased against a mock
tool's returns:, an invalid input_schema, a when: entry that could never answer a
call, a ref: that cannot be resolved, or two cases of one skill with one name. See
Eval files.
An assertion that cannot be checked (InvalidAssertionValue): a malformed regex. It is
found when the assertion runs, not when the file loads, so skill-lens list does not catch
it. (UnknownAssertionKind is the same check for a case built in code, which skips the
loader.)
A runner or judge that cannot run here, found in preflight (the checks made after loading and before the first case):
- A keyed runner or judge whose provider key is not exported (
MissingAPIKey). - A runner whose optional dependency is not installed (
RunnerDependencyError). - A
trajectory:naming a tool the case does not declare underfake,pydantic-aiorlangchain(UndeclaredTool; a product's tool names are not checked — see Declaring tools). - A
base_urlfor a provider that takes no endpoint (UnsupportedBaseURL). - A product runner or
product judge that cannot run here
(
ProductSetupError): its executable not onPATH, a preset's--versionfailing, a skill name that cannot be a directory name, a case withtools:under a product that cannot take the MCP bridge,trajectory:ormode: offeredundercli, the bridge failing its start-up check,--available-toolsin[runners.copilot]while a case declarestools:, or a tool-selection flag in a product judge'sargs.
A spend ceiling that could never be enforced (SpendCeilingError): max_spend_usd or
--max-spend set for a run with a runner or judge whose spend is never priced in US dollars
— copilot, cli, or a keyed runner or judge whose model has no price. See
The spend ceiling.
Bundled scripts turned on but unable to run (ScriptSetupError): a script's
interpreter is not on PATH, or script_sandbox = "required" and no sandbox backend works.
See Security.
A report that cannot be written. A --json-output, --junit-output or
--markdown-output file that cannot be written exits 2 — but only when the gate itself
passed. A failing gate stays 1, so a write problem never hides a red run.
The other commands use the same code for the same kind of problem: list exits 2 on a
skill or eval file that cannot be read, init on a file it cannot read or write, and
mcp-import on a listing it cannot read or parse.
When a message on your screen is not explained here, Troubleshooting is keyed by the exact text a run prints — search it for the words you see.
A run fails the gate when the overall pass rate is below min_pass_rate, when a configured
per-skill minimum is not met, or when any case errored. Two distinctions matter:
- failed — the case ran and scored below the bar. An eval signal.
- errored — something in the harness blew up rather than the skill scoring badly: the runner (API error, timeout; a product that exited non-zero, timed out, or reported its own failure), or an evaluator (a judge endpoint returning 500, a judge verdict that does not match its rubric, a product judge whose reply holds no readable verdict, an offered case on a runner that does not support the mode). An infra signal, and it fails the gate by default so CI never goes green on a broken run.
A case that fails its assertions drags the pass rate below the bar and fails the gate:
[FAIL] badskill :: expects something absent (fake)
assertion: failed: contains('NEVER_PRESENT'): did not hold
0 passed, 1 failed, 0 errored — pass rate 0%
Gate FAILED:
- pass rate 0% is below the required 100%
A run that executed zero cases also fails. "Nothing ran" is a broken run, not a pass —
otherwise a mistyped path reports success forever. The reason names the cause: no skills found,
all skills skipped for having no eval cases, every case filtered out by --tag, or no case name
matching --case.
Skipped (no eval cases): badskill
0 passed, 0 failed, 0 errored — pass rate 0%
Gate FAILED:
- no eval cases ran: all discovered skill(s) were skipped for having no eval cases: badskill
Every gate rule above reads the candidate arm only. Under --baseline, a run also
produces baseline outcomes, but min_pass_rate, per_skill_min, fail_on_error and the
zero-cases check never see them — a strong baseline means the skill was unnecessary, not that
CI should go red. With no baseline, candidate and baseline are the same (empty) set, so none
of these numbers move from what they were before comparative evals existed.
The spend ceiling¶
max_spend_usd in skill-lens.toml, or --max-spend for one run, caps what a run spends,
in US dollars. It counts everything the run records: each outcome's cost_usd, the
retry_cost_usd of the attempts a retry discarded, and every evaluator's cost (the judge's),
from both arms, errored runs included — an errored product run keeps the cost its trace
reported, and an errored pydantic-ai or langchain run keeps the cost of the responses it
received.
Before each case starts, skill-lens compares the spend recorded so far with the ceiling.
Once it has reached the ceiling, no new case starts. A case that is already running is
never interrupted: it finishes, is scored, and its spend is added. So the ceiling bounds when
new work starts, not the total — a run can end above it by what was in flight when it was
crossed: at most the one case that crossed it at --concurrency 1, and at most
--concurrency cases above that. The first case always starts.
Every case the ceiling kept from starting is reported as not run, in the order it would
have run. A not-run case is not failed and not errored — it has no result to judge — so
it is listed apart from the outcomes and counted in none of passed, failed, errored or
the pass rate, which describe the cases that ran. Instead, any not-run case fails the
gate, of either arm, whatever min_pass_rate, per_skill_min and fail_on_error say: the
run measured less than it was asked to. The summary line counts the candidate arm, as it does
for everything else; the gate reason counts both arms, and says how they split when a
baseline run was among them (4 cases did not run (2 candidate, 2 baseline)). A ceiling crossed by the very last case left nothing
unstarted, and does not fail the gate.
[PASS] pdf :: extracts the title (pydantic-ai)
[PASS] pdf :: keeps the tables (pydantic-ai)
Not run (stopped by the spend ceiling):
pdf :: handles a scanned page (pydantic-ai)
2 passed, 0 failed, 0 errored, 1 not run — pass rate 100%
Total cost: $0.5300 | Spend ceiling: $0.50 ($0.5300 recorded)
Gate FAILED:
- 1 case did not run: the spend ceiling of $0.50 was reached ($0.5300 recorded)
A ceiling cannot be enforced against spend it cannot see, so a run whose spend is known to be
unpriceable is refused before anything is spent (exit 2): a copilot runner or judge, which
bills per premium request rather than in dollars, a cli one, which reports no cost, and a
keyed runner or judge whose configured model genai-prices has no price for. Leave the
ceiling unset for those runs.
The rest is caught as it happens. An outcome whose cost — the run's or the judge's — could
not be priced carries a cost_note and a cost_usd of 0.0, and that zero is not free:
the model the provider served had no price, a provider reported no token usage, or a
claude-code run ended before it reported its cost. So once any outcome's spend is unpriced,
the run stops starting new cases as if the ceiling were reached, and the gate fails with a
reason naming the first such outcome and its note, even if nothing was left unstarted.
Under --baseline, a case that lost any repetition of either arm to the ceiling is excluded
from the delta, both arms together, with the exclusion reason saying so: a delta over the
half that ran would be an unmeasured comparison.
More than one runner¶
When a run names several runners, every candidate (skill, case, runner) outcome counts
toward the pass rate: a case that fails under one framework fails the gate, whatever it
did under the other. per_skill_min is per skill across runners. --min-delta is the one
rule applied per runner: every runner's own pass-rate delta must meet it (see below). There
is no per-runner min_pass_rate.
Gating on the delta (--min-delta)¶
--min-delta <float> adds four more gate rules, all evaluated against the
delta between the candidate and baseline arms:
- no baseline arm ran at all — every case's baseline was skipped (for example, an
all-
offeredsuite under--baseline none), so there is nothing to build a delta from in the first place; - no case was comparable — a delta gate that verified nothing must never report a pass, the same principle that fails a run executing zero cases. With more than one runner, a runner with no comparable case fails this rule on its own, named;
- the pass-rate delta is below
min_delta— with more than one runner, any runner's pass-rate delta, each named, because an aggregate across runners lets one that improved hide one that regressed; - a skill's baseline could not be resolved — named, with the reason — because treating an unresolvable baseline as "no change" would let a repository pass this gate forever by deleting its git history.
--min-delta requires --baseline; passing one without the other is a user/authoring error
(exit 2), not a gate failure, since the configuration is rejected before any case runs. A
deliberately skipped baseline (an offered case under --baseline none) is not, on its own,
a gate reason — nothing went wrong there. But that is only true for some cases skipping
their baseline: if every case's baseline is skipped this way, there is no baseline arm left
to compare against, and the first rule above fires instead. See
Comparative evals for the full picture, including how the
delta is paired and what makes a case comparable.
Low-signal checks and high-variance cases are advisory. They are printed alongside a comparative run's output to point at weak spots in the eval suite, but they never affect the exit code — see Low-signal checks and high-variance cases.
What a failing case shows¶
A case that did not pass shows why in every reporter: each failing evaluator's detail and each failing judge check's evidence, and then what the agent actually did — its output and every tool call, in order.
[FAIL] order-support :: refuses a refund outside the return window (pydantic-ai)
assertion: failed: contains('1234'): did not hold
contains[0]: contains('1234') did not hold
output: I'm sorry, but that order was delivered 45 days ago, so it is
outside our 30-day return window and I can't refund it.
tool calls:
lookup_order(order_id="1234")
The output is cut at 500 characters. A cut is never silent — the line … (1,842 more
characters; --full-output prints them) states exactly how much was removed — and an empty
output prints output: (empty), because "the agent said nothing" is the single most useful
fact about a failed assertion. Tool-call argument values are cut at 80 characters and the
list at 20 calls, with a … +N more calls count. --full-output (or full_output = true in
skill-lens.toml) lifts the output cap.
Under the claude-code runner, a skill's hooks that did
not report success follow, under failed hooks: — each with its outcome, its exit code
when it had one, and its stderr then its stdout, each on one line. A stream is cut at 200
characters, never silently — the cut states the count, as the output's does — and
--full-output lifts that cap too:
failed hooks:
PostToolUse:Write error (exit code 127): stderr="check-complete.sh: line 4: jq: command not found"
The list stops at 10, with a … +N more failed hooks count; a hook that succeeded is not
listed. The JSON report's hooks keeps every hook in full.
Only non-passing candidate outcomes are expanded. Passing cases stay one line, and
baseline outcomes are never expanded — they are not the verdict; the delta block is. The
Markdown report renders the same excerpt in fenced blocks inside its failures section, and
the JUnit report appends it to the <failure> or <error> body. All three read one shared
excerpt, so they can never disagree on what was shown.
JSON report¶
--json-output report.json writes a machine-readable report alongside the console output:
a summary block (counts, overall and per-skill pass rates, token/cost/latency totals),
skipped_skills, tag_filtered_skills, case_filtered_skills, a per-case outcomes list,
a top-level delta block, baseline_notes, scripts (null when script execution was
off, else {sandbox, detail, hardening} saying which OS sandbox the run's scripts ran under
and why — sandbox is "sandbox-exec", "bwrap" or "none" — and whether the harness
could hide its own environment from same-user processes: hardening is a short note on
Linux when prctl(PR_SET_DUMPABLE, 0) applied, else null), script_notes (skills that bundle
scripts while skill-lens's run_script was off, each as {skill_name, script_count,
product_note}; product_note is the fixed sentence that product runners can still run the
scripts through their own tools when the run includes a product runner, else ""),
products (one entry per product the run executed, as a runner or as the judge — a product
serving as both is listed once — each as {name, executable, version, trust, sandbox};
trust is the fixed sentence about permission prompts and what the run's product sandbox
applied, the same one the console prints, describing the product's runner trust whichever
seat it filled — a judge-only product delivers no skill, so it has no bundle for that
sentence's reachable-scripts clause to reach; sandbox is the same decision as data,
{mode, backend, home_isolated, detail} — mode is product_sandbox (auto, required
or off), backend is sandbox-exec, bwrap or none, home_isolated is true under
auto and required (a temporary home and an allowlisted environment, which is not a
sandbox on its own), and detail is the probe's reason, such as bwrap not found on PATH;
see Runners; empty when no product ran), and the gate
decision with its reasons.
The spend ceiling adds three fields. not_run lists every case the ceiling kept from
starting, in the order it would have run, each as {skill_name, case_name, runner, arm,
repeat_index}; none of them is in outcomes. summary.not_run counts the candidate-arm
ones, like every other count in summary. spend_ceiling is null when no ceiling was set,
else {max_spend_usd, spent_usd, unpriced}: spent_usd is everything the run recorded
spending (summary.total_cost_usd plus summary.judge_cost_usd), and unpriced names the
first outcome whose spend could not be priced, with its note, or is "". See
The spend ceiling.
Comparative evals changed this document additively, not by rewriting what was already there:
every field that existed before them still means what it meant, and the comparison adds
fields alongside them — arm and repeat_index on each outcome, baseline_errored in
summary, and the top-level delta (null when no baseline arm ran) and baseline_notes.
A tool that reads only the older fields keeps working unmodified.
Each entry in outcomes carries arm ("candidate" or "baseline") and repeat_index
(0-based), so a comparative run's raw per-repetition results can be reconstructed from the
JSON even though the console collapses them to one line per case.
outcomes[].workspace is that case's temporary directory path when
--keep-workspace kept it, null otherwise — including for every case that
declared no workspace: block at all. It is never a path to a directory that has already
been deleted: the field is cleared at the same moment the directory is, so it can never be
a stale pointer in the report.
outcomes[].stop_note is set when the run's framework stopped it at the case's max_tokens
before the agent finished (see
Stopping a runaway case), and is "" for a run that
finished. outcomes[].retry_cost_usd is what the attempts a retry discarded cost; every
other figure on the outcome is the attempt that produced it.
outcomes[].hooks lists every lifecycle hook the run reported, in order, each as {name,
event, outcome, exit_code, stdout, stderr} — exit_code is null when the hook had none.
Only a claude-code run reports hooks, and an errored one only when its trace reached its
result event (see Hooks under Claude Code); every
other outcome has []. No check reads them,
so they never change a status or the exit code.
outcomes[].cost_note and outcomes[].usage_note say why cost_usd or a token count is
0 rather than measured — an unpriced model, a product that bills per request rather than
per token, a trace that reported no usage or no cost, or a product run that ended before
reporting what it spent (a timeout, a cut trace, no result event: claude-code timed out
before reporting cost) — each empty string when the figure beside it is a real
measurement. An errored product outcome keeps the notes its trace reported, as it keeps the
figures (below): an errored Copilot run carries Copilot's premium-request cost_note like a
clean one. A run that ended before reporting its spend is errored too, and its note is there
so a run that may have spent money is not read as free; summary.total_cost_usd is then a
lower bound. Either way the console and Markdown totals say some costs were not priced. An
errored outcome that never started a process has empty notes, because its 0 is a
measurement.
outcomes[].scores[].cost_note does the same for an evaluator's own spend, which is
summary.judge_cost_usd — today the judge's: an unpriced judge model, a product judge that
bills per request, or a judge run that ended before reporting its cost. Any such note makes
the totals line say Judge overhead: not priced or some judge costs not priced, apart
from the run's own cost line.
delta is the full comparison object — pass-rate, token, cost and latency deltas, per-case
stats, low-signal checks, high-variance cases and notes — and is null when no baseline arm
ran. Its top-level deltas aggregate every runner; delta.runners has the same deltas for each
runner, with comparable_cases, and is what --min-delta reads when there is more than one.
Every delta.low_signal[] and delta.high_variance[] entry names its runner, so one case
flagged under two runners appears once per runner. baseline_notes lists why a skill's or
case's baseline was skipped or unavailable.
summary.baseline_errored counts errored baseline repetitions apart from summary.errored,
which is candidate-only, for the same reason the gate itself only reads the candidate arm
(above): an errored baseline invalidates that case's delta, it does not mean the skill broke.
summary's token, cost and latency totals sum both arms — money spent is money spent —
while summary.passed / summary.failed / summary.errored / summary.pass_rate stay
candidate-only, because those are what the gate reads. For the same reason the totals include
errored outcomes: an errored product run keeps the tokens and cost its trace reported (a
Claude Code run stopped by --max-budget-usd, say), so its outcomes[] figures can be
non-zero. They only add to the totals; the outcome is still errored, and no check reads it.
JUnit XML¶
--junit-output writes a JUnit report, the format GitHub, GitLab, Jenkins, CircleCI and
Buildkite all ingest natively.
| skill-lens | JUnit |
|---|---|
passed |
<testcase> with no child |
failed |
<testcase> with <failure> |
errored |
<testcase> with <error> |
| a skill with no cases | <testcase> with <skipped> |
| a candidate case the spend ceiling kept from starting | <testcase> with <skipped> |
The failed/errored split is the same one the exit code and the JSON report use: a
<failure> means the case ran and scored below bar, an <error> means the runner or an
evaluator blew up.
Only the candidate arm becomes test cases. Under --baseline, a failing baseline is the
evidence that the skill helped, so rendering it as a <failure> would turn CI red for the
skill working.
A run with no eval cases emits a single <testcase> carrying an <error> that repeats the
gate's reasons. An empty tests="0" file renders green in most CI UIs, which would contradict
the exit code of 1.
For the same reason, a run the spend ceiling failed gets one more
<testcase name="spend ceiling">, in a suite named skill-lens, carrying an <error> with
the gate's ceiling reasons. Otherwise a run whose every case that ran passed would render as
green and skipped against exit code 1.
When scripts were enabled, every <testsuite> — a skill that ran, a skipped or filtered
skill, and the synthetic zero-case error suite alike — carries <properties> as its first
child, with skill-lens.scripts.sandbox and skill-lens.scripts.detail: properties are
where JUnit puts run-level facts, and a testcase is the wrong place for something true of
the whole run. The console and Markdown reports print the same fact as one line (scripts: on,
sandbox: <backend> on the console, with the probe's detail in parentheses when the backend
is none, and ; <hardening note> appended when the harness could hide its own
environment — see Runners) and name every skill whose
bundled scripts did not run because execution was off.
When a product ran — as a runner or as the judge — the same <properties> element carries
skill-lens.products on every suite: one value naming each product, its version, its
executable and its trust sentence, joined with ;. The console prints the same fact as one
product <name> <version> (<executable>): <trust> line per product, and the Markdown
summary as a footnote; see Runners.