Skip to content

feat: source a real codebase into each task environment - #278

Merged
slowdini merged 11 commits into
devfrom
feat/codebase-source
Aug 17, 2026
Merged

feat: source a real codebase into each task environment#278
slowdini merged 11 commits into
devfrom
feat/codebase-source

Conversation

@slowdini

Copy link
Copy Markdown
Owner

Closes #252. Foundation ticket for #244 — the full-codebase eval testbed.

What changes

An eval set can declare a codebase and every (eval, condition, run) environment is built from a
real 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 git url + required ref,
or a local path. files/files_root survive, re-cast as an overlay applied on top of the checkout.

Before / after

A prepared environment used to be a fresh git init holding only what the runner placed:

$ git -C env-g1-with_skill log --oneline
9c1f0a2 eval-magic task baseline

It is now the project, with its history, on its own branch:

$ git -C env-g1-with_skill log --oneline | head -3
685317e eval-magic task baseline
9ba3fcd second
8e02560 first
$ git -C env-g1-with_skill remote -v          # empty — nothing can reach the source
$ git -C env-g1-with_skill symbolic-ref --short HEAD
main
$ git -C env-g1-with_skill rev-parse refs/eval-magic/baseline HEAD
685317ea9d9bcce33a31227f623d8635f03b7f3c
685317ea9d9bcce33a31227f623d8635f03b7f3c

Notable decisions

The task-repository lifecycle had two invariants a real codebase breaks by construction. It
git inited every environment from nothing, and verify_task_repository rejected any remote. A
sourced environment now keeps the .git its clone brought, has remotes stripped rather than asserted
absent, and is marked with refs/eval-magic/baseline — which #255 measures against. Fixture-only
evals still get git init on work, unchanged.

The baseline git add drops --force. Forcing made sense when every file present was one the
runner 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 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 outside
every later diff.

A local path is 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, so host_local: true is recorded, the run warns, and the BASELINE.md
row says so. Where the directory is a repository, its origin and the resolved commit are recorded
too — those resolve anywhere.

The resolver runs git with the operator's configuration held off. Found while testing: an
inherited url.<base>.insteadOf rewrites the codebase URL, so the tree sourced is not the tree the
report cites — a silent wrong answer rather than a failure. System and global config are off, the
GIT_CONFIG_COUNT mechanism is cleared, and clone/init get an empty template so a configured
init.templateDir cannot seed hooks.

src/source/ is a shared module, not inlined. #253 resolves the skills under test through the
same resolver, so it knows nothing about what a codebase is.

Provenance

The resolved commit — not the declared ref, which moves — reaches conditions.json, each run.json,
benchmark.json, and BASELINE.md. Absent-when-empty throughout, so fixture-only artifacts are
byte-identical.

Also fixed

tests/run/git_isolation.rs pinned %aI as ...Z, which git renders as +00:00 on 2.43 and Z
only on newer versions — green on CI, red on any host with the older git. Normalized.

Verification

cargo fmt --check && cargo build && cargo test && cargo clippy --all-targets -- -D warnings

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 codebase is the new shipped guide — the feature has no CLI flag, so --help cannot
carry 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)

🤖 Generated with Claude Code

slowdini and others added 11 commits August 16, 2026 19:50
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>
@slowdini
slowdini merged commit 37eec5e into dev Aug 17, 2026
8 checks passed
@slowdini
slowdini deleted the feat/codebase-source branch August 17, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Source a real codebase into each task environment

1 participant