Skip to content

feat(skill-evals): journal every live eval run and generate Run history from it - #1928

Merged
potiuk merged 7 commits into
mainfrom
eval-run-journal
Nov 1, 2026
Merged

potiuk merged 7 commits into
mainfrom
eval-run-journal

Conversation

@justinmclean

Copy link
Copy Markdown
Member

Summary

  • The Run history table in each evals/<skill>/README.md was hand-maintained, so it could drift from what was actually run and no row could be audited back to a run.
  • Every live --cli eval run now appends one record (skill, ref, branch, model, exact CLI strings, inputs, per-case outcome, cost, notes) to an append-only evals/<skill>/runs.jsonl; the README table is generated from it by a new skill-eval-journal CLI and guarded by a check-eval-run-history prek hook.
  • The eight existing tables are imported as legacy records and render back byte for byte.

Type of change

  • Python package (tools/*/ with pyproject.toml)
  • Documentation (docs/, README.md, CONTRIBUTING.md)
  • CI / dev loop (prek, workflows, validators)

Test plan

  • prek run --files <changed files> passes (every hook except skill-token-count, which needs a tokenizer download the authoring sandbox blocked; no SKILL.md is touched here)
  • uv run --directory tools/skill-evals --group dev pytest: 100 passed (26 new in tests/test_journal.py); ruff check and ruff format --check clean
  • skill-eval-journal render tools/skill-evals/evals --check and skill-eval-journal check tools/skill-evals/evals both exit 0 against the imported journals
  • Runner integration tests: a --cli run under the evals tree journals one record with the failed-case labels; a run outside the tree writes nothing; --no-journal, --journal FILE and --journal-model behave as documented
  • A live claude -p run to confirm the record lands in a real suite (not possible in the authoring sandbox)

RFC-AI-0004 compliance

  • Sandbox — no new network reach; the runner only reads git rev-parse / git status locally
  • Vendor neutrality — no project-specific values; the model name is whatever the CLI string carries

Linked issues

Refs #1626 (the hand-maintained Run history convention this replaces).

Notes for reviewers (optional)

  • The journal is the evidence and the table is a view. check --against origin/main fails when a committed journal was edited or truncated, so a wrong record is corrected by appending another one.
  • A bare claude -p carries no --model, so the record's model is null and the table shows (unrecorded) unless --journal-model is passed. magpie-run-evals.sh is deliberately left unchanged in this PR; wiring --journal-model into it would change the user-scope copy's hash.
  • Branch runs are journaled with their branch name and rendered as `sha` (branch), replacing the old "runs on a branch are not logged" rule, because a pre-merge run is exactly the evidence a reviewer wants.
  • Generative-AI disclosure: authored with Claude Code (Fable 5.1) under maintainer direction; commits carry the Generated-by: trailer.

@github-actions github-actions Bot added family:tools tools/* family:docs Docs, MISSION.md, READMEs family:ci .github workflows, prek, validators family:issue issue-* skills capability:triage Sweep + classify + propose disposition capability:fix Implement code change to resolve an issue capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement capability:reassess Re-run historical issues against current code capability:stats Read-only dashboards, metrics, governance evidence substrate:framework-dev Tool substrate: build / validate / eval the framework itself labels Oct 31, 2026

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generating the Run history table from an append-only journal is the right fix for hand-kept tables drifting. Two things need fixing before it can land: the unsandboxed eval wrapper now appends to whatever runs.jsonl points at, and two PRs that both record a run conflict on that file.

Commands outside the sandbox (tools/skill-evals/src/skill_evals/journal.py:140)

Commands outside the sandbox. Entries in sandbox.excludedCommands (gh, the vetted-ops entry points) and any call run with the sandbox turned off are not filtered.

with journal.open("a", encoding="utf-8") as fh:
    fh.write(line + "\n")

~/.claude/scripts/magpie-run-evals.sh runs the runner outside the sandbox. That is why it resolves its target and refuses anything outside evals/ ("refusing to run it unsandboxed"), and why the runner checks containment on every fixture read. The journal write gets no such check. append_record opens evals/<skill>/runs.jsonl in append mode and follows a symlink, so a runs.jsonl symlink the agent can create inside the tree (the tree is writable in the sandbox) makes the next wrapper run append one JSON line, unsandboxed, to any file the user can write. The line's content is partly agent-shaped: case labels come from directory names.

Suggested fix: Before appending, require that the resolved journal path stays under the resolved evals root and that runs.jsonl itself is not a symlink, and open it with O_NOFOLLOW; add a test with a planted symlink.

Flagged by both the primary and adversarial reviewers.

worth running on every change (tools/skill-evals/src/skill_evals/journal.py:132)

Self-eval — the authoring model grading itself — is a smoke test for the cheap failure class (invalid JSON, missing fields, off-spec shape, fixture / prompt drift) and is worth running on every change; run a cross-model pass for substantive changes (new steps, prompt restructures, behaviour changes that cross a classification boundary).

def append_record(journal: Path, record: dict) -> None:
    """Append one record as a single JSON line. Never rewrites the file."""

Every PR that runs a suite, which AGENTS.md asks for on every change, now appends a line to the end of that suite's tracked runs.jsonl. Two open PRs that both ran the same suite each add a line after the same last line, so the second to merge conflicts. Tried with plain git: two branches that each append one record to a one-line runs.jsonl fail with Automatic merge failed. Each conflict needs a hand resolution of an append-only file, and the README table has to be regenerated after it.

With runs.jsonl merge=union in the attributes, the same merge keeps both lines with no conflict; after a rebase the branch's records land after main's, so check --against origin/main still sees a prefix.

Suggested fix: Add tools/skill-evals/evals/*/runs.jsonl merge=union to .gitattributes, with a test that merges two appended journals and passes check --against.

Smaller observations

  • See inline comments on tools/skill-evals/src/skill_evals/journal.py:503, tools/skill-evals/src/skill_evals/journal.py:262, tools/skill-evals/src/skill_evals/runner.py:1620, tools/skill-evals/src/skill_evals/runner.py:1651, tools/skill-evals/src/skill_evals/runner.py:1601, tools/skill-evals/src/skill_evals/journal.py:211.

This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. After you've
addressed the points above and pushed an update, an Apache Magpie
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.

More on how Apache Magpie handles maintainer review:
Contributing guide.

@justinmclean

Copy link
Copy Markdown
Member Author

Thanks Jarek. Fixed: the symlink write, the non-git crash, the case counts, manual-only rows, unrecorded-model rows keyed by CLI, merge=union for runs.jsonl, a deleted journal failing check --against, and the snapshot taken before the first case. I've kept journaling on by default, since recording every live run is the point of the change; --no-journal covers smoke runs. Wiring check --against into CI needs a base ref that exists in CI and locally, so I'd do that as a follow-up.

claude and others added 6 commits November 1, 2026 10:34
…ry from it

The Run history table in evals/<skill>/README.md was maintained by hand,
so it could drift from what was actually run and no row could be audited
back to a run. Replace the hand edit with an append-only journal.

- Every `--cli` run whose path lies under tools/skill-evals/evals/<skill>/
  appends one JSON record to that suite's runs.jsonl: skill, ref, branch,
  dirty flag, model, the exact --cli / --grader-cli strings, path and tag
  filters, per-outcome counts, the labels of every failed or errored case,
  cost (wall seconds, CLI calls, grader calls) and a --journal-note.
  --journal FILE, --journal-model NAME and --no-journal adjust it; a path
  outside the repository's evals tree is never journaled by default.
- New `skill-eval-journal` CLI: `render` regenerates each README's Run
  history table from its journal (one row per date/ref/branch/model batch,
  newest first, `case failed k of n` per failing case); `check` verifies
  journals parse, tables match, and with --against <ref> that committed
  records were only appended to; `import-readme` migrates a hand-written
  table into legacy records.
- The eight existing tables are imported; each renders back byte for byte.
- `check-eval-run-history` prek hook fails a table that does not match
  its journal.
- README "Recording runs" and the meta-and-quality-tooling spec describe
  the new rules.

Generated-by: Claude Code (Fable 5.1)
…check

The runs.jsonl journals are JSON Lines data with no comment syntax, so
they cannot carry a licence header; the rat workflow flagged all eight.
List them in .rat-excludes beside the existing *.jsonl exclusion.

Generated-by: Claude Code (Fable 5.1)
…ding --against

CodeQL flagged `root` in skill-eval-journal main() as possibly used before
assignment: it is set inside a try whose except calls parser.error(), which
exits but is opaque to the analyser. Declare both variables up front and
raise after parser.error so the exit is explicit.

Also refuse an --against value that starts with a dash: it is interpolated
into `git show <ref>:<path>` and would otherwise be read by git as an
option rather than a revision.

Generated-by: Claude Code (Fable 5.1)
…hat graded them

Review fixes:

- The eval wrapper runs the runner outside the sandbox, and the journal
  write followed a symlink, so a planted runs.jsonl link could append a
  line to any file the user can write. append_record now refuses a
  symlinked journal, opens with O_NOFOLLOW, and the default journal must
  resolve inside the evals tree.
- find_repo_root raises RuntimeError, not FileNotFoundError, so a suite
  run outside any git checkout died in the journal step after finishing
  its cases. Both lookups now catch it and skip the journal.
- "failed k of n" used the batch's record count as n. Records now carry
  graded_cases and n counts the runs that graded that case; older records
  still count every case. A batch where nothing was graded renders as
  manual, not pass, and runs with no recorded model are keyed and shown
  by their CLI so two tools never share a row.

Generated-by: Claude Code (Opus 5.5)
…hot before the first case

Review follow-ups:

- Two PRs that each recorded a run appended after the same last line and
  conflicted. .gitattributes now merges runs.jsonl with merge=union,
  which keeps both lines; a test merges two appended journals using the
  repository's own attribute line.
- check --against skipped a journal missing from the working copy, so
  deleting a whole runs.jsonl passed the append-only check. It now fails
  when the ref has a journal the working copy lacks.
- The git snapshot was taken after the last case, so a commit or branch
  switch during a long run was recorded instead of the checkout whose
  prompts were loaded. It is now taken before the first case.

Generated-by: Claude Code (Opus 5.5)
…le permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
The journal records the --cli and --grader-cli strings and is committed to
a public repository, so a CLI spelled with an inline secret
(ANTHROPIC_API_KEY=sk-… claude -p, tool --api-key …, -H 'Authorization: …')
would have been published. Inline VAR=value assignments and the values of
credential and header flags are now recorded as <redacted>; --model and
the rest of the command are kept.

Generated-by: Claude Opus 5.5

@potiuk potiuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — every blocking point from my review is fixed, and I checked each against the code rather than the reply: the journal write refuses a symlinked runs.jsonl, opens with O_NOFOLLOW and stays under the evals root (the eval wrapper never forwards --journal, so the explicit-path branch cannot be reached unsandboxed); a non-git suite no longer crashes after its cases; the snapshot is taken before the first case; a case's denominator is the runs that graded it; unrecorded-model rows are keyed by CLI; and runs.jsonl union-merges. Keeping journaling on by default, with --no-journal for smoke runs, is fine. I rebased the branch onto main and pushed one fixup commit, 76a0ea5 (please pull before pushing again): the journal records the --cli / --grader-cli strings and is committed to a public repository, so a CLI spelled with an inline secret (ANTHROPIC_API_KEY=sk-… claude -p, --api-key …, -H 'Authorization: …') would have been published. redact_cli now records inline VAR=value assignments and the values of credential and header flags as <redacted>, keeping --model and the rest; tested, and noted in the README.

Please open a tracking issue for wiring skill-eval-journal check --against into CI, so the append-only rule is enforced rather than documented. When this is squashed, the Copilot Autofix Co-authored-by: trailer and the Claude git authorship on the other commits are left out of the merge commit, per the project's no-AI-co-author rule.


This review was drafted by an AI-assisted tool and
confirmed by an Apache Magpie maintainer. The maintainer
approving this PR has read the findings and signed off. If
something feels off, please reply on the PR and a maintainer
will follow up.

More on how Apache Magpie handles maintainer review:
Contributing guide.

@potiuk
potiuk merged commit 0ab64d6 into main Nov 1, 2026
11 checks passed
@potiuk
potiuk deleted the eval-run-journal branch November 1, 2026 10:16
@github-actions github-actions Bot added the family:dev Repo dev: .asf.yaml, spec-loop specs, Node manifests, .claude/settings.json label Nov 1, 2026
potiuk added a commit that referenced this pull request Nov 1, 2026
Review fixups, after moving the change onto main now that the journal
(#1928) has merged:

- flaky counted every record in a batch as a run of every case, so a
  --path / --tag filtered run or a manual-only run added to a case's n
  without grading it, and a case that failed every run that graded it was
  reported as flaky, at the top of the list. A case's denominator is now
  the runs that graded it, as in the Run history table: graded_cases for
  new records, every case for records from before the field, and the
  Result text's own n for legacy rows.
- A batch with no recorded model is keyed on the CLI internally; the
  report now names that CLI ("(unrecorded, `claude -p`)", and "cli" in
  --json) instead of printing the internal key.
- The README and --help say a batch is the same date, ref, branch and
  model.

Generated-by: Claude Opus 5.5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

capability:fix Implement code change to resolve an issue capability:reassess Re-run historical issues against current code capability:resolve Close-out: invalidate, dedupe, CVE allocate, announcement capability:stats Read-only dashboards, metrics, governance evidence capability:triage Sweep + classify + propose disposition family:ci .github workflows, prek, validators family:dev Repo dev: .asf.yaml, spec-loop specs, Node manifests, .claude/settings.json family:docs Docs, MISSION.md, READMEs family:issue issue-* skills family:tools tools/* substrate:framework-dev Tool substrate: build / validate / eval the framework itself

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants