Invariants¶
These are decided behaviours, not accidents. Several were bugs caught in review. Each one has a test asserting it, so a later change cannot quietly undo it.
This page is a reference to look things up in, not a page to read end to end. For how the tool is built — the protocols, the module map, the data flow — see Architecture.
The core contract¶
errored is not failed¶
failed means the case ran and scored below the bar — an eval signal about the skill.
errored means the runner itself blew up — an infra signal about the harness. Conflating
them makes a broken API key look like a bad skill. Runners therefore never raise for
provider failures; they set RunResult.error. Errored cases fail the gate by default so CI
never goes green on a run that did not actually happen.
An errored evaluator errors the case¶
errored is not failed applies to evaluators too: a judge endpoint returning 500 must not
read as a skill that got worse. The case is reported as errored, with the evaluator's own
diagnostic on EvalScore.detail.
Judges never raise for provider failures¶
They set JudgeVerdict.error, the same way runners set RunResult.error.
A run executing zero cases fails the gate¶
"Nothing ran" is a broken run, not a pass — otherwise a mistyped path reports success
forever. gating.evaluate_gate distinguishes the causes: no skills found, all skills
skipped for having no cases, every case filtered out by --tag, or no case name matched
--case.
Authoring errors abort the run; they never score as failures¶
An unknown assertion kind, a malformed regex, an undeclared tool name in a trajectory
block, or an unknown YAML key is a mistake in the user's files — it says nothing about the
skill. Scoring it as a failure would be a lie about the skill's quality.
orchestrator.run_evals lets these propagate; cli.py catches them via _AUTHORING_ERRORS
and exits 2.
Which tools a case has is the runner's to know, so the declared-name rule for trajectory: is made in preflight, per runner — never in the loader¶
A framework runner offers a case its mock tools: and, with a workspace:, the six
built-ins; a product runner offers the product's own tools (Bash), which skill-lens cannot
list; and one invocation may run the same case through both. So FakeRunner,
PydanticAIRunner and LangChainRunner refuse a called / forbidden / order /
call_args name outside their set (UndeclaredTool, exit 2, for the cases planned for that
runner, before any case runs and before any spend), ProductRunner refuses no name, cli
refuses trajectory: outright, and the loader — hence skill-lens list — accepts any name.
The earlier loader check made the documented product combination unwritable: under a product
a trajectory: may name the product's own tools, which no case declares, and the loader
refused every name it did not find in tools:.
check_trajectory_names in runners/preflight.py is the one implementation; the message
names the runner, so a multi-runner invocation says which one refused.
Exit codes are the CI contract¶
Gate passed 0, gate failed 1, user or authoring error 2. In cli.py, a JSON-write
failure escalates to 2 only when the gate itself passed — a write problem must never mask an
already-failing gate.
run_evals is library API: a new parameter is appended, never inserted¶
A caller that bound a parameter positionally before a newer one existed must still bind the
same thing. keep_workspace and workspace_limits therefore keep their positions after
executor_factory, and options — added after both — sits last. The legacy pair still
works (keep_workspace=True keeps the directory, workspace_limits= reaches the workspace)
but can never enable scripts, which it predates; passing options together with either
legacy argument raises ValueError, the same rejection evaluators with judge gets. The
CLI passes options only. tests/test_orchestrator.py pins the order and both forms.
An unfilled scaffold is an authoring error, not a failure¶
skill-lens init writes TODO(skill-lens) into every field the author must supply, and
cases/loader.py rejects any case still containing it — before schema validation, so the
message names the field rather than its type. Enforcing this in the loader rather than the
generator makes it unconditional: hand-written stubs get it too, and no CI configuration can
opt out of it.
extra="forbid" on every user-authored model¶
EvalCase, AssertionSpec, ToolSpec, TrajectorySpec, BudgetSpec, Config. Without
it, a typo like assertion: yields a case that passes vacuously — the worst possible
failure mode for an eval tool. It is also on RunResult, where it makes writing the derived
tokens field a loud error rather than a total that silently disagrees with the split it
was priced from.
All file IO pins encoding="utf-8"¶
All file IO pins encoding="utf-8" and re-raises as a typed parse error (SkillParseError,
CaseParseError, ConfigError) naming the file and the field.
YAML goes through yaml_loading.safe_load¶
PyYAML's SafeLoader implements YAML 1.1, which turns bare yes/no/on/off into
booleans. An assertion value: yes is meant as the string.
Secrets come from environment variables only¶
Secrets come from environment variables only — never from skill-lens.toml. A config file
is committed; a key must not be.
A base URL is config; a key is environment¶
A base URL is config; a key is environment (issue #54). A self-hosted endpoint is not a
secret and is the same for every contributor, so base_url and judge_base_url live in the
file and --base-url overrides the first, in the order every other key follows: flag > file >
the provider's own variable (OPENAI_BASE_URL, OLLAMA_BASE_URL, which the framework reads
when skill-lens passes nothing) > the provider's default. The variable is a fallback, never
an override — an override would break the documented order, and would need skill-lens to
keep a per-provider table of variable names. Three rules keep the value honest:
validate_base_urlis the one check, called by the field validator and by the flag: a barehttp(s)://host[...], and never a userinfo part (user:secret@host), which is a credential in a committed file — refused with the secrets message. A malformed URL is refused at load time (exit 2) because the client library would otherwise surface it from the first case as a connection error: an errored case, when the mistake is in the file.- The endpoint travels with the model.
judge_base_urlempty means the judge usesbase_urlexactly when it usesmodel—judge_modelunset and no--judge-model. A judge with a model of its own gets its provider's default unlessjudge_base_urlnames one, so a cloud judge under a local runner is never pointed atlocalhost. An unconditional fallback would do exactly that; no fallback at all would make the common case — one local model for both seats — two lines instead of one.--base-urlis read wherever--modelis and refused wherever--modelis (exit 2): a product reaches its own endpoint. - A provider that cannot take the endpoint is refused in preflight, before any spend,
never silently ignored. Under PydanticAI,
resolve_modelbuilds the provider withinfer_model(model, provider_factory=...)and raisesUnsupportedBaseURLwhen the provider class's constructor has nobase_urlparameter (deepseek,azure,openrouter, ...) or when the model is an object that already carries a client; under LangChain,check_base_urlbuilds the chat model once withbase_url=and reports any refusal under the seat (runner langchain,judge langchain). Both keyed judges gained a no-argumentpreflight()for it, returningNone— no product status. With nobase_urlnothing is checked and nothing changes: the string reaches the framework untouched, so the pre-issue behaviour is the fallback by construction, not by a second code path. The API-key check is unchanged:ollama:needs none, andopenai:at a local server still needsOPENAI_API_KEYexported, because the provider's client requires one. The value is never printed in a report.
Agent-framework imports appear in exactly four modules¶
Agent-framework imports appear in exactly four modules — runners/pydantic_ai.py,
judges/pydantic_ai.py, runners/langchain.py and judges/langchain.py.
runners/tools.py builds framework-neutral mock tools and the adapters wrap them. The
prompt rules (runners/prompting.py) and the retry loop (runners/retry.py) import no
framework, which is what lets both adapters share them. The product runner
(runners/product.py), the product judge (judges/product.py), the trace parsers
(runners/traces.py) and the MCP bridge's runner side (runners/mcp.py) import
subprocess and json, not a framework: a product is an executable and a trace grammar,
and process.py and mcp_bridge.py beneath them import nothing from the project at all.
tests/test_framework_isolation.py scans the whole package for top-level framework imports
and allows only those four files; it matches import forms, so cli.py importing our own
skill_lens.runners.pydantic_ai is not a false positive. This is what keeps the Runner
and Judge seams real rather than nominal.
Cost lookup degrades, never raises¶
An unpriced model yields cost_usd = 0.0 plus a cost_note. Pricing is reporting metadata;
it must never be why a run errors. In BudgetEvaluator, an unpriceable max_cost_usd limit
is skipped — not counted as passed — and that skip is recorded as a failing CheckResult.
passed requires every declared limit to hold, so any budget block that declares an
unpriceable max_cost_usd fails the case, whether or not it is the only check declared: a
case whose max_tokens and max_latency_ms both hold still fails if max_cost_usd could
not be priced, because that one check was never verified. score, by contrast, is the
fraction of evaluated limits that held — the unpriced limit is excluded from that divisor
entirely, so it neither inflates nor deflates the score the way a false pass would. A
repository running an unpriced model with a budget: block that mixes a priced limit with
max_cost_usd will see those cases turn red on an upgrade to this behavior; the fix is to
drop max_cost_usd for that provider, not to treat the skip as a pass.
Nothing scores a vacuous pass¶
The rule that an unpriceable budget limit fails rather than passing generalises: a rubric with no configured judge is errored, and a judge check that passes without citing evidence is recorded as a failure. An unsupported PASS is an LLM judge's characteristic failure mode, so it gets a mechanical defence rather than a prompt asking nicely.
skill-lens derives passed and score from per-check verdicts¶
The judge is never asked for a blended number. It returns a verdict per check, and a check that passes without citing evidence is recorded as a failure.
An unscripted FakeJudge errors rather than passing¶
That is what makes judge = "fake" safe as the built-in default: an unchecked rubric is
never a green case.
A rubric entry phrased against a mock tool's returns: is an authoring error¶
A rubric entry phrased against a mock tool's returns: is an authoring error (exit 2, load
time). The judge is shown the task, expected, the response and the files judge.artifacts
names — never what a tool returned — so "does not invent any detail not present in the
mocked data" is unverifiable as written: a judge that follows its own "fail when ambiguous"
rule turns it red, and one that does not passes it unread, which is the worse outcome
because nothing in the report says the check was never verified.
cases/checks.find_hidden_data_reference matches a fixed, documented vocabulary — mock or
mocked before data/response/result/return/value/output/tool, tool or
mock (possessive or not) before returned/returns/return value/response/result/
output/data/value, and returned by the tool/mock — each alternative needing a
second word, so a rubric about a testing skill ("proposes a mock for the HTTP client") or
about the response ("names the tool it would use") is not caught; the loader raises
CaseParseError naming the entry and the phrase, with both fixes: reword against the
response, or put the data in a workspace: file named under judge.artifacts. It is a
heuristic, applied to every case whether or not it declares tools: (the judge never sees a
return in any case), and rewording is the escape hatch. The second layer is the prompt:
SYSTEM_PROMPT tells every judge — framework and product alike — what it was not shown
(tool returns, tool calls, mock data) and that a check decidable only against those is a
fail with the evidence saying so, so a line the vocabulary does not catch fails honestly
rather than passing unread under a lenient model.
Judge spend never enters RunResult¶
It lives on EvalScore.cost_usd and is reported as judge overhead. budget: measures the
skill's efficiency, not the harness's.
Mock tools accept any arguments¶
A model hallucinating an argument is an eval signal about the skill; raising would surface it as an infra error instead.
Cassettes are replay-only and secret-free¶
Recording is a deliberate, key-bearing act. A missing cassette skips; a mismatched request fails rather than reaching the network.
skill_lens (underscore) never appears in user-facing output¶
The user-facing name is skill-lens everywhere: command, config file, distribution. The
GitHub repository keeps its older name, skill-evaluator, so
uses: EmadMokhtar/skill-evaluator@v<version> installing skill-lens is expected, not a
mistake. tests/test_naming.py fails if the pre-rename name reappears outside
docs/superpowers/, which is a historical archive and is never rewritten. CHANGELOG.md is
exempt from that scan on the same grounds and for one more: cz bump regenerates it from
commit subjects and footers written before the rename, so an edit there would misquote the
commit it came from and be undone by the next release. It is not left unguarded — a second
test reads the file and allows only the exact lines history produced, so the old name
arriving through a commit subject written after the rename still fails.
FakeRunner.run returns model_copy(deep=True)¶
FakeRunner.run returns model_copy(deep=True) so a caller cannot corrupt scripted state.
Comparative evals¶
Absent --baseline, what runs is identical to a single-arm run¶
One arm, no delta block, the same one-line-per-outcome layout, and JSON that keeps every
prior key and value with additive ones alongside (arm, repeat_index, a null delta,
baseline_notes). Console output is not byte-identical: a failing case now prints one
indented line per failed check, because the assertion, trajectory and budget evaluators emit
per-check evidence, where once only the judge did. That is strictly more information, not a
change in what runs; Comparative evals covers it in full. none names a kind of
baseline — the flag being unset, not --baseline none, is what turns comparison off.
Upgrading must never silently double a bill.
Baseline outcomes never count toward the gate¶
Baseline outcomes never count toward the gate, and never toward errored. RunReport.total
/ passed / failed / errored / pass_rate / pass_rate_by_skill all read
candidate_outcomes; baseline_outcomes and baseline_errored exist so the comparison
side is visible without ever feeding the numbers the gate reads. A strong baseline means the
skill was unnecessary, not that CI should go red.
The baseline arm never receives the skill's name, description or instructions¶
The baseline arm never receives the skill's name, description or instructions under
--baseline none. prompting.system_prompt emits a neutral BASELINE_PREAMBLE instead of
the normal # {name} header whenever both description and instructions are empty. The
rule keys on emptiness, not on variant, so no runner can — or has to — branch on which arm
it is serving; a runner that could branch on the arm could cheat the comparison.
A baseline that cannot be resolved is reported, never assumed to be "no change"¶
resolve_previous returns a BaselineUnavailable rather than treating silence as evidence.
Without --min-delta it is a note; with --min-delta it fails the gate, because treating
"we couldn't check" as "nothing changed" would let a repository pass forever by deleting its
git history.
The delta is paired¶
comparison.build_delta excludes a case from both halves of the delta the moment either
arm cannot be honestly compared — a skipped baseline, an unresolvable one, or every
repetition of an arm erroring. Keeping the surviving half would bias the aggregate with data
that has no partner to be measured against.
--min-delta without --baseline is a user error (exit 2)¶
A delta gate that checks nothing must never report a pass — the same vacuous-pass rejection every other gate rule in this project applies. Gating on a delta with no comparable case fails for the same reason, mirroring "a run executing zero cases fails the gate".
Low-signal and high-variance flags never change the exit code¶
They are diagnostics about the eval suite — a weak assertion, an unstable case — not verdicts on the skill. A flag that could block a merge trains people to ignore flags, and a flaky provider would be indistinguishable from a genuinely bad skill.
resolve_previous never raises for environmental failures¶
resolve_previous never raises for environmental failures — no git, no repository, an
untracked SKILL.md, an exhausted history window. Each comes back as a
BaselineUnavailable with a reason, the same discipline runners and judges follow for
provider failures. Subprocesses run without a shell, decode as UTF-8, and carry a timeout,
so a hung git cannot hang CI.
A previous baseline follows a byte-for-byte move of SKILL.md, and nothing looser¶
git log -- SKILL.md stops at the commit that created the file's current path, so a skill
whose directory moved lost every earlier version, and --min-delta failed on it until its
next edit. resolve_previous carries on from the old path when — and only when — the commit
that created the current path moved SKILL.md there unchanged: git diff-tree
--find-renames=100% against that commit's first parent reports a rename, R100, onto this
exact path.
A copy is never followed, not even one whose source is deleted in a later commit, and neither
is a move that edited the file in the same commit. Both come back as "no earlier version",
which the report explains, never as another file's history. That rules out git log
--follow: it follows copies as well as renames, and matches by similarity, so a skill
started from another skill's SKILL.md would silently be measured against that skill's old
instructions.
Every path is read from the repository root as a literal pathspec — [ and * are glob
syntax otherwise, and a glob would let a sibling directory's commits use up the history — the
bundle comes from the directory the skill had at the resolved commit, and HISTORY_LIMIT
counts commits across every path together. tests/test_baseline_resolution.py pins each
case: one move and two, a move out of the repository root, an old name that needs quoting, a
skill reached through a symlink, a copy, a copy whose source is deleted later, an edited
move, an unrelated move in the creating commit, and the limit spanning a move.
A version: that YAML does not parse as a string is an authoring error¶
SkillParseError, exit 2. YAML resolves 1.20 and 1.2 to the same float, so two
genuinely different versions would silently compare equal under --baseline previous.
Three-part semver (1.0.0) is already a string and needs no quoting.
Deterministic evaluators emit per-check verdicts¶
Deterministic evaluators emit per-check verdicts, ids derived from the case (never the
result) so the same id names the same check in both arms: {kind}[{index}] for assertions,
called:{tool} / forbidden:{tool} / order / max_calls / skill_triggered /
call_args[{index}] for trajectory, max_tokens / max_cost_usd / max_latency_ms for
budget. This is what lets comparison.py name a specific low-signal check rather than only
flag a whole case.
Reporting and concurrency¶
JUnit reports the candidate arm only¶
Under --baseline, a failing baseline is the evidence that the skill helped. Rendering it
as <failure> would paint CI red for the skill working — the same reason every RunReport
aggregate reads candidate_outcomes.
<failure> is failed; <error> is errored¶
The project's central distinction, given a native rendering: an exploded runner must not
look like a skill that got worse. errored covers two different sources, and both must
render as <error>: a runner that raised, and an evaluator that did (a judge endpoint
returning 500 leaves RunResult.error unset and puts its diagnostic on EvalScore.detail
instead). reporters/junit.py's _error_body reads the runner's error first and falls back
to the errored evaluators' own details, so an evaluator's own diagnostic is what gets
reported — never attributed to the runner that ran cleanly.
JUnit output is always well-formed XML¶
ElementTree escapes &, < and > but emits control characters raw, so a model
returning \x00 would produce a file every parser rejects. Illegal characters are stripped
before they reach the tree.
A zero-case run produces a JUnit <error>, not an empty green suite¶
tests="0" renders green in most CI UIs, which would contradict the exit code of 1.
Markdown truncation gives up detail before it gives up meaning, and never hides how much it gave up¶
Optional blocks (totals, per-skill table, delta, failure detail, low-signal / high-variance,
skipped skills) are dropped first, from the end. If gate reasons still do not fit, they are
elided behind a truthful +N more reasons count rather than being cut silently, so a
clipped comment can never imply the reasons it shows were all of them. Only a budget too
small to hold even the verdict and summary falls back to a hard character cut on the
assembled text. Truncation lives in the renderer, not the caller — a caller slicing the
returned string after the fact would cut a <details> block open, or a count line in half.
Reporters never do IO to a service¶
They return a string. The CLI writes files; the workflow posts comments. A GitHub client inside a reporter would put token scopes and network failure inside a pure function.
--concurrency 1 constructs no executor¶
The plain sequential loop it falls back to produces the same ordering and the same exception propagation as any other concurrency level reading its futures in submission order, and it is what lets the cassette tier (vcrpy is order-sensitive and not thread-safe) still match requests. It is not, though, a literal replay of the pre-concurrency behaviour in every respect: discovery is now always a separate, sequential pass that loads every skill's cases before any of them run, so a malformed eval file anywhere aborts the whole run before a single case runs — where once discovery and execution were interleaved per skill, and an earlier skill's cases could complete (and be paid for) before a later skill's bad file was even read.
Outcome order is submission order, never completion order¶
render_console iterates report.outcomes and build_delta groups by insertion order, so
completion-order results would make output churn between identical runs.
Concurrency never turns an authoring error into a case failure, and the surfaced error is deterministic¶
Futures are read in submission order, so the lowest-index failure is always the one that
propagates out of run_evals. On a failure, only the futures queued after it are
cancelled — a worker dequeues an item before marking its own future running, so a
lower-index future can still be pending, and cancelling it would let a higher-index error
surface instead of the lowest one. The executor is shut down with cancel_futures=True on
any exception, including a submit call that itself raises (an executor left running keeps
its workers alive, so the interpreter's own exit handler would finish the very work the
abort exists to abandon). If a custom executor_factory ever returns fewer results than
work items with no exception to explain it, _execute raises rather than handing the gate a
quietly partial run.
Runners, judges and evaluators must be safe to share across threads¶
No mutable instance state touched by run/evaluate/judge. This holds today for free:
the fakes read immutable dicts and return model_copy(deep=True), the deterministic
evaluators have no instance state, and PydanticAIRunner builds a fresh agent per run. It
is a constraint on what comes next.
Release and supply chain¶
The action fails closed¶
shell: bash steps already run under bash --noprofile --norc -eo pipefail, so -e is on
before the action's own script runs a line; the run step captures the CLI's exit code itself
(code=0; skill-lens run ... || code=$?) before -e gets a chance to discard it. Every
step after that — publishing the step summary, reading the JSON report, re-raising the exit
code — carries if: always(), so a failing run still gets its summary published and its
outputs read. The final step exits "${CODE:-1}": an empty code means the run step never
completed at all (a failed install, a cancelled job), and a gate that cannot prove it passed
must fail rather than default to success.
Nothing publishes that has not been verified in the same run¶
release.yml chains three jobs — verify (lint, format, the full suite), release
(cz bump, push, build, upload) and publish (download that artifact, upload to PyPI) —
with needs:, not across separate workflows. That shape is forced: GitHub starts no new
workflow run from a push made with GITHUB_TOKEN, so a publish workflow listening on tag
pushes would never fire, and the release would tag and then silently ship nothing. Chaining
also gives the property worth having — publish is unreachable except through a green
verify, and it uploads the artifact release built rather than rebuilding, so the bytes
that ship are the bytes that were tested. Publishing is irreversible: PyPI refuses a
re-upload of a version that already exists, which is why every gate here fails closed. The
corollary is that there is no manual path to PyPI; a locally bumped and pushed tag
produces a run with nothing to release.
A merge with no releasable commit publishes nothing and fails nothing¶
cz bump signals "nothing to release" through its exit code — 21 (NoneIncrementExit)
and 3 (NoCommitsFoundError) — so the bump step deliberately runs without set -e, which
would discard the code before it could be read, and checks every other command by hand
instead.
A run that main outran publishes nothing and fails nothing¶
Two merges a few minutes apart each start a release run, and the concurrency group does
not keep one alone from start to finish — GitHub checks the group when a job starts, so a
run between its verify and release jobs can be overtaken; and a merge that lands during
verify has moved main regardless. The earlier run's cz bump then rests on a commit
that is no longer the tip, and its --atomic push is rejected as a non-fast-forward with
nothing landed. The push step treats that as the same no-op as cz's exit 21 and 3: it
fetches main, and if this run's commit is a strict ancestor of the new tip it records
pushed=false and exits 0, naming the commit that overtook it in the summary. That is
sound because the later push has a run of its own whose cz bump reads every commit since
the last tag, the earlier commit included. The ancestor test is what keeps it from being a
vacuous pass: main unmoved but the push refused, or main rewritten to a history without
this commit, still fail with the push's own exit code, because no later run is known to
carry the commit. Every step after the push, and the bumped output publish reads, gate
on the push step's pushed rather than on the bump step's bumped, so a version that was
cut but never pushed is never verified, built or published. tests/test_release_workflow.py
runs the step's script verbatim against a local bare origin for each of these cases, which
is also why the version reaches the script as an environment variable rather than a ${{ }}
expression.
The pushed release tag is annotated, and the job proves the tag on origin is this run's¶
git push --follow-tags pushes only annotated tags, and Commitizen creates a lightweight
one unless told otherwise, so annotated_tag = true is what stops the bump commit reaching
main while its tag dies on the runner. Because publish is reached through needs: and
not through the tag, that loss would not stop a release: a git ls-remote check runs right
after the push and fails loudly instead. That check compares targets, not existence:
--follow-tags also sends only the tags origin does not already have, so a same-named tag
already on origin — pushed by hand, pointing elsewhere — is not rejected but silently
skipped, and the bump commit lands under a tag that names unreleased code. The step lists
refs/tags/<tag> and refs/tags/<tag>^{} (the peeled commit; a lightweight tag has no
peeled line and its ref is the commit) and requires the target to be the commit it just
pushed; tests/test_release_workflow.py runs that script verbatim against a local origin
holding such a tag. The push is --atomic so the commit and tag land together or not at
all, and cz bump --check-consistency aborts before writing anything if a file listed in
version_files no longer contains the current version — a flag on the command, because
Commitizen reads it only from the CLI and never from pyproject.toml. That abort is the
last line rather than the first: tests/test_release_config.py asserts the same property on
every pull request, so a reformatted pin is caught where it is cheap to fix instead of
costing a release on main. It asserts it by replaying Commitizen's own algorithm — each
(file, pattern) pair in sorted order, the file rewritten in place after every pair —
because Commitizen writes the file back between patterns, and a line that spells two
versions has only one to give: the first pattern takes it, the second finds the line already
bumped, and the release aborts if that pattern has no other line. Every version spelling
gets a line of its own. A further test requires every spelled version to be the current one,
so a line a long-lived branch added at an older version cannot ride along untouched release
after release.
The tag the lookup builds is checked against tag_format rather than trusted. The workflow
hardcodes the v prefix that [tool.commitizen] tag_format configures — two copies of one
string in two files — so tests/test_release_workflow.py derives the prefix from the setting
and requires the workflow to use it. Changing the format alone would tag correctly, push
successfully, and break only the lookup, failing after the push: the one unrecoverable state
here, since the version is spent, publish never became eligible to re-run, and a fresh run
finds nothing to release.
The version in action.yml always equals the package version¶
The action's install-spec input is what a workflow installs, and its default pins a
version. That pin and the package version are two places that must agree: a release that
changes one and not the other ships an action installing somebody else's version.
tests/test_release_config.py::test_the_action_pins_the_current_version reads
inputs.install-spec.default out of action.yml, requires it to be a pinned install spec
(skill-lens[<extras>]==<version>) rather than an unpinned one, and requires that version to
equal skill_lens.__version__. What keeps the two in step at release time — the
version_files patterns, and the replay that proves each still rewrites a line — is the
invariant above.
The three plugin manifests agree, spell no version, and the portable one declares Agent Plugins 1.0.0¶
plugins/skill-lens/ is the skill-lens plugin, and it has three manifests because the
agents that install it disagree on where to look. plugin.json is the portable
Agent Plugins manifest that Copilot, VS Code and Codex read;
.claude-plugin/plugin.json is the one Claude Code reads; .cursor-plugin/plugin.json is the
one Cursor reads first, and the first thing its marketplace reviewers check for. They hold the
same fields, except $schema, with the same values: a description edited in one alone would
describe the plugin differently depending on the agent. Cursor's copy differs in two ways:
its author keeps only name, because Cursor's closed schema takes a name and an email but
not a url; and it adds displayName, category and logo, fields only Cursor reads. The
logo is named by its docs-site URL, not shipped in the plugin, so the one file under
docs/assets/ serves both the plugin listing and the publisher form; the URL must name a file
that exists under docs/.
None spells a version, and neither does the marketplace entry. The agents install the
plugin from the tip of main, not from a release, and treat version as a cache key. Tied
to the package version, it held existing installs on their old files whenever the skill
changed between releases — a docs: edit to SKILL.md releases nothing — while a fresh
install got the new files under the same number: Claude Code answered "already at the latest
version" and kept the old copy. With no version, Claude Code keys each install on the commit,
so every change reaches an existing install at its next update.
The portable manifest uses only the ten top-level fields the specification allows — its
schema is closed, so a client-specific field such as hooks belongs under extensions — and
it declares the published 1.0.0 schema, not the 1.1.0 working draft: Codex rejects a
plugin that declares an Agent Plugins version it does not support. It is a regular file,
because Codex also refuses a symlinked one. .claude-plugin/marketplace.json, at the
repository root, lists ./plugins/skill-lens as the one plugin, and
.cursor-plugin/marketplace.json holds the same catalog where Cursor looks for it — Cursor
finds a plugin outside the repository root only through that file. The two are equal as JSON,
a copy rather than a link for the same reason as the manifests, and both use only the fields
Cursor's closed marketplace schema allows, since the one catalog serves every agent.
tests/test_plugin.py asserts each of these, and reads every skill's frontmatter itself to
check the Agent Skills rules a client applies before it loads one — skill-lens's own loader
falls back to the directory name when name: is missing, and a client does not.
The plugin directory ships the manifests, a README, the license and the skills, and nothing else¶
An install copies plugins/skill-lens/ whole, so whatever sits in it ships to every user of
every agent. tests/test_plugin.py holds it to the three manifests, README.md — what a
marketplace listing shows, which must name every skill the plugin ships — LICENSE, and per
skill its
SKILL.md (spelled exactly), references/, scripts/, assets/ and its own
evals/*.eval.yaml suite; any other tracked file there fails, a stray note or a .DS_Store
included. The suite ships because skill-lens finds a suite only beside the SKILL.md it
tests; the docs tell anyone copying the skill into a repository they run skill-lens on to
leave evals/ behind.
LICENSE is a copy of the root one, byte for byte: the install copies nothing above the
plugin directory, and the MIT license asks for its notice in every copy. Nothing in the
directory resolves outside it, and no link in it dangles or loops, because a conformant client
denies a package path that resolves outside the plugin and the install would not carry its
target. So the skill lives in the plugin, and .claude/skills/writing-skill-evals links
into it — the repository's own sessions run exactly the skill an install delivers — never
the other way round. The directory is named after the plugin, so the two names never differ
in a cache path or an error message.
No long-lived publishing credential exists¶
PyPI accepts the upload because the job proves its identity with a short-lived token
(Trusted Publishing, over OIDC), so publish needs id-token: write and nothing else — it
cannot write to the repository. Permissions are granted per job against a workflow-level
permissions: {}, so a job added later inherits nothing.
A cassette refresh proves its recordings replay, and checks them for secrets, before pushing¶
It re-records with --record-mode=rewrite: once only fills in a missing cassette and
write-protects one already loaded, so it cannot refresh an existing recording. It then
stages the recordings before either check, because git diff cannot see an untracked file
and a freshly recorded cassette is exactly that — without staging, the scan would read as a
lock while checking nothing on the one path that creates a file. It hands back a branch,
never a pull request, because a pull request opened with GITHUB_TOKEN gets no CI checks,
and on a cassette refresh those checks are the whole point of the review.
The dependency audit is one command, spelled identically in three places, and its exceptions live in one table¶
uv audit reads uv.lock — the exact set anyone installs — and asks OSV about every
package in every extra and group, dev tooling included, because that is what runs on
maintainers' machines and in CI. It runs in security.yml on every pull request, every push
to main, weekly on a schedule and on demand; in release.yml's verify job; and in the
pre-push hook. The schedule is what makes it a monitor rather than a check: an advisory
can be published against a lockfile nobody has touched, and only a timer notices. verify
re-runs it rather than trusting the pull request's green check because an advisory can land
between the merge and the tag, and nothing publishes that verify did not pass.
tests/test_security_checks.py requires the three commands to be byte-identical, so an
exception can never apply to CI and not to the release, or to a laptop and not to CI.
Exceptions go in [tool.uv.audit] in pyproject.toml, which every copy of the command reads,
and only as ignore-until-fixed: it stops hiding an advisory the day a fixed version exists,
so the list can only shrink on its own. Plain ignore hides a finding forever and is rejected.
uv does not validate that table — a misspelled key is silently dropped and the finding silently
kept — so the same test rejects any key but the allowed one. Any finding fails; a severity
threshold is a decision someone has to defend for every advisory, while "fix it or record why
not" is a decision made once. --locked makes uv fail when pyproject.toml and uv.lock
disagree instead of quietly auditing a fresh resolution nobody installs, and
--preview-features audit acknowledges that the command is still a uv preview feature — if
its interface changes, the wiring tests fail on the pull request that bumps uv, not in a
release.
The build backend is audited and pinned too. uv.lock records what the project installs, not
what builds it: [build-system] requires is resolved fresh at build time, so the artifact
could be produced by a hatchling the audit never saw. A build dependency group mirrors
those requirements — a test keeps the two lists equal, or the group would audit a backend the
build does not use — which puts the backend and its own dependencies in the lockfile. The
release then exports that group (uv export --frozen --only-group build) as a constraint file
for uv build --build-constraint, so what builds the published wheel is exactly what verify
audited.
The audit runs at push time locally, not commit time¶
It needs the network. A commit hook would fail offline and teach people to skip it; a push needs the network anyway, so the check costs nothing extra there and cannot be blamed on a bad connection.
Ruff's S rules are on, and a false positive is suppressed at the site with its reason¶
The flake8-bandit family rides on the existing ruff check, so it runs everywhere lint
does with no extra step to forget. Five src/ sites trip it and all are deliberate:
baseline.py starts git by name because an absolute path is wrong on most machines and a
missing git must come back as BaselineUnavailable, never a crash; yaml_loading.py passes
StrictBoolLoader to yaml.load, and ruff cannot see that the loader subclasses
SafeLoader; scripts.py starts the sandbox probe and the interpreter from an argv list
with no shell — the module's whole point; process.py runs taskkill on Windows the same
way and finds it on PATH by name for the same reason baseline.py finds git;
runners/product.py starts the agent product and its version probe from an argv list with
no shell, the prompt as one element. Each carries an inline noqa with that reason.
tests/** and scripts/** have per-directory ignores for assert,
subprocess-with-fixed-argv, XML parsing and literal /tmp strings used as fake path values.
A rule is never switched off for src/ because one site trips it.
Every action is pinned to a commit SHA with a # vX.Y.Z comment, and nothing grants write access at the workflow level¶
actions/checkout@v4 runs whatever v4 points at on the day, so a compromised or mistaken
re-tag would run different code in CI with no change in this repository; a commit hash
cannot be moved. The trailing version comment is what keeps a pin readable, and it is the
comment Dependabot rewrites when it bumps the SHA, so the two never disagree. Dependabot
watches both uv.lock and the actions weekly, because a pinned hash never moves on its own;
its commit prefixes are Conventional Commit types, checked by running them through
cz check, since the pull-request title becomes the commit that cz bump parses.
Permissions are granted per job against a top-level block that is empty or read-only, so a
job added later inherits nothing and the docs build job — third-party tooling on the
checkout — never holds the token that publishes to Pages. tests/test_supply_chain.py holds
all of it.
The SBOM never enters dist/, and the GitHub Release is created only after PyPI accepted the upload¶
The CycloneDX export reads the lockfile verify just audited, --frozen, for the runtime
dependencies and the pydantic-ai extra — what an installer gets, not the dev or docs
groups that ship to nobody. It is written to sbom/ and uploaded as its own artifact
because publish sends every file in the dist artifact to PyPI, which would reject an
SBOM and fail the upload after the tag is already pushed. github-release needs publish:
a GitHub Release is the first outward-facing sign of a version, and one that exists before
the upload could advertise a version pip install cannot find. The job is re-run safe — the
documented recovery for any release step — because creation is skipped when
gh release view finds the release, and assets are uploaded with --clobber so a partial
run converges instead of refusing. Its notes are cz changelog <version> --dry-run: the
same commits that chose the version number, and nothing else, are the source of truth for
what a release contains. The notes are written in release, which already has the checkout,
the history and the tools, and handed on as an artifact — so github-release, the one job
holding contents: write, has no checkout, runs no uv, and installs nothing. Everything
it publishes was built and verified by an earlier job; a compromised dependency or build
hook never executes under the token that can write releases. (The docs build job, by
contrast, needs pages: read and not only contents: read: actions/configure-pages calls
GET /repos/{owner}/{repo}/pages and fails the job when that call is refused — a tightening
that would only have shown up on the next push to main.)
Workspaces and files¶
A built-in tool never raises; it returns a message the model can read¶
list_files, read_file and write_file in runners/tools.py catch PathRefused,
OSError and UnicodeError and turn every one into an ordinary tool-result string.
Workspace's own methods (resolve, read, write) do raise — that split is
deliberate: an evaluator or the loader wants an exception, because a refused path there is a
genuine authoring error, but a tool must never raise, because the model choosing a bad path
is an eval signal and an exception would surface it as an infra failure instead.
No path outside the workspace root can be read or written¶
Every candidate path is resolved and then checked against the root with
Path.is_relative_to, never trusted from its spelling alone — check_relative_path rejects
an absolute path, a drive, or a .. segment before resolution even runs, so the refusal
message can name what's wrong rather than a location the author never wrote. The root itself
is resolved once, at creation (Path(tempfile.mkdtemp(...)).resolve()): macOS resolves
/tmp to /private/tmp, and an unresolved root would make every later containment check
compare two spellings of the same directory.
Every work item gets its own workspace, and two arms never share one¶
orchestrator._run_one creates a workspace fresh for each (skill, case, runner, arm,
repeat_index) combination via tempfile.mkdtemp, which is atomic — there is no window in
which two work items racing for a directory name could collide. A shared directory would let
the baseline arm read what the candidate wrote, or vice versa, corrupting the very
comparison --baseline exists to make.
The workspace preamble is byte-identical in both arms and never names the skill¶
WORKSPACE_PREAMBLE is appended in prompting.instructions purely on whether the case has
a workspace, never on which arm is running — the same discipline BASELINE_PREAMBLE already
follows for the skill's own text. Added to the candidate arm only, that text would itself
become part of what --min-delta measures, inflating (or deflating) a comparison that is
supposed to isolate the skill's contribution.
RunResult.workspace is non-null only while the directory exists, and the orchestrator stamps it unconditionally¶
_run_one overwrites whatever the runner returned with
workspace.root if workspace is not None else None after every run, including the None
case — so a non-conforming adapter that ignores the workspace parameter cannot smuggle a
path of its own into the report, and a case with no workspace: block can never show one
either. Once the directory is deleted, the field is cleared in the same step, so it can
never point at something that is already gone.
Workspace cleanup never changes a verdict, and lives in a finally¶
Workspace.cleanup suppresses its own errors (shutil.rmtree(..., ignore_errors=True)) —
deleting a temp directory is harness housekeeping, and a cleanup failure turning a passing
case red would be the tool reporting on itself instead of on the skill. It runs in
_run_one's finally block specifically so that an authoring error raised by an evaluator
still deletes the directory on its way out, rather than leaking it.
A workspace creation or seeding failure is errored, never failed¶
A full disk or a permissions problem says nothing about the skill under test, so
create_workspace raises WorkspaceError and _run_one reports the case as errored,
with the exception's message in RunResult.error. Seeding is subject to exactly the same
rule: a seed file that cannot be written trips the same path, because a seed the harness
itself could not write says nothing about the skill either — and a half-seeded directory
would be worse than none, so create_workspace cleans up before re-raising.
Artifacts reach the judge as fenced, untrusted data¶
Each is wrapped in its own <artifact id="..." name="...">...</artifact id="..."> block,
and its id is derived from both the trusted name and the untrusted content — salted
with the name because sha256("") is a published constant (e3b0c442...) any model could
reproduce from memory, so an empty artifact would otherwise get a guessable fence id a
later artifact could echo as a forged closer. An artifact's boundary is its first
matching closing tag — the opposite of the response fence's "last matching closer wins" rule
— because each artifact block is closed immediately: unlike the response, which is always
the last thing before the checks, an artifact may be followed by more attacker-controlled
text (another artifact, or the checks list itself), so "last wins" would not be a safe rule
there.
file: or judge.artifacts in a case with no workspace: block is an authoring error¶
file: or judge.artifacts in a case with no workspace: block is an authoring error
(exit 2), and so is a judge.artifacts entry that could never be produced (one that fails
check_relative_path, e.g. ../escape.txt). Both are caught in cases/loader.py before
any case runs — a case with no filesystem can never satisfy either, so scoring it as a
failure would blame the skill for a check that could not have held under any output.
Judge artifact bytes are capped, and truncation is visible¶
MAX_ARTIFACT_BYTES and MAX_ARTIFACTS_TOTAL_BYTES bound the untrusted, model-produced
content a judge prompt can carry — a judge handed a large volume of irrelevant text grades
worse, not better — and a cut file is marked with a visible
... [truncated, N bytes omitted] note rather than being silently shortened. A sentinel
((not produced), (not valid UTF-8 text), (omitted, artifact budget exhausted)) never
consumes that budget: the cap bounds untrusted model content, and a sentinel is fixed,
harness-authored text with nothing for a content budget to police.
An unknown assertion kind is caught at load time¶
An unknown assertion kind is caught at load time, before any case runs and before any money
is spent. cases/loader.py's _validate_assertions checks every kind against its own
field-requirements table — deliberately a second table, not a reuse of
AssertionEvaluator's _CHECKS (one maps a kind to a predicate, the other to which fields
it requires and allows), pinned together by a test rather than an import. Discovery is a
separate sequential pass ahead of execution, so a bad kind anywhere in a suite aborts the
whole run before the first case, real or fake, is charged for.
A configured cap reaches the workspace¶
max_file_bytes, max_files and max_total_bytes flow from Config through cli.py's
WorkspaceLimits construction, through orchestrator.run_evals's options
(RunOptions.limits), into every create_workspace call. A limit read from config and then
dropped somewhere on that path would leave the built-in default silently in force, and the
only symptom would be a refusal message quoting a number the user never set.
Every kept directory is printed, however keeping was turned on¶
_kept_workspaces in reporters/console.py renders a Kept workspaces section whenever
any outcome carries a non-null workspace, regardless of whether --keep-workspace or
the config file's keep_workspace is what kept it. That is what makes
keep_workspace = true safe to commit: a persistent setting that produced no visible output
would fill a disk with nothing on screen to explain why.
Bundled scripts and the sandbox¶
Script execution is off unless the run turned it on¶
allow_scripts / --allow-scripts is the only switch; nothing in an eval file or a
SKILL.md can enable it. A SKILL.md under evaluation is, by construction, code nobody has
vetted, and the person who writes the eval is usually the person who wrote the skill.
Reading the bundle needs no opt-in: reading a file the repository already contains changes
nothing.
skill-lens.toml is inside the trust boundary¶
In a pull_request workflow the checkout is the pull request, so the file can turn scripts
on for itself. The action's allow-scripts input is unset by default, so the file decides;
pull_request_target, collaborator-pull-request and self-hosted workflows should pass
allow-scripts: false explicitly. Security and CI say so.
The bundle is scripts/, references/, assets/ and nothing else¶
SkillBundle refuses any other first path component, so *.eval.yaml and evals/ — the
expected answers — are never readable by the agent. listing() also leaves out a symbolic
link whose target resolves outside the root, so it never advertises a file read would
refuse.
Skill.bundle_root defaults to None, and only the loader and the baseline resolver set it¶
Keying the bundle tools on Skill.path would leak the candidate's scripts into the
--baseline none arm, since every Skill has a path; a Skill built by hand in a test has
no bundle for the same reason.
A script's environment is built from an allowlist, never inherited¶
PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ (plus what Python needs to start on
Windows), then TMPDIR/TMP/TEMP, PYTHONDONTWRITEBYTECODE and PYTHONIOENCODING. The
key that pays for the run is absent by construction, not by remembering to delete it.
The allowlist stops inheritance; hiding the harness's own environment is the OS's job, and the report says whether it was done¶
A same-user process can ask the kernel for the harness's exec-time environment. On Linux
(/proc/<pid>/environ) scripts.harden_process calls prctl(PR_SET_DUMPABLE, 0) from
preflight — after the interpreter and sandbox checks, so a run that stops there never pays
the cost (no core dumps, no same-user debugger) — and its note travels
ScriptRuntime.hardening → ScriptStatus.hardening → the console line, the Markdown line
and the JSON report. It is best effort and never raises; root ignores the flag; bwrap
makes it moot by unsharing the PID namespace. On macOS the read is the kern.procargs2
sysctl behind ps -E, which sandbox-exec does not gate at all (a blanket
(deny sysctl-read) refusing every other sysctl still admits it; process-info* rules do
not touch it; /bin/ps merely cannot exec because it is setuid), so harden_process
returns None there and the docs say the key is exposed. tests/test_sandbox_live.py pins
both facts on macOS against a target spawned with the secret in its exec-time environment —
never monkeypatch.setenv, because the kernel serves the block it copied at exec, and a
test that planted the secret afterwards could not fail.
A script runs with shell=False, its arguments as argv, always¶
Nothing the model sends is joined into a command line; a NUL byte in an argument is refused
by Popen before anything is spawned, and that refusal is text the model reads.
The process group is killed after every exit, not only a timeout¶
process.reap_and_kill_group, called from scripts.py and runners/product.py, runs
whether the script exited on its own or ran past script_timeout_seconds, so a script that
starts sleep 1000 and exits at once leaves nothing behind — the promise the docs make,
which a kill confined to the TimeoutExpired branch did not keep. On POSIX the order is
observe-kill-reap. Where os.waitid exists — Linux, and macOS from Python 3.13; CPython
does not build it on macOS before then, and the supported floor is 3.11.4 — _exit_observed
uses os.waitid(P_PID, pid, WEXITED | WNOWAIT) to see the exit without collecting it,
os.killpg(process.pid, SIGKILL) kills the group while the leader's pid is still held (so
it cannot have been reused), and process.wait() reaps last. Where it is missing, or if
something already reaped the child (ECHILD), the fallback is Popen.wait then the same
killpg: still safe from pid reuse, because POSIX forbids fork from returning a pid that
matches an existing process group id, and a group with live members is exactly the case
where the kill matters. tests/test_scripts.py exercises the fallback on every interpreter
by deleting os.waitid with monkeypatch. The group id is process.pid because
start_new_session=True makes the child a session leader; a getpgid lookup would fail
after the reap, exactly when the kill matters. ProcessLookupError and PermissionError
(macOS, when the only member left is the zombie leader) are both "already gone". On Windows
the kill is taskkill /T /F wrapped in except OSError, then process.kill();
taskkill /T walks the tree from the parent, so after the parent has exited on its own
there is no tree to walk, and a background process outlives a normal exit there — the docs
say so. One honest limit everywhere: the kill is a group kill, so a script that calls
os.setsid() leaves the group and survives it on every POSIX platform; only under bwrap
do --unshare-pid and --die-with-parent still take it down with the sandbox, and a stock
Ubuntu runner may not have a working bwrap.
Script output is read from files, capped, and a cut is never silent¶
Captured into memory, a script printing gigabytes inside the timeout would take the harness
down. The capture files are read back through the descriptors the harness opened before
the process started — Popen shares the open-file description with the child, so the inode
is fixed at spawn time — never by re-opening the path, because the script owns the scratch
directory and could put a symlink (to smuggle in another file's content) or a FIFO (whose
open would block forever) at that path.
run_script never raises, and an unrunnable script is never RunResult.error¶
A missing script, a path outside scripts/, an unmapped extension, an interpreter that is
not on PATH or will not start — each is text the model reads; that reading is the eval
signal. Every refusal carries what the model needs for its next call to be right: the
bundled scripts when the name was wrong, the allowed extensions when the interpreter was.
The sandbox decision is made once per run and appears on the report¶
preflight runs in run_evals after discovery and before any case: required without a
backend, and a missing interpreter for a discovered script, raise ScriptSetupError (exit 2)
before any money is spent. The backend is executed, not merely found on PATH. Preflight
and the "bundles N scripts; execution is off" notes both iterate every discovered skill,
including one --tag or --case filters out or that has no cases — a fail-closed check
that quietly skipped some skills would not be one. Baseline bundles are not preflighted; an
interpreter is looked up again at call time, and a miss there is a refusal, not an error.
The probe also fails closed on a temporary-directory path containing a double quote, which a
sandbox-exec profile cannot express safely.
The sandbox hides other skill-lens temporary directories, and re-allows the bundle explicitly¶
The macOS profile allows by default, denies network* and file-write*, re-allows writes
under the workspace and scratch (and file-write-data on /dev/null), denies file-read*
under the resolved system temporary directory, then re-allows reads under the workspace,
scratch and the bundle; bwrap mounts an empty tmpfs over the temporary directory and
binds the same three back in (the bundle read-only). The bundle must be re-allowed because a
--baseline previous bundle is extracted under the temporary directory. The temp-dir
denial is a subpath, not a regex: an escaped path in a regex literal would silently stop
matching on any metacharacter — a fail-open the blanket subpath cannot have. Reads
elsewhere are allowed; the docs say so rather than pretend otherwise.
bwrap does not block Unix-domain sockets¶
bwrap does not block a Unix-domain socket such as /var/run/docker.sock; macOS's
(deny network*) does. Security records the difference with the rest of the
backend's limits.
A previous baseline carries its own bundle¶
A previous baseline carries its own bundle, extracted with git archive <sha> --
:(literal)<dir> run from the repository root, where <dir> is the directory the skill had
at that commit — after a move it no longer exists in the checkout, so there is nowhere to
run git archive <sha> -- . from — and the <dir>/ prefix stripped from every member
(verified: <sha>:./ gives an empty archive). It is then filtered to the three directories, with tarfile's data filter — which is why
requires-python is >=3.11.4, the release that added filter=. The commit is the one
that last edited SKILL.md at the previous version — the version is the authority — so a
bundle-only commit after it is invisible, and a very large historical assets/ can hit the
10-second git timeout, which comes back as BaselineUnavailable
(cannot archive commit …), a note, never an error. A commit with no bundle gives
bundle_root=None — never the candidate's.
Baseline bundle directories are deleted when the run ends, however it ends¶
_BaselineStore.cleanup() runs in a finally around execution. --keep-workspace does not
keep them: they are an input, and the commit they came from is in the report already.
Bundle tools require a workspace: block, and their names are reserved in every workspace case¶
The workspace is the script's working directory and, with its scratch directory, the only
host area the sandbox lets it write to; a name that is sometimes free is a name nobody can
rely on. The tools are registered in mode: offered too — an agent that declines the skill
has no reason to call them, and one that triggers it needs them exactly as a loaded case
does.
The workspace preamble is unchanged by the bundle tools¶
The workspace preamble is unchanged — byte-identical in both arms, naming no skill. The
bundle tools describe themselves; "run scripts/count.py" comes from SKILL.md, which is
the thing under measurement.
A script's temporary files never enter the workspace¶
TMPDIR points at a per-call scratch directory outside it, deleted afterwards, so
list_files, file-produced and the judge's artifacts see only what the agent and the
script deliberately produced. A script writes to disk directly, so the workspace caps cannot
refuse it beforehand: the directory is measured after the call, an overrun becomes a
warning: line on the tool result, and every later write_file is refused by the existing
projection. Nothing bounds what a script can leave on disk — a sparse file has any
apparent size for almost no blocks — so the bound that matters is on the way back in: every
reader refuses a file over max_file_bytes before opening it (next).
Only a regular file or a directory is ever resolved, and reads are capped¶
An earlier version's Workspace assumed its only writer was Workspace.write, which
creates regular files; a bundled script can create anything. Workspace._inspect and
SkillBundle._inspect share two helpers in workspace.py: resolve_under, which turns a
Path.resolve() failure into PathRefused (3.11 and 3.12 raise RuntimeError on a symlink
loop; 3.13 stops resolving and the stat that follows raises ELOOP — both must become one
refusal), and stat_regular, which refuses an existing target that is neither S_ISREG nor
S_ISDIR. The check is os.stat on the resolved path (symlinks already followed), never
an open: opening a FIFO blocks until a writer connects, which no reader in the harness is,
so a FIFO a script planted under the name an assertion reads would hang the evaluator, the
judge, and read_file inside the agent loop where no timeout applies. Workspace.read and
SkillBundle.read then refuse st_size > max_file_bytes before reading a byte, naming the
cap the way write does. The agent's read_file sees the configured value, because the
orchestrator created that workspace with the repository's limits; the evaluators and the
judge rebuild Workspace(root=…) from the result's path with default limits, so their
ceiling is the 1 MB default even where max_file_bytes is raised, and the bundle always
uses the default — docs/configuration.md says so. A sparse file with an apparent size of
50 GB used to be read_text()ed whole before any budget applied, a MemoryError escaping
the assertion evaluator. file-produced is unaffected by the cap: it asks whether the file
exists. Every FIFO test runs the read in a thread with a join timeout, so a regression fails
the suite rather than hanging it.
The author's path and the run's target are judged separately¶
AssertionEvaluator calls check_relative_path on the file: first and raises
InvalidAssertionValue for a path that could never name a workspace file — empty, absolute,
.. — because that is a mistake in the eval file. A PathRefused raised afterwards by
Workspace.resolve or read (a symlink the script planted that resolves outside, a FIFO, a
loop, an over-size file) is a failed CheckResult carrying the refusal text as
evidence, never an exception: it is the skill's doing, and an exception would turn an eval
failure into exit 2 and, under --concurrency, cancel every queued case. The judge draws
the same line — a refused artifact is rendered as NOT_PRODUCED, except an over-size one,
which Workspace.read raises as FileTooLarge (a PathRefused subclass) and the judge
renders as TOO_LARGE ((too large to read)), because the file exists and "not produced"
would misdescribe the skill's output to the judge; it is a harness-authored sentinel, so
like the others it never consumes the artifact content budget — and runners/tools.py turns
every one of these into a tool-result string.
scripts= reaches a runner only when execution is on¶
_run_one passes the keyword only when the runtime is set, so a third-party runner written
against a version without bundled scripts keeps working until the day someone turns scripts
on — at which point the TypeError names run() as the method that cannot take the keyword
and escapes run_evals as an uncaught traceback (exit 1), rather than the scripts silently
never running. Both bundled adapters, PydanticAI and LangChain, take it and register the
same six built-in tools under the same conditions.
Failure context, the run matrix and scaffolding¶
Output is expanded only under non-passing candidate outcomes¶
failure_context returns None for a passed outcome, a baseline outcome or an outcome with
no result, and every reporter renders nothing on None. Fifty green cases stay fifty lines,
and a baseline outcome — which is not the verdict — is never expanded.
A cut to the failure excerpt is never silent¶
FailureContext.cut is the exact number of characters removed and cut_note states it,
with the flag that lifts the cap. A truncated excerpt that looked complete would be worse
than none.
The three reporters render one FailureContext¶
Console, Markdown and JUnit call the same helper and may differ only in markup. Two excerpts computed separately would drift the first time one of them changed.
A --case matching nothing fails the gate¶
The fourth zero-cases cause, beside no skills, no cases and --tag: case_filtered_skills
on RunReport records the skills the filter emptied, and evaluate_gate names the flag. A
typo in a filter is not a pass.
--case has no config key¶
A filter chooses which cases one invocation runs. A config file that permanently narrowed
the suite would let a green run measure less than the repository declares. full_output and
keep_workspace are rendering knobs with no such failure mode, which is why they may live
in the file.
Every candidate (skill, case, runner) outcome counts toward the gate, and none counts twice¶
The orchestrator has always run a skill × case × runner matrix and every reporter keys on
CaseOutcome.runner; naming more than one runner on the CLI or in the config came later.
The one new rule is that a name given twice is refused rather than collapsed —
cli._resolve_runners and Config.default_runner's validator both enforce it — because
under --repeat and --baseline a duplicate would weight one framework's vote double.
--runner replaces the configured list; it never appends.
init never creates an evals/ directory beside existing *.eval.yaml files¶
Discovery prefers evals/ when it exists, so creating it would hide the files already there
from every later run — silently, with nothing red. scaffold_target writes beside
SKILL.md in that layout.
Batch init never overwrites¶
A skill with any eval file is skipped and named; --force in batch mode is a user error.
Rewriting every suite in a repository must never be one flag away.
The unfilled-scaffold scan covers mapping keys as well as values¶
workspace: files: is keyed by filename; an unfilled filename would otherwise seed a file
literally named after the placeholder.
examples/greeting stays at 1.1.0 or later, with 1.0.0 in history¶
--baseline previous resolves an earlier declared version from git, so the shipped
comparative example only works because the bump is real and the earlier version is on
main.
Mock tools¶
An imported mock is the server's schema verbatim, and returns is never invented¶
mcp-import copies name and inputSchema byte-for-byte into input_schema: and writes
the TODO(skill-lens) sentinel for returns (and for a missing description), so a pasted
block cannot run until the author has said what the tool returns. The listing says nothing
about return values; a generated one would be exactly the silent drift the import exists to
remove. The server's outputSchema, when declared, rides along as a comment.
parameters and input_schema are exclusive, and input_schema is validated at load time¶
Both set, a schema that fails check_schema, or a top-level type other than object is an
authoring error (exit 2) in cases/loader.py, before any case runs — the same treatment an
assertion's json_schema gets.
The shorthand is closed; a declared schema is open¶
build_mock_tool adds additionalProperties: false and marks every key required only when
it derived the schema from parameters:, where the author wrote every key. A declared
input_schema is deep copied and passed as written, because fidelity to the server it
stands in for is its reason to exist.
A tool name is what providers accept¶
A tool name is what providers accept: ^[A-Za-z0-9_-]{1,64}$, the rule OpenAI and Anthropic
enforce, in place of isidentifier(). MCP tool names are routinely hyphenated and a mock
must answer to the name the skill's prose and the live server use; the import keeps the
name, and a name outside the rule is an error naming the tool, never a rewrite.
skill_tool_name — the offered-skill tool — is the one place a name is rewritten, and it
is total over the same rule: anything outside [A-Za-z0-9_] becomes _ (the hyphen
included, because the recorded cassettes register order-support as order_support), a
leading digit gets skill_, and the result is cut to 64 characters after the prefix. A
Python identifier was not enough: café is one, and every provider rejects it.
mcp-import never touches the network¶
SOURCE is a file or -; a live --server is deliberately deferred (see the design spec).
EvalCase.tools holds only ToolSpec¶
A ref: is resolved by cases/loader.py on the raw case mapping, before
EvalCase.model_validate: ToolSpec forbids unknown keys and requires a name, so a
reference is not a ToolSpec and must never become one half-built. Resolving there is what
keeps every runner, evaluator, reporter and product preflight unchanged — the product
runners serve (or, under cli, refuse) a referenced tool exactly as an inline one, and the
duplicate-name, built-in-name, offered-skill and trajectory checks all read the resolved
name.
A ref: may set returns: and nothing else¶
The library owns the contract (name, description, schema); the case owns the scenario.
Overriding the contract per case would reintroduce the drift the library exists to remove;
ToolRef (extra="forbid") is what refuses it.
An unresolvable ref: is an authoring error at load time¶
An unresolvable ref: is an authoring error at load time, exit 2, before any case runs: no
tool_libraries: key (the message says to add one), an unknown name (the message lists the
names the imports declare), a missing, unreadable or malformed library. Never an errored or
failed case.
A name two imported libraries declare is refused naming both files¶
A name two imported libraries declare is refused naming both files — never resolved by position — and so are one file declaring a name twice and one file imported twice (listed twice, or a directory plus a file inside it). A runner named twice is refused rather than de-duplicated for the same reason.
Library paths are relative to the eval file, never the working directory, and never absolute¶
Library paths are relative to the eval file, never the working directory, and never absolute
(is_absolute(), drive or root, the three checks check_relative_path makes — but ..
is allowed, because the library sits above the skill by design). The same eval file resolves
identically under discovery, --evals, list and from any checkout.
A library is checked as an eval file is¶
A library is checked as an eval file is: the sentinel (keys and values), the tool-name rule, unknown keys at the tool and at the top level, schema validity — each refusal naming the library file and the tool's position, because that is the file to fix; the case loader then prefixes the eval file that imported it.
A library is a top-level tools: list and nothing else¶
A library is a top-level tools: list and nothing else — the block mcp-import prints, so
mcp-import tools.json > shared-tools/server.yaml is the whole import step.
The resolver never mutates parsed YAML¶
An anchored tools: list aliased into two cases resolves in both; the resolver returns a
new mapping with a new list.
call_args matches structurally and coerces nothing¶
structural_match in matching.py walks the entry against the argument dict the runner
recorded — never a serialised string, so key order and whitespace cannot matter. contains
ignores keys it does not name at every level; equals requires exactly the named keys; a
list matches element by element at equal length; a scalar must be equal — and a bool only
ever equals a bool, because Python's True == 1 would otherwise let limit: 1 pass on a
call that sent true. "1" never equals 1. No runner changed: every adapter and both
trace parsers already fill ToolCall.arguments, and a payload one could not parse sits
under _raw, which never matches a structural check and appears in the evidence.
A tool that was never called fails call_args, under every: true too¶
"At least one call matched" and "every call matched" are both false over zero calls; a pass
there would be the vacuous pass this project refuses everywhere else. every exists because
the issue's own example needs it: a skill told to fetch only active threads, that fetches
everything first and re-fetches filtered, passes the default and fails every.
One subject per call_args entry¶
Exactly one of contains / equals; contains: {} is refused because {} is a subset of
every dict — called: spelled longer — while equals: {} is kept because "called with no
arguments" is something a call can fail. These are shape rules, so they live in a
model_validator on CallArgsSpec and a programmatic EvalCase gets them too; the
loader's existing ValidationError wrapping names the file and case. The declared-tool
rule is the runner's, in check_trajectory_names beside called / forbidden / order,
built-ins included where a workspace: exists — see the invariant on trajectory names
above.
Ids are positional, call_args[{index}]¶
Ids are positional, call_args[{index}], because two entries may name one tool, and derived
from the case so both arms pair. A failing check's evidence renders the arguments every call
carried as sorted JSON and cuts at _ARGUMENTS_LIMIT with the removed count stated — a
write_file call can carry a document — while the failure excerpt keeps the full calls.
returns: takes three shapes, and the shape says which rule applies¶
A skill whose instructions loop over a tool — fetch item A, follow its parent link, fetch B,
stop when there is none — cannot be exercised by a mock that hands back one fixed value.
returns: therefore takes three shapes, and the shape says which rule applies: a string
answers every call; a list of strings is a sequence, consumed in the order the calls
arrive; a list of when:/value: mappings is a lookup, answered by the first entry
whose when: keys all equal the call's arguments. A list that mixes the two, or an empty
list, is refused by ToolSpec itself (_check_returns_shape, before Pydantic's union would
report both branches), so an eval file can never be half one thing and half the other.
ToolRef.returns takes the same three shapes: the case still owns the scenario and nothing
else.
A sequence repeats its last entry once used up¶
The last entry is the steady state a skill that keeps calling should see — the item with no
parent, the job that is done — and trajectory.max_calls is the check for a loop that
should have ended. A visible "no more entries" message would instead teach the model
something about the harness. The counter lives in the built tool (a locked closure in
build_mock_tool), never on the runner, so every build — each arm, each attempt, each work
item under --concurrency — starts from the top, and a framework running several calls from
one model turn in parallel still hands out each entry exactly once. Which parallel call gets
which entry is the framework's choice; where the argument, not the position, decides, the
docs say to use a lookup.
A retried attempt gets fresh tools¶
Both keyed adapters build the agent inside the retried callable (_run_with_retries /
_invoke take a builder, not an agent). An agent reused across attempts would hand the
second attempt's first call the sequence's second entry, and a transient 429 would silently
change what the skill was shown.
A lookup entry that could never answer a call is an authoring error¶
A lookup entry that could never answer a call is an authoring error (exit 2), caught by
check_tool_returns in cases/checks.py at load time for an inline tool, a library tool
and a ref: override alike — the ref's returns: is checked against the contract the
library declared, because the resolver copies the raw value and _validate_tools reads the
resolved tool. Three refusals: a when: key the tool's closed schema can never carry (the
parameters: shorthand, or an input_schema with additionalProperties: false and listed
properties; an open schema may key on anything), an empty when: (the fallback spelled
confusingly — drop the key), and an entry an earlier entry already answers (a fallback above
it, the same when:, or a when: it only narrows), since the first match wins. All three
are checks that could never fire, the vacuous case this project refuses everywhere.
A call no lookup entry answers gets a message, never an exception¶
The skill asked for an argument the author did not script — an eval signal — so the model
reads NO_RESPONSE_SCRIPTED (the tool's name and the arguments as sorted JSON,
default=str so an unencodable value cannot make a mock raise) and the transcript shows it.
An entry with no when: is the fallback for authors who want a real error payload instead.
A when: matches by the call_args.contains rule, through the one matcher¶
ToolResponse.matches is matching.structural_match(when, arguments, exact=False): a
subset at every level, a list element by element at equal length, "1" never 1, and a
bool only ever equal to a bool — the same function evaluators/trajectory.py runs, so an
author learns one rule for naming arguments and the two can never drift. The loader's
reachability check uses the same matches, so "unreachable" means exactly what the runtime
would do.
Reading evals.json¶
evals.json is converted to raw case mappings and validated by the existing pipeline¶
cases/evals_json.py checks the shape of the file and stops: it produces the same raw case
mappings the YAML loader reads. EvalCase.model_validate and the loader's _validate_*
functions are the rules, so a JSON case and a YAML case meet identical checks, and no runner,
evaluator, reporter, model or exit code knows which file a case came from. CaseParseError
lives in cases/errors.py and the loader re-exports it, because the loader imports the
converter and the converter raises the error.
evals.json keys are strict, and nothing to grade is refused¶
An unknown top-level or per-case key is an authoring error (exit 2) naming the key. There is
no leniency flag: the only thing that stops a misspelled assertion: from dropping every
check is that it is refused. assertions (the Agent Skills guide) and expectations
(skill-creator) are one list under two names, and both in one eval is refused as
ambiguous. An eval with no statements and no expected_output is refused, never passed;
expected_output alone becomes the single check The output satisfies: <expected_output>,
because the guide defines it as a description of success and tells authors to start with
only a prompt and an expected output. A leading byte-order mark is skipped before parsing.
evals.json id is never a boolean and the case name is eval-<id>¶
bool is an int in Python, so true would otherwise become id 1. 1 and "1" give the
same case name and collide.
evals.json input files are text inside the skill directory, read at load time¶
The converter uses check_relative_path, resolve_under and stat_regular from
workspace.py, so a FIFO, a symlink loop, a link out of the directory, a folder, a file that
is not UTF-8 or one over max_file_bytes aborts the run before any case executes. The
load-time limit is the default cap because the loader has no configuration; the configured
cap still applies when the workspace is seeded. A workspace: block exists only when files
is non-empty.
Product runners¶
The product sees SKILL.md verbatim (its text as written; line endings are normalised)¶
Skill.markdown is the file as the author wrote it, never a re-rendering from the parsed
fields; only the loader and the baseline resolver set it. The loader reads it with
read_text(encoding="utf-8"), which is text mode: a \r\n in the file becomes \n in
Skill.markdown, so the delivered file is the file's text verbatim, not its bytes -- a
newline-preserving read would push \r\n into the framework runners' system prompts too, so
the loader stays as it is. Products honour frontmatter keys skill-lens does not model
(allowed-tools, disable-model-invocation, license); a re-rendering would change the
product's behaviour and the eval would measure the re-rendering. deliver_skill writes it
with newline="" so no further translation changes a byte on the way out, and copies
scripts/, references/ and assets/ beside it — those three and nothing else, so an eval
file beside SKILL.md never reaches the product. The --baseline none skill has an empty
markdown, so no directory is written for it: the rule is keyed on emptiness, not on which
arm is running, the same rule BASELINE_PREAMBLE uses, so a runner never has to know which
arm it is serving.
The prompt is the task verbatim, and the baseline-none arm never sees the skill's name¶
The product owns its system prompt; anything skill-lens added would be part of what
--min-delta measures. mode: loaded invokes the skill through the product's own spelling
(/<name> <task> for both presets, invoke for cli); mode: offered sends the bare
task. The baseline arm, having nothing to invoke, gets the bare task in both modes.
skill_triggered comes only from the product's own load signal¶
Copilot's skill tool request (toolRequests[] named skill, arguments.skill) or its
skill.invoked event, Claude Code's Skill tool call. Copilot emits skill.invoked for a
slash invocation only — the probe recorded it from /ping, loaded mode — while a skill the
model chose itself is a skill tool request with no event, on 1.0.37 and 1.0.86-2 alike;
reading the event alone failed every positive offered case under --runner copilot (issue #50).
Inferring it from the output would let a model that guessed the answer read as a triggered
skill. A product with no such signal — cli — makes mode: offered an authoring error
under that runner, never a silent false, which would pass every negative control.
ProductRunner.run still returns None rather than False for a generic product, so the
runner never claims what it cannot observe.
A limit the product cannot measure fails, it never passes¶
RunResult.usage_note is to tokens what cost_note is to cost: 0 <= max_tokens is always
true, so BudgetEvaluator records a max_tokens under a non-empty usage_note as a
failing not evaluated check carrying the note, exactly as it already did for
max_cost_usd under cost_note, and excludes it from score's divisor. Copilot bills per
premium request (its billing unit: one counted request to a model, not a token count), so
its cost is 0.0 with a cost_note naming the count and max_cost_usd fails as not
evaluated; Claude Code reports total_cost_usd at list price (the provider's published
per-token price), a real and comparable number, so it has none.
A truncated trace is RunResult.error, never a partial parse¶
The final event is the last line, and losing it loses the output. invoke compares the
captured size against max_output_bytes and read_trace turns an over-size capture into an
error naming the key, so the fix is one config line. The other way round: a complete trace
carrying the product's own error message (session.error, is_error) is the best
explanation there is and wins over the exit code; only an incomplete trace, or one with no
error of its own, gets the exit code and the stderr tail instead.
Naming a product runner is the trust decision, and the report says so¶
The product runs with permission prompts disabled (--allow-all-tools,
--dangerously-skip-permissions) because it cannot run non-interactively otherwise, and
with the full environment (os.environ.copy()) because it needs its own auth — the opposite
of scripts.script_environment's allowlist, on purpose: here the product is the harness,
not the subject. No skill-lens sandbox applies; bundled scripts are reachable through the
product's own tools whatever allow_scripts says, which governs only run_script — gating
the runner on it would also switch on run_script for every framework runner in the same
matrix. TRUST_NOTE is fixed harness text on ProductStatus.trust, on the model rather
than in each reporter, so the three reporters cannot drift and the JSON carries the sentence
a human reads.
Preflight spends nothing¶
ProductRunner.preflight finds the executable on PATH and, for the two presets,
executes its --version (a copilot that cannot start is exit 2 up front, not thirty
errored cases — the same rule as the sandbox probe; cli has no version command, so only
the PATH lookup applies to it), checks every skill name is one directory entry, and
refuses tools: under cli and under a preset whose command names another executable —
the products that cannot take the MCP bridge,
never under a preset that can — and trajectory: or mode: offered under cli, all
before the first case; when a planned case declares tools: under a preset that serves
them, it starts the bridge once with --check rather than refusing. It checks no
trajectory: name against anything: under a product the names are the product's own tools,
which skill-lens cannot enumerate (a translation table would be a third moving target), so
that rule belongs to the framework runners' preflight —
see the invariant above. The orchestrator hands it only the candidate-arm (skill, case)
pairs planned for that runner, once each: compatibility is a property of (case, runner),
so a case --tag or --case filtered out is not its concern, and neither arm nor repeat
changes the answer. deliver_skill re-checks the skill name at run time because under
--baseline previous it comes from a historical SKILL.md that never passed preflight;
that failure is RunResult.error for the arm it concerns, never a raise.
process.py is the one implementation¶
process.py is the one implementation of the process-group start, the timed wait, the kill
after every exit, and the capped read through the harness's own handle. scripts.py and
runners/product.py spawn under opposite trust models but must agree on those mechanics,
and one implementation is what stops two callers drifting. It imports nothing from the rest
of the project.
--model with nothing to read it is a user error, and so is --judge-model¶
--runner fake --model x was once silently ignored; with --runner copilot that silence is
a mistake that is easy to miss, because the flag looks honoured while the product runs its
own default. cli.py refuses --model unless a keyed runner is named or a keyed judge with
no judge_model of its own will fall back to it, and refuses --judge-model unless the
judge is keyed. A product's model is set with [runners.<name>] args, which never reaches
the wrong runner.
tools: under a product that cannot take the MCP bridge is an authoring error¶
tools: under a product that cannot take the MCP bridge is an authoring error, not a
silently emptier run: cli has no known flag, and a preset whose command names another
executable is not known to take the preset's. Ignoring the block would make
trajectory: called: fail for a reason that says nothing about the skill and forbidden:
pass vacuously. Under the two presets the block is served; see the MCP bridge.
ProductRunner.run never raises for a product failure¶
A timeout, a non-zero exit, a product-reported failure, a truncated trace, a prompt over
MAX_PROMPT_BYTES, an executable missing at run time and a ProductSetupError from
delivery are all RunResult.error. ProductSetupError propagates only from preflight,
where cli.py turns it into exit 2.
command replaces the argv; args appends; presets forbid skills_dir and invoke¶
Two keys with two meanings: drop an isolation flag with command, add a model with args.
command must contain exactly one element equal to {prompt}, not in the executable slot,
substituted as a whole argv element and never through a shell; args must not contain it. A
preset is the verified spelling for its product; a repository that needs a different skill
directory or invocation is describing a different product and says so with cli.
[runners.<name>] holds no token: the product reads its own.
A product judge's verdict is the first balanced JSON object in the reply, validated as JudgeOutput; anything else is JudgeVerdict.error¶
A product has no structured-output mode, so judges/product.py sends the shared prompt
(judges/prompt.py's rules and request, the same text the framework judges send) as one
user turn closed by a line asking for one JSON object and nothing else, then reads the reply
with extract_json_object: one pass over the text, a stack of unmatched { positions
outside JSON strings, the earliest opening brace that closes wins — so an outer object beats
an inner one that closed first, and a } inside quoted evidence never ends the object
early. The object is validated by _RawVerdict, a strict local view of JudgeOutput
(extra="forbid", checks required, title="JudgeOutput" so the error names the shape
callers asked for) — local so that the shared JudgeOutput the framework judges bind as
structured output stays lenient and its generated JSON schema, and the cassettes recorded
against it, are unchanged. No object, a cut-off object, the wrong shape, an empty object or
a key beside checks is JudgeVerdict(error="JudgeOutputInvalid: ...") naming the actual
mismatch, one attempt, which JudgeEvaluator reports as an errored case: an unreadable
verdict is an infra signal, not a low score, and the error says so here rather than
surfacing several layers away as a mismatched id set. A product failure — timeout, non-zero
exit, truncated output, an executable gone since preflight — is JudgeVerdict.error the
same way, through the runner's own read_trace; the judge never raises. An over-size prompt
is checked in the judge before the product starts, also JudgeVerdict.error, but never
reaches read_trace. The judge's working directory is a fresh empty temporary directory
removed in a finally, and holds no skill: the judge grades text and must not discover the
skill under test. Product.judge_args is appended only when the product judges —
("--tools", "") for claude-code and ("--available-tools=skill-lens-none",) for
copilot, each verified against the product to leave the model no tool, built-in or MCP;
Copilot ignores an empty --available-tools list, so the list names one tool that does not
exist, as a single = element so the variadic option can never swallow what follows it —
after the table's args, so a repository's model flag still applies, and only while
command still names the preset's executable, since a wrapper is not known to accept the
flag. Product.tool_flag names the product's own tool-selection flag (--tools,
--available-tools); both products accumulate a repeated one rather than taking the last
(verified on both), so a table args or command entry equal to it or starting with
<flag>= would hand the judge tools back behind judge_args, and
ProductJudge.preflight() raises ProductSetupError (exit 2) naming the entry before any
case runs. judge_temperature is not consulted: no product exposes it. Tokens, cost, cost
note and model come from the trace, so cli reports none and Copilot reports a per-request
note, as under the runner. ProductJudge.preflight() finds the executable and runs the
preset's --version with role="judge" in the message. cli.py demands a key and a
judge_model only for the two keyed judges in _KEYED_JUDGES; a product judge is built
from its table with neither, and --judge-model with a product judge is refused as a user
error.
A case's tools: reach a preset product through a stdio MCP server skill-lens ships, and the product starts it¶
The framework adapters register mock tools as callables inside the agent loop they drive; a
product owns its loop, and what it can take is an MCP server named in a config file.
mcp_bridge.py is that server, started by the product as
python -m skill_lens.mcp_bridge <spec> — sys.executable, so the package is importable
with no environment of its own — and runners/mcp.py is the runner's side: per invocation,
write_bridge puts the spec (name, description, the schema build_mock_tool registers,
returns) and the product's config into a fresh directory of its own, never the working
directory, which the product can list and a file-produced assertion can read; the config
travels as the last argv element (--mcp-config=<file> for Claude Code, beside the preset's
--strict-mcp-config; --additional-mcp-config=@<file> for Copilot), one element with =
so Claude Code's variadic option can never swallow what follows. The directory is deleted in
the run's finally, kept by nothing. The bridge is started with -P, so the product's
working directory — the case's workspace — never enters its import path, and it imports only
matching from the project, because the fewer things a child the product starts needs, the
fewer ways that start can fail: a when: must match by the one rule every runner uses, and
matching itself imports nothing from the project. It answers returns: in all three
shapes by the rules runners/tools.py applies — one value; a sequence in call order, its
counter in the server process, so every invocation starts from the top; a when: lookup, a
call no entry answers getting the NO_RESPONSE_SCRIPTED wording the runner writes into the
spec — and it writes nothing but JSON to stdout, because stdout is the protocol. No
dependency was added: four JSON-RPC methods do not need an SDK.
Mock tools under a product need no new opt-in¶
A mock returns canned text and executes nothing — it adds a fixed answer to a tool the skill
may call, not a capability the product lacked — so a case with tools: runs under a product
the way it runs under a framework runner, in both modes and both arms. Naming the product
runner remains the trust decision, and TRUST_NOTE is unchanged.
The trace names the tool the product's way; the result names it the case's way¶
The model sees mcp__skill-lens__<name> under Claude Code and skill-lens-<name> under
Copilot (each verified by reading what the product sends the model), and the trace reports
the call so. restore_tool_names maps each declared tool back, by its exact product
spelling and nothing looser, so trajectory: called/forbidden/order — which the loader
already restricts to declared names — read identically under every runner; every other call
keeps the product's name, per the decision not to normalise tool names across products. The
transcript keeps the product's spelling. Tool calls are still what the model requested, read
from the trace; the bridge's record is not the source of the trajectory.
A product that ran but never asked the bridge for its tools is errored, never a failed called:¶
The server appends a list event to a record file on every tools/list; Bridge.connected
reads it after the run. It is product-independent — Claude Code's init event and Copilot's
session.mcp_servers_loaded both report server status, but the record needs neither parser
to change. A trace error wins over the connection check, because a product that failed is
the better explanation.
Preflight proves the bridge starts and refuses what would hide it, before any quota is spent¶
When a planned candidate case declares tools:, probe_bridge runs the module once with
--check under sys.executable — executed, not merely found, like the version probe and
the sandbox probe — and a BridgeSetupError is a ProductSetupError, exit 2. The same
preflight refuses --available-tools in [runners.copilot] (verified: it keeps only the
tools it names, MCP included), mirroring the judge's refusal of the flag; Claude Code's
--tools governs the built-in set only (verified: the MCP tool stays listed under
--tools "") and is not refused. Config.product drops mcp along with the version probe
and judge_args when command names another executable, so tools: under such a table is
refused with a message naming the flag the wrapper is not known to take. The product judge
is built from the same Product but never writes a bridge: it grades with no tools.
ProductRunner.run still never raises¶
A bridge directory that cannot be written is OSError → RunResult.error; a runner reached
with tools: and no mcp (a caller that skipped preflight) returns an errored result
rather than running the case without its tools.