* refactor(exports)!: split the testing surface on COST, and name the cost The exports map split the testing API by TEST TIER — `vigiles/testing`, `vigiles/unit`, `vigiles/integration`, `vigiles/e2e`. That taxonomy already had a home: the vitest projects, keyed on test-file NAMES. The exports map was a second, competing implementation of it, and zero of 19 comparable packages publish `./unit` / `./integration` / `./e2e` subpaths. It splits on COST now, on two axes at once — the PATH and the NAME. "." -> ./dist/test.js (was ./dist/core/spec.js) "./spec" -> ./dist/core/spec.js unchanged "./eval" -> ./dist/eval-surface.js NEW ./linting ./hook ./claude-code ./codex ./adapter ./experimental ./vitest ./jest unchanged DELETED: ./testing, ./unit, ./integration, ./e2e. 14 subpaths -> 11. No `./test` subpath: the bare package name IS the testing surface. Two names for one door is the duplication being removed (`.` + `./spec` used to be exactly that). The cost: CLI-verb symmetry is now one-sided — `vigiles/eval` rhymes with `vigiles eval`, the free half answers to the plain package name. Accepted; `@playwright/test` publishes no subpath naming a test type at all. THE NAME AXIS. Every runtime export on `./eval` is prefixed `paid_`: paid_runEval, paid_measure, paid_measureArms, paid_measureTriggerRate, paid_judge, paid_judged, paid_claudeEvalDriver. The path warns ONCE at the top of a file; the name warns EVERY time, at the call site. Reading `await judged(trace, "did it refuse?")` on line 140, the import is long out of view. Not a new idiom here — `vigiles/experimental` already pairs a quarantined path with an `experimental_` prefix; this applies the device to a second axis. TYPES ARE NOT PREFIXED, deliberately: the prefix warns about CALLING something, and a type is never called. `paid_EvalReport` would be noise — and since the 29 report types are re-exported from BOTH barrels, prefixing them would put `paid_` names on the FREE surface. ⚠️ THE PREFIX OVERSTATES SLIGHTLY, AND THIS IS RECORDED, NOT HIDDEN. `paid_judged(rubric, { judge: myFn })` calls your function and spends nothing; the `measure*` family takes an injectable `evalDriver`. Only the DEFAULT path bills (`paid_judge` is the one where `paid_` is exact — no injection seam). `metered_` would be precise but reads a beat slower, and a warning not absorbed at a glance is not a warning. Clarity won; the imprecision is written into the module docstring, each symbol's JSDoc, and docs/testing-api.md. ALSO FIXED: a PRE-EXISTING violation of the older convention. `makeDockerRuntime` sat unprefixed among six `experimental_` siblings on `./experimental`. One missing prefix is worse than none: the six that comply teach the reader that an unprefixed name is safe. Renamed at source to `experimental_makeDockerRuntime`. BOTH CONVENTIONS NOW HAVE A GATE, because that violation is exactly what prose buys you. `scripts/check-export-prefixes.mjs` reads the built `.d.ts` with the TypeScript compiler (so it can tell a value from a type instead of grepping) and FAILS with exit 1 — it is a gate, not a nudge. Wired as `npm run exports:check` and a CI step beside `docs:check`. ITS TEST HAS BOTH HALVES, and mutation says so. `scripts/check-export-prefixes.test.ts` (12 cases, colocated) fires on planted violations AND is silent on the legitimate surface next door. Four mutations, each failing on its OWN assertion: drop alias resolution -> 2 fail (the vacuous-pass: `./eval` is built entirely from `export { x as paid_x }`, and an alias reports Alias, not Value — a check that skipped resolution would read every export as a type and pass on a fully unprefixed barrel) exit(1) -> exit(0) -> 1 fail (gate degrades to nudge) delete the ./experimental entry from SURFACES -> 1 fail (see below) never push a violation -> 4 fail 🔴 THE THIRD MUTATION PASSED GREEN AT FIRST — a hole in the TEST, not the check. An entire axis could stop being policed with nothing turning red: the axis test passes its own surface list, the "real barrels" test is satisfied by a SHORTER list, and the CLI test still exits 1 on its `./eval` defect. Fixed by asserting SURFACES covers exactly the quarantined subpaths. The CLI test was vacuous the same way (its fixture lacked `./experimental`, so it exited 1 either way) and now runs a clean twin that must exit 0. NOTHING WAS LOST, AND IT IS MEASURED. Union of the four old barrels: 190 symbols. New: 183 on the root + 36 on ./eval, overlapping in 29 report types re-exported from both on purpose. 183 + 36 - 29 = 190; set difference empty in BOTH directions (comparing `paid_x` to `x` by identity). `vigiles/testing` was NOT a superset of `vigiles/unit`: 11 symbols (all of guardrail-check.ts, plus decideHook / parseHookOutput) existed only on `unit` and are on the root now. TWO HONEST COSTS, in the docstrings rather than hidden: 1. The type coupling crosses the boundary and cannot be removed. ~27 helpers (assertImproves, compareArms, diffReports, cost/latency/tokens, formatEvalReport, …) read an eval RESULT and spend nothing, so they are free and unprefixed — but their argument types are defined on the paid side. Hence the 29 duplicated types, so nobody imports `./eval` for a type alone. 2. Free is not fast. The old "real CLI, no API key" tier collapses into the root, so the import no longer signals that `runHarnessTest` spawns a real claude under bubblewrap for ~40s. Duration lives in the runner config now. `.` moving off `core/spec` means every doc example importing a spec builder from bare "vigiles" now says "vigiles/spec" (11 blocks). `docs:check` is what caught those. Entry points are enumerated in four places independently of package.json; all updated: scripts/api-extractor.mjs (1:1 with exports now — `vigiles.api.md` is the test surface, `vigiles-spec.api.md` is new), typedoc.json, eslint.config.mjs (AGNOSTIC_SURFACE + the no-barrel-imports list; both rules verified by mutation to still fire on the new filenames), vitest.config.mjs comment. Gates, by name and in order: npx vitest run (2939 passed, 25 skipped, 174 files) · npm run lint (0 errors, 206 warnings — unchanged baseline) · npx prettier --check . · npm run api:check (11 entries, no drift) · npm run build · plus npm run docs:check (91 blocks, 0 findings) and npm run exports:check. Also `node dist/cli.js lint` exits 0 with every compiled-markdown hash valid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd * ci: закрыть класс «сгенерированный файл протух, а CI зелёный» Шаг «Check generated types compile» перегенерирует файл и компилирует результат. Свежесгенерированный компилируется всегда — поэтому закоммиченная копия в проверке НЕ УЧАСТВУЕТ. Замер 16.08: отслеживаемый `.vigiles/generated.d.ts` отставал на 314 строк / ~147 записей, и CI всё это время была зелёной. Название шага честное — он проверяет, что типы КОМПИЛИРУЮТСЯ, а не что они актуальны. Именно поэтому дыра пережила ревью: шаг делает ровно то, что обещает. Предсказание записано за восемь дней до: в .gitignore репозитория zernie/mine 08.08 этим обосновано, почему тот репозиторий свою копию не отслеживает — «drift in a generated file is invisible unless something regenerates it with --check. Upstream vigiles tracks its own copy and it IS stale on main for exactly that reason». Через восемь дней стало хуже. Запись предсказания не помогла; помогает строка в CI. Проверено обеими половинами: на актуальном файле проходит, на подложенном дрейфе (одна строка в конец) падает. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uvk2Zx66BAuxuLGLFL2umd --------- Co-authored-by: Claude <noreply@anthropic.com>
16 KiB
Harness testing — Codex specifics
The harness-testing guide is the harness-agnostic core: the
levels, the Trace model, the runners, evals. This page is the OpenAI Codex
adapter (vigiles/codex) — how you point the deterministic tier at real
codex exec, what maps from the agnostic API, and the one thing that doesn't
(subagents, by design).
Codex is shipped: registered in ADAPTERS (the CLI auto-detects a
.codex/config.toml or AGENTS.md repo) and exported as vigiles/codex. The
layer-2 deterministic path — runHarnessTest(spec, { adapter: codexAdapter })
— is full and proven against the real codex binary. See the capability matrix in
docs/harnesses.md for where Codex sits relative to Claude Code.
Contents
- Select Codex by the
{ adapter }option - A worked deterministic example
- The OpenAI Responses mock (
startCodexMock) - What maps, and what doesn't
- Authenticating Codex (for the eval tier)
- Keeping eval results fresh + the nudge (Codex)
- See also
Select Codex by the { adapter } option
The agnostic runner defaults to Claude Code; you pick Codex by passing the adapter object — the same surface, a different transport:
import { runHarnessTest } from "vigiles";
import { codexAdapter } from "vigiles/codex";
const r = await runHarnessTest(spec, { adapter: codexAdapter });
codexAdapter carries Codex's five ports plus the layer-2 HarnessTestDriver
(argv + mock + parse), so runHarnessTest dispatches through it without the
agnostic surface importing the adapter — exactly the seam Claude Code rides. The
adapter must declare capabilities.harnessTesting and carry a
harnessTestDriver; codexAdapter does both.
A worked deterministic example
Codex's non-interactive codex exec runs the scripted mock to completion, and the
final assistant text lands in the trace's output. The script is plain text turns
({ text }) — there's no scriptModel wrapper on the Codex side; you pass the
ModelTurn[] directly. The example below mirrors the Claude Code "fired and
landed" shape: assert the scripted reply reached the agent's output and that
the mock recorded the prompt (so the request actually reached the model):
import { runHarnessTest } from "vigiles";
import { codexAdapter } from "vigiles/codex";
const r = await runHarnessTest(
{
prompt: "say exactly HELLO_CODEX and nothing else",
model: [{ text: "HELLO_CODEX" }],
// Codex has no confined path; an inline-only spec is trusted, so the
// default "auto" runs direct — pass false to be explicit.
sandbox: false,
timeoutMs: 60_000,
},
{ adapter: codexAdapter },
);
r.output.includes("HELLO_CODEX"); // the scripted reply reached the output
r.exitCode === 0;
r.modelRequests.length >= 1; // …and the mock recorded the request
// the prompt reached the model:
r.modelRequests.flatMap((q) => q.messages.map((m) => m.text)).join("\n");
This is the shape exercised by the gated real-codex test in
src/adapters/codex/harness-test.test.ts — it runs when codex is on PATH
(installed in CI) and skips otherwise.
Keyless. codex exec is pointed at the in-process Responses mock via the
keyless -c model_providers.mock.* flag recipe (codexMockArgs / codexMockEnv
in the Codex runtime) — no OPENAI_API_KEY, no network. The driver places the
mock-wiring -c flags after exec (before it they're parsed as global flags
and ignored), runs --ephemeral +
--dangerously-bypass-approvals-and-sandbox so the turn runs unattended,
--skip-git-repo-check so it works in a bare temp dir, and --ignore-user-config
so the host's ~/.codex stays out.
The OpenAI Responses mock (startCodexMock)
Where Claude Code scripts the Anthropic Messages SSE, Codex speaks the OpenAI
Responses API: a turn-consuming request hits POST /v1/responses with
Accept: text/event-stream, and the mock answers with the proven
response.created → … → response.completed SSE sequence that makes codex emit
one assistant text message. There is no count-tokens endpoint.
runHarnessTest starts and stops the mock for you via the driver. To drive it
directly — or to assert over the captured requests — use startCodexMock from
vigiles/codex:
import { startCodexMock } from "vigiles/codex";
const mock = await startCodexMock([{ text: "HELLO_CODEX" }]);
try {
// point a `codex exec` at mock.url via the keyless -c recipe…
// mock.requests → each { prompt, model, toolNames } the mock received
} finally {
await mock.close();
}
The wire format is not guessed — renderResponsesSSE emits the exact 9-event
sequence captured from live codex traffic (codex-cli 0.139.0), and a gated test
drives real codex exec against it.
What maps, and what doesn't
| Surface | Codex |
|---|---|
| Instructions | AGENTS.md (plain markdown) — Lint compiles + verifies it; CC output byte-identical |
| Skills | minimal SKILL.md (name/description, via dialect.skillFrontmatter) |
| Deterministic harness | ✅ runHarnessTest({ adapter: codexAdapter }) against real codex exec (keyless) |
| Hook veto | ✅ same wire level as CC — block via exit 2 |
| Plugin/MCP manifest | TOML (config.toml + [mcp_servers]), read format-aware by the loader |
| Subagents | ⛔ deliberate non-goal — a Codex subagent is a TOML concurrency table, not a contract |
runEval / trigger-rate |
🧪 EXPERIMENTAL — the number can be wrong (Codex has no skill-selection event, so "did it fire?" is inferred from a SKILL.md read). Built + wired (audit --harness=codex, measureTriggerRate({ evalDriver: codexEvalDriver })) and it prints a loud caveat, but do not treat the number as a measurement. Deterministic audit is fully supported. See below. |
Subagents are a boundary, not a gap. A Codex subagent is an [agents.<name>]
TOML concurrency table (max_threads / max_depth), not a tool-contract file —
vigiles's agent() builder doesn't map onto it, so it isn't compiled to Codex
(references in it are still verified). Tool-contract subagent testing
(agent-runtime, the railway result contract) is Claude Code only. See
docs/harnesses.md footnote ¹.
Tool-call parsing differs by tier. The deterministic driver (parseCodexRun)
reads codex exec's plain text into output only. The eval tier reads
codex exec --json and parses tool calls + usage via parseCodexEvalRun.
Trigger-rate on Codex is EXPERIMENTAL
The deterministic vigiles audit is fully supported on Codex — refs, rings,
tool contracts, MCP, hooks. This section is only about the behavioral
trigger-rate tier ("does this skill actually fire?"), which is experimental on
Codex — and Codex only. On Claude Code it's a supported measurement.
Why — the number can be wrong in both directions. Claude Code emits a discrete
skill-selection event, so "did the skill fire?" is a fact. Codex emits no such
event. So codexSkillFired infers firing from whether the model read the
skill's SKILL.md (a command_execution). That heuristic fails two ways:
- False negative — the skill's content is already in context (cached), so the
model doesn't re-read
SKILL.md→ counted as "didn't fire" when it did → recall reads too low. - False positive — the model reads
SKILL.mdwhile just listing/exploring, without using the skill → counted as "fired" → recall reads too high.
Because the number can be off either way, presenting it as a measurement would violate vigiles's whole precision premise (a wrong number is worse than no number). So we don't. Concretely:
codexEvalDriver.experimentalcarries the caveat;measureTriggerRatewarns on stderr and stampsreport.experimental, andformatTriggerRateReport/formatBehavioralReportprint a loud⚠ EXPERIMENTALline above the numbers. Sovigiles audit --harness=codex's behavioral column always says so.- Treat any Codex trigger-rate as directional only, and pair it with a judged check rather than gating on the rate.
What would promote it to supported: a live run that measures the oracle's
own accuracy — drive real codex exec --json over skills with known
ground-truth firing, and compare codexSkillFired against it. That result decides:
promote to supported, keep experimental, or drop Codex to deterministic-only. It's
gated on having codex on PATH + Codex quota (pin codex 0.139.0 — 0.141
regressed the keyless mock; real eval turns need auth + egress).
(Matches the docs/harnesses.md matrix footnote ².)
Authenticating Codex (for the eval tier)
The deterministic tier (runHarnessTest) is keyless — it points codex exec at the in-process Responses mock, so it needs no login. Only the eval
tier (real model) needs Codex auth. Three ways, in order of fit:
-
Device-code flow — best for a headless / remote / sandbox env (no browser on the box, no
localhostcallback to forward):codex login --device-authIt prints a URL (
https://auth.openai.com/codex/device) and a one-time code. Open the URL on any device, enter the code, and the CLI — which is polling — completes the login; the token is written to~/.codexon the box running the command. This is the flow to use when an agent/CI sets Codex up for you. Verify withcodex login status. -
Browser flow — local dev only: plain
codex loginopens a browser and redirects tohttp://localhost:1455. Fine on your laptop; it does not work in a remote sandbox (the callback hits the sandbox's loopback, not yours) — use--device-auththere. -
API key (no browser): pipe a key in —
printenv OPENAI_API_KEY | codex login --with-api-keyUse this when you have a key and want a non-interactive setup, or when the ChatGPT plan on the account doesn't include Codex.
The eval tier also needs network egress to the Codex/OpenAI backend (the
deterministic tier doesn't — it only talks to the in-process mock). Install the
CLI with npm i -g @openai/codex (pin the version — the codex exec --json
event schema can shift between releases; validated against codex-cli 0.141.0).
Keeping eval results fresh + the nudge (Codex)
The eval lock works on Codex exactly as on Claude Code — --check just hashes your
eval's inputs, so it needs no harness binary. See the agnostic
guide for how it works.
What's different on Codex is how the reminder reaches the agent. Here's the honest status of each channel:
| Channel | On Codex? |
|---|---|
eval --check staleness gate (CI) |
✅ identical — the hard gate is fully there |
test-harness skill (documents the lock) |
✅ installs via the cross-agent skills CLI |
AGENTS.md one-line note (opt-in) |
✅ idiomatic on Codex — it's the normal place for tool guidance (not written for you) |
| The PostToolUse nudge hook | ✅ Codex honors additionalContext injection on PostToolUse (and SessionStart / UserPromptSubmit / PreToolUse / SubagentStart), so the reminder reaches the agent — the same channel as Claude Code |
So on Codex today: the skill, the optional AGENTS.md note, and the
hook-delivered nudge all work. The CI gate (eval --check) is unaffected — it
works the same everywhere.
See also
docs/harness-testing.md— the harness-agnostic core: levels, theTracemodel, the runners, evals, vitest/jest, CLI, CI.docs/harness-testing-claude-code.md— the Claude Code parallel (scriptModel+ the Anthropic mock,${CLAUDE_PLUGIN_ROOT}, the bubblewrap sandbox).docs/harnesses.md— the adapter/import model + the capability matrix (where Codex sits, with the subagent +runEvalfootnotes).