Proposal (rationale)
rungs eject promises that a repository can keep its checks after uninstalling Rungs, but the
materialized runner imports a partial TypeScript source copy whose dependency closure is absent.
Even if that loader failure is repaired, the documented aggregate entry point remains the managed
.ai/rungs.mjs launcher, which always invokes the exact npm package. Direct gate commands and the
normal local/CI workflow therefore disagree about whether ejection actually ended the dependency.
This is one boundary, not two independent fixes: a standalone engine that cannot run the registry is not the consumer workflow, and a local aggregate launcher pointing at an incomplete engine only makes the same failure easier to reach.
Decision
accepted — 2026-09-05, during the user-authorised Arena Lab bootstrap. Fix F-042 and F-045
together. Build a dependency-bundled Node artifact as part of the Rungs package, materialize that
artifact plus the selected gate data at ejection, and replace the managed launcher with a local
ejected launcher whose supported surface is check. Both individual rewritten gate commands and
node .ai/rungs.mjs check [tier] must work after all access to npm, the installed package and the
producer checkout is removed.
Plan
Requirements
- Ship a reproducibly built ejected runner containing the complete engine/runtime dependency
closure, including current transitive TOML/XML needs, with no imports resolved from a consumer’s
node_modulesor the producer checkout. - Preserve every runnable gate: converted declared gates execute the frozen engine/table; existing repository-owned command gates retain their command; hooks remain excluded from aggregate runs.
- Materialize the module manifests and raw gate tables needed by meta-gates and skill extension ownership, plus substituted JSON tables used for frozen execution.
- Switch the managed
.ai/rungs.mjsto a package-free local launcher during the same preflighted operation. It must support aggregatecheck, ordered tiers, exit status, findings and ledger behavior, and clearly refuse lifecycle commands no longer available after ejection. - Make repeated ejection byte-idempotent and fail before mutation if existing
.rungscontent or a diverged launcher cannot be safely claimed. - Keep the package and source paths honest: the packed CLI copies the built artifact it actually ships, and source tests exercise that same artifact rather than a parallel handwritten runner.
Impacts
scripts/build.mjs, a dedicated ejected-runner entry point,src/lifecycle.ts, engine runtime module-root discovery,.ai/gates.tomlrewriting and the instructions-owned launcher transition.- Package contents and integrity expectations; focused core tests and the packed existing-consumer journey.
- README/help/ejected README and generated instructions must narrow “stop depending” to the exact retained check surface and explain how to re-adopt Rungs.
- WI-073’s complete-operation path preflight should protect every materialized file before the first write; no eject-specific path join may reopen that boundary.
Approach
Add a second esbuild entry point that bundles engine code and all package dependencies into one ESM
file for Node 22. The ejected runner has two explicit modes: a gate id executes one frozen declared
gate, while check parses the rewritten registry, applies the same ordered-tier selection, runs
frozen gates in-process and repository-owned command gates as commands, appends the same local
ledger fields, prints findings and exits nonzero for fail/unimplemented/error or an empty/unknown
tier.
Copy the bundle into .rungs/ together with substituted JSON execution tables and the minimal raw
module manifests/tables needed by gate-meta and skill-extension lookup. Recognize only the exact
Rungs-generated command form as a converted declared gate; an arbitrary command carrying engine
metadata stays a repository command. Generate a small .ai/rungs.mjs forwarder that uses
process.execPath, never npm or PATH lookup.
Precompute bytes and validate destinations before writing. On a repeat, recognize the ejected registry/launcher and reproduce identical output instead of appending another marker or losing the table inventory.
Acceptance criteria / tests
- Without the fix, a declared gate from an ejected tracked consumer fails at module loading; with
it, both
node .rungs/run-gate.mjs <id>andnode .ai/rungs.mjs checkexecute and return the expected pass/fail statuses. - After ejection, rename or remove the isolated installed package prefix and run with no usable npm
or producer path. Aggregate checks still execute every non-hook gate, including repository command
gates, and use only files committed inside the consumer plus
process.execPath. - Fast/full/default tier selection, unknown/empty tiers, unimplemented/error outcomes, finding
text, aggregate exit code and
.ai/.gate-ledger.jsonlfields match the production runner’s semantics on the same fixture registry. gates-self-tests-both-directionsand skill frontmatter extension checks retain their pre-eject verdicts from materialized raw metadata rather than passing on an empty module set.- The rewritten
.ai/rungs.mjscontains no package spec/npm invocation, local and generated CI continue calling it, and non-check commands fail with an explicit ejected-state explanation. - A second eject produces no byte or Git diff. Pre-existing/diverged owned destinations refuse the entire operation without partially rewriting the registry or launcher.
npm pack --jsonincludes the exact bundled asset; a packed isolated existing-repository journey proves its integrity, removes package access, runs direct and aggregate checks, and leaves the producer checkout unchanged.- Focused tests, full
npm test, package dry-run,git diff --checkand all registered Rungs gates pass. The exact pushed SHA passes all six OS/Node matrix cells and the site job.
Out of scope
- Preserving
render,upgrade,add,backlog archive, concurrency commands or any other Rungs lifecycle command after ejection; the retained product is the frozen check system. - Removing Node itself from an ejected consumer, updating frozen engines after ejection, or making externally authored command gates independent of the runtimes they deliberately invoke.
- WI-076 archive containment, F-043 branch-local exemption evidence, F-034 line-ending behavior, publishing v0.4.0 or adopting it in Arena.
Execution
Executed 2026-09-06 on feature/WI-077-standalone-ejected-checks, cut from main
3de9111 under the WI-085 programme, after WI-073’s
path boundary had landed (2026-09-05). Baseline reproduction before any change: the built CLI’s
eject failed before writing (it resolved dist/glob.ts), and the source CLI’s ejected gate failed
with ERR_MODULE_NOT_FOUND for smol-toml — engines.ts imports the TOML parser, the self-test
runner, the manifest loader and substitution, none of which the five copied files carried.
What was built, following the Approach:
scripts/build.mjsgains a second esbuild entry,src/ejected-runner.ts→dist/ejected-runner.mjs, withpackages: "bundle": 186 KB, every remaining import anode:builtin.ejectcopies that artifact and refuses if it is not built — it never copies loose sources again.src/ejected.tsholds the runtime roots (module set, frozen tables, producing version) and the one command form a converted gate may take.engines.ts,check.tsandsubstitute.tsread the roots instead of deriving paths fromimport.meta.url, which inside a bundle under.rungs/pointed at amodules/the consumer does not have and at apackage.jsonthat does not exist — the second of those threw insideresolveParams, the caller’s catch returned no table, and the meta-gate passed without running a fixture.check.tsowns thecheckcommand end to end (checkCommand, moved verbatim fromcli.ts) plus an ejected mode in which a converted gate runs in-process from its frozen JSON table. The production CLI runs the same converted entry as the repository command it is, so both paths reach the same engine on the same table.ejectmaterialises.rungs/run-gate.mjs,.rungs/tables/<module>-<table>.json(substituted with the installed parameters),.rungs/modules/<name>/{module.toml,gates/*.toml}for every installed module and every frozen table’s owner,.rungs/ejected.jsonand.rungs/README.md; rewrites the registry block-wise (the old lazy regex, on an already-converted block, ran into the next block); and replaces.ai/rungs.mjswith a forwarder that runs the bundle throughprocess.execPathand refuses every other command by name. Hooks keepkind = "declared": the runner skips them by trigger and their tables are frozen for dispatch.- Preflight: every destination goes through
preflightEmittedPathsbefore the first write; a.rungs/not written by a prior eject, or a launcher whose hash no longer matches the install record and is not already the forwarder, refuses the whole operation (EjectRefusal). A repeat reuses the recorded ejection date and rewrites nothing that is byte-identical after newline normalisation.
Deviations, with reasons.
- The frozen tables are JSON and the raw tables are copied. The plan listed both; keeping both is deliberate rather than redundant — the JSON is what runs (the parameter source is gone after ejection, so re-substituting is impossible), the raw TOML is what the meta-gate’s fixtures and the skill-extension lookup read. One source of truth per question.
- The retained launcher surface is declared as a list (
EJECTED_RETAINED, currentlycheck) rather than hard-coded, because WI-086 dispatches the shell-safety hook through the same launcher and must addhookto it; a hook adapter that points at a command ejection removed would block every tool call in an ejected repo. - Ejection adds one markdown file,
.rungs/README.md, sogates-links-resolveexamines one file more than before; the regression expects exactly that difference instead of blind equality.
A mistake worth recording. The first version of the end-to-end regression compared the 186 KB
runner as a Buffer against a string. The equality always failed, and the assertion library then
built a character-level diff of two 186 KB values — 221 seconds, then Array buffer allocation failed, and the host went down three times before the cause was found. Large artefacts are now
compared by digest in both test files, with the reason beside each comparison.
Review
Against each acceptance criterion, 2026-09-06, Windows 11, Node v22.22.3, npm 10.9.8:
- Loading. Before: reproduced as above. After:
test/core.test.js“eject ships a package-free runner…” runsnode .rungs/run-gate.mjs instructions-core-size(exit 0; exit 1 with the budget finding onceAGENTS.mdis padded) andnode .ai/rungs.mjs check(exit 1, because the fixture carries a deliberately red repository command) from a disciplined consumer. - Package absent.
test/package.test.jsejects with the packed candidate from the isolated prefix, commits, renames the prefix away, emptiesnpm_execpathand pointsnpm_config_cacheat an empty offline directory, then runs the direct gate (exit 0) andcheck full(exit 0,0 fail · 0 unimplemented · 0 error, the adopted repository validator passing). What this does not prove: that Node is absent fromPATH— repository command gates need the runtimes they invoke, so the Node directory stays; stated in the test. - Semantics. The core regression records
runGatesbefore ejection and compares the ejectedcheckgate by gate: same ids in the same order, same status, same examined count (the link gate examines one more file,.rungs/README.md, asserted exactly);fastselects the same subset,--fullthe superset,bananais refused with the same sentence, a declared tier selecting nothing exits 1 with “no gates in the quick tier”; the ledger has one line per gate with the fieldsat, id, status, ms, examined. Output is the same function (checkCommand), so the agreement is by construction and the test is a guard against regression. - Meta-gates. The ejected
gates-self-tests-both-directionsexamines the same count as the production run (asserted per gate) and the package journey asserts a non-zero examined count after the prefix is gone. Skill extension ownership reads the same materialised manifests. - Launcher. The forwarder contains neither
@rungs/clinornpm(asserted in both tests), runs the bundle viaprocess.execPath, and answersrender/upgrade --applywith exit 1 and an explanation naming ejection. The generated workflow andAGENTS.mdstill callnode .ai/rungs.mjs check, unchanged. - Idempotence and refusal. A second eject with a different stamp reports
unchanged, the tree digest of.rungs/, the registry and the launcher is identical,git statusis clean, and a dry-run lists onlyunchanged. A foreign.rungs/and an edited launcher each throwEjectRefusalwith the registry and launcher byte-identical afterwards and no.rungs/written. - Packed journey.
npm pack --jsonlistsdist/ejected-runner.mjs(asserted); the journey’s integrity check, prefix removal, direct and aggregate ejected checks and producer-unchanged assertion all pass; the consumer rolls back to its seed commit. - Suite and gates.
npm run buildthennode --test --test-concurrency=1 test/*.test.js: 144 tests, 141 pass, 0 fail, 3 platform skips, 165 s serial.node src/cli.ts check: 30 pass, 0 fail, 0 unimplemented, 0 error.npm pack --dry-run --json:@rungs/cli@0.4.0, 118 entries.git diff --checkclean. The exact-SHA OS/Node matrix is pending: this branch is not pushed, so no remote run exists for it; recorded as unobserved, not as passed.
Status moves to review; done and the archive move happen in the change that lands the branch.