feat(cige): run, classify, replay, and safely repair CIGE test cases

VI
6h ago
+9239-15

The problem

An e2e test written today mixes four things in one TypeScript body:

  • where it runs
  • why it exists
  • what the agent must never do
  • the path it takes

When the test goes red, nothing in it says which of these broke. A failed agent.act can mean the staging API is down, the flow moved, the product has a bug, or the agent found a shortcut that should never have counted as a pass. An agent asked to fix the test can't tell whether it may edit the steps, the setup, or the assertion. A fix that edits the assertion so the test goes green hides a product bug.

CIGE is a test case standard for agentic runners that separates those concerns. The paper reports the authors' experience on an industry codeless agentic testing platform: about 90% less maintenance effort, 38% fewer false positives, and 62% less execution time.

FieldSaysWho may change it
ContextWhere the test runs, and pointers to its spec, release notes, and expected outputA repair for environment drift, approved by a human
IntentWhy it exists and what a pass needsA human. A repair may only add criteria or evidence
GuardrailsWhat the agent must never doA repair may make them stricter, never looser
ExecutionThe first path to tryA repair for a changed flow

e2e already has the pieces: atomic act goals, judges that never see system or the ledger, agentContext, onAction, blocked versus failed verdicts with a closed code table, tags, sessions, platforms, and per-test traces. This PR maps CIGE onto them in a new package and a consumer skill. It changes nothing in packages/e2e.

What CIGE adds

  • Test cases structured for developers and agents: schema-validated JSON for people, run summaries and a skill for agents, for authoring and maintenance.
  • A field-scoped self-healing workflow: a first failure classification in every run summary, and role-scoped guides (classify, recover, repair, escalate) that one agent or several can follow, gated by replay and a mechanical Intent check (e2e-cige diff).
  • Pluggable ground truth for classification: spec, release notes, and expected output per build, fetched on request, with readiness checks before anything fails.
  • An extensible implementation of the CIGE standard: x- fields, plugins, and custom step kinds, with validation intact.
  • Per-test runtime guardrails: in every agent instruction, enforced after every action (replays included), extensible with plugin checks; the judges stay independent of them.
  • Intent as identity: unique per case and the test title, so runs filter by it (--grep), and by case id (--tag cige:<id>).
  • Evidence beyond the screen that fails closed, plus a screen-only-evidence signal for a pass that may be false.
  • Variants across data, platforms, personas, and sessions, under one Intent.
  • No change to e2e core.

What this PR does not claim: the paper's measured gains (they come from another platform), or guardrails the runner blocks before an action (deny stops the step after it).

What a customer does

  1. Writes a case as JSON (tests/cige/checkout.cige.json), with editor validation from the shipped cige-1 schema.
  2. Declares the project's ground truth one time, in cige.config.ts: hooks, environments and how each resets, the build id from CI, and resolvers for the spec, release notes, and expected output. The cige skill asks for these before it writes the first case.
  3. Runs npx e2e run. Each case is one test per variant, tagged cige:<id>. Each result gets a run summary.
  4. On a failure, the cige skill reads the summary, classifies it, makes only the repair that kind allows, replays the case, and proves with e2e-cige diff that the Intent held.
{
  "id": "checkout-discount",
  "Context": { "system": "Shop web app", "environment": "staging", "open": "/cart", "specRef": "docs/specs/checkout.md", "buildRef": "releases/{build}.md" },
  "Intent": {
    "statement": "A shopper can check out with the discount code {code}",
    "evidence": [{ "id": "order-record", "hook": "orderPersisted" }]
  },
  "Guardrails": ["Use only the test card", { "id": "no-refund", "rule": "Never refund an order", "deny": { "actions": ["tool:refund"] } }],
  "Execution": [{ "id": "e1", "step": "apply the discount code {code}" }, { "id": "e2", "step": "pay with the test card" }],
  "Variants": [{ "id": "save10", "params": { "code": "SAVE10" } }, { "id": "save20-mobile", "params": { "code": "SAVE20" }, "platforms": ["ios", "android"] }]
}
// cige.config.ts: read by cigeSuite(), the cige() reporter, and the e2e-cige command
export default defineCige({
  cases: 'tests/cige/*.cige.json',
  build: process.env.GITHUB_SHA,
  hooks: { orderPersisted: async ({ screen }) => (await fetch(`/test-api/orders/${await screen.getByTestId('order-id').textContent()}`)).ok },
  environments: { staging: { config: 'e2e.staging.config.ts', reset: resetStaging } },
  refs: { build: ({ build }) => ({ url: `https://github.com/acme/shop/commit/${build}` }) },
});
flowchart LR
  F[.cige.json + cige.config.ts] --> S[cigeSuite: one test per variant]
  S --> E["Execution: agent.act + guardrails, deny via onAction"]
  E --> V["Intent.evidence (hook must return true), then Intent judged"]
  V --> R["cige(): .e2e/cige/id.run-summary.json"]
  R --> K["cige skill: classify"]
  K -->|infrastructure| ENV["e2e-cige env reset"]
  K -->|stale path| EX["rewrite Execution"]
  K -->|false pass| G["add guardrail / evidence"]
  K -->|defect| D["e2e-cige refs: file bug or propose Intent"]
  ENV --> RP["e2e-cige replay id"]
  EX --> RP
  G --> RP
  RP --> DF["e2e-cige diff: blocks a weakened case"]

