feat: source a real codebase into each task environment - #278
Merged
Conversation
An eval environment could only be assembled from individual fixture files copied out of `<skill>/evals/`, so there was no way to say "run this task against *this project*". Add a `codebase` block: a git `url` + required `ref`, or a local `path`. It is declarable at the config level as a default and overridable per eval, mirroring how `runs` already works. `ref` is required on a git source because the runner records the resolved SHA. An eval tracking a moving branch could not be re-run against the tree it actually measured, which is the whole point of recording provenance. The schema owns the structural contract, but `oneOf` cannot explain itself: a git source missing its `ref` reports only that the block matched neither branch, never naming `ref`. A small check ahead of the schema names the mistakes worth a sentence, and covers the whitespace-only case that `minLength: 1` admits. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The codebase block needs turning into a real tree, and #253 needs the same machinery for skills, so this lands as a shared module rather than inline in the codebase path. Nothing in it knows what a codebase is. Two phases, deliberately split. `resolve` is read-only, so a run fails on an unreachable repository or a ref that does not exist before it has built any part of a workspace. `materialize` then clones a source that has history, or copies and initializes one that does not — either way the destination is a Git repository with no remote, since a task environment must not be able to reach the source it came from. Two details worth naming, both pinned by tests: `ls-remote` runs unfiltered. Passing a ref pattern suppresses the `ref: refs/heads/<x>\tHEAD` line, and that line is the only way to learn the remote's default branch — which is where a tag or a bare SHA has to land, having no branch of its own. One unfiltered call answers both questions. An annotated tag resolves through `refs/tags/<x>^{}` to the commit it peels to. The tag object itself is not a commit and cannot be checked out as one. A local path is materialized as a clean checkout of its committed state, so uncommitted work in the source is not carried; resolution warns when the source is dirty rather than letting that pass unnoticed. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The task-repository assertion pinned `%aI` as `2000-01-01T00:00:00Z`, but git renders a zero UTC offset as `+00:00` on 2.43 and `Z` only on newer versions. The test therefore passed on CI and failed on any host with the older git, for a difference in spelling rather than in behavior. Normalize the offset before comparing, so the assertion stays exact about the instant it cares about without pinning a git version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolution now happens before anything is created, so an unreachable repository or a ref that does not exist fails while the run has still built nothing. Each distinct codebase is materialized once per iteration and every `(group, condition, run)` environment is provisioned from that one tree; `files` is copied on top, making it an overlay on a real project rather than the whole of the environment. The task-repository lifecycle had two invariants a real codebase breaks by construction: it `git init`ed every environment from nothing, and it rejected any remote. A sourced environment now keeps the `.git` its clone brought, has its remotes stripped rather than asserted absent, and stays on the branch the codebase itself was on. A fixture-only environment still starts from `git init` on `work`, so evals that declare no codebase are untouched. Both kinds now mark their start state with `refs/eval-magic/baseline`, which #255 measures against. It sits outside `refs/heads/`, so it adds nothing to what the agent under test sees. The baseline `git add` drops `--force`. Forcing made sense when every file in the environment was one the runner had placed; against a real repository it would sweep `target/` or `node_modules/` into the state every run starts from. The add now respects the codebase's `.gitignore`, and the paths the runner placed — harness config directories and the fixture overlay — are forced in on top, so a codebase that ignores `.claude/` cannot hide the staged skill from the baseline and put the condition under test outside every later diff. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…base Sourcing runs git against a URL from an eval config, on a host whose git configuration belongs to someone else. Inherited, that configuration decides things the runner has to decide itself. `url.<base>.insteadOf` is the sharp one: it rewrites the URL, so the tree sourced is not the tree the report cites — a silent wrong answer rather than a failure. `init.templateDir` is the quiet one: it seeds hooks into a repository the write guard assumes has none. Every invocation in the module now runs with system and global configuration switched off, the `GIT_CONFIG_COUNT` environment mechanism cleared, and an empty template directory passed to `clone` and `init`. Tested at the run boundary rather than in a unit test: the injection mechanism is process-global environment variables, which a unit test cannot set without racing every other test in the binary. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A report that cites a codebase has to say which tree it measured, and the declared ref cannot say it — a branch moves. `conditions.json` now carries each distinct resolved codebase with the commit it resolved to and the evals built from it, and every dispatch task carries the same record, which is the route it takes to each run record. One shape, `CodebaseRecord`, is shared by every surface so a reader never has to reconcile two spellings of one resolution. A `path` source is flagged `host_local`. Another machine has that directory somewhere else, or nowhere, so a run citing it is not reproducible from the config alone. Nothing can fix that, so the artifact states it instead of implying a reproducibility it does not have — and where the directory is a repository, its `origin` is recorded too, since `origin_url` + `revision` does resolve anywhere. Fixture-only iterations serialize unchanged: the field is omitted when empty. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ercise `mod.rs` reached 759 lines with 422 of them tests — the test module had grown larger than the implementation it covers. CLAUDE.md's rule is a size trigger, and this crossed it. No behavior change; the same twelve tests run from a sibling file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…seline Provenance stopped at `conditions.json` and `dispatch.json`, which is short of where it is read. Grading consumes `run.json` and nothing else, so without the record there a result cannot be tied to a tree at the granularity that matters — the individual run. `benchmark.json` is the artifact a published comparison is read from. `BASELINE.md` is what someone reads when deciding whether to believe the claim. All three now carry it, and both schemas gain the property (each is `additionalProperties: false`, so the artifacts would otherwise fail their own validation). The `BASELINE.md` row names the resolved commit rather than the ref, since a branch has moved by the time the baseline is read. A host-local path says so in the cell and shows its origin URL, which is the part a reader elsewhere can actually resolve. Absent-when-empty throughout, so fixture-only artifacts are byte-identical. Refs #252 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The codebase block has no CLI flag, so `--help` cannot carry its rules and a config author has nowhere to discover them. It gets its own shipped topic, which `build.rs` picks up from `docs/guides/`. The guide leads with the parts that cannot be inferred from the schema: that a git ref is mandatory and why, that `files` layers over the checkout rather than replacing it, what the resulting repository looks like, and that a local path is not reproducible by anyone reading the published results. The isolation guide gains a short section separating the two boundaries it would otherwise be read as covering: what a dispatch can load is not what a dispatch can reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the property programmatically rewrote both files: every compact one-line object was expanded, and the em dash in the run-record title was escaped to `—` — a content change to a shipped description, buried in 450 lines of formatting churn. Hand-written now, in the surrounding style. Both diffs are additions only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The origin-citation test registered the remote with the host's native path separators but asserted against the forward-slash form. On Linux those are one string, so the mismatch was invisible; on Windows the assertion compared a backslash path against a slash path and failed. Git stores a remote URL byte-for-byte and eval-magic cites it unchanged, so the fix is to hold both ends to the host's own spelling. That also makes the assertion load-bearing on Windows: any separator normalization between the source repo and conditions.json now shows up here rather than passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #252. Foundation ticket for #244 — the full-codebase eval testbed.
What changes
An eval set can declare a
codebaseand every(eval, condition, run)environment is built from areal checkout of it instead of a handful of fixture files.
{ "skill_name": "working-with-tdd", "codebase": { "url": "https://github.com/slowdini/example-project", "ref": "v1.4.0" }, "evals": [ { "id": "add-a-feature", "prompt": "...", "expected_output": "...", "files": ["docs/TASK.md"] }, { "id": "big-refactor", "prompt": "...", "expected_output": "...", "codebase": { "path": "../fixtures/legacy-service" } } ] }Config-level default, per-eval override, mirroring
runs. Sources are a giturl+ requiredref,or a local
path.files/files_rootsurvive, re-cast as an overlay applied on top of the checkout.Before / after
A prepared environment used to be a fresh
git initholding only what the runner placed:It is now the project, with its history, on its own branch:
Notable decisions
The task-repository lifecycle had two invariants a real codebase breaks by construction. It
git inited every environment from nothing, andverify_task_repositoryrejected any remote. Asourced environment now keeps the
.gitits clone brought, has remotes stripped rather than assertedabsent, and is marked with
refs/eval-magic/baseline— which #255 measures against. Fixture-onlyevals still get
git initonwork, unchanged.The baseline
git adddrops--force. Forcing made sense when every file present was one therunner placed; against a real repository it would sweep
target/ornode_modules/into the stateevery run starts from. The add now respects the codebase's
.gitignore, and runner-placed paths —harness config directories and the fixture overlay — are forced in on top, so a codebase that ignores
.claude/cannot hide the staged skill from the baseline and put the condition under test outsideevery later diff.
A local
pathis materialized as a clean checkout of its committed state. History intact,uncommitted work and ignored build output not carried; resolution warns when the source is dirty.
Host-local provenance is labelled rather than papered over. Nobody else can resolve
../fixtures/legacy-service, sohost_local: trueis recorded, the run warns, and theBASELINE.mdrow says so. Where the directory is a repository, its
originand the resolved commit are recordedtoo — those resolve anywhere.
The resolver runs git with the operator's configuration held off. Found while testing: an
inherited
url.<base>.insteadOfrewrites the codebase URL, so the tree sourced is not the tree thereport cites — a silent wrong answer rather than a failure. System and global config are off, the
GIT_CONFIG_COUNTmechanism is cleared, andclone/initget an empty template so a configuredinit.templateDircannot seed hooks.src/source/is a shared module, not inlined. #253 resolves the skills under test through thesame resolver, so it knows nothing about what a codebase is.
Provenance
The resolved commit — not the declared ref, which moves — reaches
conditions.json, eachrun.json,benchmark.json, andBASELINE.md. Absent-when-empty throughout, so fixture-only artifacts arebyte-identical.
Also fixed
tests/run/git_isolation.rspinned%aIas...Z, which git renders as+00:00on 2.43 andZonly on newer versions — green on CI, red on any host with the older git. Normalized.
Verification
865 lib + 178 CLI + 134 run tests pass; fmt and clippy clean. Every acceptance criterion on #252 was
also checked end to end against a real repository: history preserved, no remotes, baseline ref equal
to HEAD, clean tree, on the codebase's own branch, gitignored output excluded from the baseline,
overlay visible at its declared path, and the recorded revision matching the source's HEAD.
Docs
eval-magic docs codebaseis the new shipped guide — the feature has no CLI flag, so--helpcannotcarry its rules. The isolation guide gains a section separating the two boundaries it would otherwise
be read as covering: what a dispatch can load is not what a dispatch can reach.
Follow-ups (unchanged scope, tracked on #244)
git clone --localoff a SHA-keyed cache.capture_iteration_baselinesstill copies each working tree, so a large codebase is correct but slow.
🤖 Generated with Claude Code