Files
zernie__vigiles/docs/migrating-from-promptfoo.md
T
zernie 4acfd31fc9 feat(eval)!: an eval file describes, the runner performs — importing one no longer spends (#159)
* fix(audit): coverage caveats printed by lint never reached the audit report

`formatUntestedReport` grew two qualifier lines — a test file still carrying
the retired `vigiles:covers` marker, and a run record older than the text it
measured. `audit` assembles its own fact block from the SAME UntestedReport
and carried neither, so the note that exists to explain a silent migration was
itself silent for anyone whose habit is `audit` rather than `lint`.

Measured before the fix on a fixture whose only harness carries the marker:

  lint  → "1 test file(s) still carry the retired `vigiles:covers` marker …"
  audit → "Untested surfaces: 0" and nothing else

The caveats now come from one exported builder (`coverageCaveats`) that both
renderers read, so a third qualifier reaches both callers or neither — a
renderer can no longer carry half the list. `ScanReport.coverageCaveats` is
absent rather than `[]` when empty, because the browser twin structurally
cannot compute these (no `.vigiles/coverage.json` without a filesystem) and
byte-parity between the two engines is asserted field-for-field.

Both halves tested at the product level (`node dist/cli.js audit`): it fires on
a repo carrying the marker and stays silent on one that does not. Proven by two
mutations, each with the patched source grepped first: dropping the render line
fails the "fires" assertion; emitting a caveat unconditionally fails the
"silent" one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* fix(coverage): the suffix that stopped counting now says so

`*.test.*` left DEFAULT_TEST_GLOBS in 15.x because a default vitest/jest run
collects exactly those names and a skill test spawns a model — so a misnamed
harness was being executed, and paid for, by somebody else's runner. The
removal was announced to nobody. Its own comment argues "the migration is a
rename, and the untested finding prints the exact path", but the path printed
is the SURFACE's, together with a suggestion to add `<surface>.eval.mjs`. An
author with `foo.test.mjs` already lying beside `foo/SKILL.md` is therefore
told to write the test they wrote, with neither the file nor the reason named.

The sibling half of the same migration — the retired `vigiles:covers` marker —
got its explanatory line at the time. This is the half that did not.

Scoped to surfaces that are otherwise UNCOVERED: that is the only position
where the count contradicts what the author can see in the directory. A stray
`*.test.*` beside a properly covered surface is somebody's ordinary unit test
and none of our business — asserted, because an advisory line that fires on the
innocent case gets muted within a day.

Nothing about coverage changes: the name still grants nothing. Measured silent
on all seven real corpora available here (this repo, six vendored dogfood
plugins) and on the `mine` knowledge base; it fires on a planted fixture in
both `lint` and `audit`, the latter through the shared caveat list added in the
previous commit rather than a second wiring.

Three mutations, each with the patched source grepped before the run and each
dying on its own assertion: dropping the note from the caveat list · widening
the scope from untested to every considered surface · dropping the suffix test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* refactor(scan): compute the caveat list once

Cosmetic follow-up to the two commits above: `coverageCaveats(coverage)` was
called twice in the same object literal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* fix(coverage): the retired-suffix note claimed a cost the guard already closes

The note said a `*.test.*` harness "spawns a model" under someone else's
runner. `refuseUnderForeignRunner` guards every spawn door and refuses under
vitest/jest — measured here against the detector: argv of
`node_modules/vitest/vitest.mjs` yields "vigiles refused to spawn a model:
this process is running under vitest." What actually happens is that the
foreign run FAILS on the file, which is the true and smaller claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* feat(eval)!: an eval file DESCRIBES its eval; `vigiles eval` runs it

Importing a `*.eval.*` file spent real money. These files did their work in the
module body, and in ESM `import` IS execution — so the cheapest imaginable
question, "does this file even parse?", launched a paid run against a live
model when it was asked with `import()`. That happened, and was paid for.

The one guard that exists does not close the door and cannot be made to. Under
`node -e 'import(f)'` there is no `process.argv[1]` at all, so
`foreignRunner(undefined)` is `null` and `refuseUnderForeignRunner` stays
silent; and no process fact distinguishes that from a legitimate runner doing
the same import, so an argv-shaped guard would have to refuse the correct
invocation to catch the wrong one — the exact failure that got
`process.env.VITEST` rejected in `core/foreign-runner.ts`.

So the shape changed instead. An eval file default-exports a description:

    export default defineEval({
      measureTriggerRate: { …spec… },
      assert: (report) => assertTriggerRate(report, { min: 0.8 }),
    });

`defineEval` builds a plain value; there is no call in it to run. The five paid
runners are reached only by `dist/eval-entry.js`, the program `vigiles eval`
now spawns per file — not a new verb, the interpreter the existing verb uses.
`defineEval` is exported from the FREE root barrel, not `vigiles/eval`, and
that is the point: after this change no eval file imports a runner at all.

Three loud edges, because a silent no-op would be the same defect in a new
place: `node x.eval.mjs` refuses and names `vigiles eval`; a file that declares
nothing (or two measurements) is reported by the runner instead of exiting
green; and a half-migrated file — a description plus a leftover top-level
`measure(…)` — hits `refuseDuringEvalLoad` during the runner's own import and
throws a migration message instead of billing. `node --check` still works and
is what the free syntax check should be.

Subtractions: every eval file loses its `process.env.VIGILES_TRIALS ||
process.argv[2]` line, its `console.log(format…Report(report))`, its
`if (report.n === 0) throw`, its top-level `await`, and its `vigiles/eval`
import. The runner does all five, once.

BREAKING CHANGE: `*.eval.*` files must now default-export `defineEval({…})`
instead of calling a runner at the top level, and `node <file>.eval.mjs` no
longer runs an eval. To migrate: move the runner call into a key named after
that runner (`runEval` · `measure` · `measureArms` · `measureTriggerRate` ·
`measureSelectionMatrix` — declare exactly one), move anything you did with the
report into `assert(report)`, and delete the trials line, the `format…` print
and the `report.n === 0` check — `vigiles eval` does those. A capability probe
that sat at the top of the file becomes `skipIf()`; `measureSelectionMatrix`'s
directory argument becomes a `pluginDir` field; and
`measureTriggerRate(spec, { evalDriver })`'s second argument becomes a
top-level `evalDriver` key. `vigiles eval --trials=N` still overrides trials.
Run with `vigiles eval <file>`; a file left on the old shape fails with a
message naming docs/harness-testing.md, never silently. All 19 eval files in
this repository were migrated in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

* chore: regenerate the browser-parity fixture and generated types

The eval-definition change rewrote one report hint (`paid_measureTriggerRate`
→ `export default defineEval({ measureTriggerRate: … })`), which is exactly
the divergence the parity test is built to catch. Regenerated per that test's
own instruction for an intentional engine change; the diff is that one line —
grade and every other field are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-18 20:34:40 +05:00

9.9 KiB
Raw Permalink Blame History

Migrating skill evals from promptfoo

The README has the pitch; this is the how-to. If you already A/B skills with promptfoo and the metered-API bill is the pain, this maps your setup onto vigiles — same questions, run on your Claude subscription ($0 beyond the sub), loading your real Claude Code / Codex harness as it ships.

Why move

promptfoo is a solid LLM eval runner, but for Claude Code / Codex skill testing it has two structural costs:

  • It bills per token, every run. The default anthropic: provider hits the metered API. Complex skills × trials × redteam matrices is how a suite reaches four figures a year.
  • It tests a re-assembled agent, not your shipped harness. A skill's real behavior depends on the assembled harness — the real system prompt + your CLAUDE.md/AGENTS.md + skill selection + hooks. A YAML-configured provider can't reproduce that, and the #1 skill question — does the description actually fire? — isn't something completion-grading reaches.

vigiles runs the same behavioral checks on the subscription you already pay for, against the harness loaded the way a user installs it. See why it's affordable.

Concept mapping

promptfoo vigiles Notes
providers: [anthropic:messages:claude-…] (metered API) model: "sonnet" on the sub (apiKeySource: "none") The cost win — your own claude CLI, no metered billing.
prompts: + tests[].vars the task (+ paid_measureTriggerRate prompts) One prompt or a varied set.
a test with assert: one paid_measure run with a checks: array Each assertion becomes a check.
baseline vs skill (two providers/configs) paid_measureArms({ arms: { … } }) The A/B is first-class; the delta is the signal.
"did the skill even get used?" paid_measureTriggerRate (recall + precision) The CC-specific question promptfoo can't cleanly answer.
a whole skill dir installed pluginDir: "./skills/my-skill" Loads the real plugin (skills + hooks) natively.

Assertion mapping

Each promptfoo assert entry maps to a vigiles check (from vigiles — except paid_judged, which calls a model and lives on vigiles/eval):

promptfoo assert.type vigiles check Notes
contains output("substring") A string matcher is a substring test.
icontains output(/substring/i) Case-insensitive → a regex.
regex output(/pattern/)
equals output(/^exact$/) Anchor the regex.
llm-rubric paid_judged("rubric", …) Model-graded, on the sub. threshold → min.
cost cost({ maxUsd }) vigiles reads the real total_cost_usd.
latency latency({ maxMs })
javascript / python a custom measure((ctx) => …) Not auto-portable — read ctx.file()/ctx.output and return 1/0 yourself.
similar (embeddings) — No direct equivalent; use paid_judged or a custom check.

ℹ️ vigiles won't silently pretend to convert what it can't. Anything not in the table above stays a deliberate manual step, not a false green — the same "valid is not true" discipline vigiles applies everywhere.

Worked example — one test, side by side

promptfoo (promptfooconfig.yaml, billed per token):

providers: [anthropic:messages:claude-sonnet-4]
prompts: ["Summarize {{doc}} in one sentence."]
tests:
  - vars: { doc: "…" }
    assert:
      - type: icontains
        value: "invoice"
      - type: llm-rubric
        value: "A single, accurate one-sentence summary"
        threshold: 0.7
      - type: cost
        threshold: 0.01

vigiles (summary.eval.mjs, on your subscription — see the runnable examples/harness/from-promptfoo.eval.mjs):

import { defineEval, output, cost, assertRates } from "vigiles"; // free: a description cannot spend
import { paid_judged } from "vigiles/eval"; // a Check whose default judge calls a model

export default defineEval({
  measure: {
    task: "Summarize in.txt in one sentence. Write it to out.txt, then stop.",
    fixture: { "in.txt": "…" },
    model: "sonnet",
    trials: 5,
    checks: [
      output(/invoice/i), // icontains
      paid_judged("A single, accurate one-sentence summary", { min: 0.7 }), // llm-rubric
      cost({ maxUsd: 0.01 }), // cost
    ],
  },
  assert: (report) => assertRates(report, { min: 0.8 }), // each check ≥80% of trials
});

Like promptfoo's YAML, the file is a description: npx vigiles eval summary.eval.mjs runs it. Unlike a JS file that calls the runner at the top level, importing it cannot spend anything.

To A/B a skill against no-skill, declare measureArms: { arms: { baseline: {}, skill: { pluginDir: "./skills/my-skill" } }, … } instead and read the per-arm delta.

What moves cleanly — and what doesn't yet

Your promptfoo tests… Status on vigiles
Behavior / output assertions (contains, regex, rubric) ✅ Move cleanly, on the sub.
Cost / latency assertions ✅ Move cleanly (real total_cost_usd).
"Does the skill fire?" ✅ Better — paid_measureTriggerRate (recall + precision).
Baseline vs skill A/B ✅ First-class (paid_measureArms + significance).
Redteam suites ⚠️ Not covered. vigiles has a safety-hook battery (assertBlocksDisasters) and judged adversarial checks, but no redteam generator. Keep promptfoo (or your own adversarial prompts) for that.
Skills with real side effects (write to a DB, etc.) 🧪 vigiles has an experimental tier that runs the skill against a disposable container and checks the real result — R3. promptfoo has no equivalent (it grades the model's text; it never runs a real side effect), so this is a capability you gain, not lose.
javascript/python assertions ✍️ Manual — port the logic into a custom measure callback (the spec field, not the runner).

See also