How CIGE maps onto e2e

CIGEe2e
IntentThe test title and the cige:<id> tag, then a final agent.assert. The judge never reads the guardrails or the acting agent's summaries, so a lenient prompt can't lower the bar
EvidenceA pass needs every Intent.evidence item: a screen judgment, or a hook in your code. A hook holds only when it returns true; a forgotten return fails. A hook that throws is ASSERTION_INCONCLUSIVE (a backend that's down is not a product defect), unless it threw an e2e error, which keeps its code
ContextagentContext, facts only. platforms, requires, session, and agent become the test's options. specRef, buildRef, and expectedRef never reach the model
VariantsOne test per variant (cige:<id>/<variant>): its values fill {name} placeholders, as params for act steps and as text for judgments
GuardrailsAppended to each act instruction. A deny rule is checked in onAction after every action, replayed ones included, and stops the step with POLICY_DENIED
ExecutionOne agent.act per step, or agent.assert for a verify step. Ids give a failure a stable name such as e2

The reporter's first guess at a failure type (packages/cige/src/classify.ts) reads the error class and step status e2e already writes to report.json (error.category, a blocked step), so a new code in core needs no change here. Only the test-class path codes are named, typed as AgentError['code']:

Where and whatfailureType
A precondition failed, or an error of class infrastructure or configuration (not POLICY_DENIED or REPLAY_STALE)infrastructure
An Execution step failed: LOCATOR_NOT_FOUND, LOCATOR_AMBIGUOUS, ACTION_FAILED, REPLAY_STALEoutdated-test-logic
A verify, a piece of evidence, the Intent, or a plugin's check step was judged falseproduct-defect
An act step whose goal the agent could not reach (ASSERTION_FAILED)Absent, signal goal-unreachable: a changed flow or a defect
A blocked test-class step (not APP_NOT_OPEN), a spent budget, the clock, no conclusionAbsent, signal automation-limit
A guardrail violation, an agent's refusal citing one (guardrail-refused:<id>), an inconclusive judgment, anything elseAbsent; signals names what it saw

A false positive is never set by the reporter: the skill decides it from the screen-only-evidence signal and the trace.

The e2e-cige command

CommandDoes
replay <id> [--variant v]Runs the environment's reset (refuses an environment without one), then e2e run --config <env config> --tag cige:<id> --no-cache, and exits with e2e's code. Other cases' summaries and all refs stay
env provision|reset|teardown <name>Restores an environment without touching any case
refs <id>Runs the config's resolvers on request and writes the spec, release notes, and expected output to .e2e/cige/<id>.refs/. Never runs during a run
check [--resolve]Lists each case that lacks a spec or build ref, a build for {build}, or an environment with a reset, before anything fails. The reporter adds a CIGE gaps row under the run summary
diff --base origin/mainFrom the merge base, exits 1 when a change weakens or redirects a case: Intent changed, a variant's data changed, a criterion, evidence item, guardrail, deny pattern, variant, or case removed, any Context change, or a change to the CIGE config or any module it imports. It finds those by loading the config in a child Node process, then parsing each module (Node's type stripping, then acorn) for lazy imports, so # aliases, workspace packages (one built to an ignored dist counts by its sources), symlinks, lazily imported hooks, and the resolution fields of the package.json that maps them count. A config it can't load or follow (a computed import()/require(), createRequire, eval) blocks as config-unverified. Loading runs the config's top-level code, so diff needs the run's env. App code the config doesn't import, data files a hook reads, and installed packages are out of its reach. Rewriting Execution and additions pass, so tightening after a false pass needs no human. --allow-intent is the human's override and records nothing

It spawns the installed e2e bin; it uses no internal API.

Extending CIGE

The schema stays closed, so a misspelled key still fails to load. Three layers open it on purpose:

LayerHowWhere it goes
Your own fieldsx- keys on any object ("x-owner", "x-deny-host" on a guardrail), kept as extensionsHooks and plugins read them. The model never sees them unless a plugin adds them. Top-level ones are copied into summaries. diff blocks an x- change under Intent or on a guardrail
Your own behaviorplugins in cige.config.ts: context, onAction, classify, summarize, readiness, stepsContext lines, guardrail checks driven by x- data, classification changes (classification.adjustedBy), summary fields under extensions.<plugin>, check gaps
Your own steps{ "id": "e4", "use": "order-api", "with": {...} }Runs a plugin's step kind in Execution order; variants fill with; a failure names the step
export const shop: CigePlugin = {
  name: 'shop',
  context: (testCase) => {
    const flags = testCase.context.extensions?.['x-feature-flags'];
    return Array.isArray(flags) ? `Feature flags on: ${flags.join(', ')}` : undefined;
  },
  steps: {
    'order-api': async ({ step }) => {
      const response = await fetch(new URL(String(step.with.path), 'http://localhost:3000'));
      if (response.status >= 500) throw new AgentError('ENVIRONMENT_UNAVAILABLE', `GET ${String(step.with.path)} answered ${response.status}`);
      if (response.status !== step.with.expect) throw new AgentError('ASSERTION_FAILED', `GET ${String(step.with.path)} answered ${response.status}`);
    },
  },
};

A new extension point is a plugin hook, never an open key (AGENTS.md says so).

The cige skill, in this repo

skills/cige/ (installed with npx skills add tester-army/e2e --skill cige, and in the e2e plugin) routes each job to one reference: authoring, classify, recover, repair, escalate. Each may write only its own field, replays with e2e-cige replay, and ends with e2e-cige diff --json in the hand-off. Authoring asks the user, one time per project, where specs, release notes, and expected output live and how each environment resets.

Cost to the repo

  • No change to packages/e2e. The package imports from e2e only. Peer e2e >=0.21.0 <1; zod, acorn, and oxc-parser (the version e2e ships) as its dependencies; a patch changeset.
  • cige-1 is pinned by packages/cige/schema/cige-1.schema.json, generated from the loader's zod schema at build (a test fails on a stale copy), with a valid and an invalid fixture.
  • docs/cige.mdx under Features, three typechecked examples in docs/examples/cige/, and links from the README, docs/coding-agents.mdx, docs/agents.mdx, and docs/reference/agent.mdx. The shipped e2e skill doesn't mention it. The field reference lives in packages/cige/README.md.
  • The testbed's weekly agent suite gains two CIGE cases (apps/testbed/tests-agent/cige/): the wizard with two variants, and the todo list with a deny guardrail. Each pass is backed by a code check of the page.
  • 125 package tests, no model and no browser.
  • The repo's verify skill gains a @e2e-dev/cige row, so a later change to the package or the skill is proven on the testbed, not only by unit tests.
  • First release needs a maintainer: per CONTRIBUTING.md, a new public package is published by hand once and connected to its npm trusted publisher before release.yml can publish it. The changeset queues @e2e-dev/cige, so that step has to happen first, or the changeset waits.
  • I'll maintain it and answer its issues.

Not in this PR

  • Blocking an action before it runs. onAction fires after the action, which the docs page says. Blocking needs the built-in agent exported as a wrappable executor, a core API question for an issue first.
  • Writing report extensions from a reporter. The summaries are a sidecar file.
  • YAML, and importers from Gherkin and existing *.e2e.ts files.

Verified

Ran it locally: yes, on the built packages, with a scripted executor and live with gpt-6-luna.

Earlier review rounds (3bc72bc3; cubic's follow-ups in 528c1a28, c21b45dc, 4dd1f130; the second review in ba31fd9d and cubic's follow-ups in f6f65134 and 71faade4, on main as of 46437451). pnpm check green, 116 package tests, the e2e unit tests (3060 passed), the testbed suite (79 passed, 1 skipped). Each fix reverted on its own, rebuilt, and tested:

MutationTests
context-changed not blocking2 fail
config-changed not blocking2 fail
config left out of diff2 fail
imported modules not followed1 fails
deny.summary patterns skipped2 fail
setup categories ignored (classification)1 fails
a blocked step not an automation limit1 fails
committed schema stale1 fails
config graph walked only in the working tree (a deleted config or module)2 fail
a missing --config accepted1 fails
POLICY_DENIED/REPLAY_STALE typed infrastructure by class1 fails
a blocked APP_NOT_OPEN counted as an automation limit1 fails
guardrails diffed by text as a set (one of two same-text dropped)1 fails
a plugin named x-… allowed1 fails
a refusal needs the word "guardrail" to cite one1 fails
a symlink at the merge base not followed (its target deleted)1 fails
a directory accepted as --config1 fails
the loaded graph dropped (a #hooks/order alias)1 fails
a hook's lazy import('#hooks/lazy') not resolved1 fails
config-unverified not blocking1 fails
a computed import(name) not detected1 fails
a package.json imports remap not checked1 fails

Third review (fdcb362e, 7c6c9a36; cubic's follow-ups in ea6a3cb3 and 60d550c6). The regex scanner is gone: src/imports.ts strips types with module.stripTypeScriptTypes and parses with acorn. pnpm check green, 119 package tests. The review's cases, each a scratch git repo (commit, edit, diffSince('HEAD')), at 71faade4 and now:

Case71faade4Now
'orders/*' … '*/' around a lazy import('#hooks/check'), hook editedblocking: 0config-changed: hooks/check.ts
const sep = '//'; … import(name)blocking: 0config-unverified: imports a module by a computed name
createRequire result named reqblocking: 0config-unverified: calls createRequire
@acme/hooks built to an ignored dist, src edited and rebuiltblocking: 0config-changed: packages/hooks/src/index.ts
tracked link behind #hooks/order retargeted to impl/loose.tsblocking: 0config-changed: hooks/order.ts

Each rule turned off on its own fails diff.test.ts:

MutationTests
a non-literal import() accepted1 fails
require read as a value accepted1 fails
createRequire accepted1 fails
eval accepted (globalThis.eval, (0, eval))1 fails
Function accepted (const make = Function)1 fails
a require: key read as a use1 fails
a destructure (const { require: r } = module) read as a key1 fails
a computed member (module['require']) accepted1 fails
any string equal to a loader's name blocking1 fails
a lazy require('./hook') resolved as ESM, not CommonJS1 fails
a relative or # specifier that doesn't resolve accepted1 fails
a resolved relative file not checked to exist1 fails
a bare . or .. not read as relative (unresolved check, merge-base walk)1 fails each
types not stripped before parsing1 fails
an ignored dist not standing for its package's sources1 fails
an untracked file in the config's own package accepted1 fails
built output parsed (a bundler's computed import blocks)1 fails
listed symlinks not counted1 fails
the merge-base walk dropped2 fail
lazy specifiers not followed3 fail
installed node_modules packages walked1 fails

On the testbed with the built CLI, the real config: diff --base HEAD with nothing changed prints No CIGE case or config changed since HEAD. (exit 0); the workspaceCreated hook rewritten to return true gives config-changed: apps/testbed/cige.config.ts (exit 1). In this repo the testbed imports e2e and @e2e-dev/cige as workspace packages, so a branch that edits them now counts as config-changed there; a project that installs them from npm doesn't see that. No CI job runs e2e-cige diff. CI's Node 26 job is the first run of stripTypeScriptTypes on 26; locally only Node 24 ran it. If it's missing there, diff fails closed.

Fourth review (c793a4bc; cubic's follow-ups in 6e070e70; upstream main merged in 0718153f). pnpm check green, 122 package tests. The review's shapes, each a scratch git repo with the hook flipped to pass, at 60d550c6 and now:

Case60d550c6Now
A: Module._load(path) from node:moduleblocking: 0config-unverified: imports node:module
B: m['create' + 'Require'] on import * as m from 'node:module'blocking: 0config-unverified: imports node:module
C: vm.runInThisContext("…createRequire(…)('./check.cjs')")blocking: 0config-unverified: imports node:vm
D: new Worker("import('./hooks/check.mjs')", { eval: true })blocking: 0config-unverified: imports node:worker_threads
@acme/hooks bundles @acme/shared; shared's src editedblocking: 0config-changed: packages/shared/src/index.ts

Shape E's globalThis['ev' + 'al'] still passes, as the review expects, and the README's limits say so. Its (async () => {}).constructor blocks since the fifth review. Each new rule turned off on its own fails diff.test.ts, and so does every earlier one (38 mutations in all):

MutationTests
an import of a loading builtin not blocking1 fails
node: prefix required, or bare name required1 fails each
vm, worker_threads, or inspector/promises left out1 fails each
the builtin rule skipped for a re-export or a require1 fails each
process.getBuiltinModule read accepted1 fails
a stand-in package's sources not walked (a module imported by path)1 fails
a stand-in's workspace dependencies not standing in1 fails
a stand-in's directory not counted (a deleted source)1 fails
the config's own package standing in (its cases read as config)1 fails
a stand-in source's untraceable import blocking1 fails
a file reached strictly after leniently not walked strictly1 fails
links not read from index modes, retyped paths dropped, deleted files listed1 fails each
an untracked file reached through a stand-in's sources accepted1 fails
a stand-in package.json that doesn't parse accepted1 fails

On the testbed with the built CLI: diff --base HEAD prints No CIGE case or config changed since HEAD. (exit 0), and workspaceCreated rewritten to return true exits 1. Queueing a stand-in's sources under the strict rules, as first suggested, made that run config-unverified for good: it parsed all of packages/e2e and packages/cige, tests included, and hit the runner's computed config import, its node:module hooks, and TypeScript parameter properties that strip-only mode refuses. Node 26 dropped stripTypeScriptTypes's transform mode, so those can't be read either; the package.json dependencies cover a bundled package a source Node can't strip imports.

Fifth review (003692ab; cubic's follow-up in f7fd3377). pnpm check is green, and the 122 package tests pass on Node 24.22.0 and on 24.8.0, the engines.node floor CI's test (24.8.0) job runs. That job failed on 0718153f because Node 24.8.0's rmSync throws ERR_FS_EISDIR on a symlink to a directory, so the test helper now uses unlinkSync. The review's shapes, each a scratch git repo with the hook flipped to pass, at 0718153f and now:

Case0718153fNow
process[`getBuiltinModule`]('module').createRequire(…)('./check.cjs')blocking: 0config-unverified: reads process.getBuiltinModule
globalThis[`eval`]("…createRequire(…)('./check.cjs')")blocking: 0config-unverified: runs code from a string
hooks/loader.cjs: module.constructor._load(__dirname + '/../check.cjs')blocking: 0config-unverified: reads .constructor
(() => {}).constructor("return process.getBuiltinModule('module')")()blocking: 0config-unverified: reads .constructor
import('data:text/javascript,export{createRequire}from"node:module"')blocking: 0config-unverified: imports a data: URL
run({ files: ['./check.cjs'], isolation: 'none' }) from node:testblocking: 0config-unverified: imports node:test

process.mainModule blocks too. It already blocked before, through the .require read in the repro. Stand-in sources stay lenient. With their refusals blocking, diff on the testbed reports config-unverified on every run and names about 90 files: the runner's computed config import, node:module in at least 12 files, and the web engine's Function. The README now names a computed load in a workspace package's sources as out of reach. Each rule turned off on its own fails diff.test.ts (45 mutations in all, every one fails a test):

MutationTests
a template-literal key read as text, a computed member key or a computed destructure key not checked1 fails each
a computed key in an object literal blocking1 fails
.constructor or process.mainModule read accepted1 fails each
a data: import accepted, an uppercase DATA: one accepted, or node:test left out1 fails each

Sixth review (7b0346ae; upstream main merged in 431d3aea). pnpm check is green, and the 122 package tests pass on Node 24.22.0, 24.8.0, and 26. The review's shapes, each a scratch git repo with the hook flipped to pass, at 431d3aea and now:

Case431d3aeaNow
A stand-in's source re-exports local/loader.mjs (no package), which calls createRequire(…)('../hooks/check.cjs')blocking: 0config-unverified
The same, with a computed import(p) in loader.mjsblocking: 0config-unverified
A stand-in's source holds an enum and re-exports local/check.mjs; check.mjs flippedblocking: 0config-changed: local/check.mjs
process.binding('contextify')blocking: 0config-unverified
process.dlopen(…)blocking: 0config-unverified
import repl from 'node:repl'blocking: 0config-unverified

When Node's strip-only mode refuses a file, oxc-transform@0.155.0 now compiles it, the version e2e already ships. Node 26 rejects mode: 'transform' at runtime (ERR_INVALID_ARG_VALUE), so that's not an option. The lockfile differs from main only by cige's importer. (Not so: the @pnpm/exe entries were still missing at 7b0346ae; the seventh review restores them.) On the testbed, diff --base HEAD still reports nothing, and the flipped workspaceCreated hook exits 1.

Of 50 mutations, 49 fail a test. The survivor is equivalent: it makes the untracked-file rule strict-only, but a file reached leniently now always sits inside a stand-in's directory, so it never reaches that rule. The new ones:

MutationTests
a file outside every stand-in walked leniently1 fails
no oxc fallback for TypeScript Node can't strip1 fails
process.binding or process.dlopen read accepted1 fails each
repl left out1 fails

Seventh review (71639603; upstream main merged in d37689db). The review's comments, sorted before fixing:

ItemBucketDone
binding and dlopen blocked as any name (cubic's P2 too)fixread only on process; process handed on blocks
A decorator hides a stand-in's importsfix the causeTypeScript parsed with oxc-parser; a stand-in source that doesn't parse blocks
@pnpm/exe lock entries still missing (and the 7b0346ae message said they were back)fixrestored; the lockfile adds 34 lines against main and removes none
No test for the trailing /, or for the oxc error pathfixpackages/hooks-extra and an unparsable stand-in source
compiled() copies e2e's lazy loaderfixoxcParser() loads as e2e/src/config/typescript.ts does, and keeps a failed load
In-package loader refusals in stand-ins; accidental vs adversarialmaintainerunchanged, documented
test (24) failed in packages/acpnot this PRthe same test failed on main at b54646f7; packages/acp matches main

The review's shapes, plus the ones a fresh-context review of this round found in a draft, each a scratch git repo with the hook flipped to pass or the tree left clean:

Cased37689dbNow
const binding = { ok: true } in the config, clean treeblocking: 1blocking: 0
A hook reads reply.binding, clean treeblocking: 1blocking: 0
A hook destructures ({ dlopen }), clean treeblocking: 1blocking: 0
A stand-in's source holds a decorator and re-exports local/check.mjs; check.mjs flippedblocking: 0config-changed: local/check.mjs
export * from 'node:process'blocking: 0config-unverified
export { default as proc } from 'node:process'blocking: 0config-unverified
A .js hook with JSX, clean treeblocking: 1blocking: 0
this.constructor.name, clean treeblocking: 1blocking: 0

const p = require('node:process'); p.binding, import p = require('process'), (await import('node:process')).binding, import('node:process').then(…), and (0, process).binding all block, before and now. Every shape from the fifth and sixth reviews still blocks.

The old and new walks over this repo's 1,192 source files (1,177 TypeScript):

  • Every file the old walk could read gives the same import list.
  • The 11 binding false positives are gone.
  • The Angular example (@Component) parses now.
  • 8 files block under the handed-on rule: 5 tests with vi.spyOn(process, …), 2 process[stream] reads, and 1 routingSlot.get(process). A config or hook reaches none of them.

With every source readable, making in-package stand-in refusals strict would cost 45 refusals on the testbed, down from 98.

Mutations are now generated instead of picked: every ||/&&, ===/!==, negation, if, set or record entry, continue, return true|false, regex flag, and the / in startsWith, in imports.ts and config-graph.ts.

  • First run, 437 mutants: 361 failed a test. The survivors led to:
    • about 20 new tests for cases nothing covered: { process }, export { process }, () => process, global.process, a computed require(name), { [eval]: 1 }, { run: eval }, an import cycle at the merge base, a dependency hoisted to the root's node_modules, and a dependency installed nowhere (dropping its loop's exit hung the walk, and no test caught it).
    • removed guards that could never fail on what the parser returns.
    • a fix for one over-block, const x = (process, 1).
  • Final run, 372 mutants: 355 fail a test. No test can tell the 17 survivors apart:
SurvivorWhy
imports.ts asProgram guardboth parsers always return a program; lint requires the narrowing
imports.ts: plain JavaScript through oxc instead of acornsame tree; only needs oxc's native binding
config-graph.ts usage checkdiff.ts always passes both arguments
base walk: seen check and missing-entry checkthe stack overflow they cause lands in entry()'s catch, which ends the walk with the same result
base walk: bare and outside-repo specifiers (4)the lookup at the base finds no such file
inRepo: file: check, root, and other-drive guards (3)fileURLToPath throws on other schemes, and Node never loads the root or another drive
an installed dependency as a stand-inadds only files git ignores
/ in the "package.json doesn't parse" messagechanges the message only
link loop: catch and non-link checks (3)a file that isn't a link resolves to itself, already in the graph

cubic on 71639603 (55577fd8). All four findings were valid. The probe found four sibling forms, marked †:

Case71639603Now
const g = globalThis; g.process.bindingblocking: 0config-unverified
const { process: p } = globalThis †blocking: 0config-unverified
globalThis['pro' + 'cess'].binding †blocking: 0config-unverified
const { default: proc } = await import('node:process')blocking: 0config-unverified
import * as ns from 'node:process'; ns.default.binding †blocking: 0config-unverified
A lazy TS hook with import { type Script } from 'node:vm', clean treeblocking: 1blocking: 0
A stand-in's App.tsx imports local/check.mjs; check.mjs flippedblocking: 0config-changed

The fourth sibling, globalThis.global.process.binding, is covered by a test.

What changed:

  • The global object. globalThis and global follow process's rule. Destructuring process out of one binds it whole, while { process: { env } } reads a field and passes.
  • A namespace's default. A process namespace's default is process.
  • Type-only imports. An import whose specifiers are all type binds no loader.
  • JSX and TSX. .tsx and .jsx sources are walked, and .tsx parses as TSX.
  • Walker bugs. A tree the walk doesn't expect fails closed, naming the error, instead of crashing the graph process.

Results:

  • Corpus: import lists are identical wherever the old walk could parse the file, and about 64 .tsx files now parse. The global rule adds 20 refusals, all inside the runner and engine (packages/e2e, packages/web), one test fixture, and one docs snippet.
  • Testbed: strict in-package refusals would now number 67. The normal walk reports nothing.
  • Mutations: 380 of 397 fail a test. The 17 survivors are the same 17 as in the table above. Every mutant of the new rules fails a test.

The second review's repro, in a scratch git repo with the built CLI: cige.config.ts imports #hooks/order (package.json imports), and hooks/order.ts is rewritten to return true. At 4dd1f130 that gave blocking: 0; now:

hooks/order.ts
  needs approval  config-changed: hooks/order.ts: the hooks, plugins, and environments a pass depends on

1 change can change what a pass means and needs a human's approval.
exit=1

Live on the testbed (gpt-6-luna, --no-cache), on this commit: the committed cases pass. orders-infra is infrastructure; its code is no longer named in the classifier. The guardrail refusal, the planted defect, the unreachable goal, and the plugin step classify as in the table below. The repair gate on the committed wizard-workspace case, against HEAD:

Changee2e-cige diff
Execution only (e1 and e2 merged)execution-changed, intentUnchanged: true, blocking: 0, exit 0; the live run of the repaired case passed
Context.open changedcontext-changed: open, blocking: 1, exit 1
The workspaceCreated evidence hook rewritten to return trueconfig-changed: apps/testbed/cige.config.ts, exit 1
cige.config.ts deletedconfig-changed: apps/testbed/cige.config.ts, exit 1
  • packages/cige/tests/suite.test.ts runs the built runner on an in-memory engine with a scripted executor, one case per branch of the workflow, plus a two-variant case. It then runs the built e2e-cige refs, replay, and env against that project.
CaseWhat happenedRun summary
passevery step passed, the evidence hook returned trueresult: pass, refs, replay command
guardrailthe executor called a mutating refund toolfailedStep: e1, POLICY_DENIED, guardrail-violation:no-refund
stalethe second step failed with LOCATOR_NOT_FOUNDfailedStep: e2, outdated-test-logic, replay: npx e2e-cige replay stale
defectthe Intent judgment failedfailedStep: intent, product-defect
evidenceevery step passed, the evidence hook returned falsefailedStep: order-record, product-defect
infrastructurethe precondition hook threw ENVIRONMENT_UNAVAILABLEphase: precondition, infrastructure
variantscard passed, voucher failedtwo summaries, variants.voucher: outdated-test-logic
plugin stepa use step's plugin threw on a 500failedStep: e2, ACTION_FAILED, no failureType, plugin-step-failed:e2, adjustedBy: [orders], extensions with the case's x-ticket and the plugin's fields
unreachablean act step's goal couldn't be reached (ASSERTION_FAILED)no failureType, goal-unreachable
refusedthe agent refused, citing the no-delete guardrail (POLICY_DENIED)guardrail-refused:no-delete
  • Live, testbed, gpt-6-luna through @ai-sdk/openai (a local-only config, not committed): the two committed cases plus five local ones, nine tests in all, --no-cache. Final run:
CaseWhat the agent didRun summary
todo-done (committed)added and checked "Water the plants"pass; the evidence hook read the checked box and "0 remaining"
wizard-workspace atlas-pro / borealis-free (committed)created each workspace on its planboth pass; the hook matched Created "Atlas" on the Pro plan from the variant's params
checkout-pay-total (the planted bug)set the Notebook quantity to 3product-defect at intent: "The order summary shows $42.00, but the Pay button shows $18.00"
orders-infranone: its precondition threwinfrastructure, phase: precondition, ENVIRONMENT_UNAVAILABLE
todo-clearread the no-delete guardrail and refused before deletingPOLICY_DENIED, guardrail-refused:no-delete, no type
wizard-billing (a step for a tab that doesn't exist)found no Billing tabASSERTION_FAILED, goal-unreachable, no type
wizard-extended ok / wrong-statuscreated the workspace; a plugin use step called the real /api/usersok passed. wrong-status ("expect": "{status}" → the number 201) is product-defect at e2, with x-owner/x-ticket and plugin fields in extensions and adjustedBy: [playground]
  • With --ai-trace, the plugin's Feature flags on: wizard-v1 context line reached the model 6 times; x-owner and x-ticket reached it 0 times; every act instruction carried the guardrail block.
  • The first live run caught two misclassifications, fixed here: an unreachable act goal was typed product-defect, and an agent's own guardrail refusal named no guardrail.
  • e2e-cige replay orders-infra printed resetting local-openai, ran e2e run --config e2e.cige-local.config.ts --tag cige:orders-infra --no-cache, and exited with e2e's code. e2e-cige refs wizard-workspace --variant atlas-pro wrote index.json and build.md (the last five commits under app/). e2e-cige check --resolve on the testbed: All 2 CIGE cases are ready to classify and replay.
  • e2e-cige diff --base upstream/main on the testbed reports the new cases as case-added and the new cige.config.ts as config-changed, which needs approval: adding hooks is a human's call.
  • Live repair loop, driven by skills/cige (gpt-6-luna, on a throwaway local branch, deleted afterwards):
StepWhat happened
BaselineThe testbed wizard gained a Billing choice and a case "A visitor can create the workspace Lyra on the Pro plan" whose e2 was "choose the Pro plan with Monthly billing". It passed live
Product driftCommit feat(wizard): drop the billing choice; every plan bills annually
Failuree2 failed: "no Monthly billing option was available". Summary: ASSERTION_FAILED, goal-unreachable, no type
Classify (classify.md)It reached the system. goal-unreachable routes to step 2. e2e-cige refs: the release notes hold the drop-billing commit; the spec still has name, Pro plan, and Create. So outdated-test-logic
Repair (repair.md, Mode A)Read the failure screen (no billing step) and changed only e2, to "choose the Pro plan", keeping its id
Replaye2e-cige replay workspace-pro: reset, then the case alone with no cache. Passed; the evidence hook read Created "Lyra" on the Pro plan
Provee2e-cige diff --json: execution-changed: steps e2, intentUnchanged: true, blocking: 0
Shortcut checkWeakening the Intent and dropping its evidence instead: diff lists 3 changes that need approval and exits 1
  • The loop caught one bug, fixed here: on a CIGE config other than cige.config.ts, the summary's refs and replay commands found no case. The new configFile option passes --config.
  • The e2e MCP server didn't connect in that session, so the repair read the failure screen the run captured instead of a live MCP session.
  • The weekly agent workflow runs the committed cases with the Gateway key.